Skip to main content

gridoxide_core/
ac_sensitivity.rs

1//! AC sensitivity analysis: how a converged operating point responds to a
2//! small change.
3//!
4//! Answers the questions that sit between a single power flow and a full
5//! contingency study. *If this generator ramps 10 MW, which line picks it up?
6//! Which bus voltage moves, and by how much? Which injection would relieve
7//! this overload fastest?* Each is a partial derivative of the solved state
8//! with respect to an input, evaluated at the operating point.
9//!
10//! # The formulation
11//!
12//! A converged power flow satisfies
13//!
14//! \\[ g(x, p) = s_{calc}(x) - s_{spec}(p) = 0 \\]
15//!
16//! with state \\(x = [\theta_{non\text{-}slack};\ |V|_{PQ}]\\) — exactly the
17//! unknown vector [`solver::newton_raphson`](crate::solver) iterates on.
18//! Differentiating and rearranging:
19//!
20//! \\[ \frac{dx}{dp} = -J^{-1} \frac{\partial g}{\partial p}
21//!                   = J^{-1} \frac{\partial s_{spec}}{\partial p} \\]
22//!
23//! where \\(J = \partial g / \partial x\\) is **the same Jacobian Newton
24//! already builds** — `jacobian::JacobianPattern`, filled at the converged
25//! state. And because a specified injection enters exactly one equation, its
26//! \\(\partial s_{spec} / \partial p\\) is a unit vector. So the response of
27//! the entire network to one injection is a single triangular solve with a
28//! unit right-hand side. That is the whole method.
29//!
30//! Any quantity of interest \\(f(x)\\) then follows by the chain rule,
31//! \\(df/dp = (\partial f/\partial x)^{T} \, dx/dp\\), and for a branch flow
32//! that row is precisely what
33//! [`branch_flow::terminal_flow_derivs`](crate::branch_flow) already computes
34//! for the state estimator.
35//!
36//! # Two directions, and when each is the cheap one
37//!
38//! The expression above can be bracketed either way, and the two groupings
39//! have genuinely different costs:
40//!
41//! - **Forward** ([`state_response`](AcSensitivity::state_response)) — solve
42//!   \\(J\,z = e_i\\) once per *variable*, then every function is a dot
43//!   product. Use when few things move and many are watched: *one generator
44//!   ramps — what happens to all 5,000 branches?*
45//! - **Adjoint** ([`function_row`](AcSensitivity::function_row)) — solve
46//!   \\(J^{T} w = \partial f/\partial x\\) once per *function*, then every
47//!   variable is a lookup. Use when one thing is watched and many could move:
48//!   *this line is overloaded — which injection relieves it?*
49//!
50//! Both run against one factorization, computed once in [`new`](
51//! AcSensitivity::new); [`sparse::RealFactorization::solve_transpose`](
52//! gridoxide_sparse::sparse::RealFactorization::solve_transpose) reuses it for the
53//! transposed direction rather than factorizing again. This mirrors the
54//! `ptdf_column`/`ptdf_row` pair in
55//! [`linear::sensitivity`](crate::linear::sensitivity), which is the same
56//! duality on the DC side.
57//!
58//! # Relationship to the DC sensitivities
59//!
60//! [`DcSensitivity`](crate::linear::sensitivity::DcSensitivity) is *exact* for
61//! the model it describes, because the DC model is linear — a PTDF is not an
62//! approximation of DC, it **is** DC. These are different: the AC problem is
63//! nonlinear, so these are first-order derivatives, accurate for small
64//! perturbations and degrading as the step grows. What they buy in exchange is
65//! everything DC discards — voltage magnitudes, reactive flows, losses.
66//!
67//! Use DC to screen thousands of cases, AC to answer precisely about the few
68//! that matter.
69//!
70//! # Transformer taps and phase shifters
71//!
72//! These are variables too, but of a different shape: an injection enters
73//! \\(\partial g/\partial p\\) as a unit vector, whereas a tap changes the
74//! branch's admittance and therefore the *calculated* injections at both its
75//! ends. Two consequences follow, and the second is easy to miss.
76//!
77//! First the sign. For an injection, \\(p\\) appears in \\(s_{spec}\\) and
78//! \\(\partial g/\partial p = -\partial s_{spec}/\partial p\\); for a tap it
79//! appears in \\(s_{calc}\\) and \\(\partial g/\partial p = +\partial
80//! s_{calc}/\partial p\\). The response picks up the opposite sign.
81//!
82//! Second, and the part a naive chain rule drops: a tapped branch's *own* flow
83//! depends on the tap **directly**, not only through the state. The full
84//! derivative is
85//!
86//! \\[ \frac{df}{dp} = \left(\frac{\partial f}{\partial x}\right)^{T}
87//!                     \frac{dx}{dp} + \left.\frac{\partial f}{\partial
88//!                     p}\right|_{x} \\]
89//!
90//! and that second term is zero for every branch except the tapped one, where
91//! it is large. Omitting it gives an answer that looks plausible everywhere
92//! and is badly wrong exactly where it is most likely to be read.
93//!
94//! The admittance derivatives themselves are unusually tidy. With
95//! \\(tap = k\,e^{j\alpha}\\), and given that `yff` always carries \\(1/k^2\\),
96//! `yft`/`ytf` always carry \\(1/k\\), and `ytt` never depends on the tap at
97//! all:
98//!
99//! \\[ \frac{\partial y_{ff}}{\partial k} = \frac{-2\,y_{ff}}{k}, \quad
100//!     \frac{\partial y_{ft}}{\partial k} = \frac{-y_{ft}}{k}, \quad
101//!     \frac{\partial y_{tf}}{\partial k} = \frac{-y_{tf}}{k}, \quad
102//!     \frac{\partial y_{tt}}{\partial k} = 0 \\]
103//!
104//! \\[ \frac{\partial y_{ft}}{\partial \alpha} = j\,y_{ft}, \quad
105//!     \frac{\partial y_{tf}}{\partial \alpha} = -j\,y_{tf}, \quad
106//!     \frac{\partial y_{ff}}{\partial \alpha} =
107//!     \frac{\partial y_{tt}}{\partial \alpha} = 0 \\]
108//!
109//! Each is a scaling of the entry itself, so no admittance has to be rebuilt
110//! from nameplate data — and the relations hold for the half-open terminal
111//! states too, where the affected entries are simply zero.
112//!
113//! # What is *not* here
114//!
115//! No contingency (outage) sensitivities. An outage is a finite change in
116//! topology, not an infinitesimal change in an input, so it is not a
117//! derivative at all — the DC side handles it with a Woodbury update
118//! (`DcSensitivity::outage_flows`) that has no equally cheap AC analogue.
119//! `batch::BatchSolver::solve_contingencies` is the AC answer, and it re-solves.
120
121use crate::branch_flow::{BranchParams, Terminal, branch_params, terminal_flow_derivs};
122use crate::jacobian::JacobianPattern;
123use crate::network::{YBusSparse, power_injections};
124use crate::types::{Bus, BusType, Line, Transformer};
125use gridoxide_sparse::sparse::RealFactorization;
126
127use num_complex::Complex;
128
129const ZERO: Complex<f64> = Complex::new(0.0, 0.0);
130
131/// Marks a bus whose quantity is not a free unknown — a slack bus's angle, or
132/// a PV or slack bus's voltage magnitude. The same sentinel
133/// `linear::sensitivity` uses, for the same reason.
134const NOT_UNKNOWN: usize = usize::MAX;
135
136/// An input the operating point can be differentiated with respect to.
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum Variable {
139    /// Active power injected at a bus, per unit.
140    ///
141    /// Undefined at a slack bus, whose injection is not an input but the
142    /// solve's own result — the response there is identically zero, exactly as
143    /// a DC PTDF column is zero at its reference.
144    ActiveInjection(usize),
145    /// Reactive power injected at a bus, per unit.
146    ///
147    /// Only meaningful at a `PQ` bus. At a `PV` or slack bus the voltage
148    /// magnitude is held, so reactive power is an output — the response is
149    /// identically zero.
150    ReactiveInjection(usize),
151    /// A transformer's off-nominal voltage-magnitude ratio `k`, by flat branch
152    /// index (lines first, then transformers).
153    ///
154    /// Zero for a line, which has no tap to move.
155    TransformerRatio(usize),
156    /// A phase-shifting transformer's angle `α`, radians, by flat branch index.
157    ///
158    /// This is the argument of the complex tap — the quantity a phase shifter
159    /// actually controls, and the one that steers active power.
160    PhaseShift(usize),
161}
162
163/// Which of the two tap quantities a variable names.
164#[derive(Clone, Copy, Debug, PartialEq, Eq)]
165enum TapKind {
166    Ratio,
167    Phase,
168}
169
170/// A quantity whose sensitivity to every variable is wanted.
171#[derive(Clone, Copy, Debug, PartialEq, Eq)]
172pub enum Function {
173    /// Active power flowing into `branch` at `terminal`.
174    BranchActivePower { branch: usize, terminal: Terminal },
175    /// Reactive power flowing into `branch` at `terminal`.
176    BranchReactivePower { branch: usize, terminal: Terminal },
177    /// A bus's voltage magnitude, per unit.
178    VoltageMagnitude(usize),
179    /// A bus's voltage angle, radians.
180    VoltageAngle(usize),
181}
182
183/// How the whole state moves in response to one variable.
184///
185/// Both vectors are indexed by bus and are always full length. A bus whose
186/// quantity is not free carries zero — a slack bus's angle does not move
187/// because it is the reference, and a `PV` bus's magnitude does not move
188/// because its controller holds it. Those zeros are answers, not gaps.
189#[derive(Clone, Debug)]
190pub struct StateResponse {
191    /// dθ/dp per bus, radians per per-unit.
192    pub d_theta: Vec<f64>,
193    /// d|V|/dp per bus, per-unit per per-unit.
194    pub d_vmag: Vec<f64>,
195}
196
197/// How one function responds to injection at every bus.
198#[derive(Clone, Debug)]
199pub struct FunctionRow {
200    /// df/dP per bus. Zero at a slack bus.
201    pub d_active: Vec<f64>,
202    /// df/dQ per bus. Zero anywhere the voltage magnitude is held.
203    pub d_reactive: Vec<f64>,
204    /// df/dk per branch — the response to each transformer's ratio. Zero for a
205    /// line.
206    ///
207    /// One adjoint solve yields this alongside the injection sensitivities, so
208    /// "which tap relieves this overload?" costs nothing beyond "which
209    /// injection does".
210    pub d_ratio: Vec<f64>,
211    /// df/dα per branch — the response to each phase shifter's angle, in
212    /// radians. Zero for a line.
213    pub d_phase: Vec<f64>,
214}
215
216/// A converged operating point, factorized once and ready to be differentiated.
217///
218/// Holds one factorization of the power-flow Jacobian at that point. Every
219/// accessor is a triangular solve against it, never a refactorization — the
220/// same contract [`DcSensitivity`](crate::linear::sensitivity::DcSensitivity)
221/// offers, and the reason both own a
222/// [`RealFactorization`] rather than
223/// borrowing the Newton path's solver.
224pub struct AcSensitivity {
225    n_buses: usize,
226    /// The branch list this was built against, in the flat order
227    /// `branch_flow::branch_params` produces: lines first, then transformers.
228    branches: Vec<BranchParams>,
229    /// Per flat branch, its complex tap `k·e^{jα}` — `None` for a line, which
230    /// has no tap to differentiate with respect to.
231    taps: Vec<Option<Complex<f64>>>,
232    /// Complex bus voltages at the linearization point.
233    v: Vec<Complex<f64>>,
234    /// Bus → its angle unknown's row, or [`NOT_UNKNOWN`] for a slack bus.
235    theta_pos: Vec<usize>,
236    /// Bus → its magnitude unknown's row, or [`NOT_UNKNOWN`] unless `PQ`.
237    vmag_pos: Vec<usize>,
238    n_unknowns: usize,
239    factorization: RealFactorization,
240}
241
242impl AcSensitivity {
243    /// Factorizes the Jacobian at the operating point `buses` describes.
244    ///
245    /// `buses` must be a **converged** solution — this linearizes about
246    /// whatever state it is handed and cannot tell a solved network from an
247    /// unsolved one, so the derivatives of a non-converged state are
248    /// meaningless rather than wrong-looking. `ybus`, `lines` and
249    /// `transformers` must be the same network that solution came from.
250    ///
251    /// Takes the branch lists rather than a finished `Vec<BranchParams>` for
252    /// the same reason [`crate::run_power_flow`] does: the tap sensitivities
253    /// need each transformer's own `tap`, which `BranchParams` has already
254    /// folded into its four admittance entries. Deriving both here also means
255    /// a caller cannot pass a branch list that disagrees with the Y-bus.
256    ///
257    /// Returns `None` if the Jacobian is singular there.
258    ///
259    /// Unlike the DC side there is no per-island bookkeeping, and none is
260    /// needed: every island carries its own slack, so the assembled Jacobian
261    /// is block-diagonal across islands and non-singular as a whole. A
262    /// sourceless island contributes no rows at all, because
263    /// `network::mark_unreferenced_islands` has already pinned its buses to
264    /// `Slack` — so it is excluded rather than making the system singular.
265    pub fn new(
266        buses: &[Bus],
267        ybus: &YBusSparse,
268        lines: &[Line],
269        transformers: &[Transformer],
270    ) -> Option<Self> {
271        let n = buses.len();
272        let branches = branch_params(lines, transformers);
273        let taps: Vec<Option<Complex<f64>>> = std::iter::repeat_n(None, lines.len())
274            .chain(transformers.iter().map(|t| Some(t.tap)))
275            .collect();
276
277        // The same layout `solver::newton_raphson` uses: every non-slack bus
278        // contributes an angle unknown, every PQ bus a magnitude unknown, in
279        // bus order, angles first. The Jacobian's rows follow the same
280        // ordering (P equations then Q equations), so a bus's P-equation row
281        // and its angle-unknown column share an index — which is what lets a
282        // variable's right-hand side be a plain unit vector.
283        let mut theta_pos = vec![NOT_UNKNOWN; n];
284        let mut vmag_pos = vec![NOT_UNKNOWN; n];
285        let mut next = 0;
286        for b in buses {
287            if b.bus_type != BusType::Slack {
288                theta_pos[b.idx] = next;
289                next += 1;
290            }
291        }
292        for b in buses {
293            if b.bus_type == BusType::PQ {
294                vmag_pos[b.idx] = next;
295                next += 1;
296            }
297        }
298        let n_unknowns = next;
299
300        let (p_calc, q_calc) = power_injections(buses, ybus);
301        let pattern = JacobianPattern::analyze(buses, ybus);
302        let mut values = Vec::with_capacity(pattern.len());
303        pattern.fill(buses, &p_calc, &q_calc, &mut values);
304        let factorization = RealFactorization::new(n_unknowns, &pattern.to_triplets(&values))?;
305
306        Some(Self {
307            n_buses: n,
308            branches,
309            taps,
310            v: crate::branch_flow::bus_voltages(buses),
311            theta_pos,
312            vmag_pos,
313            n_unknowns,
314            factorization,
315        })
316    }
317
318    pub fn n_buses(&self) -> usize {
319        self.n_buses
320    }
321
322    pub fn n_branches(&self) -> usize {
323        self.branches.len()
324    }
325
326    /// The row of `J` an injection variable perturbs, or `None` when it is not
327    /// a free input at that bus.
328    fn variable_row(&self, variable: Variable) -> Option<usize> {
329        let row = match variable {
330            Variable::ActiveInjection(bus) => *self.theta_pos.get(bus)?,
331            Variable::ReactiveInjection(bus) => *self.vmag_pos.get(bus)?,
332            _ => return None,
333        };
334        (row != NOT_UNKNOWN).then_some(row)
335    }
336
337    /// The derivative of a branch's four admittance entries with respect to one
338    /// of its tap quantities — see the module docs for the derivation.
339    ///
340    /// Every entry is a scaling of the entry itself, which is what makes this
341    /// exact without reconstructing the branch from nameplate data, and what
342    /// makes it hold unchanged for the half-open terminal states (where the
343    /// affected entries are already zero).
344    fn admittance_derivative(&self, branch: usize, kind: TapKind) -> Option<[Complex<f64>; 4]> {
345        let tap = (*self.taps.get(branch)?)?;
346        let y = &self.branches[branch].y;
347        Some(match kind {
348            TapKind::Ratio => {
349                let k = tap.norm();
350                [-2.0 * y[0] / k, -y[1] / k, -y[2] / k, ZERO]
351            }
352            TapKind::Phase => {
353                let j = Complex::new(0.0, 1.0);
354                [ZERO, j * y[1], -j * y[2], ZERO]
355            }
356        })
357    }
358
359    /// `∂s_calc/∂p` at the branch's two ends, holding the state fixed.
360    ///
361    /// These are the *only* injections a tap touches: a branch's admittance
362    /// appears in the nodal balance of its own two buses and nowhere else.
363    fn tap_injection_derivative(
364        &self,
365        branch: usize,
366        kind: TapKind,
367    ) -> Option<(Complex<f64>, Complex<f64>)> {
368        let dy = self.admittance_derivative(branch, kind)?;
369        let (f, t) = (self.branches[branch].from, self.branches[branch].to);
370        let (vf, vt) = (self.v[f], self.v[t]);
371        Some((
372            vf * (dy[0] * vf + dy[1] * vt).conj(),
373            vt * (dy[2] * vf + dy[3] * vt).conj(),
374        ))
375    }
376
377    /// `∂g/∂p` in the unknown layout, for a tap variable.
378    fn tap_residual_derivative(&self, branch: usize, kind: TapKind) -> Option<Vec<f64>> {
379        let (ds_from, ds_to) = self.tap_injection_derivative(branch, kind)?;
380        let (f, t) = (self.branches[branch].from, self.branches[branch].to);
381
382        let mut dg = vec![0.0; self.n_unknowns];
383        let mut place = |pos: usize, value: f64| {
384            if pos != NOT_UNKNOWN {
385                dg[pos] += value;
386            }
387        };
388        // A bus's P equation sits at its angle unknown's index and its Q
389        // equation at its magnitude unknown's — the same coincidence that lets
390        // an injection's right-hand side be a unit vector.
391        place(self.theta_pos[f], ds_from.re);
392        place(self.vmag_pos[f], ds_from.im);
393        place(self.theta_pos[t], ds_to.re);
394        place(self.vmag_pos[t], ds_to.im);
395        Some(dg)
396    }
397
398    /// Splits a variable into its tap branch and kind, if it is one.
399    fn as_tap(variable: Variable) -> Option<(usize, TapKind)> {
400        match variable {
401            Variable::TransformerRatio(b) => Some((b, TapKind::Ratio)),
402            Variable::PhaseShift(b) => Some((b, TapKind::Phase)),
403            _ => None,
404        }
405    }
406
407    /// How the whole state responds to one variable — the **forward**
408    /// direction, one solve.
409    ///
410    /// Returns an all-zero response for a variable that is not a free input
411    /// (active injection at a slack bus, reactive injection where the voltage
412    /// is held). That is the correct derivative, not a failure: perturbing a
413    /// quantity the solve itself determines moves nothing.
414    ///
415    /// `None` only for an out-of-range bus, or a singular solve.
416    pub fn state_response(&self, variable: Variable) -> Option<StateResponse> {
417        let zero_response = || StateResponse {
418            d_theta: vec![0.0; self.n_buses],
419            d_vmag: vec![0.0; self.n_buses],
420        };
421
422        if let Some((branch, kind)) = Self::as_tap(variable) {
423            if branch >= self.branches.len() {
424                return None;
425            }
426            // A line has no tap, so moving one moves nothing.
427            let Some(dg) = self.tap_residual_derivative(branch, kind) else {
428                return Some(zero_response());
429            };
430            // `dx/dp = -J⁻¹ ∂g/∂p`, and here `g` depends on `p` through
431            // `s_calc` — hence the negation, which the injection case does not
432            // carry because there `p` enters through `s_spec` instead.
433            let mut dx = self.factorization.solve(&dg)?;
434            for value in &mut dx {
435                *value = -*value;
436            }
437            return Some(self.scatter(&dx));
438        }
439
440        let bus = match variable {
441            Variable::ActiveInjection(b) | Variable::ReactiveInjection(b) => b,
442            _ => unreachable!("tap variables returned above"),
443        };
444        if bus >= self.n_buses {
445            return None;
446        }
447
448        let Some(row) = self.variable_row(variable) else {
449            return Some(zero_response());
450        };
451
452        let mut rhs = vec![0.0; self.n_unknowns];
453        rhs[row] = 1.0;
454        let dx = self.factorization.solve(&rhs)?;
455        Some(self.scatter(&dx))
456    }
457
458    /// Spreads a solved unknown vector back over the buses it came from.
459    fn scatter(&self, dx: &[f64]) -> StateResponse {
460        let mut d_theta = vec![0.0; self.n_buses];
461        let mut d_vmag = vec![0.0; self.n_buses];
462        for i in 0..self.n_buses {
463            if self.theta_pos[i] != NOT_UNKNOWN {
464                d_theta[i] = dx[self.theta_pos[i]];
465            }
466            if self.vmag_pos[i] != NOT_UNKNOWN {
467                d_vmag[i] = dx[self.vmag_pos[i]];
468            }
469        }
470        StateResponse { d_theta, d_vmag }
471    }
472
473    /// `(dP, dQ)` at every branch's `terminal`, for one variable.
474    ///
475    /// The AC counterpart of a PTDF column, and the reason the forward
476    /// direction exists: one solve, then a dot product per branch.
477    pub fn branch_response(
478        &self,
479        variable: Variable,
480        terminal: Terminal,
481    ) -> Option<Vec<(f64, f64)>> {
482        let state = self.state_response(variable)?;
483
484        // The direct term. A tapped branch's own flow depends on its tap
485        // without the state moving at all, so that branch — and only that
486        // branch — picks up an extra `∂f/∂p|_x`. Leaving it out yields an
487        // answer that is right everywhere except on the branch being adjusted,
488        // which is the one most likely to be read.
489        let direct = Self::as_tap(variable).and_then(|(branch, kind)| {
490            Some((branch, self.tap_flow_derivative(branch, kind, terminal)?))
491        });
492
493        Some(
494            self.branches
495                .iter()
496                .enumerate()
497                .map(|(i, branch)| {
498                    let d = terminal_flow_derivs(branch, terminal, &self.v);
499                    let (near, far) = branch.buses(terminal);
500                    let mut dp = d.dp_dtheta_near * state.d_theta[near]
501                        + d.dp_dtheta_far * state.d_theta[far]
502                        + d.dp_dv_near * state.d_vmag[near]
503                        + d.dp_dv_far * state.d_vmag[far];
504                    let mut dq = d.dq_dtheta_near * state.d_theta[near]
505                        + d.dq_dtheta_far * state.d_theta[far]
506                        + d.dq_dv_near * state.d_vmag[near]
507                        + d.dq_dv_far * state.d_vmag[far];
508                    if let Some((tapped, s)) = direct {
509                        if tapped == i {
510                            dp += s.re;
511                            dq += s.im;
512                        }
513                    }
514                    (dp, dq)
515                })
516                .collect(),
517        )
518    }
519
520    /// `∂f/∂p|_x` for a tapped branch's own terminal flow — the direct term.
521    ///
522    /// Same shape as [`tap_injection_derivative`](Self::tap_injection_derivative),
523    /// but read at the requested terminal rather than at both ends.
524    fn tap_flow_derivative(
525        &self,
526        branch: usize,
527        kind: TapKind,
528        terminal: Terminal,
529    ) -> Option<Complex<f64>> {
530        let (ds_from, ds_to) = self.tap_injection_derivative(branch, kind)?;
531        Some(match terminal {
532            Terminal::From => ds_from,
533            Terminal::To => ds_to,
534        })
535    }
536
537    /// `∂f/∂x` in the unknown layout — the gradient of a function with respect
538    /// to the state.
539    fn function_gradient(&self, function: Function) -> Option<Vec<f64>> {
540        let mut grad = vec![0.0; self.n_unknowns];
541        let mut place = |pos: usize, value: f64| {
542            if pos != NOT_UNKNOWN {
543                grad[pos] += value;
544            }
545        };
546
547        match function {
548            Function::BranchActivePower { branch, terminal }
549            | Function::BranchReactivePower { branch, terminal } => {
550                let params = self.branches.get(branch)?;
551                let d = terminal_flow_derivs(params, terminal, &self.v);
552                let (near, far) = params.buses(terminal);
553                let active = matches!(function, Function::BranchActivePower { .. });
554                let (dth_near, dth_far, dv_near, dv_far) = if active {
555                    (d.dp_dtheta_near, d.dp_dtheta_far, d.dp_dv_near, d.dp_dv_far)
556                } else {
557                    (d.dq_dtheta_near, d.dq_dtheta_far, d.dq_dv_near, d.dq_dv_far)
558                };
559                place(self.theta_pos[near], dth_near);
560                place(self.theta_pos[far], dth_far);
561                place(self.vmag_pos[near], dv_near);
562                place(self.vmag_pos[far], dv_far);
563            }
564            Function::VoltageMagnitude(bus) => {
565                place(*self.vmag_pos.get(bus)?, 1.0);
566            }
567            Function::VoltageAngle(bus) => {
568                place(*self.theta_pos.get(bus)?, 1.0);
569            }
570        }
571        Some(grad)
572    }
573
574    /// How one function responds to injection at *every* bus — the **adjoint**
575    /// direction, one solve.
576    ///
577    /// The AC counterpart of a PTDF row. Where
578    /// [`branch_response`](Self::branch_response) answers "one variable moves,
579    /// what happens everywhere", this answers "one thing is watched, what
580    /// moves it" — which is the question an overload actually poses.
581    pub fn function_row(&self, function: Function) -> Option<FunctionRow> {
582        let grad = self.function_gradient(function)?;
583        // `w = J⁻ᵀ ∂f/∂x`, so `wᵀ e_i = (∂f/∂x)ᵀ J⁻¹ e_i` — the same scalar the
584        // forward direction computes, obtained for every `i` at once.
585        let w = self.factorization.solve_transpose(&grad)?;
586
587        let mut d_active = vec![0.0; self.n_buses];
588        let mut d_reactive = vec![0.0; self.n_buses];
589        for i in 0..self.n_buses {
590            if self.theta_pos[i] != NOT_UNKNOWN {
591                d_active[i] = w[self.theta_pos[i]];
592            }
593            if self.vmag_pos[i] != NOT_UNKNOWN {
594                d_reactive[i] = w[self.vmag_pos[i]];
595            }
596        }
597
598        // Taps come out of the *same* adjoint vector, at no extra solve:
599        // `df/dp = -wᵀ ∂g/∂p + ∂f/∂p|_x`. Only the sign and the direct term
600        // differ from the injection case, and the direct term is nonzero only
601        // when the function is the tapped branch's own flow.
602        let mut d_ratio = vec![0.0; self.branches.len()];
603        let mut d_phase = vec![0.0; self.branches.len()];
604        for branch in 0..self.branches.len() {
605            for (kind, out) in [
606                (TapKind::Ratio, &mut d_ratio),
607                (TapKind::Phase, &mut d_phase),
608            ] {
609                let Some(dg) = self.tap_residual_derivative(branch, kind) else {
610                    continue;
611                };
612                let mut value: f64 = -dg.iter().zip(&w).map(|(g, wi)| g * wi).sum::<f64>();
613                if let Function::BranchActivePower {
614                    branch: b,
615                    terminal,
616                }
617                | Function::BranchReactivePower {
618                    branch: b,
619                    terminal,
620                } = function
621                {
622                    if b == branch {
623                        let s = self
624                            .tap_flow_derivative(branch, kind, terminal)
625                            .expect("the tap exists, it was read just above");
626                        value += if matches!(function, Function::BranchActivePower { .. }) {
627                            s.re
628                        } else {
629                            s.im
630                        };
631                    }
632                }
633                out[branch] = value;
634            }
635        }
636
637        Some(FunctionRow {
638            d_active,
639            d_reactive,
640            d_ratio,
641            d_phase,
642        })
643    }
644}