1use surge_network::Network;
35use tracing::{debug, info};
36
37use crate::solver::PreparedDcStudy;
38use crate::types::*;
39
40pub(crate) const BRIDGE_THRESHOLD: f64 = 1e-6;
46
47#[derive(Debug, Clone, PartialEq, Default)]
49pub enum DcSensitivitySlack {
50 #[default]
52 SingleSlack,
53 SlackWeights(Vec<(usize, f64)>),
55 HeadroomSlack(Vec<usize>),
57}
58
59#[derive(Debug, Clone, PartialEq, Default)]
61pub struct DcSensitivityOptions {
62 pub slack: DcSensitivitySlack,
64}
65
66impl DcSensitivityOptions {
67 #[inline]
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 #[inline]
75 pub fn with_slack_weights(slack_weights: &[(usize, f64)]) -> Self {
76 Self {
77 slack: DcSensitivitySlack::SlackWeights(slack_weights.to_vec()),
78 }
79 }
80
81 #[inline]
83 pub fn with_headroom_slack(participating_bus_indices: &[usize]) -> Self {
84 Self {
85 slack: DcSensitivitySlack::HeadroomSlack(participating_bus_indices.to_vec()),
86 }
87 }
88}
89
90#[derive(Debug, Clone, PartialEq, Default)]
92pub struct PtdfRequest {
93 pub monitored_branch_indices: Option<Vec<usize>>,
95 pub bus_indices: Option<Vec<usize>>,
97 pub options: DcSensitivityOptions,
99}
100
101impl PtdfRequest {
102 pub fn new() -> Self {
104 Self::default()
105 }
106
107 pub fn for_branches(monitored_branch_indices: &[usize]) -> Self {
109 Self::new().with_monitored_branches(monitored_branch_indices)
110 }
111
112 pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
114 self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
115 self
116 }
117
118 pub fn with_bus_indices(mut self, bus_indices: &[usize]) -> Self {
120 self.bus_indices = Some(bus_indices.to_vec());
121 self
122 }
123
124 pub fn with_options(mut self, options: DcSensitivityOptions) -> Self {
126 self.options = options;
127 self
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Default)]
133pub struct LodfRequest {
134 pub monitored_branch_indices: Option<Vec<usize>>,
136 pub outage_branch_indices: Option<Vec<usize>>,
138}
139
140impl LodfRequest {
141 pub fn new() -> Self {
143 Self::default()
144 }
145
146 pub fn for_branches(
148 monitored_branch_indices: &[usize],
149 outage_branch_indices: &[usize],
150 ) -> Self {
151 Self::new()
152 .with_monitored_branches(monitored_branch_indices)
153 .with_outage_branches(outage_branch_indices)
154 }
155
156 pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
158 self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
159 self
160 }
161
162 pub fn with_outage_branches(mut self, outage_branch_indices: &[usize]) -> Self {
164 self.outage_branch_indices = Some(outage_branch_indices.to_vec());
165 self
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Default)]
171pub struct LodfMatrixRequest {
172 pub branch_indices: Option<Vec<usize>>,
174}
175
176impl LodfMatrixRequest {
177 pub fn new() -> Self {
179 Self::default()
180 }
181
182 pub fn for_branches(branch_indices: &[usize]) -> Self {
184 Self::new().with_branches(branch_indices)
185 }
186
187 pub fn with_branches(mut self, branch_indices: &[usize]) -> Self {
189 self.branch_indices = Some(branch_indices.to_vec());
190 self
191 }
192}
193
194#[derive(Debug, Clone, PartialEq, Default)]
196pub struct OtdfRequest {
197 pub monitored_branch_indices: Vec<usize>,
199 pub outage_branch_indices: Vec<usize>,
201 pub bus_indices: Option<Vec<usize>>,
203 pub options: DcSensitivityOptions,
205}
206
207impl OtdfRequest {
208 pub fn new(monitored_branch_indices: &[usize], outage_branch_indices: &[usize]) -> Self {
210 Self {
211 monitored_branch_indices: monitored_branch_indices.to_vec(),
212 outage_branch_indices: outage_branch_indices.to_vec(),
213 bus_indices: None,
214 options: DcSensitivityOptions::default(),
215 }
216 }
217
218 pub fn with_bus_indices(mut self, bus_indices: &[usize]) -> Self {
220 self.bus_indices = Some(bus_indices.to_vec());
221 self
222 }
223
224 pub fn with_options(mut self, options: DcSensitivityOptions) -> Self {
226 self.options = options;
227 self
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Default)]
233pub struct N2LodfRequest {
234 pub outage_pair: (usize, usize),
236 pub monitored_branch_indices: Option<Vec<usize>>,
238}
239
240impl N2LodfRequest {
241 pub fn new(outage_pair: (usize, usize)) -> Self {
243 Self {
244 outage_pair,
245 monitored_branch_indices: None,
246 }
247 }
248
249 pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
251 self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
252 self
253 }
254}
255
256#[derive(Debug, Clone, PartialEq, Default)]
258pub struct N2LodfBatchRequest {
259 pub outage_pairs: Vec<(usize, usize)>,
261 pub monitored_branch_indices: Option<Vec<usize>>,
263}
264
265impl N2LodfBatchRequest {
266 pub fn new(outage_pairs: &[(usize, usize)]) -> Self {
268 Self {
269 outage_pairs: outage_pairs.to_vec(),
270 monitored_branch_indices: None,
271 }
272 }
273
274 pub fn with_monitored_branches(mut self, monitored_branch_indices: &[usize]) -> Self {
276 self.monitored_branch_indices = Some(monitored_branch_indices.to_vec());
277 self
278 }
279}
280
281pub fn compute_ptdf(network: &Network, request: &PtdfRequest) -> Result<PtdfRows, DcError> {
302 let monitored_branch_indices_storage;
303 let monitored_branch_indices =
304 if let Some(indices) = request.monitored_branch_indices.as_deref() {
305 indices
306 } else {
307 monitored_branch_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
308 &monitored_branch_indices_storage
309 };
310 info!(
311 buses = network.n_buses(),
312 monitored = monitored_branch_indices.len(),
313 "computing PTDF rows for monitored branches"
314 );
315 let mut model = PreparedDcStudy::new(network)?;
316 model.compute_ptdf_with_options(
317 monitored_branch_indices,
318 request.bus_indices.as_deref(),
319 &request.options,
320 )
321}
322
323pub fn compute_lodf(network: &Network, request: &LodfRequest) -> Result<LodfResult, DcError> {
344 let monitored_branch_indices_storage;
345 let monitored_branch_indices =
346 if let Some(indices) = request.monitored_branch_indices.as_deref() {
347 indices
348 } else {
349 monitored_branch_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
350 &monitored_branch_indices_storage
351 };
352 let outage_branch_indices = request
353 .outage_branch_indices
354 .as_deref()
355 .unwrap_or(monitored_branch_indices);
356 info!(
357 monitored = monitored_branch_indices.len(),
358 outage = outage_branch_indices.len(),
359 "computing LODF for monitored/outage branch sets"
360 );
361 let mut model = PreparedDcStudy::new(network)?;
362 let lodf = model.compute_lodf(monitored_branch_indices, outage_branch_indices)?;
363 debug!(
364 rows = lodf.n_rows(),
365 cols = lodf.n_cols(),
366 "LODF subset computed"
367 );
368 Ok(lodf)
369}
370
371pub fn compute_lodf_pairs(
375 network: &Network,
376 monitored_branch_indices: &[usize],
377 outage_branch_indices: &[usize],
378) -> Result<LodfPairs, DcError> {
379 info!(
380 monitored = monitored_branch_indices.len(),
381 outage = outage_branch_indices.len(),
382 "computing LODF pairs for monitored/outage branch sets"
383 );
384 let mut model = PreparedDcStudy::new(network)?;
385 model.compute_lodf_pairs(monitored_branch_indices, outage_branch_indices)
386}
387
388pub fn compute_lodf_matrix(
392 network: &Network,
393 request: &LodfMatrixRequest,
394) -> Result<LodfMatrixResult, DcError> {
395 let branch_indices_storage;
396 let branches = if let Some(indices) = request.branch_indices.as_deref() {
397 indices
398 } else {
399 branch_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
400 &branch_indices_storage
401 };
402 info!(
403 branches = branches.len(),
404 "computing LODF matrix via KLU column solves"
405 );
406 let mut model = PreparedDcStudy::new(network)?;
407 let lodf = model.compute_lodf_matrix(branches)?;
408 debug!(
409 rows = lodf.n_rows(),
410 cols = lodf.n_cols(),
411 "LODF matrix computed"
412 );
413 Ok(lodf)
414}
415
416pub fn compute_otdf(network: &Network, request: &OtdfRequest) -> Result<OtdfResult, DcError> {
436 compute_otdf_with_options(
437 network,
438 &request.monitored_branch_indices,
439 &request.outage_branch_indices,
440 request.bus_indices.as_deref(),
441 &request.options,
442 )
443}
444
445fn compute_otdf_with_options(
448 network: &Network,
449 monitored_branch_indices: &[usize],
450 outage_branch_indices: &[usize],
451 bus_indices: Option<&[usize]>,
452 options: &DcSensitivityOptions,
453) -> Result<OtdfResult, DcError> {
454 info!(
455 monitored = monitored_branch_indices.len(),
456 outage = outage_branch_indices.len(),
457 buses = bus_indices
458 .map(|buses| buses.len())
459 .unwrap_or(network.n_buses()),
460 "computing OTDF for monitored/outage branch sets"
461 );
462 let mut model = PreparedDcStudy::new(network)?;
463 model.compute_otdf_with_options(
464 monitored_branch_indices,
465 outage_branch_indices,
466 bus_indices,
467 options,
468 )
469}
470
471pub(crate) fn collect_selected_bus_indices(
472 network: &Network,
473 bus_indices: Option<&[usize]>,
474) -> Result<Vec<usize>, DcError> {
475 let selected_bus_indices = bus_indices
476 .map(|indices| indices.to_vec())
477 .unwrap_or_else(|| (0..network.n_buses()).collect());
478 let mut seen = vec![false; network.n_buses()];
479 for &bus_idx in &selected_bus_indices {
480 if bus_idx >= network.n_buses() {
481 return Err(DcError::InvalidNetwork(format!(
482 "OTDF bus index {bus_idx} out of range (len={})",
483 network.n_buses()
484 )));
485 }
486 if std::mem::replace(&mut seen[bus_idx], true) {
487 return Err(DcError::InvalidNetwork(format!(
488 "OTDF bus index {bus_idx} requested more than once"
489 )));
490 }
491 }
492 Ok(selected_bus_indices)
493}
494
495pub(crate) fn compute_otdf_from_ptdf(
496 network: &Network,
497 monitored_branch_indices: &[usize],
498 outage_branch_indices: &[usize],
499 bus_indices: &[usize],
500 ptdf: &PtdfRows,
501) -> Result<OtdfResult, DcError> {
502 let bus_map = network.bus_index_map();
503 let mut result = OtdfResult::new(
504 monitored_branch_indices,
505 outage_branch_indices,
506 bus_indices,
507 network.n_branches(),
508 network.n_buses(),
509 );
510
511 for (outage_pos, &outage_branch_idx) in outage_branch_indices.iter().enumerate() {
512 let branch_k = match network.branches.get(outage_branch_idx) {
513 Some(branch) if branch.in_service => branch,
514 _ => {
515 for (monitored_pos, &monitored_branch_idx) in
516 monitored_branch_indices.iter().enumerate()
517 {
518 if let Some(ptdf_row_m) = ptdf.row(monitored_branch_idx) {
519 let target = result.vector_at_mut(monitored_pos, outage_pos);
520 for (value_pos, &bus_idx) in bus_indices.iter().enumerate() {
521 target[value_pos] = ptdf_row_m[bus_idx];
522 }
523 }
524 }
525 continue;
526 }
527 };
528
529 let Some(&from_k) = bus_map.get(&branch_k.from_bus) else {
530 continue;
531 };
532 let Some(&to_k) = bus_map.get(&branch_k.to_bus) else {
533 continue;
534 };
535 let ptdf_row_k = ptdf.row(outage_branch_idx).ok_or_else(|| {
536 DcError::InvalidNetwork(format!(
537 "PTDF row for outage branch {outage_branch_idx} was not computed"
538 ))
539 })?;
540
541 let ptdf_kk = ptdf_row_k[from_k] - ptdf_row_k[to_k];
542 let is_bridge = (1.0 - ptdf_kk).abs() < BRIDGE_THRESHOLD;
543
544 for (monitored_pos, &monitored_branch_idx) in monitored_branch_indices.iter().enumerate() {
545 let target = result.vector_at_mut(monitored_pos, outage_pos);
546
547 if is_bridge {
548 target.fill(f64::INFINITY);
549 continue;
550 }
551
552 let ptdf_row_m = ptdf.row(monitored_branch_idx).ok_or_else(|| {
553 DcError::InvalidNetwork(format!(
554 "PTDF row for monitored branch {monitored_branch_idx} was not computed"
555 ))
556 })?;
557
558 let ptdf_mk = ptdf_row_m[from_k] - ptdf_row_m[to_k];
559 let lodf_mk = ptdf_mk / (1.0 - ptdf_kk);
560 for (value_pos, &bus_idx) in bus_indices.iter().enumerate() {
561 target[value_pos] = ptdf_row_m[bus_idx] + lodf_mk * ptdf_row_k[bus_idx];
562 }
563 }
564 }
565
566 Ok(result)
567}
568
569pub fn compute_n2_lodf(
592 network: &Network,
593 request: &N2LodfRequest,
594) -> Result<N2LodfResult, DcError> {
595 let monitored_indices_storage;
596 let monitored_indices = if let Some(indices) = request.monitored_branch_indices.as_deref() {
597 indices
598 } else {
599 monitored_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
600 &monitored_indices_storage
601 };
602 info!(
603 outage_k = request.outage_pair.0,
604 outage_l = request.outage_pair.1,
605 monitored = monitored_indices.len(),
606 "computing N-2 LODF for branch pair"
607 );
608 let mut model = PreparedDcStudy::new(network)?;
609 model.compute_n2_lodf(request.outage_pair, monitored_indices)
610}
611
612pub fn compute_n2_lodf_batch(
614 network: &Network,
615 request: &N2LodfBatchRequest,
616) -> Result<N2LodfBatchResult, DcError> {
617 let monitored_indices_storage;
618 let monitored_indices = if let Some(indices) = request.monitored_branch_indices.as_deref() {
619 indices
620 } else {
621 monitored_indices_storage = (0..network.n_branches()).collect::<Vec<_>>();
622 &monitored_indices_storage
623 };
624 info!(
625 outage_pairs = request.outage_pairs.len(),
626 monitored = monitored_indices.len(),
627 "computing N-2 LODF batch for branch pairs"
628 );
629 let mut model = PreparedDcStudy::new(network)?;
630 model.compute_n2_lodf_batch(&request.outage_pairs, monitored_indices)
631}
632
633pub(crate) fn collect_unique_branch_indices(
634 n_branches: usize,
635 monitored_branch_indices: &[usize],
636 outage_branch_indices: &[usize],
637) -> Result<Vec<usize>, DcError> {
638 let mut seen = vec![false; n_branches];
639 let mut branch_indices =
640 Vec::with_capacity(monitored_branch_indices.len() + outage_branch_indices.len());
641
642 for &branch_idx in monitored_branch_indices
643 .iter()
644 .chain(outage_branch_indices.iter())
645 {
646 if branch_idx >= n_branches {
647 return Err(DcError::InvalidNetwork(format!(
648 "branch index {branch_idx} out of range for network with {n_branches} branches"
649 )));
650 }
651 if !seen[branch_idx] {
652 seen[branch_idx] = true;
653 branch_indices.push(branch_idx);
654 }
655 }
656
657 Ok(branch_indices)
658}
659
660#[cfg(test)]
661mod tests {
662 use super::*;
663 use crate::test_util::*;
664
665 fn load_case9() -> Network {
666 load_net("case9")
667 }
668
669 fn load_case14() -> Network {
670 load_net("case14")
671 }
672
673 fn build_balanced_headroom_ptdf_network() -> Network {
674 use surge_network::network::{Branch, Bus, BusType, Generator, Load};
675
676 let mut net = Network::new("balanced_headroom_ptdf");
677 net.base_mva = 100.0;
678
679 net.buses.push(Bus::new(1, BusType::Slack, 230.0));
680 net.buses.push(Bus::new(2, BusType::PV, 230.0));
681 net.buses.push(Bus::new(3, BusType::PV, 230.0));
682 net.buses.push(Bus::new(4, BusType::PQ, 230.0));
683
684 net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
685 net.branches.push(Branch::new_line(1, 3, 0.0, 0.1, 0.0));
686 net.branches.push(Branch::new_line(2, 4, 0.0, 0.1, 0.0));
687 net.branches.push(Branch::new_line(3, 4, 0.0, 0.1, 0.0));
688 net.branches.push(Branch::new_line(2, 3, 0.0, 0.2, 0.0));
689
690 let mut g1 = Generator::new(1, 0.0, 1.0);
691 g1.pmax = 140.0;
692 net.generators.push(g1);
693
694 let mut g2 = Generator::new(2, 60.0, 1.0);
695 g2.pmin = 10.0;
696 g2.pmax = 90.0;
697 net.generators.push(g2);
698
699 let mut g3 = Generator::new(3, 60.0, 1.0);
700 g3.pmin = 20.0;
701 g3.pmax = 80.0;
702 net.generators.push(g3);
703
704 net.loads.push(Load::new(4, 120.0, 0.0));
705 net
706 }
707
708 fn get(ptdf: &PtdfRows, branch: usize, bus: usize) -> f64 {
709 ptdf.get(branch, bus)
710 }
711
712 #[test]
713 fn test_ptdf_case9() {
714 let net = load_case9();
715 let n_br = net.n_branches();
716 let n_bus = net.n_buses();
717 let all: Vec<usize> = (0..n_br).collect();
718 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
719
720 assert_eq!(ptdf.n_rows(), n_br);
721 for row_pos in 0..ptdf.n_rows() {
722 assert_eq!(ptdf.row_at(row_pos).len(), n_bus);
723 }
724
725 let slack_idx = net.slack_bus_index().unwrap();
727 for l in 0..n_br {
728 assert!(get(&ptdf, l, slack_idx).abs() < 1e-10);
729 }
730 }
731
732 #[test]
733 fn test_lodf_case9() {
734 let net = load_case9();
735 let n_br = net.n_branches();
736 let all: Vec<usize> = (0..n_br).collect();
737 let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
738
739 assert_eq!(lodf.n_rows(), n_br);
740 assert_eq!(lodf.n_cols(), n_br);
741
742 for k in 0..n_br {
743 if lodf[(k, k)].is_infinite() {
744 continue;
745 }
746 assert!(
747 (lodf[(k, k)] - (-1.0)).abs() < 1e-10,
748 "LODF diagonal [{k},{k}] = {}, expected -1.0",
749 lodf[(k, k)]
750 );
751 }
752 }
753
754 #[test]
755 fn test_lodf_subset_matches_dense_case14() {
756 let net = load_case14();
757 let all: Vec<usize> = (0..net.n_branches()).collect();
758 let monitored = vec![0, 2, 5, 7];
759 let outages = vec![1, 6, 8];
760
761 let dense = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
762 let subset = compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages)).unwrap();
763
764 assert_eq!(subset.n_rows(), monitored.len());
765 assert_eq!(subset.n_cols(), outages.len());
766 for (i, &l) in monitored.iter().enumerate() {
767 for (j, &k) in outages.iter().enumerate() {
768 let subset_val = subset[(i, j)];
769 let dense_val = dense[(l, k)];
770 assert!(
771 (subset_val - dense_val).abs() < 1e-12
772 || (subset_val.is_infinite() && dense_val.is_infinite()),
773 "subset LODF[{l},{k}] = {subset_val}, dense = {dense_val}"
774 );
775 }
776 }
777 }
778
779 #[test]
780 fn test_lodf_pairs_match_dense_case14() {
781 let net = load_case14();
782 let all: Vec<usize> = (0..net.n_branches()).collect();
783 let monitored = vec![0, 2, 5, 7];
784 let outages = vec![1, 6, 8];
785
786 let dense = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
787 let pairs = compute_lodf_pairs(&net, &monitored, &outages).unwrap();
788
789 assert_eq!(pairs.len(), monitored.len() * outages.len());
790 for &l in &monitored {
791 for &k in &outages {
792 let pair_val = pairs.get(l, k).expect("missing pair entry in sparse map");
793 let dense_val = dense[(l, k)];
794 assert!(
795 (pair_val - dense_val).abs() < 1e-12
796 || (pair_val.is_infinite() && dense_val.is_infinite()),
797 "pair LODF[{l},{k}] = {pair_val}, dense = {dense_val}"
798 );
799 }
800 }
801 }
802
803 #[test]
805 fn test_ptdf_case9_values() {
806 let net = load_case9();
807 let n_br = net.n_branches();
808 let all: Vec<usize> = (0..n_br).collect();
809 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
810 let tol = 1e-10;
811
812 assert!(
813 (get(&ptdf, 0, 1) - (-1.0)).abs() < tol,
814 "PTDF[0,1] = {}, expected -1.0",
815 get(&ptdf, 0, 1)
816 );
817 assert!(
818 (get(&ptdf, 0, 4) - (-1.0)).abs() < tol,
819 "PTDF[0,4] = {}, expected -1.0",
820 get(&ptdf, 0, 4)
821 );
822 assert!(
823 (get(&ptdf, 0, 8) - (-1.0)).abs() < tol,
824 "PTDF[0,8] = {}, expected -1.0",
825 get(&ptdf, 0, 8)
826 );
827
828 assert!(
829 (get(&ptdf, 1, 1) - (-0.361339600470035)).abs() < tol,
830 "PTDF[1,1] = {}",
831 get(&ptdf, 1, 1)
832 );
833 assert!(
834 (get(&ptdf, 1, 4) - (-0.864864864864865)).abs() < tol,
835 "PTDF[1,4] = {}",
836 get(&ptdf, 1, 4)
837 );
838 assert!(
839 (get(&ptdf, 1, 8) - (-0.124853113983549)).abs() < tol,
840 "PTDF[1,8] = {}",
841 get(&ptdf, 1, 8)
842 );
843
844 assert!(
845 (get(&ptdf, 3, 2) - 1.0).abs() < tol,
846 "PTDF[3,2] = {}",
847 get(&ptdf, 3, 2)
848 );
849 assert!(
850 get(&ptdf, 3, 1).abs() < tol,
851 "PTDF[3,1] = {}",
852 get(&ptdf, 3, 1)
853 );
854 assert!(
855 get(&ptdf, 3, 6).abs() < tol,
856 "PTDF[3,6] = {}",
857 get(&ptdf, 3, 6)
858 );
859
860 assert!(
861 (get(&ptdf, 6, 1) - (-1.0)).abs() < tol,
862 "PTDF[6,1] = {}",
863 get(&ptdf, 6, 1)
864 );
865 assert!(
866 get(&ptdf, 6, 4).abs() < tol,
867 "PTDF[6,4] = {}",
868 get(&ptdf, 6, 4)
869 );
870
871 assert!(
872 (get(&ptdf, 7, 1) - 0.638660399529965).abs() < tol,
873 "PTDF[7,1] = {}",
874 get(&ptdf, 7, 1)
875 );
876 assert!(
877 (get(&ptdf, 7, 6) - 0.532902467685077).abs() < tol,
878 "PTDF[7,6] = {}",
879 get(&ptdf, 7, 6)
880 );
881 assert!(
882 (get(&ptdf, 7, 8) - (-0.124853113983548)).abs() < tol,
883 "PTDF[7,8] = {}",
884 get(&ptdf, 7, 8)
885 );
886
887 let slack_idx = net.slack_bus_index().unwrap();
889 for l in 0..n_br {
890 assert!(
891 get(&ptdf, l, slack_idx).abs() < tol,
892 "PTDF[{l},slack={slack_idx}] = {} should be 0",
893 get(&ptdf, l, slack_idx)
894 );
895 }
896 }
897
898 #[test]
900 fn test_lodf_case14_values() {
901 let net = load_case14();
902 let n_br = net.n_branches();
903 let all: Vec<usize> = (0..n_br).collect();
904 let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
905 let tol = 1e-10;
906
907 assert!(
908 (lodf[(1, 0)] - 1.0).abs() < tol,
909 "LODF[1,0] = {}",
910 lodf[(1, 0)]
911 );
912 assert!(
913 (lodf[(2, 0)] - (-0.168846208748234)).abs() < tol,
914 "LODF[2,0] = {}",
915 lodf[(2, 0)]
916 );
917 assert!(
918 (lodf[(4, 0)] - (-0.477794835783875)).abs() < tol,
919 "LODF[4,0] = {}",
920 lodf[(4, 0)]
921 );
922 assert!(
923 (lodf[(6, 0)] - (-0.493343980479742)).abs() < tol,
924 "LODF[6,0] = {}",
925 lodf[(6, 0)]
926 );
927 assert!(
928 (lodf[(0, 2)] - (-0.207667214399152)).abs() < tol,
929 "LODF[0,2] = {}",
930 lodf[(0, 2)]
931 );
932 assert!(
933 (lodf[(5, 2)] - (-1.0)).abs() < tol,
934 "LODF[5,2] = {}",
935 lodf[(5, 2)]
936 );
937 assert!(
938 (lodf[(3, 2)] - 0.455285600326033).abs() < tol,
939 "LODF[3,2] = {}",
940 lodf[(3, 2)]
941 );
942 assert!(
943 (lodf[(3, 6)] - (-0.514489688099607)).abs() < tol,
944 "LODF[3,6] = {}",
945 lodf[(3, 6)]
946 );
947 assert!(
948 (lodf[(4, 6)] - 0.470460951978899).abs() < tol,
949 "LODF[4,6] = {}",
950 lodf[(4, 6)]
951 );
952 assert!(
953 (lodf[(7, 6)] - 0.151344601496801).abs() < tol,
954 "LODF[7,6] = {}",
955 lodf[(7, 6)]
956 );
957 assert!(
958 (lodf[(7, 8)] - 0.638989500215690).abs() < tol,
959 "LODF[7,8] = {}",
960 lodf[(7, 8)]
961 );
962
963 for k in 0..lodf.n_cols() {
964 if lodf[(k, k)].is_finite() {
965 assert!(
966 (lodf[(k, k)] - (-1.0)).abs() < tol,
967 "LODF diagonal [{k},{k}] = {}, expected -1.0",
968 lodf[(k, k)]
969 );
970 }
971 }
972 }
973
974 #[test]
976 fn test_ptdf_lodf_consistency() {
977 let net = load_case14();
978 let bus_map = net.bus_index_map();
979 let n_br = net.n_branches();
980 let all: Vec<usize> = (0..n_br).collect();
981 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
982 let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
983 let tol = 1e-8;
984
985 for k in 0..n_br {
986 let branch_k = &net.branches[k];
987 if !branch_k.in_service {
988 continue;
989 }
990 let from_k = bus_map[&branch_k.from_bus];
991 let to_k = bus_map[&branch_k.to_bus];
992 let ptdf_kk = get(&ptdf, k, from_k) - get(&ptdf, k, to_k);
993
994 if (1.0 - ptdf_kk).abs() < 1e-10 {
995 continue;
996 }
997 let denom = 1.0 - ptdf_kk;
998
999 for l in 0..n_br {
1000 if l == k {
1001 assert!(
1002 (lodf[(l, k)] - (-1.0)).abs() < tol,
1003 "LODF[{l},{k}] = {}, expected -1.0",
1004 lodf[(l, k)]
1005 );
1006 continue;
1007 }
1008 let ptdf_lk = get(&ptdf, l, from_k) - get(&ptdf, l, to_k);
1009 let expected = ptdf_lk / denom;
1010 assert!(
1011 (lodf[(l, k)] - expected).abs() < tol,
1012 "LODF[{l},{k}] = {}, expected {} (diff = {:.2e})",
1013 lodf[(l, k)],
1014 expected,
1015 (lodf[(l, k)] - expected).abs()
1016 );
1017 }
1018 }
1019 }
1020
1021 #[test]
1023 fn test_ptdf_flow_reconstruction() {
1024 let net = load_case9();
1025 let n_br = net.n_branches();
1026 let n_bus = net.n_buses();
1027 let all: Vec<usize> = (0..n_br).collect();
1028 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
1029 let dc_result = crate::solver::solve_dc(&net).expect("DC solve failed");
1030 let tol = 1e-8;
1031
1032 let p_inj = net.bus_p_injection_pu();
1033 for l in 0..n_br {
1034 let reconstructed: f64 = (0..n_bus).map(|k| get(&ptdf, l, k) * p_inj[k]).sum();
1035 assert!(
1036 (reconstructed - dc_result.branch_p_flow[l]).abs() < tol,
1037 "Branch {l}: PTDF*P_inj = {:.10}, DC flow = {:.10} (diff = {:.2e})",
1038 reconstructed,
1039 dc_result.branch_p_flow[l],
1040 (reconstructed - dc_result.branch_p_flow[l]).abs()
1041 );
1042 }
1043 }
1044
1045 #[test]
1047 fn test_ptdf_flow_reconstruction_case14() {
1048 let net = load_case14();
1049 let n_br = net.n_branches();
1050 let n_bus = net.n_buses();
1051 let all: Vec<usize> = (0..n_br).collect();
1052 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).unwrap();
1053 let dc_result = crate::solver::solve_dc(&net).expect("DC solve failed");
1054 let tol = 1e-8;
1055
1056 let p_inj = net.bus_p_injection_pu();
1057 for l in 0..n_br {
1058 let reconstructed: f64 = (0..n_bus).map(|k| get(&ptdf, l, k) * p_inj[k]).sum();
1059 assert!(
1060 (reconstructed - dc_result.branch_p_flow[l]).abs() < tol,
1061 "Branch {l}: PTDF*P_inj = {:.10}, DC flow = {:.10} (diff = {:.2e})",
1062 reconstructed,
1063 dc_result.branch_p_flow[l],
1064 (reconstructed - dc_result.branch_p_flow[l]).abs()
1065 );
1066 }
1067 }
1068
1069 #[test]
1071 fn test_lodf_case14_outage_validation() {
1072 let net = load_case14();
1073 let n_br = net.n_branches();
1074 let all: Vec<usize> = (0..n_br).collect();
1075 let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1076 let tol = 1e-8;
1077
1078 let base_result = crate::solver::solve_dc(&net).expect("base DC solve failed");
1079
1080 for &k in &[2usize, 6, 8] {
1081 if !lodf[(k, k)].is_finite() {
1082 continue;
1083 }
1084 let mut net_outaged = net.clone();
1085 net_outaged.branches[k].in_service = false;
1086 let outaged_result =
1087 crate::solver::solve_dc(&net_outaged).expect("outage DC solve failed");
1088 let flow_k_pre = base_result.branch_p_flow[k];
1089
1090 for l in 0..n_br {
1091 if l == k || !net.branches[l].in_service {
1092 continue;
1093 }
1094 let predicted = base_result.branch_p_flow[l] + lodf[(l, k)] * flow_k_pre;
1095 let actual = outaged_result.branch_p_flow[l];
1096 assert!(
1097 (predicted - actual).abs() < tol,
1098 "Outage {k}: branch {l} predicted={predicted:.10}, actual={actual:.10} (diff={:.2e})",
1099 (predicted - actual).abs()
1100 );
1101 }
1102 }
1103 }
1104
1105 #[test]
1107 fn test_lodf_case9_values() {
1108 let net = load_case9();
1109 let n_br = net.n_branches();
1110 let all: Vec<usize> = (0..n_br).collect();
1111 let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1112 let tol = 1e-8;
1113
1114 assert!(lodf[(0, 0)].is_infinite(), "Branch 0 should be a bridge");
1115 assert!(lodf[(0, 3)].is_infinite(), "Branch 3 should be a bridge");
1116 assert!(lodf[(0, 6)].is_infinite(), "Branch 6 should be a bridge");
1117
1118 assert!(
1119 (lodf[(2, 1)] - (-1.0)).abs() < tol,
1120 "LODF[2,1] = {}",
1121 lodf[(2, 1)]
1122 );
1123 assert!(
1124 (lodf[(4, 1)] - (-1.0)).abs() < tol,
1125 "LODF[4,1] = {}",
1126 lodf[(4, 1)]
1127 );
1128 assert!(
1129 (lodf[(7, 1)] - (-1.0)).abs() < tol,
1130 "LODF[7,1] = {}",
1131 lodf[(7, 1)]
1132 );
1133 assert!(
1134 (lodf[(8, 1)] - (-1.0)).abs() < tol,
1135 "LODF[8,1] = {}",
1136 lodf[(8, 1)]
1137 );
1138 assert!(
1139 (lodf[(1, 7)] - (-1.0)).abs() < tol,
1140 "LODF[1,7] = {}",
1141 lodf[(1, 7)]
1142 );
1143 assert!(
1144 (lodf[(5, 7)] - (-1.0)).abs() < tol,
1145 "LODF[5,7] = {}",
1146 lodf[(5, 7)]
1147 );
1148 assert!(
1149 (lodf[(8, 7)] - (-1.0)).abs() < tol,
1150 "LODF[8,7] = {}",
1151 lodf[(8, 7)]
1152 );
1153 assert!(lodf[(3, 0)].is_infinite(), "LODF[3,0] should be Inf");
1154 }
1155
1156 #[test]
1158 fn test_lodf_n2_brute_force_case14() {
1159 let net = load_case14();
1160 let n_br = net.n_branches();
1161 let all: Vec<usize> = (0..n_br).collect();
1162 let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1163 let tol = 1e-6;
1164
1165 let base = crate::solver::solve_dc(&net).expect("base DC solve");
1166
1167 for &(j, k) in &[(2usize, 6usize), (2, 8), (6, 8)] {
1168 if !lodf[(j, j)].is_finite() || !lodf[(k, k)].is_finite() {
1169 continue;
1170 }
1171 let mut net_outaged = net.clone();
1172 net_outaged.branches[j].in_service = false;
1173 net_outaged.branches[k].in_service = false;
1174 let outaged = match crate::solver::solve_dc(&net_outaged) {
1175 Ok(r) => r,
1176 Err(_) => continue,
1177 };
1178
1179 let lodf2_jk = match compute_n2_lodf(
1180 &net,
1181 &N2LodfRequest::new((j, k)).with_monitored_branches(&all),
1182 ) {
1183 Ok(v) => v,
1184 Err(_) => continue,
1185 };
1186 let lodf2_kj = match compute_n2_lodf(
1187 &net,
1188 &N2LodfRequest::new((k, j)).with_monitored_branches(&all),
1189 ) {
1190 Ok(v) => v,
1191 Err(_) => continue,
1192 };
1193
1194 for m in (0..n_br).filter(|&m| m != j && m != k && net.branches[m].in_service) {
1195 let predicted = base.branch_p_flow[m]
1196 + lodf2_jk[m] * base.branch_p_flow[j]
1197 + lodf2_kj[m] * base.branch_p_flow[k];
1198 let actual = outaged.branch_p_flow[m];
1199 assert!(
1200 (predicted - actual).abs() < tol,
1201 "N-2 Woodbury error for outage ({j},{k}), monitored {m}: \
1202 predicted={predicted:.10}, actual={actual:.10}, diff={:.2e}",
1203 (predicted - actual).abs()
1204 );
1205 }
1206 }
1207 }
1208
1209 #[test]
1211 fn test_ptdf_zero_for_oos_branch() {
1212 let mut net = load_case9();
1213 net.branches[2].in_service = false;
1214
1215 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&[2]))
1216 .expect("compute_ptdf should not fail");
1217 let col = ptdf.row(2).expect("PTDF row for monitored branch");
1218 for &v in col.iter() {
1219 assert_eq!(v, 0.0, "out-of-service branch column should be all zero");
1220 }
1221 }
1222
1223 #[test]
1225 fn test_pst04_n2_lodf_case9() {
1226 let net = load_case14();
1227
1228 let n_monitored = 5;
1229 let monitored: Vec<usize> = (5..5 + n_monitored).collect();
1230 let result = compute_n2_lodf(
1231 &net,
1232 &N2LodfRequest::new((0, 2)).with_monitored_branches(&monitored),
1233 )
1234 .expect("N-2 LODF should succeed for case14 pair (0,2)");
1235
1236 assert_eq!(
1237 result.len(),
1238 n_monitored,
1239 "result length should equal n_monitored"
1240 );
1241 }
1242
1243 fn make_3bus_loop() -> surge_network::Network {
1252 use surge_network::Network;
1253 use surge_network::network::{Branch, Bus, BusType, Generator, Load};
1254
1255 let mut net = Network::new("3bus_loop");
1256 net.base_mva = 100.0;
1257
1258 net.buses.push(Bus::new(1, BusType::Slack, 100.0));
1259 net.buses.push(Bus::new(2, BusType::PQ, 100.0));
1260 net.buses.push(Bus::new(3, BusType::PQ, 100.0));
1261 net.loads.push(Load::new(2, 100.0, 0.0));
1262 net.loads.push(Load::new(3, 50.0, 0.0));
1263
1264 net.generators.push(Generator::new(1, 150.0, 1.0));
1265
1266 net.branches.push(Branch::new_line(1, 2, 0.0, 0.2, 0.0));
1267 net.branches.push(Branch::new_line(2, 3, 0.0, 0.4, 0.0));
1268 net.branches.push(Branch::new_line(3, 1, 0.0, 0.3, 0.0));
1269
1270 net
1271 }
1272
1273 fn make_2bus_bridge() -> surge_network::Network {
1275 use surge_network::Network;
1276 use surge_network::network::{Branch, Bus, BusType, Generator, Load};
1277
1278 let mut net = Network::new("2bus_bridge");
1279 net.base_mva = 100.0;
1280
1281 net.buses.push(Bus::new(1, BusType::Slack, 100.0));
1282 net.buses.push(Bus::new(2, BusType::PQ, 100.0));
1283 net.loads.push(Load::new(2, 100.0, 0.0));
1284
1285 net.generators.push(Generator::new(1, 100.0, 1.0));
1286 net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
1287
1288 net
1289 }
1290
1291 #[test]
1293 fn test_otdf_case9_analytical() {
1294 let net = make_3bus_loop();
1296 let n_br = net.n_branches();
1297 let n_bus = net.n_buses();
1298 let bus_map = net.bus_index_map();
1299 let all: Vec<usize> = (0..n_br).collect();
1300
1301 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).expect("PTDF failed");
1302
1303 let outage = vec![1usize];
1305 let otdf = compute_otdf(&net, &OtdfRequest::new(&all, &outage)).expect("OTDF failed");
1306
1307 let branch_k = &net.branches[1];
1308 let from_k = bus_map[&branch_k.from_bus];
1309 let to_k = bus_map[&branch_k.to_bus];
1310 let ptdf_row_k = ptdf.row(1).unwrap();
1311 let ptdf_kk = ptdf_row_k[from_k] - ptdf_row_k[to_k];
1312 let denom = 1.0 - ptdf_kk;
1313
1314 for &m in &all {
1315 let ptdf_row_m = ptdf.row(m).unwrap();
1316 let ptdf_mk = ptdf_row_m[from_k] - ptdf_row_m[to_k];
1317 let lodf_mk = if denom.abs() < BRIDGE_THRESHOLD {
1318 0.0
1319 } else {
1320 ptdf_mk / denom
1321 };
1322
1323 let otdf_vec = otdf.vector(m, 1).expect("OTDF entry missing");
1324 assert_eq!(
1325 otdf_vec.len(),
1326 n_bus,
1327 "OTDF vector wrong length for ({m},1)"
1328 );
1329
1330 for b in 0..n_bus {
1331 let expected = ptdf_row_m[b] + lodf_mk * ptdf_row_k[b];
1332 let actual = otdf_vec[b];
1333 assert!(
1334 (actual - expected).abs() < 1e-12,
1335 "OTDF[({m},1)][{b}] = {actual:.15}, expected {expected:.15} (diff={:.2e})",
1336 (actual - expected).abs()
1337 );
1338 }
1339 }
1340 }
1341
1342 #[test]
1344 fn test_otdf_bridge_line() {
1345 let net = make_2bus_bridge();
1346 let n_bus = net.n_buses();
1347
1348 let otdf = compute_otdf(&net, &OtdfRequest::new(&[0], &[0]))
1349 .expect("OTDF should not error on bridge");
1350 let vec = otdf.vector(0, 0).expect("entry (0,0) must exist");
1351 assert_eq!(vec.len(), n_bus, "OTDF vector wrong length");
1352 for (b, &v) in vec.iter().enumerate() {
1353 assert!(
1354 v.is_infinite(),
1355 "OTDF[(0,0)][{b}] = {v}, expected INFINITY (bridge line)"
1356 );
1357 }
1358 }
1359
1360 #[test]
1362 fn test_otdf_self_consistency() {
1363 let net = make_3bus_loop();
1364 let n_br = net.n_branches();
1365 let bus_map = net.bus_index_map();
1366 let all: Vec<usize> = (0..n_br).collect();
1367
1368 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).expect("PTDF failed");
1369
1370 for &m in &all {
1373 let otdf = compute_otdf(&net, &OtdfRequest::new(&[m], &[m])).expect("OTDF failed");
1374 let otdf_vec = match otdf.vector(m, m) {
1375 Some(v) => v,
1376 None => continue,
1377 };
1378
1379 if otdf_vec.iter().any(|v| v.is_infinite()) {
1381 continue;
1382 }
1383
1384 let branch_m = &net.branches[m];
1385 let from_m = bus_map[&branch_m.from_bus];
1386 let to_m = bus_map[&branch_m.to_bus];
1387
1388 let ptdf_row_m = ptdf.row(m).unwrap();
1390 let ptdf_mm = ptdf_row_m[from_m] - ptdf_row_m[to_m];
1391 let denom = 1.0 - ptdf_mm;
1392 if denom.abs() < 1e-6 {
1393 continue;
1394 }
1395 let lodf_mm = ptdf_mm / denom; let otdf_mm = otdf_vec[from_m] - otdf_vec[to_m];
1399 assert!(
1400 (otdf_mm - lodf_mm).abs() < 1e-12,
1401 "OTDF[({m},{m})][from-to] = {otdf_mm:.15}, LODF[{m},{m}] = {lodf_mm:.15}"
1402 );
1403 }
1404 }
1405
1406 #[test]
1408 fn test_otdf_matches_formula() {
1409 let net = make_3bus_loop();
1410 let n_br = net.n_branches();
1411 let n_bus = net.n_buses();
1412 let bus_map = net.bus_index_map();
1413 let all: Vec<usize> = (0..n_br).collect();
1414
1415 let ptdf = compute_ptdf(&net, &PtdfRequest::for_branches(&all)).expect("PTDF failed");
1416 let otdf = compute_otdf(&net, &OtdfRequest::new(&all, &all)).expect("OTDF failed");
1417
1418 for &k in &all {
1419 let branch_k = &net.branches[k];
1420 let from_k = bus_map[&branch_k.from_bus];
1421 let to_k = bus_map[&branch_k.to_bus];
1422 let ptdf_row_k = ptdf.row(k).unwrap();
1423 let ptdf_kk = ptdf_row_k[from_k] - ptdf_row_k[to_k];
1424 let is_bridge = (1.0 - ptdf_kk).abs() < BRIDGE_THRESHOLD;
1425
1426 for &m in &all {
1427 let otdf_vec = otdf.vector(m, k).expect("OTDF entry missing");
1428
1429 if is_bridge {
1430 assert!(
1431 otdf_vec.iter().all(|v| v.is_infinite()),
1432 "bridge outage {k} → all OTDF should be INFINITY"
1433 );
1434 continue;
1435 }
1436
1437 let ptdf_row_m = ptdf.row(m).unwrap();
1438 let ptdf_mk = ptdf_row_m[from_k] - ptdf_row_m[to_k];
1439 let lodf_mk = ptdf_mk / (1.0 - ptdf_kk);
1440
1441 for b in 0..n_bus {
1442 let expected = ptdf_row_m[b] + lodf_mk * ptdf_row_k[b];
1443 let actual = otdf_vec[b];
1444 assert!(
1445 (actual - expected).abs() < 1e-12,
1446 "OTDF[({m},{k})][{b}]: actual={actual:.15}, expected={expected:.15}, diff={:.2e}",
1447 (actual - expected).abs()
1448 );
1449 }
1450 }
1451 }
1452 }
1453
1454 #[test]
1455 fn test_otdf_bus_subset_matches_full() {
1456 let net = load_case14();
1457 let monitored = vec![0, 3, 7];
1458 let outages = vec![1, 5];
1459 let buses = vec![0, 4, 9];
1460
1461 let full = compute_otdf(&net, &OtdfRequest::new(&monitored, &outages)).expect("full otdf");
1462 let subset = compute_otdf(
1463 &net,
1464 &OtdfRequest::new(&monitored, &outages).with_bus_indices(&buses),
1465 )
1466 .expect("subset otdf");
1467
1468 assert_eq!(subset.monitored_branches(), monitored.as_slice());
1469 assert_eq!(subset.outage_branches(), outages.as_slice());
1470 assert_eq!(subset.bus_indices(), buses.as_slice());
1471 assert_eq!(subset.n_buses(), buses.len());
1472
1473 for &m in &monitored {
1474 for &k in &outages {
1475 let full_vec = full.vector(m, k).expect("full OTDF vector");
1476 let subset_vec = subset.vector(m, k).expect("subset OTDF vector");
1477 assert_eq!(subset_vec.len(), buses.len());
1478 for (pos, &bus_idx) in buses.iter().enumerate() {
1479 assert!(
1480 (subset_vec[pos] - full_vec[bus_idx]).abs() < 1e-12
1481 || (subset_vec[pos].is_infinite() && full_vec[bus_idx].is_infinite()),
1482 "OTDF subset mismatch for ({m},{k}) bus {bus_idx}"
1483 );
1484 }
1485 }
1486 }
1487 }
1488
1489 #[test]
1495 fn test_ptdf_with_slack_weights_applies_requested_weights() {
1496 let net = load_case14();
1497 let base = compute_ptdf(&net, &PtdfRequest::for_branches(&[0])).unwrap();
1498 let options = DcSensitivityOptions::with_slack_weights(&[(1usize, 3.0), (2usize, 1.0)]);
1499 let weighted =
1500 compute_ptdf(&net, &PtdfRequest::for_branches(&[0]).with_options(options)).unwrap();
1501
1502 let base_row = base.row(0).unwrap();
1503 let weighted_row = weighted.row(0).unwrap();
1504 let correction = 0.75 * base_row[1] + 0.25 * base_row[2];
1505
1506 for bus_idx in 0..net.n_buses() {
1507 assert!(
1508 (weighted_row[bus_idx] - (base_row[bus_idx] - correction)).abs() < 1e-12,
1509 "bus {bus_idx}: weighted PTDF mismatch"
1510 );
1511 }
1512 }
1513
1514 #[test]
1515 fn test_ptdf_bus_subset_matches_full_rows() {
1516 let net = load_case14();
1517 let monitored = [0usize, 3usize];
1518 let bus_indices = [0usize, 4usize, 7usize];
1519
1520 let full = compute_ptdf(&net, &PtdfRequest::for_branches(&monitored)).expect("full ptdf");
1521 let subset = compute_ptdf(
1522 &net,
1523 &PtdfRequest::for_branches(&monitored).with_bus_indices(&bus_indices),
1524 )
1525 .expect("subset ptdf");
1526
1527 assert_eq!(subset.monitored_branches(), monitored.as_slice());
1528 assert_eq!(subset.bus_indices(), bus_indices.as_slice());
1529 assert_eq!(subset.n_cols(), bus_indices.len());
1530
1531 for &branch_idx in &monitored {
1532 let subset_row = subset.row(branch_idx).expect("subset row");
1533 let full_row = full.row(branch_idx).expect("full row");
1534 for (pos, &bus_idx) in bus_indices.iter().enumerate() {
1535 assert!(
1536 (subset_row[pos] - full_row[bus_idx]).abs() < 1e-12,
1537 "branch {branch_idx} bus {bus_idx}: subset PTDF mismatch"
1538 );
1539 }
1540 }
1541 }
1542
1543 #[test]
1544 fn test_otdf_request_matches_formula() {
1545 let net = load_case14();
1546 let monitored = [0usize];
1547 let outages = [1usize];
1548 let buses = [1usize, 2, 5];
1549 let options = DcSensitivityOptions::with_slack_weights(&[(1usize, 3.0), (2usize, 1.0)]);
1550
1551 let otdf = compute_otdf(
1552 &net,
1553 &OtdfRequest::new(&monitored, &outages)
1554 .with_bus_indices(&buses)
1555 .with_options(options.clone()),
1556 )
1557 .expect("weighted otdf");
1558 let ptdf = compute_ptdf(
1559 &net,
1560 &PtdfRequest::for_branches(&[monitored[0], outages[0]]).with_options(options.clone()),
1561 )
1562 .expect("ptdf");
1563 let lodf =
1564 compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages)).expect("lodf");
1565
1566 let otdf_vector = otdf.vector(monitored[0], outages[0]).expect("otdf vector");
1567 let monitored_row = ptdf.row(monitored[0]).expect("monitored ptdf row");
1568 let outage_row = ptdf.row(outages[0]).expect("outage ptdf row");
1569 let lodf_mk = lodf[(0, 0)];
1570
1571 for (pos, &bus_idx) in buses.iter().enumerate() {
1572 let expected = monitored_row[bus_idx] + lodf_mk * outage_row[bus_idx];
1573 assert!(
1574 (otdf_vector[pos] - expected).abs() < 1e-12,
1575 "weighted OTDF mismatch at bus {bus_idx}: actual={} expected={expected}",
1576 otdf_vector[pos]
1577 );
1578 }
1579 }
1580
1581 #[test]
1584 fn test_ptdf_headroom_slack_perturbation() {
1585 let net = build_balanced_headroom_ptdf_network();
1586 let n_br = net.n_branches();
1587 let all: Vec<usize> = (0..n_br).collect();
1588
1589 let bus_map = net.bus_index_map();
1590 let participating_buses = vec![bus_map[&1], bus_map[&2], bus_map[&3]];
1591 let options = DcSensitivityOptions::with_headroom_slack(&participating_buses);
1592 let ptdf_ds =
1593 compute_ptdf(&net, &PtdfRequest::for_branches(&all).with_options(options)).unwrap();
1594
1595 let dc_opts = DcPfOptions::with_headroom_slack(&participating_buses);
1596 let base = crate::solver::solve_dc_opts(&net, &dc_opts).expect("base DC solve");
1597
1598 let test_bus = bus_map[&4];
1602 let delta = 0.01; let mut net_perturbed = net.clone();
1604 let perturb_bus_number = net_perturbed.buses[test_bus].number;
1605 net_perturbed.loads.push(surge_network::network::Load::new(
1606 perturb_bus_number,
1607 -(delta * net.base_mva),
1608 0.0,
1609 ));
1610 let perturbed =
1611 crate::solver::solve_dc_opts(&net_perturbed, &dc_opts).expect("perturbed DC solve");
1612
1613 let tol = 1e-6;
1614 for l in 0..n_br {
1615 let actual_delta_flow = perturbed.branch_p_flow[l] - base.branch_p_flow[l];
1616 let predicted = get(&ptdf_ds, l, test_bus) * delta;
1617 assert!(
1618 (actual_delta_flow - predicted).abs() < tol,
1619 "Branch {l}: actual ΔF={:.10}, PTDF_ds×Δ={:.10} (diff={:.2e})",
1620 actual_delta_flow,
1621 predicted,
1622 (actual_delta_flow - predicted).abs()
1623 );
1624 }
1625 }
1626
1627 #[test]
1629 fn test_lodf_headroom_slack_outage_validation() {
1630 let net = load_case14();
1631 let n_br = net.n_branches();
1632 let all: Vec<usize> = (0..n_br).collect();
1633
1634 let gen_bus_indices: Vec<usize> = {
1635 let bus_map = net.bus_index_map();
1636 net.generators
1637 .iter()
1638 .filter(|g| g.in_service)
1639 .filter_map(|g| bus_map.get(&g.bus).copied())
1640 .collect()
1641 };
1642 let participating_buses = gen_bus_indices.clone();
1643 let lodf = compute_lodf_matrix(&net, &LodfMatrixRequest::for_branches(&all)).unwrap();
1644
1645 let dc_opts = DcPfOptions::with_headroom_slack(&participating_buses);
1646 let base_result = crate::solver::solve_dc_opts(&net, &dc_opts).expect("base DC solve");
1647
1648 let tol = 1e-6;
1649
1650 for &k in &[2usize, 6, 8] {
1651 if !lodf[(k, k)].is_finite() {
1652 continue;
1653 }
1654 let mut net_outaged = net.clone();
1655 net_outaged.branches[k].in_service = false;
1656 let outaged_result =
1657 crate::solver::solve_dc_opts(&net_outaged, &dc_opts).expect("outage DC solve");
1658 let flow_k_pre = base_result.branch_p_flow[k];
1659
1660 for l in 0..n_br {
1661 if l == k || !net.branches[l].in_service {
1662 continue;
1663 }
1664 let predicted = base_result.branch_p_flow[l] + lodf[(l, k)] * flow_k_pre;
1665 let actual = outaged_result.branch_p_flow[l];
1666 assert!(
1667 (predicted - actual).abs() < tol,
1668 "Outage {k}: branch {l} predicted={predicted:.10}, actual={actual:.10} (diff={:.2e})",
1669 (predicted - actual).abs()
1670 );
1671 }
1672 }
1673 }
1674
1675 #[test]
1676 fn test_lodf_and_n2_are_slack_invariant() {
1677 let net = load_case14();
1678 let monitored = vec![0, 3, 7];
1679 let outages = vec![1, 5];
1680 let outage_pairs = vec![(0, 2), (2, 0)];
1681 let single_lodf = compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages))
1682 .expect("single-slack lodf");
1683 let request_lodf = compute_lodf(&net, &LodfRequest::for_branches(&monitored, &outages))
1684 .expect("request lodf");
1685 assert_eq!(single_lodf.n_rows(), request_lodf.n_rows());
1686 assert_eq!(single_lodf.n_cols(), request_lodf.n_cols());
1687 for row in 0..single_lodf.n_rows() {
1688 for col in 0..single_lodf.n_cols() {
1689 assert!(
1690 (single_lodf[(row, col)] - request_lodf[(row, col)]).abs() < 1e-12
1691 || (single_lodf[(row, col)].is_infinite()
1692 && request_lodf[(row, col)].is_infinite()),
1693 "request LODF changed at ({row},{col})"
1694 );
1695 }
1696 }
1697
1698 let single_n2 = compute_n2_lodf_batch(
1699 &net,
1700 &N2LodfBatchRequest::new(&outage_pairs).with_monitored_branches(&monitored),
1701 )
1702 .expect("single-slack n2");
1703 let request_n2 = compute_n2_lodf_batch(
1704 &net,
1705 &N2LodfBatchRequest::new(&outage_pairs).with_monitored_branches(&monitored),
1706 )
1707 .expect("request n2");
1708 assert_eq!(single_n2.n_rows(), request_n2.n_rows());
1709 assert_eq!(single_n2.n_cols(), request_n2.n_cols());
1710 for row in 0..single_n2.n_rows() {
1711 for col in 0..single_n2.n_cols() {
1712 assert!(
1713 (single_n2[(row, col)] - request_n2[(row, col)]).abs() < 1e-12
1714 || (single_n2[(row, col)].is_infinite()
1715 && request_n2[(row, col)].is_infinite()),
1716 "request N-2 changed at ({row},{col})"
1717 );
1718 }
1719 }
1720 }
1721}