Skip to main content

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}