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}