gridoxide_core/jacobian.rs
1//! Topology-derived Jacobian assembly: analyze the sparsity pattern once,
2//! then refill values every iteration at fixed offsets.
3//!
4//! `solver::build_jacobian_triplets` rebuilds a fresh `Vec<(usize, usize,
5//! f64)>` every Newton iteration, re-deriving row and column indices that are
6//! by construction identical each time — the sparsity pattern is fixed for
7//! the lifetime of a topology, which is the very property
8//! `solver::LinearSolver` relies on to cache its symbolic factorization.
9//! [`JacobianPattern`] hoists all of that out of the loop: the `(row, col)`
10//! arrays and a per-entry "recipe" are computed once by
11//! [`analyze`](JacobianPattern::analyze), and [`fill`](JacobianPattern::fill)
12//! then writes only the `f64` values into a caller-owned buffer.
13//!
14//! Two payoffs:
15//!
16//! - **On the CPU**, per-iteration allocation and index arithmetic disappear.
17//! Jacobian assembly is 36–41% of iteration time on the cases measured.
18//! - **On a GPU**, this *is* the kernel shape. [`Entry`] is a flat,
19//! fixed-size, branch-light record; `fill` is one independent write per
20//! entry into a preallocated array at a precomputed offset, with all reads
21//! gathers from small per-bus arrays. Assembly as one flat kernel is
22//! exactly this layout, extended to a batch by adding a scenario stride.
23//!
24//! [`JacobianPattern::fill`] is a transliteration of `build_jacobian_triplets`' H/N/M/L
25//! formulas, not a rederivation. The one difference is how it reaches
26//! `sin(θi − θk)` and `cos(θi − θk)`: from each bus's own sine and cosine,
27//! once per bus, rather than per entry. That rounds differently, so the tests
28//! below hold the two to the same positions exactly and to the same values
29//! within a few units in the last place.
30
31use num_complex::Complex;
32
33use crate::network::{BusVoltages, YBusSparse};
34use crate::types::{Bus, BusType};
35
36/// Which of the Jacobian's eight distinct formulas an [`Entry`] evaluates.
37///
38/// ```text
39/// J = [ H N ] H = dP/d_ang, N = dP/d_vmag
40/// [ M L ] M = dQ/d_ang, L = dQ/d_vmag
41/// ```
42///
43/// Diagonal and off-diagonal cases are separate variants because they read
44/// different inputs, not merely different indices: the diagonal terms depend
45/// on `p_calc`/`q_calc` at the bus, the off-diagonal ones on the angle
46/// difference to a neighbor.
47#[derive(Clone, Copy, Debug, PartialEq, Eq)]
48#[repr(u8)]
49pub enum EntryKind {
50 Hii = 0,
51 Nii = 1,
52 Hik = 2,
53 Nik = 3,
54 Mii = 4,
55 Lii = 5,
56 Mik = 6,
57 Lik = 7,
58}
59
60/// One Jacobian nonzero's recipe. Deliberately flat and `Copy` — this is the
61/// record a GPU kernel would read one-per-thread.
62#[derive(Clone, Copy, Debug)]
63pub struct Entry {
64 pub kind: EntryKind,
65 /// Row bus (physical index).
66 pub i: u32,
67 /// Column bus (physical index). Equals `i` for the diagonal kinds.
68 pub k: u32,
69 /// `Y[i][k]`, captured at analyze time. The Y-bus is fixed for the
70 /// lifetime of a topology, so carrying the admittance here turns what
71 /// would be a pointer-chase through `YBusSparse::row` into a value the
72 /// entry already owns.
73 pub y: Complex<f64>,
74}
75
76/// A topology's Jacobian sparsity pattern plus the per-entry recipes needed
77/// to refill its values. Build once per topology via
78/// [`analyze`](Self::analyze); call [`fill`](Self::fill) every iteration.
79///
80/// `rows`/`cols`/`entries` are parallel arrays of the same length, in the
81/// exact emission order `solver::build_jacobian_triplets` used, so a cached
82/// symbolic factorization keyed on that order stays valid — until
83/// [`permute`](Self::permute) puts them in a sparse LU's own storage order.
84pub struct JacobianPattern {
85 pub n_unknowns: usize,
86 rows: Vec<u32>,
87 cols: Vec<u32>,
88 entries: Vec<Entry>,
89 /// Where each entry's value is in the array [`fill`](Self::fill)
90 /// computes first, parallel to `entries`: the fill itself is then one
91 /// gather per entry, with no branch on its kind.
92 src: Vec<u32>,
93 /// Row buses in non-slack order with their `Y[i][i]` (zero if the Y-bus
94 /// has no diagonal there); bus `d` owns slots `4d..4d + 4` (H, N, M, L).
95 diag: Vec<(u32, Complex<f64>)>,
96 /// Off-diagonal `(i, k)` pairs with `i` a row bus, in Y-bus order; pair
97 /// `p` owns slots `4 · diag.len() + 4p ..` (H, N, M, L).
98 pairs: Vec<(u32, u32, Complex<f64>)>,
99}
100
101impl JacobianPattern {
102 /// Derives the pattern from a topology and a bus-type assignment.
103 ///
104 /// Must be recomputed if either changes — a different set of branches, or
105 /// any bus switching `PV`↔`PQ` (which changes `n_unknowns` itself). This
106 /// is the same validity condition `solver::PersistentSolver` documents
107 /// for its cached factorization, and the two are reset together.
108 pub fn analyze(buses: &[Bus], ybus: &YBusSparse) -> Self {
109 let non_slack_idx: Vec<usize> = buses
110 .iter()
111 .filter(|b| !matches!(b.bus_type, BusType::Slack))
112 .map(|b| b.idx)
113 .collect();
114 let pq_idx: Vec<usize> = buses
115 .iter()
116 .filter(|b| matches!(b.bus_type, BusType::PQ))
117 .map(|b| b.idx)
118 .collect();
119
120 let n_angle = non_slack_idx.len();
121 let n_unknowns = n_angle + pq_idx.len();
122
123 let mut non_slack_pos: Vec<Option<usize>> = vec![None; buses.len()];
124 for (pos, &i) in non_slack_idx.iter().enumerate() {
125 non_slack_pos[i] = Some(pos);
126 }
127 let mut pq_pos: Vec<Option<usize>> = vec![None; buses.len()];
128 for (pos, &i) in pq_idx.iter().enumerate() {
129 pq_pos[i] = Some(pos);
130 }
131
132 let capacity: usize = non_slack_idx
133 .iter()
134 .map(|&i| ybus.row(i).len())
135 .sum::<usize>()
136 * 2
137 + pq_idx.iter().map(|&i| ybus.row(i).len()).sum::<usize>() * 2;
138 let mut rows = Vec::with_capacity(capacity);
139 let mut cols = Vec::with_capacity(capacity);
140 let mut entries = Vec::with_capacity(capacity);
141 let mut src = Vec::with_capacity(capacity);
142 // Slots: four per non-slack bus first (its diagonal H, N, M, L), then
143 // four per off-diagonal pair. `slot` is the entry's offset within its
144 // four, the kind's position in H, N, M, L.
145 let pair_base = 4 * n_angle;
146 let mut push = |row: usize,
147 col: usize,
148 kind: EntryKind,
149 i: usize,
150 k: usize,
151 y: Complex<f64>,
152 slot: usize| {
153 rows.push(row as u32);
154 cols.push(col as u32);
155 entries.push(Entry {
156 kind,
157 i: i as u32,
158 k: k as u32,
159 y,
160 });
161 src.push(slot as u32);
162 };
163 let has_column = |k: usize| non_slack_pos[k].is_some() || pq_pos[k].is_some();
164
165 // H and N blocks: rows = non_slack_idx (P-mismatch equations). Every
166 // pair is met here first and numbered; a PQ row's M and L walk the
167 // same neighbors in the same order below.
168 let mut diag: Vec<(u32, Complex<f64>)> = non_slack_idx
169 .iter()
170 .map(|&i| (i as u32, Complex::new(0.0, 0.0)))
171 .collect();
172 let mut pairs: Vec<(u32, u32, Complex<f64>)> = Vec::new();
173 let mut first_pair = vec![0usize; buses.len()];
174 for (row_idx, &i) in non_slack_idx.iter().enumerate() {
175 first_pair[i] = pairs.len();
176 let d = 4 * row_idx;
177 for &(k, y_ik) in ybus.row(i) {
178 if k == i {
179 diag[row_idx].1 = y_ik;
180 push(row_idx, row_idx, EntryKind::Hii, i, i, y_ik, d);
181 if let Some(col_idx) = pq_pos[i] {
182 push(
183 row_idx,
184 n_angle + col_idx,
185 EntryKind::Nii,
186 i,
187 i,
188 y_ik,
189 d + 1,
190 );
191 }
192 continue;
193 }
194 if !has_column(k) {
195 continue;
196 }
197 let at = pair_base + 4 * pairs.len();
198 pairs.push((i as u32, k as u32, y_ik));
199 if let Some(col_idx) = non_slack_pos[k] {
200 push(row_idx, col_idx, EntryKind::Hik, i, k, y_ik, at);
201 }
202 if let Some(col_idx) = pq_pos[k] {
203 push(
204 row_idx,
205 n_angle + col_idx,
206 EntryKind::Nik,
207 i,
208 k,
209 y_ik,
210 at + 1,
211 );
212 }
213 }
214 }
215
216 // M and L blocks: rows = pq_idx (Q-mismatch equations).
217 for (row_idx, &i) in pq_idx.iter().enumerate() {
218 let d = 4 * non_slack_pos[i].expect("a PQ bus is always non-slack");
219 let mut pair = first_pair[i];
220 for &(k, y_ik) in ybus.row(i) {
221 if k == i {
222 // Column is i's position among *all* non-slack buses, not
223 // just PQ ones — these differ once PV buses exist.
224 let col_idx = non_slack_pos[i].expect("a PQ bus is always non-slack");
225 push(
226 n_angle + row_idx,
227 col_idx,
228 EntryKind::Mii,
229 i,
230 i,
231 y_ik,
232 d + 2,
233 );
234 push(
235 n_angle + row_idx,
236 n_angle + row_idx,
237 EntryKind::Lii,
238 i,
239 i,
240 y_ik,
241 d + 3,
242 );
243 continue;
244 }
245 if !has_column(k) {
246 continue;
247 }
248 debug_assert_eq!((pairs[pair].0, pairs[pair].1), (i as u32, k as u32));
249 let at = pair_base + 4 * pair;
250 if let Some(col_idx) = non_slack_pos[k] {
251 push(
252 n_angle + row_idx,
253 col_idx,
254 EntryKind::Mik,
255 i,
256 k,
257 y_ik,
258 at + 2,
259 );
260 }
261 if let Some(col_idx) = pq_pos[k] {
262 push(
263 n_angle + row_idx,
264 n_angle + col_idx,
265 EntryKind::Lik,
266 i,
267 k,
268 y_ik,
269 at + 3,
270 );
271 }
272 pair += 1;
273 }
274 }
275
276 Self {
277 n_unknowns,
278 rows,
279 cols,
280 entries,
281 src,
282 diag,
283 pairs,
284 }
285 }
286
287 /// Reorders the entries so that entry `k` is the one that was at
288 /// `order[k]`: from then on [`fill`](Self::fill) writes, and
289 /// [`to_triplets`](Self::to_triplets) lists, in that order. What
290 /// `lu::SparseLu::adopt_storage_order` returns, so the fill writes
291 /// straight into the factorization's own layout.
292 ///
293 /// # Panics
294 ///
295 /// If `order` is not a permutation of `0..len()`.
296 pub fn permute(&mut self, order: &[usize]) {
297 assert_eq!(
298 order.len(),
299 self.entries.len(),
300 "order must cover every entry"
301 );
302 let mut seen = vec![false; order.len()];
303 for &i in order {
304 assert!(
305 !std::mem::replace(&mut seen[i], true),
306 "order repeats entry {i}"
307 );
308 }
309 self.rows = order.iter().map(|&i| self.rows[i]).collect();
310 self.cols = order.iter().map(|&i| self.cols[i]).collect();
311 self.entries = order.iter().map(|&i| self.entries[i]).collect();
312 self.src = order.iter().map(|&i| self.src[i]).collect();
313 }
314
315 /// Number of Jacobian nonzeros — the length of `values` that
316 /// [`fill`](Self::fill) expects.
317 pub fn len(&self) -> usize {
318 self.entries.len()
319 }
320
321 pub fn is_empty(&self) -> bool {
322 self.entries.is_empty()
323 }
324
325 pub fn rows(&self) -> &[u32] {
326 &self.rows
327 }
328
329 pub fn cols(&self) -> &[u32] {
330 &self.cols
331 }
332
333 pub fn entries(&self) -> &[Entry] {
334 &self.entries
335 }
336
337 /// Writes this iteration's Jacobian values into `values`, which is
338 /// resized to [`len`](Self::len) and otherwise fully overwritten.
339 ///
340 /// Every write is independent and lands at a fixed offset — no
341 /// allocation, no index arithmetic, no branching beyond the eight-way
342 /// `kind` dispatch. That independence is what makes this portable to one
343 /// thread per entry.
344 pub fn fill(&self, buses: &[Bus], p_calc: &[f64], q_calc: &[f64], values: &mut Vec<f64>) {
345 values.clear();
346 self.fill_into(buses, p_calc, q_calc, values);
347 }
348
349 /// [`fill`](Self::fill) without the clear — *appends* this scenario's
350 /// values to `values`. What lets `bde::BlockDiagonal` concatenate B
351 /// scenarios' blocks into one flat array for a block-diagonal solve.
352 pub fn fill_into(&self, buses: &[Bus], p_calc: &[f64], q_calc: &[f64], values: &mut Vec<f64>) {
353 self.fill_from(&BusVoltages::of(buses), p_calc, q_calc, values);
354 }
355
356 /// [`fill`](Self::fill) from voltages already taken, which the Newton
357 /// loop shares with the power injections.
358 pub fn fill_with(
359 &self,
360 v: &BusVoltages,
361 p_calc: &[f64],
362 q_calc: &[f64],
363 values: &mut Vec<f64>,
364 ) {
365 values.clear();
366 self.fill_from(v, p_calc, q_calc, values);
367 }
368
369 /// Appends, as [`fill_into`](Self::fill_into).
370 ///
371 /// In two passes. The first computes every value the Jacobian can hold,
372 /// in the order the Y-bus stores them: for each non-slack bus its
373 /// diagonal H, N, M and L, and for each off-diagonal pair its four,
374 /// which share their sine and cosine — formed from the two buses' own
375 /// rather than by calling the trigonometric functions per entry, which
376 /// made `sincos` the largest cost of an iteration. The second copies
377 /// them into the entries through the precomputed `src`, in whatever
378 /// order the entries are in.
379 ///
380 /// The point of the split is that neither pass branches on an entry's
381 /// kind. Once the entries are in a factorization's column order
382 /// ([`permute`](Self::permute)) the kinds interleave unpredictably, and
383 /// a per-entry `match` cost more than the arithmetic: this fill is
384 /// several times faster than one, on the same values.
385 fn fill_from(&self, v: &BusVoltages, p_calc: &[f64], q_calc: &[f64], values: &mut Vec<f64>) {
386 let vm = &v.vm;
387 let mut slots = Vec::with_capacity(4 * (self.diag.len() + self.pairs.len()));
388 for &(i, y) in &self.diag {
389 let i = i as usize;
390 let (vm_i, g, b) = (vm[i], y.re, y.im);
391 // `powi(2)` rather than `vm_i * vm_i` to stay textually
392 // identical to `build_jacobian_triplets`, so the diagonal agrees
393 // with it to the last bit.
394 slots.extend([
395 // H_ii = -Q_i - V_i^2 * B_ii
396 -q_calc[i] - vm_i.powi(2) * b,
397 // N_ii = P_i/V_i + V_i * G_ii
398 p_calc[i] / vm_i + vm_i * g,
399 // M_ii = P_i - V_i^2 * G_ii
400 p_calc[i] - vm_i.powi(2) * g,
401 // L_ii = Q_i/V_i - V_i * B_ii
402 q_calc[i] / vm_i - vm_i * b,
403 ]);
404 }
405 for &(i, k, y) in &self.pairs {
406 let (i, k) = (i as usize, k as usize);
407 let (vm_i, vm_k, g, b) = (vm[i], vm[k], y.re, y.im);
408 let (s_i, c_i, s_k, c_k) = (v.sin[i], v.cos[i], v.sin[k], v.cos[k]);
409 let sin = s_i * c_k - c_i * s_k;
410 let cos = c_i * c_k + s_i * s_k;
411 slots.extend([
412 // H_ik = V_i * V_k * (G_ik * sin - B_ik * cos)
413 vm_i * vm_k * (g * sin - b * cos),
414 // N_ik = V_i * (G_ik * cos + B_ik * sin)
415 vm_i * (g * cos + b * sin),
416 // M_ik = -V_i * V_k * (G_ik * cos + B_ik * sin)
417 -vm_i * vm_k * (g * cos + b * sin),
418 // L_ik = V_i * (G_ik * sin - B_ik * cos)
419 vm_i * (g * sin - b * cos),
420 ]);
421 }
422 values.extend(self.src.iter().map(|&s| slots[s as usize]));
423 }
424
425 /// Appends an identity block in this pattern's own layout: `1.0` at every
426 /// stored diagonal position, `0.0` everywhere else.
427 ///
428 /// This is how `bde::BlockDiagonal` masks a converged or diverged
429 /// scenario out of a batch. Dropping its block outright would change the
430 /// stacked matrix's sparsity pattern and invalidate the cached symbolic
431 /// factorization — the whole reason batching is cheap. Writing an
432 /// identity into the *same* stored positions keeps the pattern
433 /// bit-identical while making that scenario's update exactly zero (with a
434 /// zero right-hand side), and identity is perfectly conditioned so it
435 /// cannot degrade the factorization.
436 ///
437 /// Every diagonal position is structurally present: `Hii` supplies
438 /// `(r, r)` for every angle row and `Lii` supplies it for every
439 /// magnitude row, so no fill-in is required to write this.
440 pub fn fill_identity_into(&self, values: &mut Vec<f64>) {
441 values.reserve(self.entries.len());
442 for k in 0..self.entries.len() {
443 values.push(if self.rows[k] == self.cols[k] {
444 1.0
445 } else {
446 0.0
447 });
448 }
449 }
450
451 /// Convenience for callers that still want `(row, col, value)` triplets,
452 /// which is the shape every `solver::LinearSolver` backend consumes.
453 pub fn to_triplets(&self, values: &[f64]) -> Vec<(usize, usize, f64)> {
454 self.rows
455 .iter()
456 .zip(&self.cols)
457 .zip(values)
458 .map(|((&r, &c), &v)| (r as usize, c as usize, v))
459 .collect()
460 }
461}
462
463#[cfg(test)]
464mod tests {
465 use super::*;
466 use crate::network::{build_ybus, power_injections};
467 use crate::types::Line;
468
469 /// Builds a small mixed-type network: bus 0 Slack, bus 1 PV, buses 2-4 PQ.
470 /// The PV bus matters — it is what makes a PQ row's `M_ii` column index
471 /// differ from its row index, the one place the offset bookkeeping is
472 /// genuinely easy to get wrong.
473 fn mixed_network() -> (Vec<Bus>, YBusSparse) {
474 let bus = |idx, bus_type, vm, p, q| Bus {
475 idx,
476 bus_type,
477 voltage_mag: vm,
478 voltage_ang: 0.0,
479 p_spec: p,
480 q_spec: q,
481 q_min: -9.0,
482 q_max: 9.0,
483 u_rated: 0.0,
484 zip_terms: Vec::new(),
485 };
486 let buses = vec![
487 bus(0, BusType::Slack, 1.06, 0.0, 0.0),
488 bus(1, BusType::PV, 1.04, 0.4, 0.0),
489 bus(2, BusType::PQ, 1.0, -0.6, -0.2),
490 bus(3, BusType::PQ, 1.0, -0.3, -0.1),
491 bus(4, BusType::PQ, 1.0, -0.5, -0.15),
492 ];
493 let line = |from, to, r, x, b| Line {
494 from,
495 to,
496 r,
497 x,
498 b_shunt: b,
499 g_shunt: 0.0,
500 };
501 let lines = vec![
502 line(0, 1, 0.02, 0.06, 0.03),
503 line(0, 2, 0.08, 0.24, 0.025),
504 line(1, 2, 0.06, 0.18, 0.02),
505 line(1, 3, 0.06, 0.18, 0.02),
506 line(2, 4, 0.04, 0.12, 0.015),
507 line(3, 4, 0.01, 0.03, 0.01),
508 ];
509 let ybus = build_ybus(buses.len(), &lines, &[]).finish();
510 (buses, ybus)
511 }
512
513 /// Values agree with the reference to rounding: the fill forms the
514 /// angle difference's sine and cosine from the buses' own, which rounds
515 /// differently from evaluating them at the difference.
516 fn assert_same_value(got: f64, expected: f64, what: &str) {
517 assert!(
518 (got - expected).abs() <= 1e-13 * expected.abs().max(1.0),
519 "{what}: {got} vs {expected}"
520 );
521 }
522
523 /// The precomputed-offset assembler must reproduce the reference
524 /// implementation's `(row, col, value)` sequence — same order and same
525 /// indices exactly, values to rounding (see `assert_same_value`).
526 #[test]
527 fn pattern_matches_reference() {
528 let (mut buses, ybus) = mixed_network();
529 let pattern = JacobianPattern::analyze(&buses, &ybus);
530 let mut values = Vec::new();
531
532 // Several distinct operating points: a flat start, then perturbed
533 // angles and magnitudes, so agreement can't be an artifact of every
534 // sin/cos being evaluated at zero.
535 for step in 0..4 {
536 for (j, b) in buses.iter_mut().enumerate() {
537 b.voltage_ang = -0.01 * (step * (j + 1)) as f64;
538 if b.bus_type == BusType::PQ {
539 b.voltage_mag = 1.0 - 0.013 * step as f64 * (j + 1) as f64;
540 }
541 }
542 let (p_calc, q_calc) = power_injections(&buses, &ybus);
543
544 let expected = jacobian_triplets_reference(&buses, &ybus, &p_calc, &q_calc);
545 pattern.fill(&buses, &p_calc, &q_calc, &mut values);
546 let got = pattern.to_triplets(&values);
547
548 assert_eq!(got.len(), expected.len(), "nonzero count at step {step}");
549 for (idx, (g, e)) in got.iter().zip(&expected).enumerate() {
550 assert_eq!(
551 (g.0, g.1),
552 (e.0, e.1),
553 "entry {idx} position at step {step}"
554 );
555 assert_same_value(g.2, e.2, &format!("entry {idx} value at step {step}"));
556 }
557 }
558 }
559
560 /// The same gate on the repo's committed 3-bus fixture, which has no PV
561 /// bus — exercises the all-PQ path where row and column indices coincide.
562 #[test]
563 fn pattern_matches_reference_on_network_json() {
564 let mut path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
565 // The fixture is the workspace root's, two levels above this crate.
566 path.push("../../tests/data/network.json");
567 let raw = std::fs::read_to_string(path).expect("read network.json");
568 let network: crate::json::NetworkData = serde_json::from_str(&raw).expect("parse");
569 let buses = network.buses;
570 let ybus = build_ybus(buses.len(), &network.lines, &[]).finish();
571
572 let (p_calc, q_calc) = power_injections(&buses, &ybus);
573 let expected = jacobian_triplets_reference(&buses, &ybus, &p_calc, &q_calc);
574
575 let pattern = JacobianPattern::analyze(&buses, &ybus);
576 let mut values = Vec::new();
577 pattern.fill(&buses, &p_calc, &q_calc, &mut values);
578 let got = pattern.to_triplets(&values);
579
580 assert_eq!(got.len(), expected.len());
581 for (idx, (g, e)) in got.iter().zip(&expected).enumerate() {
582 assert_eq!((g.0, g.1), (e.0, e.1), "entry {idx} position");
583 assert_same_value(g.2, e.2, &format!("entry {idx} value"));
584 }
585 }
586
587 /// `permute` reorders what `fill` writes and `to_triplets` lists, and
588 /// nothing else.
589 #[test]
590 fn permute_reorders_the_entries() {
591 let (buses, ybus) = mixed_network();
592 let (p_calc, q_calc) = power_injections(&buses, &ybus);
593 let mut pattern = JacobianPattern::analyze(&buses, &ybus);
594 let mut values = Vec::new();
595 pattern.fill(&buses, &p_calc, &q_calc, &mut values);
596 let before = pattern.to_triplets(&values);
597
598 let order: Vec<usize> = (0..pattern.len()).rev().collect();
599 pattern.permute(&order);
600 pattern.fill(&buses, &p_calc, &q_calc, &mut values);
601 let after = pattern.to_triplets(&values);
602 let expected: Vec<_> = order.iter().map(|&i| before[i]).collect();
603 assert_eq!(after, expected);
604 }
605
606 /// `fill` must fully overwrite its buffer, so reusing one across
607 /// iterations (the entire point) can never leak stale values.
608 #[test]
609 fn fill_overwrites_a_reused_buffer() {
610 let (buses, ybus) = mixed_network();
611 let pattern = JacobianPattern::analyze(&buses, &ybus);
612 let (p_calc, q_calc) = power_injections(&buses, &ybus);
613
614 let mut fresh = Vec::new();
615 pattern.fill(&buses, &p_calc, &q_calc, &mut fresh);
616
617 let mut dirty = vec![f64::NAN; pattern.len() * 3];
618 pattern.fill(&buses, &p_calc, &q_calc, &mut dirty);
619
620 assert_eq!(dirty.len(), pattern.len());
621 assert_eq!(dirty, fresh);
622 }
623
624 /// Derives the index bookkeeping `build_jacobian_triplets` needs and calls
625 /// it, so a caller holding only `(buses, ybus, p_calc, q_calc)` can obtain
626 /// the Jacobian triplets directly.
627 ///
628 /// Exists as the **reference oracle** for `jacobian::JacobianPattern`, whose
629 /// precomputed-offset assembler must agree with this to rounding (see
630 /// `jacobian`'s own tests). Kept deliberately naive — it re-derives
631 /// everything from scratch on every call, which is exactly the cost
632 /// `JacobianPattern` was written to remove, and exactly what makes it a
633 /// trustworthy oracle.
634 fn jacobian_triplets_reference(
635 buses: &[Bus],
636 ybus: &YBusSparse,
637 p_calc: &[f64],
638 q_calc: &[f64],
639 ) -> Vec<(usize, usize, f64)> {
640 let non_slack_idx: Vec<usize> = buses
641 .iter()
642 .filter(|b| !matches!(b.bus_type, BusType::Slack))
643 .map(|b| b.idx)
644 .collect();
645 let pq_idx: Vec<usize> = buses
646 .iter()
647 .filter(|b| matches!(b.bus_type, BusType::PQ))
648 .map(|b| b.idx)
649 .collect();
650
651 let n_angle = non_slack_idx.len();
652 let mut non_slack_pos: Vec<Option<usize>> = vec![None; buses.len()];
653 for (pos, &i) in non_slack_idx.iter().enumerate() {
654 non_slack_pos[i] = Some(pos);
655 }
656 let mut pq_pos: Vec<Option<usize>> = vec![None; buses.len()];
657 for (pos, &i) in pq_idx.iter().enumerate() {
658 pq_pos[i] = Some(pos);
659 }
660 let cap = jacobian_triplet_capacity(ybus, &non_slack_idx, &pq_idx);
661
662 build_jacobian_triplets(
663 buses,
664 ybus,
665 &non_slack_idx,
666 &pq_idx,
667 &non_slack_pos,
668 &pq_pos,
669 n_angle,
670 p_calc,
671 q_calc,
672 cap,
673 )
674 }
675
676 /// Upper bound on `build_jacobian_triplets`' output length for a given
677 /// topology — computed once per solve (not per iteration) and reused via
678 /// `Vec::with_capacity`, since the sparsity pattern, and hence this bound,
679 /// never changes across a solve's iterations while `build_jacobian_triplets`
680 /// itself starts a fresh `Vec` from scratch every iteration. Each Y-bus
681 /// neighbor contributes at most 2 triplets to the H/N block (one per
682 /// non-slack row) and at most 2 to the M/L block (one per PQ row).
683 fn jacobian_triplet_capacity(
684 ybus: &YBusSparse,
685 non_slack_idx: &[usize],
686 pq_idx: &[usize],
687 ) -> usize {
688 let non_slack_degree: usize = non_slack_idx.iter().map(|&i| ybus.row(i).len()).sum();
689 let pq_degree: usize = pq_idx.iter().map(|&i| ybus.row(i).len()).sum();
690 2 * non_slack_degree + 2 * pq_degree
691 }
692
693 /// The **reference implementation** of Jacobian assembly, retained as the
694 /// oracle `jacobian::JacobianPattern` is checked against. The
695 /// Newton loop itself no longer calls this — it refills values at
696 /// precomputed offsets instead — so it lives here, beside the tests it serves.
697 /// Kept deliberately straightforward: its value is being obviously correct,
698 /// not being fast.
699 ///
700 /// Assembles the Newton-Raphson Jacobian's nonzero entries as `(row, col,
701 /// value)` triplets, walking only each unknown bus's actual Y-bus neighbors
702 /// (`ybus.row(i)`) instead of the full cross product of unknown-bus indices.
703 /// Every topological neighbor is always included regardless of its computed
704 /// value (even if it happens to evaluate to exactly zero at some iteration),
705 /// so the resulting sparsity *pattern* is identical across calls — required
706 /// for `RealSparseSystem`'s cached symbolic factorization to stay valid.
707 ///
708 /// Block structure (H/N/M/L), matching the original dense Jacobian:
709 /// ```text
710 /// J = [ H N ] H = dP/d_ang, N = dP/d_vmag
711 /// [ M L ] M = dQ/d_ang, L = dQ/d_vmag
712 /// ```
713 #[allow(clippy::too_many_arguments)]
714 fn build_jacobian_triplets(
715 buses: &[Bus],
716 ybus: &YBusSparse,
717 non_slack_idx: &[usize],
718 pq_idx: &[usize],
719 non_slack_pos: &[Option<usize>],
720 pq_pos: &[Option<usize>],
721 n_angle: usize,
722 p_calc: &[f64],
723 q_calc: &[f64],
724 triplet_capacity: usize,
725 ) -> Vec<(usize, usize, f64)> {
726 let vm: Vec<f64> = buses.iter().map(|b| b.voltage_mag).collect();
727 let va: Vec<f64> = buses.iter().map(|b| b.voltage_ang).collect();
728 let mut triplets = Vec::with_capacity(triplet_capacity);
729
730 // H and N blocks: rows = non_slack_idx (P-mismatch equations).
731 for (row_idx, &i) in non_slack_idx.iter().enumerate() {
732 for &(k, y_ik) in ybus.row(i) {
733 if k == i {
734 // H_ii = -Q_i - V_i^2 * B_ii
735 triplets.push((row_idx, row_idx, -q_calc[i] - vm[i].powi(2) * y_ik.im));
736 // N_ii = P_i/V_i + V_i * G_ii (only if i has a magnitude unknown)
737 if let Some(col_idx) = pq_pos[i] {
738 triplets.push((
739 row_idx,
740 n_angle + col_idx,
741 p_calc[i] / vm[i] + vm[i] * y_ik.re,
742 ));
743 }
744 continue;
745 }
746 let angle_ik = va[i] - va[k];
747 if let Some(col_idx) = non_slack_pos[k] {
748 // H_ik = V_i * V_k * (G_ik * sin(d_ik) - B_ik * cos(d_ik))
749 let h_ik =
750 vm[i] * vm[k] * (y_ik.re * angle_ik.sin() - y_ik.im * angle_ik.cos());
751 triplets.push((row_idx, col_idx, h_ik));
752 }
753 if let Some(col_idx) = pq_pos[k] {
754 // N_ik = V_i * (G_ik * cos(d_ik) + B_ik * sin(d_ik))
755 let n_ik = vm[i] * (y_ik.re * angle_ik.cos() + y_ik.im * angle_ik.sin());
756 triplets.push((row_idx, n_angle + col_idx, n_ik));
757 }
758 }
759 }
760
761 // M and L blocks: rows = pq_idx (Q-mismatch equations).
762 for (row_idx, &i) in pq_idx.iter().enumerate() {
763 for &(k, y_ik) in ybus.row(i) {
764 if k == i {
765 // M_ii = P_i - V_i^2 * G_ii; column is i's position among
766 // *all* non-slack buses, not just PQ ones (may differ from
767 // row_idx once PV buses exist).
768 let col_idx = non_slack_pos[i].expect("a PQ bus is always non-slack");
769 triplets.push((
770 n_angle + row_idx,
771 col_idx,
772 p_calc[i] - vm[i].powi(2) * y_ik.re,
773 ));
774 // L_ii = Q_i/V_i - V_i * B_ii
775 triplets.push((
776 n_angle + row_idx,
777 n_angle + row_idx,
778 q_calc[i] / vm[i] - vm[i] * y_ik.im,
779 ));
780 continue;
781 }
782 let angle_ik = va[i] - va[k];
783 if let Some(col_idx) = non_slack_pos[k] {
784 // M_ik = -V_i * V_k * (G_ik * cos(d_ik) + B_ik * sin(d_ik))
785 let m_ik =
786 -vm[i] * vm[k] * (y_ik.re * angle_ik.cos() + y_ik.im * angle_ik.sin());
787 triplets.push((n_angle + row_idx, col_idx, m_ik));
788 }
789 if let Some(col_idx) = pq_pos[k] {
790 // L_ik = V_i * (G_ik * sin(d_ik) - B_ik * cos(d_ik))
791 let l_ik = vm[i] * (y_ik.re * angle_ik.sin() - y_ik.im * angle_ik.cos());
792 triplets.push((n_angle + row_idx, n_angle + col_idx, l_ik));
793 }
794 }
795 }
796
797 triplets
798 }
799}