oxigrid 0.1.2

Pure Rust Energy Systems Simulation & Optimization Library
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
//! Y-bus (nodal admittance matrix) construction.
//!
//! Assembles the sparse complex admittance matrix from branch π-models and
//! bus shunt data.  The result is a CSC `CsMat<Complex64>` suitable for
//! power flow and state estimation solvers.
use crate::error::Result;
use crate::network::topology::PowerNetwork;
use num_complex::Complex64;
use sprs::{CsMat, TriMat};

/// Build the nodal admittance matrix (Y-bus) for `network`.
///
/// Each in-service branch contributes four entries using the π-model:
///
/// ```text
///   Y_ii += ys / |tap|² + jb/2
///   Y_jj += ys + jb/2
///   Y_ij += −ys / tap*
///   Y_ji += −ys / tap
/// ```
///
/// where `ys = 1/(r + jx)` and `tap = t·e^{jφ}` (1.0 for plain lines).
/// Bus shunt elements (`gs + jbs`) are added to the diagonal and scaled by
/// `1/base_mva`.
///
/// Returns a CSC sparse matrix of size `n × n`.
///
/// # Examples
///
/// ```rust
/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
/// use oxigrid::network::topology::PowerNetwork;
/// use oxigrid::network::bus::{Bus, BusType};
/// use oxigrid::network::branch::Branch;
/// use oxigrid::network::admittance::build_y_bus;
///
/// let mut net = PowerNetwork::new(100.0);
/// net.buses.push(Bus::new(1, BusType::Slack));
/// net.buses.push(Bus::new(2, BusType::PQ));
/// net.branches.push(Branch {
///     from_bus: 1, to_bus: 2,
///     r: 0.01, x: 0.1, b: 0.02,
///     rate_a: 100.0, rate_b: 100.0, rate_c: 100.0,
///     tap: 0.0, shift: 0.0, status: true,
/// });
///
/// let y_bus = build_y_bus(&net)?;
/// assert_eq!(y_bus.rows(), 2);
/// assert_eq!(y_bus.cols(), 2);
/// // Diagonal entries must be non-zero (series + shunt admittance)
/// assert!(y_bus.nnz() > 0);
/// # Ok(()) }
/// ```
pub fn build_y_bus(network: &PowerNetwork) -> Result<CsMat<Complex64>> {
    let n = network.bus_count();
    let mut ybus = TriMat::new((n, n));

    for branch in &network.branches {
        if !branch.status {
            continue;
        }

        let i = network.bus_index(branch.from_bus)?;
        let j = network.bus_index(branch.to_bus)?;

        let ys = Complex64::new(branch.r, branch.x).inv(); // series admittance
        let bc = Complex64::new(0.0, branch.b / 2.0); // line charging

        let tap = branch.tap_complex();
        let tap_conj = tap.conj();
        let tap_mag_sq = tap.norm_sqr();

        // Pi-model with tap
        // Y_ii += ys / |tap|^2 + bc
        // Y_jj += ys + bc
        // Y_ij += -ys / tap*
        // Y_ji += -ys / tap

        let yii = ys / tap_mag_sq + bc;
        let yjj = ys + bc;
        let yij = -ys / tap_conj;
        let yji = -ys / tap;

        ybus.add_triplet(i, i, yii);
        ybus.add_triplet(j, j, yjj);
        ybus.add_triplet(i, j, yij);
        ybus.add_triplet(j, i, yji);
    }

    // Add shunt elements from bus data
    for (i, bus) in network.buses.iter().enumerate() {
        if bus.gs != 0.0 || bus.bs != 0.0 {
            let y_shunt = Complex64::new(bus.gs, bus.bs) / network.base_mva;
            ybus.add_triplet(i, i, y_shunt);
        }
    }

    Ok(ybus.to_csc())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::network::branch::Branch;
    use crate::network::bus::{Bus, BusType};

    #[test]
    fn test_simple_2bus_ybus() {
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.01,
            x: 0.1,
            b: 0.02,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).unwrap();
        assert_eq!(ybus.rows(), 2);
        assert_eq!(ybus.cols(), 2);

        // Y_12 should be -ys = -(r - jx) / (r^2 + x^2)
        let ys = Complex64::new(0.01, 0.1).inv();
        let y12 = *ybus.get(0, 1).unwrap_or(&Complex64::new(0.0, 0.0));
        assert!((y12 + ys).norm() < 1e-10);
    }

    #[test]
    fn test_ybus_line_admittance() {
        // Single branch r=0.05, x=0.3, b=0.0; no line charging.
        // Off-diagonal Y_ij should equal -ys exactly.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.05,
            x: 0.3,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let ys = Complex64::new(0.05, 0.3).inv();
        let y01 = ybus.get(0, 1).copied().unwrap_or(Complex64::new(0.0, 0.0));
        // Y_ij = -ys (no tap, no charging)
        assert!((y01 + ys).norm() < 1e-10);
    }

    #[test]
    fn test_ybus_diagonal_includes_charging() {
        // Single branch r=0.01, x=0.1, b=0.02.
        // Diagonal Y_ii = ys + bc where bc = j*(b/2) = j*0.01.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.01,
            x: 0.1,
            b: 0.02,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let ys = Complex64::new(0.01, 0.1).inv();
        let bc = Complex64::new(0.0, 0.02 / 2.0); // j * 0.01
        let expected_diag = ys + bc;
        let y00 = ybus.get(0, 0).copied().unwrap_or(Complex64::new(0.0, 0.0));
        assert!(
            (y00.re - expected_diag.re).abs() < 1e-10,
            "diagonal real part mismatch: got {}, expected {}",
            y00.re,
            expected_diag.re
        );
        assert!(
            (y00.im - expected_diag.im).abs() < 1e-10,
            "diagonal imag part mismatch: got {}, expected {}",
            y00.im,
            expected_diag.im
        );
    }

    #[test]
    fn test_ybus_off_diagonal_is_negative_series() {
        // Same branch r=0.01, x=0.1, b=0.02; tap=0.0 (plain line → tap=1).
        // Off-diagonal Y_ij = -ys (charging does NOT appear off-diagonal).
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.01,
            x: 0.1,
            b: 0.02,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let ys = Complex64::new(0.01, 0.1).inv();
        let y01 = ybus.get(0, 1).copied().unwrap_or(Complex64::new(0.0, 0.0));
        assert!(
            (y01.re - (-ys.re)).abs() < 1e-10,
            "off-diagonal real mismatch: got {}, expected {}",
            y01.re,
            -ys.re
        );
        assert!(
            (y01.im - (-ys.im)).abs() < 1e-10,
            "off-diagonal imag mismatch: got {}, expected {}",
            y01.im,
            -ys.im
        );
    }

    #[test]
    fn test_ybus_symmetry_plain_line() {
        // For a plain line (tap=0.0 → effective 1.0), Y_ij == Y_ji.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.02,
            x: 0.15,
            b: 0.04,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let y01 = ybus.get(0, 1).copied().unwrap_or(Complex64::new(0.0, 0.0));
        let y10 = ybus.get(1, 0).copied().unwrap_or(Complex64::new(0.0, 0.0));
        assert!(
            (y01 - y10).norm() < 1e-10,
            "symmetry violated: Y_01={:?}, Y_10={:?}",
            y01,
            y10
        );
    }

    #[test]
    fn test_ybus_open_circuit_branch_zero() {
        // A branch with status=false contributes nothing; nnz should be 0.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.01,
            x: 0.1,
            b: 0.02,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: false,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        assert_eq!(
            ybus.nnz(),
            0,
            "open-circuit branch should contribute zero entries, got nnz={}",
            ybus.nnz()
        );
    }

    #[test]
    fn test_ybus_three_bus_diagonal_sum() {
        // 3-bus network: bus 0 connected to both bus 1 and bus 2.
        // Y_00 accumulates two branch contributions; Y_11 and Y_22 only one each.
        // |Y_00| must be greater than |Y_11| and |Y_22|.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.buses.push(Bus::new(3, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.02,
            x: 0.2,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 3,
            r: 0.03,
            x: 0.3,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let y00 = ybus.get(0, 0).copied().unwrap_or(Complex64::new(0.0, 0.0));
        let y11 = ybus.get(1, 1).copied().unwrap_or(Complex64::new(0.0, 0.0));
        let y22 = ybus.get(2, 2).copied().unwrap_or(Complex64::new(0.0, 0.0));
        assert!(
            y00.norm() > y11.norm(),
            "|Y_00|={} should exceed |Y_11|={}",
            y00.norm(),
            y11.norm()
        );
        assert!(
            y00.norm() > y22.norm(),
            "|Y_00|={} should exceed |Y_22|={}",
            y00.norm(),
            y22.norm()
        );
    }

    #[test]
    fn test_ybus_bus_shunt_element() {
        // Bus 0 has gs=0.01, bs=0.05; these should be added to Y[0,0] as gs+j*bs scaled by 1/base_mva.
        let mut net = PowerNetwork::new(100.0);
        let mut bus0 = Bus::new(1, BusType::Slack);
        bus0.gs = 0.01;
        bus0.bs = 0.05;
        net.buses.push(bus0);
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.02,
            x: 0.2,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let ys = Complex64::new(0.02, 0.2).inv();
        // Without shunt, Y[0,0] = ys. With shunt it gets extra (gs+j*bs)/base_mva.
        let shunt = Complex64::new(0.01, 0.05) / 100.0;
        let expected_diag = ys + shunt;
        let y00 = ybus.get(0, 0).copied().unwrap_or(Complex64::new(0.0, 0.0));
        assert!(
            (y00.re - expected_diag.re).abs() < 1e-12,
            "shunt real part mismatch: got {}, expected {}",
            y00.re,
            expected_diag.re
        );
        assert!(
            (y00.im - expected_diag.im).abs() < 1e-12,
            "shunt imag part mismatch: got {}, expected {}",
            y00.im,
            expected_diag.im
        );
    }

    #[test]
    fn test_ybus_transformer_asymmetric() {
        // For a transformer with tap != 1 (and tap != 0), the π-model produces
        // Y_ij = -ys/tap*  and  Y_ji = -ys/tap  which differ when tap is real != 1.
        // Also diagonal entries differ: Y_ii = ys/|tap|^2 + bc  vs  Y_jj = ys + bc.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        // tap = 1.05 (real, no phase shift) → effective tap = 1.05 + j*0
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.01,
            x: 0.1,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 1.05,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let y01 = ybus.get(0, 1).copied().unwrap_or(Complex64::new(0.0, 0.0));
        let y10 = ybus.get(1, 0).copied().unwrap_or(Complex64::new(0.0, 0.0));
        // For real tap t > 0: Y_ij = -ys/t  and  Y_ji = -ys/t  (conj of real = itself)
        // so Y_ij == Y_ji in the real-tap case — but the diagonal entries differ.
        let y00 = ybus.get(0, 0).copied().unwrap_or(Complex64::new(0.0, 0.0));
        let y11 = ybus.get(1, 1).copied().unwrap_or(Complex64::new(0.0, 0.0));
        // Y_00 = ys / tap^2 (b=0 so no charging); Y_11 = ys
        // Since tap = 1.05, Y_00.re < Y_11.re (dividing by 1.05^2 > 1)
        assert!(
            y00.re < y11.re,
            "transformer diagonal asymmetry: Y_00.re={} should be < Y_11.re={}",
            y00.re,
            y11.re
        );
        // Off-diagonal magnitudes must equal (symmetric off-diagonal for real tap)
        assert!(
            (y01.norm() - y10.norm()).abs() < 1e-12,
            "off-diagonal magnitude mismatch for real tap: |Y_01|={}, |Y_10|={}",
            y01.norm(),
            y10.norm()
        );
    }

    #[test]
    fn test_ybus_parallel_branches_additive() {
        // Two parallel branches between bus 0 and bus 1 with the same parameters.
        // The off-diagonal |Y_01| should be twice that of a single branch.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        let branch = Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.03,
            x: 0.3,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        };
        net.branches.push(branch.clone());
        net.branches.push(branch);

        // Single-branch reference
        let mut net_single = PowerNetwork::new(100.0);
        net_single.buses.push(Bus::new(1, BusType::Slack));
        net_single.buses.push(Bus::new(2, BusType::PQ));
        net_single.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.03,
            x: 0.3,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus_double = build_y_bus(&net).expect("build_y_bus parallel failed");
        let ybus_single = build_y_bus(&net_single).expect("build_y_bus single failed");

        let y01_double = ybus_double
            .get(0, 1)
            .copied()
            .unwrap_or(Complex64::new(0.0, 0.0));
        let y01_single = ybus_single
            .get(0, 1)
            .copied()
            .unwrap_or(Complex64::new(0.0, 0.0));
        assert!(
            (y01_double.norm() - 2.0 * y01_single.norm()).abs() < 1e-12,
            "parallel branches: |Y_01| double={} should be 2x single={}",
            y01_double.norm(),
            y01_single.norm()
        );
    }

    #[test]
    fn test_ybus_kcl_row_sum_no_shunt() {
        // For a plain line with no shunt elements, KCL requires that each row of
        // Y-bus sums to approximately 0 (only line-charging b/2 contributes; with
        // b=0 the sum is exactly 0).
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.04,
            x: 0.4,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        for row in 0..2 {
            let mut row_sum = Complex64::new(0.0, 0.0);
            for col in 0..2 {
                row_sum += ybus
                    .get(row, col)
                    .copied()
                    .unwrap_or(Complex64::new(0.0, 0.0));
            }
            assert!(
                row_sum.norm() < 1e-12,
                "KCL row sum for row {} = {:?} (expected ~0)",
                row,
                row_sum
            );
        }
    }

    #[test]
    fn test_ybus_phase_shift_complex_off_diagonal() {
        // A branch with shift != 0 produces complex tap = t * e^{j*phi}.
        // The off-diagonal Y_ij = -ys/tap*  and  Y_ji = -ys/tap differ in phase.
        let mut net = PowerNetwork::new(100.0);
        net.buses.push(Bus::new(1, BusType::Slack));
        net.buses.push(Bus::new(2, BusType::PQ));
        let shift_deg: f64 = 5.0;
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.01,
            x: 0.1,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 1.0,
            shift: shift_deg,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus failed");
        let y01 = ybus.get(0, 1).copied().unwrap_or(Complex64::new(0.0, 0.0));
        let y10 = ybus.get(1, 0).copied().unwrap_or(Complex64::new(0.0, 0.0));
        // With non-zero phase shift, |Y_ij| == |Y_ji| (both = |ys|/|tap|), but the
        // complex values themselves differ because dividing by tap* vs tap applies
        // opposite rotations of ±φ.
        assert!(
            (y01.norm() - y10.norm()).abs() < 1e-12,
            "phase-shift off-diagonals should have equal magnitude: |Y_01|={}, |Y_10|={}",
            y01.norm(),
            y10.norm()
        );
        // The two entries must NOT be equal (phase shift breaks symmetry).
        assert!(
            (y01 - y10).norm() > 1e-10,
            "phase-shift off-diagonals should differ: Y_01={:?}, Y_10={:?}",
            y01,
            y10
        );
    }

    #[test]
    fn test_ybus_4bus_ring_nnz() {
        // 4-bus ring: 0-1, 1-2, 2-3, 3-0.
        // Expected nnz = 4 diagonal + 8 off-diagonal = 12.
        let mut net = PowerNetwork::new(100.0);
        for id in 1..=4 {
            net.buses.push(Bus::new(id, BusType::PQ));
        }
        let pairs = [(1, 2), (2, 3), (3, 4), (4, 1)];
        for (f, t) in pairs {
            net.branches.push(Branch {
                from_bus: f,
                to_bus: t,
                r: 0.02,
                x: 0.2,
                b: 0.0,
                rate_a: 100.0,
                rate_b: 100.0,
                rate_c: 100.0,
                tap: 0.0,
                shift: 0.0,
                status: true,
            });
        }

        let ybus = build_y_bus(&net).expect("build_y_bus 4-bus ring failed");
        assert_eq!(
            ybus.nnz(),
            12,
            "4-bus ring should have 12 non-zero entries, got {}",
            ybus.nnz()
        );
    }

    #[test]
    fn test_ybus_two_isolated_subgraphs() {
        // Buses 0-1 connected; buses 2-3 connected; no cross-graph edge.
        // Y[0,2] and Y[0,3] and Y[1,2] and Y[1,3] must all be zero.
        let mut net = PowerNetwork::new(100.0);
        for id in 1..=4 {
            net.buses.push(Bus::new(id, BusType::PQ));
        }
        net.branches.push(Branch {
            from_bus: 1,
            to_bus: 2,
            r: 0.01,
            x: 0.1,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });
        net.branches.push(Branch {
            from_bus: 3,
            to_bus: 4,
            r: 0.02,
            x: 0.2,
            b: 0.0,
            rate_a: 100.0,
            rate_b: 100.0,
            rate_c: 100.0,
            tap: 0.0,
            shift: 0.0,
            status: true,
        });

        let ybus = build_y_bus(&net).expect("build_y_bus two-subgraphs failed");
        // Cross entries between the two subgraphs must be zero (absent in sparse repr)
        for &(r, c) in &[
            (0usize, 2usize),
            (0, 3),
            (1, 2),
            (1, 3),
            (2, 0),
            (3, 0),
            (2, 1),
            (3, 1),
        ] {
            let val = ybus.get(r, c).copied().unwrap_or(Complex64::new(0.0, 0.0));
            assert!(
                val.norm() < 1e-15,
                "cross-subgraph entry Y[{},{}] should be 0, got {:?}",
                r,
                c,
                val
            );
        }
    }

    #[test]
    fn test_ybus_resistive_vs_reactive_diagonal_imag() {
        // A highly resistive line (large r, small x) should have a smaller
        // imaginary diagonal entry than a highly reactive line (small r, large x).
        let params_resistive = (0.4_f64, 0.01_f64); // r >> x
        let params_reactive = (0.01_f64, 0.4_f64); // x >> r

        let make_net = |r: f64, x: f64| {
            let mut net = PowerNetwork::new(100.0);
            net.buses.push(Bus::new(1, BusType::Slack));
            net.buses.push(Bus::new(2, BusType::PQ));
            net.branches.push(Branch {
                from_bus: 1,
                to_bus: 2,
                r,
                x,
                b: 0.0,
                rate_a: 100.0,
                rate_b: 100.0,
                rate_c: 100.0,
                tap: 0.0,
                shift: 0.0,
                status: true,
            });
            net
        };

        let net_r = make_net(params_resistive.0, params_resistive.1);
        let net_x = make_net(params_reactive.0, params_reactive.1);

        let ybus_r = build_y_bus(&net_r).expect("resistive ybus failed");
        let ybus_x = build_y_bus(&net_x).expect("reactive ybus failed");

        // ys = 1/(r+jx); for resistive: Im(ys) is small; for reactive: Im(ys) is large (negative)
        let y00_r = ybus_r
            .get(0, 0)
            .copied()
            .unwrap_or(Complex64::new(0.0, 0.0));
        let y00_x = ybus_x
            .get(0, 0)
            .copied()
            .unwrap_or(Complex64::new(0.0, 0.0));

        // Imaginary part of diagonal = -x/(r^2+x^2).  More reactive → larger |Im|.
        assert!(
            y00_x.im.abs() > y00_r.im.abs(),
            "reactive line diagonal |Im| {} should exceed resistive line diagonal |Im| {}",
            y00_x.im.abs(),
            y00_r.im.abs()
        );
    }
}