1use std::collections::{HashMap, HashSet};
14use std::time::Instant;
15
16use faer::Mat;
17use surge_network::Network;
18use surge_network::network::BusType;
19use surge_network::network::apply_angle_reference_subset;
20use surge_solution::{PfModel, PfSolution, SolveStatus};
21use surge_sparse::KluSolver;
22use surge_topology::islands::detect_islands;
23use tracing::{debug, error, info, warn};
24
25use crate::bprime::{BprimeSparseCsc, build_bprime_sparse_for_buses, build_p_injection_for_buses};
26use crate::types::*;
27
28struct PreparedDcKernel {
29 bus_indices: Vec<usize>,
30 branch_indices: Vec<usize>,
31 bprime: BprimeSparseCsc,
32 reduced_to_full: Vec<usize>,
33 p_injection_base: Vec<f64>,
34 reference_angle0_rad: f64,
35 klu: KluSolver,
36}
37
38struct PreparedSolveInputs<'a> {
39 bus_map: &'a HashMap<u32, usize>,
40 bus_indices: &'a [usize],
41 branch_indices: &'a [usize],
42 bprime: &'a BprimeSparseCsc,
43 klu: &'a mut KluSolver,
44 p_inj_base: &'a [f64],
45 reference_angle0_rad: f64,
46}
47
48pub struct PreparedDcStudy<'a> {
55 network: &'a Network,
56 bus_map: HashMap<u32, usize>,
57 branch_metadata: Vec<BranchDcMetadata>,
58 downward_headroom_by_bus_pu: Vec<f64>,
59 kernels: Vec<PreparedDcKernel>,
60 bus_kernel_indices: Vec<Option<usize>>,
61 branch_kernel_indices: Vec<Option<usize>>,
62 bus_island_ids: Vec<usize>,
63 singleton_bus_indices: Vec<usize>,
64}
65
66pub struct LodfColumnBuilder<'model, 'network> {
73 model: &'model mut PreparedDcStudy<'network>,
74 reduced_bus_columns_by_kernel: Vec<Vec<Option<Vec<f64>>>>,
75}
76
77pub struct N2LodfColumnBuilder<'model, 'network> {
83 monitored_branch_indices: Vec<usize>,
84 column_branch_indices: Vec<usize>,
85 column_positions: Vec<Option<usize>>,
86 single_outage_columns: HashMap<usize, Vec<f64>>,
87 lodf_columns: LodfColumnBuilder<'model, 'network>,
88}
89
90impl<'model, 'network> LodfColumnBuilder<'model, 'network> {
91 fn new(model: &'model mut PreparedDcStudy<'network>) -> Self {
92 let reduced_bus_columns_by_kernel = model
93 .kernels
94 .iter()
95 .map(|kernel| vec![None; kernel.bprime.dim])
96 .collect();
97 Self {
98 model,
99 reduced_bus_columns_by_kernel,
100 }
101 }
102
103 pub fn compute_column(
108 &mut self,
109 monitored_branch_indices: &[usize],
110 outage_branch_idx: usize,
111 ) -> Result<Vec<f64>, DcError> {
112 let branch_k = self.model.branch_metadata(outage_branch_idx)?;
113 let (from_k, to_k) = self.model.branch_terminal_indices(outage_branch_idx)?;
114 let kernel_idx = self.model.branch_kernel_index(outage_branch_idx)?;
115 let b_k = branch_k.b_dc;
116
117 let from_column = self.ensure_reduced_bus_column(kernel_idx, from_k)?;
118 let to_column = self.ensure_reduced_bus_column(kernel_idx, to_k)?;
119
120 let ptdf_k_from = b_k
121 * (self.b_inverse_element(kernel_idx, from_column, from_k)
122 - self.b_inverse_element(kernel_idx, from_column, to_k));
123 let ptdf_k_to = b_k
124 * (self.b_inverse_element(kernel_idx, to_column, from_k)
125 - self.b_inverse_element(kernel_idx, to_column, to_k));
126 let ptdf_kk = ptdf_k_from - ptdf_k_to;
127
128 if (1.0 - ptdf_kk).abs() < crate::sensitivity::BRIDGE_THRESHOLD {
129 return Ok(vec![f64::INFINITY; monitored_branch_indices.len()]);
130 }
131
132 let denom = 1.0 - ptdf_kk;
133 let mut lodf_column = Vec::with_capacity(monitored_branch_indices.len());
134 for &monitored_branch_idx in monitored_branch_indices {
135 if monitored_branch_idx == outage_branch_idx {
136 lodf_column.push(-1.0);
137 continue;
138 }
139
140 let branch_l = self.model.branch_metadata(monitored_branch_idx)?;
141 if !branch_l.is_sensitivity_active() {
142 lodf_column.push(0.0);
143 continue;
144 }
145 if self.model.branch_kernel_indices[monitored_branch_idx] != Some(kernel_idx) {
146 lodf_column.push(0.0);
147 continue;
148 }
149
150 let from_l = branch_l.from_full;
151 let to_l = branch_l.to_full;
152 let b_l = branch_l.b_dc;
153 let ptdf_l_from = b_l
154 * (self.b_inverse_element(kernel_idx, from_column, from_l)
155 - self.b_inverse_element(kernel_idx, from_column, to_l));
156 let ptdf_l_to = b_l
157 * (self.b_inverse_element(kernel_idx, to_column, from_l)
158 - self.b_inverse_element(kernel_idx, to_column, to_l));
159 let ptdf_lk = ptdf_l_from - ptdf_l_to;
160 lodf_column.push(ptdf_lk / denom);
161 }
162
163 Ok(lodf_column)
164 }
165
166 pub fn stream_columns<F>(
168 &mut self,
169 monitored_branch_indices: &[usize],
170 outage_branch_indices: &[usize],
171 mut visit_column: F,
172 ) -> Result<(), DcError>
173 where
174 F: FnMut(usize, usize, &[f64]) -> Result<(), DcError>,
175 {
176 for (outage_pos, &outage_branch_idx) in outage_branch_indices.iter().enumerate() {
177 let column = self.compute_column(monitored_branch_indices, outage_branch_idx)?;
178 visit_column(outage_pos, outage_branch_idx, &column)?;
179 }
180 Ok(())
181 }
182
183 fn ensure_reduced_bus_column(
184 &mut self,
185 kernel_idx: usize,
186 full_bus_idx: usize,
187 ) -> Result<Option<usize>, DcError> {
188 let kernel = &self.model.kernels[kernel_idx];
189 let Some(reduced_idx) = self.model.kernels[kernel_idx]
190 .bprime
191 .full_to_reduced
192 .get(full_bus_idx)
193 .copied()
194 .flatten()
195 else {
196 return Ok(None);
197 };
198
199 if self.reduced_bus_columns_by_kernel[kernel_idx][reduced_idx].is_none() {
200 let mut rhs = vec![0.0f64; kernel.bprime.dim];
201 rhs[reduced_idx] = 1.0;
202 if self.model.kernels[kernel_idx].klu.solve(&mut rhs).is_err() {
203 return Err(DcError::SingularMatrix);
204 }
205 self.reduced_bus_columns_by_kernel[kernel_idx][reduced_idx] = Some(rhs);
206 }
207
208 Ok(Some(reduced_idx))
209 }
210
211 fn b_inverse_element(
212 &self,
213 kernel_idx: usize,
214 reduced_bus_column: Option<usize>,
215 full_bus_idx: usize,
216 ) -> f64 {
217 let kernel = &self.model.kernels[kernel_idx];
218 match (
219 reduced_bus_column,
220 kernel
221 .bprime
222 .full_to_reduced
223 .get(full_bus_idx)
224 .copied()
225 .flatten(),
226 ) {
227 (Some(column_idx), Some(row_idx)) => self.reduced_bus_columns_by_kernel[kernel_idx]
228 [column_idx]
229 .as_ref()
230 .expect("reduced-bus column should exist after ensure_reduced_bus_column")[row_idx],
231 _ => 0.0,
232 }
233 }
234}
235
236impl<'model, 'network> N2LodfColumnBuilder<'model, 'network> {
237 fn new(
238 model: &'model mut PreparedDcStudy<'network>,
239 monitored_branch_indices: &[usize],
240 candidate_outage_branch_indices: &[usize],
241 ) -> Result<Self, DcError> {
242 let n_branches = model.network.n_branches();
243 let mut column_branch_indices = Vec::with_capacity(
244 monitored_branch_indices.len() + candidate_outage_branch_indices.len(),
245 );
246 let mut column_positions = vec![None; n_branches];
247
248 for &branch_idx in monitored_branch_indices {
249 if branch_idx >= n_branches {
250 return Err(DcError::InvalidNetwork(format!(
251 "monitored branch index {branch_idx} out of range (len={n_branches})"
252 )));
253 }
254 if column_positions[branch_idx].is_none() {
255 column_positions[branch_idx] = Some(column_branch_indices.len());
256 column_branch_indices.push(branch_idx);
257 }
258 }
259
260 for &branch_idx in candidate_outage_branch_indices {
261 if branch_idx >= n_branches {
262 return Err(DcError::InvalidNetwork(format!(
263 "candidate outage branch index {branch_idx} out of range (len={n_branches})"
264 )));
265 }
266 if column_positions[branch_idx].is_none() {
267 column_positions[branch_idx] = Some(column_branch_indices.len());
268 column_branch_indices.push(branch_idx);
269 }
270 }
271
272 Ok(Self {
273 monitored_branch_indices: monitored_branch_indices.to_vec(),
274 column_branch_indices,
275 column_positions,
276 single_outage_columns: HashMap::new(),
277 lodf_columns: LodfColumnBuilder::new(model),
278 })
279 }
280
281 pub fn compute_column(
293 &mut self,
294 first_outage_branch_idx: usize,
295 second_outage_branch_idx: usize,
296 ) -> Result<Vec<f64>, DcError> {
297 if first_outage_branch_idx == second_outage_branch_idx {
298 return Err(DcError::Infeasible(format!(
299 "N-2 outage pair must contain two distinct branches, got ({first_outage_branch_idx}, {second_outage_branch_idx})"
300 )));
301 }
302
303 self.ensure_outage_column(first_outage_branch_idx)?;
304 self.ensure_outage_column(second_outage_branch_idx)?;
305
306 let first_position = self.branch_position(first_outage_branch_idx)?;
307 let second_position = self.branch_position(second_outage_branch_idx)?;
308
309 let first_column = self
310 .single_outage_columns
311 .get(&first_outage_branch_idx)
312 .expect("outage column should exist after ensure_outage_column");
313 let second_column = self
314 .single_outage_columns
315 .get(&second_outage_branch_idx)
316 .expect("outage column should exist after ensure_outage_column");
317
318 let lodf_second_first = first_column[second_position];
319 let lodf_first_second = second_column[first_position];
320 let denom = 1.0 - lodf_second_first * lodf_first_second;
321
322 if !lodf_second_first.is_finite()
323 || !lodf_first_second.is_finite()
324 || !denom.is_finite()
325 || denom.abs() < crate::sensitivity::BRIDGE_THRESHOLD
326 {
327 return Err(DcError::Infeasible(format!(
328 "N-2 LODF denominator invalid for branch pair ({first_outage_branch_idx},{second_outage_branch_idx})"
329 )));
330 }
331
332 let mut n2_column = Vec::with_capacity(self.monitored_branch_indices.len());
333 for &monitored_branch_idx in &self.monitored_branch_indices {
334 let monitored_position = self.branch_position(monitored_branch_idx)?;
335 let lodf_monitored_first = first_column[monitored_position];
336 let lodf_monitored_second = second_column[monitored_position];
337 n2_column
338 .push((lodf_monitored_first + lodf_monitored_second * lodf_second_first) / denom);
339 }
340
341 Ok(n2_column)
342 }
343
344 pub fn stream_columns<F>(
346 &mut self,
347 outage_pairs: &[(usize, usize)],
348 mut visit_column: F,
349 ) -> Result<(), DcError>
350 where
351 F: FnMut(usize, (usize, usize), &[f64]) -> Result<(), DcError>,
352 {
353 for (pair_pos, &outage_pair) in outage_pairs.iter().enumerate() {
354 let column = self.compute_column(outage_pair.0, outage_pair.1)?;
355 visit_column(pair_pos, outage_pair, &column)?;
356 }
357 Ok(())
358 }
359
360 fn ensure_outage_column(&mut self, outage_branch_idx: usize) -> Result<(), DcError> {
361 if self.single_outage_columns.contains_key(&outage_branch_idx) {
362 return Ok(());
363 }
364 let Some(_) = self.column_positions.get(outage_branch_idx) else {
365 return Err(DcError::InvalidNetwork(format!(
366 "outage branch index {outage_branch_idx} out of range (len={})",
367 self.column_positions.len()
368 )));
369 };
370 if self.column_positions[outage_branch_idx].is_none() {
371 return Err(DcError::InvalidNetwork(format!(
372 "outage branch index {outage_branch_idx} not in N-2 outage universe"
373 )));
374 }
375
376 let column = self
377 .lodf_columns
378 .compute_column(&self.column_branch_indices, outage_branch_idx)?;
379 self.single_outage_columns.insert(outage_branch_idx, column);
380 Ok(())
381 }
382
383 fn branch_position(&self, branch_idx: usize) -> Result<usize, DcError> {
384 self.column_positions
385 .get(branch_idx)
386 .and_then(|pos| *pos)
387 .ok_or_else(|| {
388 DcError::InvalidNetwork(format!(
389 "branch index {branch_idx} not available in the N-2 column universe"
390 ))
391 })
392 }
393}
394
395impl<'a> PreparedDcStudy<'a> {
396 pub fn new(network: &'a Network) -> Result<Self, DcError> {
398 Self::build(network, true)
399 }
400
401 pub fn new_unchecked(network: &'a Network) -> Result<Self, DcError> {
410 Self::build(network, false)
411 }
412
413 fn build(network: &'a Network, validate_ready: bool) -> Result<Self, DcError> {
414 if network.n_buses() == 0 {
415 return Err(DcError::EmptyNetwork);
416 }
417 if validate_ready {
418 network
419 .validate_for_dc_solve()
420 .map_err(|err| DcError::InvalidNetwork(err.to_string()))?;
421 } else {
422 network
423 .validate_structure()
424 .map_err(|err| DcError::InvalidNetwork(err.to_string()))?;
425 }
426
427 let bus_map = network.bus_index_map();
428 let islands = detect_islands(network, &bus_map);
429 let branch_metadata = build_branch_metadata(network, &bus_map)?;
430 let downward_headroom_by_bus_pu = build_downward_headroom_by_bus(network, &bus_map);
431 let mut kernels = Vec::new();
432 let mut bus_kernel_indices = vec![None; network.n_buses()];
433 let mut branch_kernel_indices = vec![None; network.n_branches()];
434 let mut bus_island_ids = vec![0usize; network.n_buses()];
435 let mut singleton_bus_indices = Vec::new();
436 for (island_id, island_buses) in islands.components.iter().enumerate() {
437 for &bus_idx in island_buses {
438 bus_island_ids[bus_idx] = island_id;
439 }
440 if island_buses.len() <= 1 {
441 if let Some(&bus_idx) = island_buses.first() {
442 singleton_bus_indices.push(bus_idx);
443 }
444 continue;
445 }
446
447 let slack_idx = choose_island_slack_idx(network, island_buses)?;
448 let island_bus_set: HashSet<usize> = island_buses.iter().copied().collect();
449 let branch_indices = island_branch_indices(network, &island_bus_set, &bus_map);
450 let bprime = if island_buses.len() == network.n_buses() && islands.n_islands == 1 {
451 build_bprime_sparse_for_buses(network, None, slack_idx)
452 } else {
453 build_bprime_sparse_for_buses(network, Some(&island_bus_set), slack_idx)
454 };
455 let p_injection_base = build_p_injection_for_buses(
456 network,
457 &bprime.full_to_reduced,
458 slack_idx,
459 Some(&island_bus_set),
460 );
461 let mut reduced_to_full = vec![0usize; bprime.dim];
462 for (full_idx, reduced_idx) in bprime.full_to_reduced.iter().enumerate() {
463 if let Some(ri) = reduced_idx {
464 reduced_to_full[*ri] = full_idx;
465 }
466 }
467 let mut klu = KluSolver::new(bprime.dim, &bprime.col_ptrs, &bprime.row_indices)
468 .map_err(|_| DcError::SingularMatrix)?;
469 if klu.factor(&bprime.values).is_err() {
470 return Err(DcError::SingularMatrix);
471 }
472
473 let kernel_idx = kernels.len();
474 for &bus_idx in island_buses {
475 bus_kernel_indices[bus_idx] = Some(kernel_idx);
476 }
477 for &branch_idx in &branch_indices {
478 branch_kernel_indices[branch_idx] = Some(kernel_idx);
479 }
480
481 kernels.push(PreparedDcKernel {
482 bus_indices: island_buses.clone(),
483 branch_indices,
484 bprime,
485 reduced_to_full,
486 p_injection_base,
487 reference_angle0_rad: network.buses[slack_idx].voltage_angle_rad,
488 klu,
489 });
490 }
491
492 Ok(Self {
493 network,
494 bus_map,
495 branch_metadata,
496 downward_headroom_by_bus_pu,
497 kernels,
498 bus_kernel_indices,
499 branch_kernel_indices,
500 bus_island_ids,
501 singleton_bus_indices,
502 })
503 }
504
505 pub fn solve(&mut self, opts: &DcPfOptions) -> Result<DcPfSolution, DcError> {
507 let start = Instant::now();
508 let mut theta_all = vec![0.0f64; self.network.n_buses()];
509 let mut branch_flow_all = vec![0.0f64; self.network.n_branches()];
510 let mut p_inject_all = vec![0.0f64; self.network.n_buses()];
511 let mut total_slack_p = 0.0f64;
512 let mut slack_distribution = HashMap::new();
513
514 for (bus_idx, kernel_idx) in self.bus_kernel_indices.iter().enumerate() {
515 if kernel_idx.is_none() {
516 theta_all[bus_idx] = self.network.buses[bus_idx].voltage_angle_rad;
517 apply_angle_reference_subset(
518 &mut theta_all,
519 self.network,
520 &[bus_idx],
521 bus_idx,
522 self.network.buses[bus_idx].voltage_angle_rad,
523 opts.angle_reference,
524 );
525 }
526 }
527 for &bus_idx in &self.singleton_bus_indices {
528 let slack_p = compute_singleton_slack_injection_pu(self.network, bus_idx, opts);
529 total_slack_p += slack_p;
530 p_inject_all[bus_idx] = slack_p;
531 if opts.participation_factors.is_some() || opts.headroom_slack_bus_indices.is_some() {
532 slack_distribution.insert(bus_idx, slack_p * self.network.base_mva);
533 }
534 }
535
536 for kernel in &mut self.kernels {
537 let result = solve_prepared(
538 self.network,
539 opts,
540 PreparedSolveInputs {
541 bus_map: &self.bus_map,
542 bus_indices: &kernel.bus_indices,
543 branch_indices: &kernel.branch_indices,
544 bprime: &kernel.bprime,
545 klu: &mut kernel.klu,
546 p_inj_base: &kernel.p_injection_base,
547 reference_angle0_rad: kernel.reference_angle0_rad,
548 },
549 start,
550 )?;
551
552 for &bus_idx in &kernel.bus_indices {
553 theta_all[bus_idx] = result.theta[bus_idx];
554 p_inject_all[bus_idx] = result.p_inject_pu[bus_idx];
555 }
556 for &branch_idx in &kernel.branch_indices {
557 branch_flow_all[branch_idx] = result.branch_p_flow[branch_idx];
558 }
559
560 total_slack_p += result.slack_p_injection;
561 slack_distribution.extend(result.slack_distribution);
562 }
563
564 let solve_time = start.elapsed().as_secs_f64();
565 Ok(DcPfSolution {
566 theta: theta_all,
567 branch_p_flow: branch_flow_all,
568 slack_p_injection: total_slack_p,
569 solve_time_secs: solve_time,
570 total_generation_mw: if opts.participation_factors.is_some()
571 || opts.headroom_slack_bus_indices.is_some()
572 {
573 self.network.total_load_mw() + total_slack_p * self.network.base_mva
574 } else {
575 0.0
576 },
577 slack_distribution,
578 p_inject_pu: p_inject_all,
579 island_ids: self.compute_island_ids(),
580 })
581 }
582
583 fn compute_island_ids(&self) -> Vec<usize> {
584 self.bus_island_ids.clone()
585 }
586
587 pub fn run_analysis(
589 &mut self,
590 request: &DcAnalysisRequest,
591 ) -> Result<DcAnalysisResult, DcError> {
592 let monitored_branch_indices = request
593 .monitored_branch_indices
594 .clone()
595 .unwrap_or_else(|| (0..self.network.n_branches()).collect());
596 let sensitivity_options = request.sensitivity_options.clone().unwrap_or_else(|| {
597 if let Some(pf_weights) = request.pf_options.participation_factors.as_ref() {
598 crate::sensitivity::DcSensitivityOptions::with_slack_weights(pf_weights)
599 } else if let Some(bus_indices) = request.pf_options.headroom_slack_bus_indices.as_ref()
600 {
601 crate::sensitivity::DcSensitivityOptions::with_headroom_slack(bus_indices)
602 } else {
603 crate::sensitivity::DcSensitivityOptions::default()
604 }
605 });
606 let normalized_slack =
607 self.resolve_sensitivity_slack_by_kernel(&sensitivity_options.slack)?;
608 let ptdf_bus_indices =
609 self.resolve_selected_bus_indices(request.ptdf_bus_indices.as_deref())?;
610 let power_flow = self.solve(&request.pf_options)?;
611 let ptdf = self.compute_ptdf_with_resolved_slack(
612 &monitored_branch_indices,
613 Some(&ptdf_bus_indices),
614 &normalized_slack,
615 )?;
616 let otdf_bus_indices = if request.otdf_outage_branch_indices.is_empty() {
617 Vec::new()
618 } else {
619 request
620 .otdf_bus_indices
621 .clone()
622 .unwrap_or_else(|| (0..self.network.n_buses()).collect())
623 };
624 let otdf = if request.otdf_outage_branch_indices.is_empty() {
625 None
626 } else {
627 Some(self.compute_otdf_with_resolved_slack(
628 &monitored_branch_indices,
629 &request.otdf_outage_branch_indices,
630 Some(&otdf_bus_indices),
631 &normalized_slack,
632 )?)
633 };
634 let lodf = if request.lodf_outage_branch_indices.is_empty() {
635 None
636 } else {
637 Some(self.compute_lodf(
638 &monitored_branch_indices,
639 &request.lodf_outage_branch_indices,
640 )?)
641 };
642 let n2_lodf = if request.n2_outage_pairs.is_empty() {
643 None
644 } else {
645 Some(self.compute_n2_lodf_batch(&request.n2_outage_pairs, &monitored_branch_indices)?)
646 };
647
648 Ok(DcAnalysisResult {
649 power_flow,
650 monitored_branch_indices,
651 ptdf,
652 ptdf_bus_indices,
653 otdf,
654 otdf_outage_branch_indices: request.otdf_outage_branch_indices.clone(),
655 otdf_bus_indices,
656 lodf,
657 lodf_outage_branch_indices: request.lodf_outage_branch_indices.clone(),
658 n2_lodf,
659 n2_outage_pairs: request.n2_outage_pairs.clone(),
660 })
661 }
662
663 pub fn compute_ptdf(
665 &mut self,
666 monitored_branch_indices: &[usize],
667 ) -> Result<PtdfRows, DcError> {
668 self.compute_ptdf_with_resolved_slack(
669 monitored_branch_indices,
670 None,
671 &self.single_slack_by_kernel(),
672 )
673 }
674
675 pub fn compute_loss_sensitivities_adjoint(
682 &mut self,
683 theta: &[f64],
684 ) -> Result<Vec<f64>, DcError> {
685 let n_bus = self.network.n_buses();
686 if theta.len() != n_bus {
687 return Err(DcError::InvalidNetwork(format!(
688 "loss sensitivity theta length {} does not match network bus count {}",
689 theta.len(),
690 n_bus
691 )));
692 }
693
694 let mut rhs_by_kernel: Vec<Vec<f64>> = self
695 .kernels
696 .iter()
697 .map(|kernel| vec![0.0_f64; kernel.bprime.dim])
698 .collect();
699
700 for (branch_idx, branch) in self.network.branches.iter().enumerate() {
701 let branch_meta = self.branch_metadata(branch_idx)?;
702 if !branch_meta.is_sensitivity_active() {
703 continue;
704 }
705 if branch.r.abs() < 1e-20 {
706 continue;
707 }
708 let Some(kernel_idx) = self.branch_kernel_indices[branch_idx] else {
709 continue;
710 };
711
712 let flow_pu =
713 branch_meta.b_dc * (theta[branch_meta.from_full] - theta[branch_meta.to_full]);
714 let weight = 2.0 * branch.r * flow_pu * branch_meta.b_dc;
715 if weight.abs() < 1e-20 {
716 continue;
717 }
718
719 let kernel = &self.kernels[kernel_idx];
720 let rhs = &mut rhs_by_kernel[kernel_idx];
721 if let Some(ri_from) = kernel.bprime.full_to_reduced[branch_meta.from_full] {
722 rhs[ri_from] += weight;
723 }
724 if let Some(ri_to) = kernel.bprime.full_to_reduced[branch_meta.to_full] {
725 rhs[ri_to] -= weight;
726 }
727 }
728
729 let mut dloss_dp = vec![0.0_f64; n_bus];
730 for (kernel_idx, kernel) in self.kernels.iter_mut().enumerate() {
731 let rhs = &mut rhs_by_kernel[kernel_idx];
732 if rhs.is_empty() {
733 continue;
734 }
735 kernel.klu.solve(rhs).map_err(|_| DcError::SingularMatrix)?;
736 for (ri, &full_bus_idx) in kernel.reduced_to_full.iter().enumerate() {
737 dloss_dp[full_bus_idx] = rhs[ri];
738 }
739 }
742
743 Ok(dloss_dp)
744 }
745
746 pub fn compute_ptdf_request(
748 &mut self,
749 request: &crate::sensitivity::PtdfRequest,
750 ) -> Result<PtdfRows, DcError> {
751 let monitored_branch_indices_storage;
752 let monitored_branch_indices = if let Some(indices) =
753 request.monitored_branch_indices.as_deref()
754 {
755 indices
756 } else {
757 monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
758 &monitored_branch_indices_storage
759 };
760 self.compute_ptdf_with_options(
761 monitored_branch_indices,
762 request.bus_indices.as_deref(),
763 &request.options,
764 )
765 }
766
767 fn compute_ptdf_with_resolved_slack(
768 &mut self,
769 monitored_branch_indices: &[usize],
770 bus_indices: Option<&[usize]>,
771 normalized_slack_by_kernel: &[Option<Vec<(usize, f64)>>],
772 ) -> Result<PtdfRows, DcError> {
773 let selected_bus_indices = self.resolve_selected_bus_indices(bus_indices)?;
774 let n_branches = self.network.n_branches();
775 let mut result = PtdfRows::new(
776 monitored_branch_indices,
777 &selected_bus_indices,
778 n_branches,
779 self.network.n_buses(),
780 );
781 let mut monitored_by_kernel = vec![Vec::<(usize, usize)>::new(); self.kernels.len()];
782 for (row_pos, &branch_idx) in monitored_branch_indices.iter().enumerate() {
783 let branch = self.branch_metadata(branch_idx)?;
784 if !branch.is_sensitivity_active() {
785 continue;
786 }
787 if let Some(kernel_idx) = self.branch_kernel_indices[branch_idx] {
788 monitored_by_kernel[kernel_idx].push((row_pos, branch_idx));
789 }
790 }
791 let mut bus_positions_by_kernel = vec![Vec::<(usize, usize)>::new(); self.kernels.len()];
792 for (bus_pos, &bus_idx) in selected_bus_indices.iter().enumerate() {
793 if let Some(kernel_idx) = self.bus_kernel_indices[bus_idx] {
794 bus_positions_by_kernel[kernel_idx].push((bus_pos, bus_idx));
795 }
796 }
797
798 for kernel_idx in 0..self.kernels.len() {
799 if monitored_by_kernel[kernel_idx].is_empty() {
800 continue;
801 }
802
803 let local_monitored: Vec<usize> = monitored_by_kernel[kernel_idx]
804 .iter()
805 .map(|&(_, branch_idx)| branch_idx)
806 .collect();
807 let local_buses: Vec<usize> = bus_positions_by_kernel[kernel_idx]
808 .iter()
809 .map(|&(_, bus_idx)| bus_idx)
810 .collect();
811 let local_ptdf = self.compute_kernel_ptdf_with_resolved_slack(
812 kernel_idx,
813 &local_monitored,
814 Some(&local_buses),
815 normalized_slack_by_kernel
816 .get(kernel_idx)
817 .and_then(|weights| weights.as_deref()),
818 )?;
819 for (local_row_pos, &(global_row_pos, _)) in
820 monitored_by_kernel[kernel_idx].iter().enumerate()
821 {
822 let source_row = local_ptdf.row_at(local_row_pos);
823 let target_row = result.row_at_mut(global_row_pos);
824 for (local_bus_pos, &(global_bus_pos, _)) in
825 bus_positions_by_kernel[kernel_idx].iter().enumerate()
826 {
827 target_row[global_bus_pos] = source_row[local_bus_pos];
828 }
829 }
830 }
831
832 Ok(result)
833 }
834
835 fn compute_kernel_ptdf_with_resolved_slack(
836 &mut self,
837 kernel_idx: usize,
838 monitored_branch_indices: &[usize],
839 bus_indices: Option<&[usize]>,
840 normalized_slack: Option<&[(usize, f64)]>,
841 ) -> Result<PtdfRows, DcError> {
842 let uses_full_bus_axis = bus_indices.is_none();
843 let mut active_branches = Vec::with_capacity(monitored_branch_indices.len());
844 for (row_pos, &branch_idx) in monitored_branch_indices.iter().enumerate() {
845 let branch = self.branch_metadata(branch_idx)?;
846 if !branch.is_sensitivity_active() {
847 continue;
848 }
849 active_branches.push((row_pos, branch.b_dc, branch.from_full, branch.to_full));
850 }
851 let kernel = &mut self.kernels[kernel_idx];
852 let selected_bus_indices = bus_indices
853 .map(|indices| indices.to_vec())
854 .unwrap_or_else(|| kernel.bus_indices.clone());
855 let mut result = PtdfRows::new(
856 monitored_branch_indices,
857 &selected_bus_indices,
858 self.network.n_branches(),
859 self.network.n_buses(),
860 );
861
862 const PTDF_SOLVE_BATCH_SIZE: usize = 128;
863 for branch_chunk in active_branches.chunks(PTDF_SOLVE_BATCH_SIZE) {
864 let chunk_len = branch_chunk.len();
865 let mut rhs_batch = vec![0.0f64; kernel.bprime.dim * chunk_len];
866
867 for (chunk_pos, &(_, _, from_full, to_full)) in branch_chunk.iter().enumerate() {
868 let rhs = &mut rhs_batch
869 [chunk_pos * kernel.bprime.dim..(chunk_pos + 1) * kernel.bprime.dim];
870 if let Some(ri_from) = kernel.bprime.full_to_reduced[from_full] {
871 rhs[ri_from] = 1.0;
872 }
873 if let Some(ri_to) = kernel.bprime.full_to_reduced[to_full] {
874 rhs[ri_to] -= 1.0;
875 }
876 }
877
878 kernel
879 .klu
880 .solve_many(&mut rhs_batch, chunk_len)
881 .map_err(|_| DcError::SingularMatrix)?;
882
883 for (chunk_pos, &(row_pos, branch_b, _, _)) in branch_chunk.iter().enumerate() {
884 let rhs =
885 &rhs_batch[chunk_pos * kernel.bprime.dim..(chunk_pos + 1) * kernel.bprime.dim];
886 let row = result.row_at_mut(row_pos);
887 let correction = normalized_slack
888 .map(|participants| {
889 participants
890 .iter()
891 .map(|&(bus_idx, weight)| {
892 let raw_value = kernel.bprime.full_to_reduced[bus_idx]
893 .map(|ri| branch_b * rhs[ri])
894 .unwrap_or(0.0);
895 raw_value * weight
896 })
897 .sum()
898 })
899 .unwrap_or(0.0);
900 if uses_full_bus_axis {
901 for (ri, &full_bus_idx) in kernel.reduced_to_full.iter().enumerate() {
902 row[full_bus_idx] = branch_b * rhs[ri] - correction;
903 }
904 if normalized_slack.is_some() && kernel.bprime.slack_idx < row.len() {
905 row[kernel.bprime.slack_idx] = -correction;
906 }
907 } else {
908 for (bus_pos, &full_bus_idx) in selected_bus_indices.iter().enumerate() {
909 row[bus_pos] = kernel.bprime.full_to_reduced[full_bus_idx]
910 .map(|ri| branch_b * rhs[ri] - correction)
911 .unwrap_or(-correction);
912 }
913 }
914 }
915 }
916
917 Ok(result)
918 }
919
920 pub fn compute_ptdf_with_options(
921 &mut self,
922 monitored_branch_indices: &[usize],
923 bus_indices: Option<&[usize]>,
924 options: &crate::sensitivity::DcSensitivityOptions,
925 ) -> Result<PtdfRows, DcError> {
926 let normalized_slack = self.resolve_sensitivity_slack_by_kernel(&options.slack)?;
927 self.compute_ptdf_with_resolved_slack(
928 monitored_branch_indices,
929 bus_indices,
930 &normalized_slack,
931 )
932 }
933
934 pub fn compute_otdf(
936 &mut self,
937 monitored_branch_indices: &[usize],
938 outage_branch_indices: &[usize],
939 ) -> Result<OtdfResult, DcError> {
940 let all_bus_indices: Vec<usize> = (0..self.network.n_buses()).collect();
941 self.compute_otdf_with_resolved_slack(
942 monitored_branch_indices,
943 outage_branch_indices,
944 Some(&all_bus_indices),
945 &self.single_slack_by_kernel(),
946 )
947 }
948
949 pub fn compute_otdf_request(
951 &mut self,
952 request: &crate::sensitivity::OtdfRequest,
953 ) -> Result<OtdfResult, DcError> {
954 self.compute_otdf_with_options(
955 &request.monitored_branch_indices,
956 &request.outage_branch_indices,
957 request.bus_indices.as_deref(),
958 &request.options,
959 )
960 }
961
962 pub(crate) fn compute_otdf_with_options(
963 &mut self,
964 monitored_branch_indices: &[usize],
965 outage_branch_indices: &[usize],
966 bus_indices: Option<&[usize]>,
967 options: &crate::sensitivity::DcSensitivityOptions,
968 ) -> Result<OtdfResult, DcError> {
969 let normalized_slack = self.resolve_sensitivity_slack_by_kernel(&options.slack)?;
970 self.compute_otdf_with_resolved_slack(
971 monitored_branch_indices,
972 outage_branch_indices,
973 bus_indices,
974 &normalized_slack,
975 )
976 }
977
978 fn compute_otdf_with_resolved_slack(
979 &mut self,
980 monitored_branch_indices: &[usize],
981 outage_branch_indices: &[usize],
982 bus_indices: Option<&[usize]>,
983 normalized_slack_by_kernel: &[Option<Vec<(usize, f64)>>],
984 ) -> Result<OtdfResult, DcError> {
985 let selected_bus_indices =
986 crate::sensitivity::collect_selected_bus_indices(self.network, bus_indices)?;
987 let union_branch_indices = crate::sensitivity::collect_unique_branch_indices(
988 self.network.n_branches(),
989 monitored_branch_indices,
990 outage_branch_indices,
991 )?;
992 let ptdf = self.compute_ptdf_with_resolved_slack(
993 &union_branch_indices,
994 None,
995 normalized_slack_by_kernel,
996 )?;
997 crate::sensitivity::compute_otdf_from_ptdf(
998 self.network,
999 monitored_branch_indices,
1000 outage_branch_indices,
1001 &selected_bus_indices,
1002 &ptdf,
1003 )
1004 }
1005
1006 #[allow(clippy::type_complexity)]
1007 fn resolve_sensitivity_slack_by_kernel(
1008 &self,
1009 slack: &crate::sensitivity::DcSensitivitySlack,
1010 ) -> Result<Vec<Option<Vec<(usize, f64)>>>, DcError> {
1011 let mut per_kernel = vec![None; self.kernels.len()];
1012 match slack {
1013 crate::sensitivity::DcSensitivitySlack::SingleSlack => Ok(per_kernel),
1014 crate::sensitivity::DcSensitivitySlack::SlackWeights(slack_weights) => {
1015 let mut grouped_weights = vec![Vec::new(); self.kernels.len()];
1016 for &(bus_idx, weight) in slack_weights {
1017 if bus_idx >= self.network.n_buses() {
1018 return Err(DcError::InvalidNetwork(format!(
1019 "slack weight bus index {bus_idx} out of range for network with {} buses",
1020 self.network.n_buses()
1021 )));
1022 }
1023 if let Some(kernel_idx) = self.bus_kernel_indices[bus_idx] {
1024 grouped_weights[kernel_idx].push((bus_idx, weight));
1025 }
1026 }
1027 for (kernel_idx, weights) in grouped_weights.into_iter().enumerate() {
1028 let normalized = self.normalize_sensitivity_slack_weights(weights)?;
1029 if !normalized.is_empty() {
1030 per_kernel[kernel_idx] = Some(normalized);
1031 }
1032 }
1033 Ok(per_kernel)
1034 }
1035 crate::sensitivity::DcSensitivitySlack::HeadroomSlack(participating_bus_indices) => {
1036 let mut grouped_weights = vec![Vec::new(); self.kernels.len()];
1037 for &bus_idx in participating_bus_indices {
1038 if bus_idx >= self.network.n_buses() {
1039 return Err(DcError::InvalidNetwork(format!(
1040 "participating bus index {bus_idx} out of range for network with {} buses",
1041 self.network.n_buses()
1042 )));
1043 }
1044 let Some(kernel_idx) = self.bus_kernel_indices[bus_idx] else {
1045 continue;
1046 };
1047 if bus_idx == self.kernels[kernel_idx].bprime.slack_idx {
1048 continue;
1049 }
1050 let headroom = self.downward_headroom_by_bus_pu[bus_idx];
1051 if headroom > 0.0 {
1052 grouped_weights[kernel_idx].push((bus_idx, headroom));
1053 }
1054 }
1055 for (kernel_idx, weights) in grouped_weights.into_iter().enumerate() {
1056 let normalized = self.normalize_sensitivity_slack_weights(weights)?;
1057 if !normalized.is_empty() {
1058 per_kernel[kernel_idx] = Some(normalized);
1059 }
1060 }
1061 Ok(per_kernel)
1062 }
1063 }
1064 }
1065
1066 fn normalize_sensitivity_slack_weights<I>(
1067 &self,
1068 slack_weights: I,
1069 ) -> Result<Vec<(usize, f64)>, DcError>
1070 where
1071 I: IntoIterator<Item = (usize, f64)>,
1072 {
1073 let n_buses = self.network.n_buses();
1074 let mut normalized_weights: HashMap<usize, f64> = HashMap::new();
1075 let mut total_weight = 0.0;
1076
1077 for (bus_idx, weight) in slack_weights {
1078 if bus_idx >= n_buses {
1079 return Err(DcError::InvalidNetwork(format!(
1080 "slack weight bus index {bus_idx} out of range for network with {n_buses} buses"
1081 )));
1082 }
1083 if !weight.is_finite() {
1084 return Err(DcError::InvalidNetwork(format!(
1085 "slack weight for bus {bus_idx} must be finite"
1086 )));
1087 }
1088 let clipped_weight = weight.max(0.0);
1089 if clipped_weight > 0.0 {
1090 *normalized_weights.entry(bus_idx).or_insert(0.0) += clipped_weight;
1091 }
1092 total_weight += clipped_weight;
1093 }
1094
1095 if total_weight < 1e-12 {
1096 return Ok(Vec::new());
1097 }
1098
1099 let mut normalized_weights: Vec<(usize, f64)> = normalized_weights
1100 .into_iter()
1101 .map(|(bus_idx, weight)| (bus_idx, weight / total_weight))
1102 .collect();
1103 normalized_weights.sort_unstable_by_key(|(bus_idx, _)| *bus_idx);
1104 Ok(normalized_weights)
1105 }
1106
1107 fn resolve_selected_bus_indices(
1108 &self,
1109 bus_indices: Option<&[usize]>,
1110 ) -> Result<Vec<usize>, DcError> {
1111 crate::sensitivity::collect_selected_bus_indices(self.network, bus_indices)
1112 }
1113
1114 fn single_slack_by_kernel(&self) -> Vec<Option<Vec<(usize, f64)>>> {
1115 vec![None; self.kernels.len()]
1116 }
1117
1118 fn branch_kernel_index(&self, branch_idx: usize) -> Result<usize, DcError> {
1119 self.branch_kernel_indices
1120 .get(branch_idx)
1121 .copied()
1122 .flatten()
1123 .ok_or_else(|| {
1124 DcError::Infeasible(format!(
1125 "branch {branch_idx} does not belong to an active prepared DC island"
1126 ))
1127 })
1128 }
1129
1130 pub fn lodf_columns(&mut self) -> LodfColumnBuilder<'_, 'a> {
1132 LodfColumnBuilder::new(self)
1133 }
1134
1135 pub fn n2_lodf_columns(
1137 &mut self,
1138 monitored_branch_indices: &[usize],
1139 candidate_outage_branch_indices: &[usize],
1140 ) -> Result<N2LodfColumnBuilder<'_, 'a>, DcError> {
1141 N2LodfColumnBuilder::new(
1142 self,
1143 monitored_branch_indices,
1144 candidate_outage_branch_indices,
1145 )
1146 }
1147
1148 pub fn compute_lodf(
1150 &mut self,
1151 monitored_branch_indices: &[usize],
1152 outage_branch_indices: &[usize],
1153 ) -> Result<LodfResult, DcError> {
1154 let mut lodf =
1155 Mat::<f64>::zeros(monitored_branch_indices.len(), outage_branch_indices.len());
1156 let mut lodf_columns = self.lodf_columns();
1157 lodf_columns.stream_columns(
1158 monitored_branch_indices,
1159 outage_branch_indices,
1160 |outage_pos, _outage_branch_idx, column| {
1161 for (monitored_pos, &value) in column.iter().enumerate() {
1162 lodf[(monitored_pos, outage_pos)] = value;
1163 }
1164 Ok(())
1165 },
1166 )?;
1167 Ok(LodfResult::new(
1168 monitored_branch_indices,
1169 outage_branch_indices,
1170 lodf,
1171 ))
1172 }
1173
1174 pub fn compute_lodf_request(
1176 &mut self,
1177 request: &crate::sensitivity::LodfRequest,
1178 ) -> Result<LodfResult, DcError> {
1179 let monitored_branch_indices_storage;
1180 let monitored_branch_indices = if let Some(indices) =
1181 request.monitored_branch_indices.as_deref()
1182 {
1183 indices
1184 } else {
1185 monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1186 &monitored_branch_indices_storage
1187 };
1188 let outage_branch_indices = request
1189 .outage_branch_indices
1190 .as_deref()
1191 .unwrap_or(monitored_branch_indices);
1192 self.compute_lodf(monitored_branch_indices, outage_branch_indices)
1193 }
1194
1195 pub fn compute_lodf_pairs(
1199 &mut self,
1200 monitored_branch_indices: &[usize],
1201 outage_branch_indices: &[usize],
1202 ) -> Result<LodfPairs, DcError> {
1203 let mut pairs =
1204 HashMap::with_capacity(monitored_branch_indices.len() * outage_branch_indices.len());
1205 let mut lodf_columns = self.lodf_columns();
1206 lodf_columns.stream_columns(
1207 monitored_branch_indices,
1208 outage_branch_indices,
1209 |_, outage_branch_idx, column| {
1210 for (monitored_pos, &monitored_branch_idx) in
1211 monitored_branch_indices.iter().enumerate()
1212 {
1213 pairs.insert(
1214 (monitored_branch_idx, outage_branch_idx),
1215 column[monitored_pos],
1216 );
1217 }
1218 Ok(())
1219 },
1220 )?;
1221 Ok(LodfPairs::new(
1222 monitored_branch_indices,
1223 outage_branch_indices,
1224 pairs,
1225 ))
1226 }
1227
1228 pub fn compute_lodf_matrix(&mut self, branches: &[usize]) -> Result<LodfMatrixResult, DcError> {
1230 let lodf = self.compute_lodf(branches, branches)?;
1231 Ok(LodfMatrixResult::new(branches, lodf.into_parts().2))
1232 }
1233
1234 pub fn compute_lodf_matrix_request(
1236 &mut self,
1237 request: &crate::sensitivity::LodfMatrixRequest,
1238 ) -> Result<LodfMatrixResult, DcError> {
1239 let branch_indices_storage;
1240 let branch_indices = if let Some(indices) = request.branch_indices.as_deref() {
1241 indices
1242 } else {
1243 branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1244 &branch_indices_storage
1245 };
1246 self.compute_lodf_matrix(branch_indices)
1247 }
1248
1249 pub fn compute_n2_lodf(
1251 &mut self,
1252 outage_pair: (usize, usize),
1253 monitored_branch_indices: &[usize],
1254 ) -> Result<N2LodfResult, DcError> {
1255 let (k, l) = outage_pair;
1256 let n_br = self.network.n_branches();
1257
1258 if k >= n_br || l >= n_br {
1259 return Err(DcError::Infeasible(format!(
1260 "outage_pair ({k}, {l}) out of range for network with {n_br} branches"
1261 )));
1262 }
1263 if k == l {
1264 return Err(DcError::Infeasible(format!(
1265 "N-2 outage pair must contain two distinct branches, got ({k}, {l})"
1266 )));
1267 }
1268 let mut n2_columns = self.n2_lodf_columns(monitored_branch_indices, &[k, l])?;
1269 let values = n2_columns.compute_column(k, l)?;
1270 Ok(N2LodfResult::new(
1271 monitored_branch_indices,
1272 outage_pair,
1273 values,
1274 ))
1275 }
1276
1277 pub fn compute_n2_lodf_request(
1279 &mut self,
1280 request: &crate::sensitivity::N2LodfRequest,
1281 ) -> Result<N2LodfResult, DcError> {
1282 let monitored_branch_indices_storage;
1283 let monitored_branch_indices = if let Some(indices) =
1284 request.monitored_branch_indices.as_deref()
1285 {
1286 indices
1287 } else {
1288 monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1289 &monitored_branch_indices_storage
1290 };
1291 self.compute_n2_lodf(request.outage_pair, monitored_branch_indices)
1292 }
1293
1294 pub fn compute_n2_lodf_batch(
1296 &mut self,
1297 outage_pairs: &[(usize, usize)],
1298 monitored_branch_indices: &[usize],
1299 ) -> Result<N2LodfBatchResult, DcError> {
1300 let mut unique_outages = Vec::with_capacity(outage_pairs.len() * 2);
1301 for &(first, second) in outage_pairs {
1302 unique_outages.push(first);
1303 unique_outages.push(second);
1304 }
1305 unique_outages.sort_unstable();
1306 unique_outages.dedup();
1307
1308 let mut n2_columns = self.n2_lodf_columns(monitored_branch_indices, &unique_outages)?;
1309 let mut result = Mat::<f64>::zeros(monitored_branch_indices.len(), outage_pairs.len());
1310 n2_columns.stream_columns(outage_pairs, |pair_pos, _pair, column| {
1311 for (monitored_pos, &value) in column.iter().enumerate() {
1312 result[(monitored_pos, pair_pos)] = value;
1313 }
1314 Ok(())
1315 })?;
1316 Ok(N2LodfBatchResult::new(
1317 monitored_branch_indices,
1318 outage_pairs,
1319 result,
1320 ))
1321 }
1322
1323 pub fn compute_n2_lodf_batch_request(
1325 &mut self,
1326 request: &crate::sensitivity::N2LodfBatchRequest,
1327 ) -> Result<N2LodfBatchResult, DcError> {
1328 let monitored_branch_indices_storage;
1329 let monitored_branch_indices = if let Some(indices) =
1330 request.monitored_branch_indices.as_deref()
1331 {
1332 indices
1333 } else {
1334 monitored_branch_indices_storage = (0..self.network.n_branches()).collect::<Vec<_>>();
1335 &monitored_branch_indices_storage
1336 };
1337 self.compute_n2_lodf_batch(&request.outage_pairs, monitored_branch_indices)
1338 }
1339
1340 fn branch_terminal_indices(&self, branch_idx: usize) -> Result<(usize, usize), DcError> {
1341 let branch = self.branch_metadata(branch_idx)?;
1342 if !branch.in_service {
1343 return Err(DcError::Infeasible(format!(
1344 "branch {branch_idx} is out of service and cannot be used as an outage"
1345 )));
1346 }
1347 if !branch.has_reactance {
1348 return Err(DcError::Infeasible(format!(
1349 "branch {branch_idx} has near-zero reactance and cannot be used as an outage"
1350 )));
1351 }
1352 Ok((branch.from_full, branch.to_full))
1353 }
1354
1355 #[inline(always)]
1356 fn branch_metadata(&self, branch_idx: usize) -> Result<BranchDcMetadata, DcError> {
1357 self.branch_metadata
1358 .get(branch_idx)
1359 .copied()
1360 .ok_or_else(|| {
1361 DcError::InvalidNetwork(format!(
1362 "branch index {branch_idx} out of range (len={})",
1363 self.network.branches.len()
1364 ))
1365 })
1366 }
1367}
1368
1369fn compute_singleton_slack_injection_pu(
1370 network: &Network,
1371 bus_idx: usize,
1372 opts: &DcPfOptions,
1373) -> f64 {
1374 let mut full_to_reduced = vec![None; network.n_buses()];
1375 full_to_reduced[bus_idx] = Some(0);
1376 let included_buses = HashSet::from([bus_idx]);
1377 let effective_injection =
1378 build_p_injection_for_buses(network, &full_to_reduced, usize::MAX, Some(&included_buses));
1379 let bus_number = network.buses[bus_idx].number;
1380 let external_p_pu: f64 = opts
1381 .external_p_injections_mw
1382 .iter()
1383 .filter(|(target_bus, _)| *target_bus == bus_number)
1384 .map(|(_, p_mw)| *p_mw / network.base_mva)
1385 .sum();
1386 -(effective_injection[0] + external_p_pu)
1387}
1388
1389pub fn solve_dc(network: &Network) -> Result<DcPfSolution, DcError> {
1404 solve_dc_opts(network, &DcPfOptions::default())
1405}
1406
1407pub fn solve_dc_opts(network: &Network, opts: &DcPfOptions) -> Result<DcPfSolution, DcError> {
1420 let mut study = PreparedDcStudy::new(network)?;
1421 study.solve(opts)
1422}
1423
1424pub fn compute_loss_sensitivities_adjoint(
1429 network: &Network,
1430 theta: &[f64],
1431) -> Result<Vec<f64>, DcError> {
1432 let mut study = PreparedDcStudy::new(network)?;
1433 study.compute_loss_sensitivities_adjoint(theta)
1434}
1435
1436pub fn run_dc_analysis(
1438 network: &Network,
1439 request: &DcAnalysisRequest,
1440) -> Result<DcAnalysisResult, DcError> {
1441 let mut study = PreparedDcStudy::new(network)?;
1442 study.run_analysis(request)
1443}
1444
1445fn build_branch_metadata(
1446 network: &Network,
1447 bus_map: &HashMap<u32, usize>,
1448) -> Result<Vec<BranchDcMetadata>, DcError> {
1449 network
1450 .branches
1451 .iter()
1452 .enumerate()
1453 .map(|(branch_idx, branch)| {
1454 let from_full = bus_map.get(&branch.from_bus).copied().ok_or_else(|| {
1455 DcError::InvalidNetwork(format!(
1456 "branch {branch_idx} references unknown from_bus {}",
1457 branch.from_bus
1458 ))
1459 })?;
1460 let to_full = bus_map.get(&branch.to_bus).copied().ok_or_else(|| {
1461 DcError::InvalidNetwork(format!(
1462 "branch {branch_idx} references unknown to_bus {}",
1463 branch.to_bus
1464 ))
1465 })?;
1466 Ok(BranchDcMetadata {
1467 from_full,
1468 to_full,
1469 b_dc: if branch.x.abs() >= crate::types::MIN_REACTANCE {
1470 branch.b_dc()
1471 } else {
1472 0.0
1473 },
1474 in_service: branch.in_service,
1475 has_reactance: branch.x.abs() >= crate::types::MIN_REACTANCE,
1476 })
1477 })
1478 .collect()
1479}
1480
1481fn build_downward_headroom_by_bus(network: &Network, bus_map: &HashMap<u32, usize>) -> Vec<f64> {
1482 let mut downward_headroom_by_bus_pu = vec![0.0; network.n_buses()];
1483 for generator in network
1484 .generators
1485 .iter()
1486 .filter(|generator| generator.in_service)
1487 {
1488 if let Some(&bus_idx) = bus_map.get(&generator.bus) {
1489 downward_headroom_by_bus_pu[bus_idx] +=
1490 (generator.p - generator.pmin).max(0.0) / network.base_mva;
1491 }
1492 }
1493 downward_headroom_by_bus_pu
1494}
1495
1496fn choose_island_slack_idx(network: &Network, island_buses: &[usize]) -> Result<usize, DcError> {
1497 island_buses
1498 .iter()
1499 .copied()
1500 .find(|&idx| network.buses[idx].bus_type == BusType::Slack)
1501 .or_else(|| {
1502 island_buses
1503 .iter()
1504 .copied()
1505 .find(|&idx| network.buses[idx].bus_type == BusType::PV)
1506 })
1507 .or_else(|| island_buses.first().copied())
1508 .ok_or(DcError::NoSlackBus)
1509}
1510
1511fn island_branch_indices(
1512 network: &Network,
1513 island_bus_set: &HashSet<usize>,
1514 bus_map: &HashMap<u32, usize>,
1515) -> Vec<usize> {
1516 network
1517 .branches
1518 .iter()
1519 .enumerate()
1520 .filter_map(|(idx, branch)| {
1521 if !branch.in_service || branch.x.abs() < crate::types::MIN_REACTANCE {
1522 return None;
1523 }
1524 let from_idx = *bus_map.get(&branch.from_bus)?;
1525 let to_idx = *bus_map.get(&branch.to_bus)?;
1526 (island_bus_set.contains(&from_idx) && island_bus_set.contains(&to_idx)).then_some(idx)
1527 })
1528 .collect()
1529}
1530
1531fn solve_prepared(
1532 network: &Network,
1533 opts: &DcPfOptions,
1534 mut prepared: PreparedSolveInputs<'_>,
1535 start: Instant,
1536) -> Result<DcPfSolution, DcError> {
1537 let n = network.n_buses();
1538 let slack_idx = prepared.bprime.slack_idx;
1539
1540 let effective_p_inj: Vec<f64> = if opts.external_p_injections_mw.is_empty() {
1542 prepared.p_inj_base.to_vec()
1543 } else {
1544 let mut p = prepared.p_inj_base.to_vec();
1545 for &(bus_number, p_mw) in &opts.external_p_injections_mw {
1546 if let Some(&full_idx) = prepared.bus_map.get(&bus_number) {
1547 if let Some(ri) = prepared.bprime.full_to_reduced[full_idx] {
1548 p[ri] += p_mw / network.base_mva;
1549 }
1550 }
1551 }
1552 p
1553 };
1554
1555 let mut p = effective_p_inj.clone();
1556 if prepared.klu.solve(&mut p).is_err() {
1557 error!(buses = n, "DC B' solve failed");
1558 return Err(DcError::SingularMatrix);
1559 }
1560
1561 let mut theta = reconstruct_theta(n, &prepared.bprime.full_to_reduced, &p);
1562
1563 let mut branch_p_flow = compute_branch_flows_subset(
1564 network,
1565 theta.as_slice(),
1566 prepared.branch_indices,
1567 prepared.bus_map,
1568 );
1569 let slack_p_1 = compute_slack_injection_subset(
1570 network,
1571 &branch_p_flow,
1572 prepared.branch_indices,
1573 prepared.bus_map,
1574 slack_idx,
1575 );
1576
1577 let (slack_p_injection, slack_distribution, total_generation_mw, p_inject_pu) =
1578 if let Some(pf_weights) = opts.participation_factors.as_ref() {
1579 if pf_weights.is_empty() {
1580 warn!("participation_factors list is empty — falling back to single slack");
1581 let p_inj_full = expand_p_inject(
1582 n,
1583 &prepared.bprime.full_to_reduced,
1584 &effective_p_inj,
1585 slack_idx,
1586 slack_p_1,
1587 );
1588 (slack_p_1, HashMap::new(), 0.0, p_inj_full)
1589 } else {
1590 apply_participation_factor_slack(
1591 network,
1592 &mut prepared,
1593 pf_weights,
1594 &effective_p_inj,
1595 slack_p_1,
1596 slack_idx,
1597 &mut theta,
1598 &mut branch_p_flow,
1599 )?
1600 }
1601 } else if let Some(participating_buses) = opts.headroom_slack_bus_indices.as_ref() {
1602 if participating_buses.is_empty() {
1603 warn!("headroom slack bus list is empty — falling back to single slack");
1604 let p_inj_full = expand_p_inject(
1605 n,
1606 &prepared.bprime.full_to_reduced,
1607 &effective_p_inj,
1608 slack_idx,
1609 slack_p_1,
1610 );
1611 (slack_p_1, HashMap::new(), 0.0, p_inj_full)
1612 } else {
1613 apply_headroom_slack(
1614 network,
1615 &mut prepared,
1616 participating_buses,
1617 &effective_p_inj,
1618 slack_p_1,
1619 slack_idx,
1620 &mut theta,
1621 &mut branch_p_flow,
1622 )?
1623 }
1624 } else {
1625 let p_inj_full = expand_p_inject(
1626 n,
1627 &prepared.bprime.full_to_reduced,
1628 &effective_p_inj,
1629 slack_idx,
1630 slack_p_1,
1631 );
1632 (slack_p_1, HashMap::new(), 0.0, p_inj_full)
1633 };
1634
1635 let solve_time = start.elapsed().as_secs_f64();
1636 info!(
1637 buses = n,
1638 branches = prepared.branch_indices.len(),
1639 solve_time_ms = format_args!("{:.3}", solve_time * 1000.0),
1640 "DC power flow solved"
1641 );
1642
1643 apply_angle_reference_subset(
1644 &mut theta,
1645 network,
1646 prepared.bus_indices,
1647 slack_idx,
1648 prepared.reference_angle0_rad,
1649 opts.angle_reference,
1650 );
1651
1652 Ok(DcPfSolution {
1653 theta,
1654 branch_p_flow,
1655 slack_p_injection,
1656 solve_time_secs: solve_time,
1657 total_generation_mw,
1658 slack_distribution,
1659 p_inject_pu,
1660 island_ids: vec![],
1661 })
1662}
1663
1664#[allow(clippy::too_many_arguments)]
1674fn apply_headroom_slack(
1675 network: &Network,
1676 prepared: &mut PreparedSolveInputs<'_>,
1677 participating_buses: &[usize],
1678 effective_p_inj: &[f64],
1679 initial_slack_p: f64,
1680 slack_idx: usize,
1681 theta: &mut Vec<f64>,
1682 branch_p_flow: &mut Vec<f64>,
1683) -> Result<(f64, HashMap<usize, f64>, f64, Vec<f64>), DcError> {
1684 let n = network.n_buses();
1685 let mut p_adj = effective_p_inj.to_vec();
1686 let mut slack_dist: HashMap<usize, f64> = HashMap::with_capacity(participating_buses.len());
1687
1688 let mut bus_headroom_pu: HashMap<usize, f64> = HashMap::new();
1689 for &bus_full_idx in participating_buses {
1690 if bus_full_idx != slack_idx
1691 && prepared
1692 .bprime
1693 .full_to_reduced
1694 .get(bus_full_idx)
1695 .copied()
1696 .flatten()
1697 .is_some()
1698 {
1699 bus_headroom_pu.entry(bus_full_idx).or_insert(0.0);
1700 }
1701 }
1702 for g in network.generators.iter().filter(|g| g.in_service) {
1703 if let Some(&bidx) = prepared.bus_map.get(&g.bus)
1704 && let Some(h) = bus_headroom_pu.get_mut(&bidx)
1705 {
1706 let headroom = if initial_slack_p >= 0.0 {
1707 (g.pmax - g.p) / network.base_mva
1708 } else {
1709 (g.p - g.pmin) / network.base_mva
1710 };
1711 *h += headroom.max(0.0);
1712 }
1713 }
1714
1715 let total_headroom_pu: f64 = bus_headroom_pu.values().sum();
1716
1717 if total_headroom_pu < 1e-12 {
1718 warn!(
1719 mismatch_mw = initial_slack_p.abs() * network.base_mva,
1720 "headroom slack: no participating generator has headroom \
1721 — mismatch absorbed by slack bus"
1722 );
1723 } else {
1724 let absorbable_pu = initial_slack_p.abs().min(total_headroom_pu);
1725 if initial_slack_p.abs() > total_headroom_pu + 1e-9 {
1726 warn!(
1727 total_headroom_mw = total_headroom_pu * network.base_mva,
1728 mismatch_mw = initial_slack_p.abs() * network.base_mva,
1729 "headroom slack: participant headroom exhausted \
1730 — slack bus absorbs remainder"
1731 );
1732 }
1733
1734 for (&bus_full_idx, &headroom) in &bus_headroom_pu {
1735 let share_pu =
1736 (headroom / total_headroom_pu) * absorbable_pu * initial_slack_p.signum();
1737 slack_dist.insert(bus_full_idx, share_pu * network.base_mva);
1738 if let Some(ri) = prepared.bprime.full_to_reduced[bus_full_idx] {
1739 p_adj[ri] += share_pu;
1740 }
1741 }
1742 }
1743
1744 let p_eff_reduced = p_adj.clone();
1745
1746 if prepared.klu.solve(&mut p_adj).is_err() {
1747 return Err(DcError::SingularMatrix);
1748 }
1749
1750 *theta = reconstruct_theta(n, &prepared.bprime.full_to_reduced, &p_adj);
1751 *branch_p_flow = compute_branch_flows_subset(
1752 network,
1753 theta.as_slice(),
1754 prepared.branch_indices,
1755 prepared.bus_map,
1756 );
1757 let slack_p_2 = compute_slack_injection_subset(
1758 network,
1759 branch_p_flow,
1760 prepared.branch_indices,
1761 prepared.bus_map,
1762 slack_idx,
1763 );
1764
1765 let total_load_mw = network.total_load_mw();
1766 let total_gen = total_load_mw + slack_p_2 * network.base_mva;
1767
1768 debug!(
1769 participating_buses = participating_buses.len(),
1770 total_gen_mw = total_gen,
1771 remaining_slack_pu = slack_p_2,
1772 "headroom slack applied"
1773 );
1774
1775 slack_dist.insert(slack_idx, slack_p_2 * network.base_mva);
1776
1777 let p_inj_full = expand_p_inject(
1778 n,
1779 &prepared.bprime.full_to_reduced,
1780 &p_eff_reduced,
1781 slack_idx,
1782 slack_p_2,
1783 );
1784 Ok((slack_p_2, slack_dist, total_gen, p_inj_full))
1785}
1786
1787#[allow(clippy::too_many_arguments)]
1793fn apply_participation_factor_slack(
1794 network: &Network,
1795 prepared: &mut PreparedSolveInputs<'_>,
1796 weights: &[(usize, f64)],
1797 effective_p_inj: &[f64],
1798 initial_slack_p: f64,
1799 slack_idx: usize,
1800 theta: &mut Vec<f64>,
1801 branch_p_flow: &mut Vec<f64>,
1802) -> Result<(f64, HashMap<usize, f64>, f64, Vec<f64>), DcError> {
1803 let n = network.n_buses();
1804 let mut p_adj = effective_p_inj.to_vec();
1805 let mut slack_dist: HashMap<usize, f64> = HashMap::with_capacity(weights.len());
1806
1807 let mut valid_weights: Vec<(usize, f64)> = Vec::with_capacity(weights.len());
1809 for &(bus_idx, w) in weights {
1810 if w > 0.0
1811 && w.is_finite()
1812 && bus_idx != slack_idx
1813 && bus_idx < n
1814 && prepared
1815 .bprime
1816 .full_to_reduced
1817 .get(bus_idx)
1818 .copied()
1819 .flatten()
1820 .is_some()
1821 {
1822 valid_weights.push((bus_idx, w));
1823 }
1824 }
1825 let total_weight: f64 = valid_weights.iter().map(|(_, w)| w).sum();
1826 if total_weight < 1e-12 {
1827 warn!("participation factor slack: no valid weights — mismatch absorbed by slack bus");
1828 let p_inj_full = expand_p_inject(
1829 n,
1830 &prepared.bprime.full_to_reduced,
1831 effective_p_inj,
1832 slack_idx,
1833 initial_slack_p,
1834 );
1835 return Ok((initial_slack_p, HashMap::new(), 0.0, p_inj_full));
1836 }
1837
1838 for &(bus_idx, w) in &valid_weights {
1841 let share_pu = (w / total_weight) * initial_slack_p;
1842 slack_dist.insert(bus_idx, share_pu * network.base_mva);
1843 if let Some(ri) = prepared.bprime.full_to_reduced[bus_idx] {
1844 p_adj[ri] += share_pu;
1845 }
1846 }
1847
1848 let p_eff_reduced = p_adj.clone();
1849
1850 if prepared.klu.solve(&mut p_adj).is_err() {
1851 return Err(DcError::SingularMatrix);
1852 }
1853
1854 *theta = reconstruct_theta(n, &prepared.bprime.full_to_reduced, &p_adj);
1855 *branch_p_flow = compute_branch_flows_subset(
1856 network,
1857 theta.as_slice(),
1858 prepared.branch_indices,
1859 prepared.bus_map,
1860 );
1861 let slack_p_2 = compute_slack_injection_subset(
1862 network,
1863 branch_p_flow,
1864 prepared.branch_indices,
1865 prepared.bus_map,
1866 slack_idx,
1867 );
1868
1869 let total_load_mw = network.total_load_mw();
1870 let total_gen = total_load_mw + slack_p_2 * network.base_mva;
1871
1872 debug!(
1873 n_participants = valid_weights.len(),
1874 total_gen_mw = total_gen,
1875 remaining_slack_pu = slack_p_2,
1876 "participation factor slack applied"
1877 );
1878
1879 slack_dist.insert(slack_idx, slack_p_2 * network.base_mva);
1880
1881 let p_inj_full = expand_p_inject(
1882 n,
1883 &prepared.bprime.full_to_reduced,
1884 &p_eff_reduced,
1885 slack_idx,
1886 slack_p_2,
1887 );
1888 Ok((slack_p_2, slack_dist, total_gen, p_inj_full))
1889}
1890
1891fn expand_p_inject(
1894 n: usize,
1895 full_to_reduced: &[Option<usize>],
1896 p_reduced: &[f64],
1897 slack_idx: usize,
1898 slack_p: f64,
1899) -> Vec<f64> {
1900 let mut p_full = vec![0.0f64; n];
1901 for (full_idx, ri) in full_to_reduced.iter().enumerate() {
1902 if let Some(ri) = ri {
1903 p_full[full_idx] = p_reduced[*ri];
1904 }
1905 }
1906 p_full[slack_idx] = slack_p;
1907 p_full
1908}
1909
1910fn reconstruct_theta(
1912 n: usize,
1913 full_to_reduced: &[Option<usize>],
1914 theta_reduced: &[f64],
1915) -> Vec<f64> {
1916 let mut theta = vec![0.0; n];
1917 for (full_idx, ri) in full_to_reduced.iter().enumerate() {
1918 if let Some(ri) = ri {
1919 theta[full_idx] = theta_reduced[*ri];
1920 }
1921 }
1922 theta
1923}
1924
1925fn compute_branch_flows_subset(
1926 network: &Network,
1927 theta: &[f64],
1928 branch_indices: &[usize],
1929 bus_map: &HashMap<u32, usize>,
1930) -> Vec<f64> {
1931 let mut branch_p_flow = vec![0.0; network.n_branches()];
1932 for &br_idx in branch_indices {
1933 let branch = &network.branches[br_idx];
1934 let from_idx = bus_map[&branch.from_bus];
1935 let to_idx = bus_map[&branch.to_bus];
1936 let phi_rad = branch.phase_shift_rad;
1937 branch_p_flow[br_idx] = branch.b_dc() * (theta[from_idx] - theta[to_idx] - phi_rad);
1938 }
1939 branch_p_flow
1940}
1941
1942fn compute_slack_injection_subset(
1943 network: &Network,
1944 branch_p_flow: &[f64],
1945 branch_indices: &[usize],
1946 bus_map: &HashMap<u32, usize>,
1947 slack_idx: usize,
1948) -> f64 {
1949 let mut slack_p = 0.0;
1950 for &br_idx in branch_indices {
1951 let branch = &network.branches[br_idx];
1952 let from_idx = bus_map[&branch.from_bus];
1953 let to_idx = bus_map[&branch.to_bus];
1954 if from_idx == slack_idx {
1955 slack_p += branch_p_flow[br_idx];
1956 }
1957 if to_idx == slack_idx {
1958 slack_p -= branch_p_flow[br_idx];
1959 }
1960 }
1961 slack_p
1962}
1963
1964pub fn to_pf_solution(result: &DcPfSolution, network: &Network) -> PfSolution {
1994 let n = network.n_buses();
1995
1996 let p_inject = result.p_inject_pu.clone();
2001 let base = network.base_mva;
2002 let branch_p_from_mw: Vec<f64> = result.branch_p_flow.iter().map(|&f| f * base).collect();
2003 let branch_p_to_mw: Vec<f64> = branch_p_from_mw.iter().map(|&f| -f).collect();
2004 let n_branches = network.n_branches();
2005
2006 PfSolution {
2007 pf_model: PfModel::Dc,
2008 status: SolveStatus::Converged,
2009 iterations: 1, max_mismatch: 0.0, solve_time_secs: result.solve_time_secs,
2012 voltage_magnitude_pu: vec![1.0; n],
2015 voltage_angle_rad: result.theta.clone(),
2016 active_power_injection_pu: p_inject,
2017 reactive_power_injection_pu: vec![0.0; n],
2021 branch_p_from_mw,
2022 branch_p_to_mw,
2023 branch_q_from_mvar: vec![0.0; n_branches],
2024 branch_q_to_mvar: vec![0.0; n_branches],
2025 bus_numbers: network.buses.iter().map(|b| b.number).collect(),
2026 island_ids: result.island_ids.clone(),
2027 q_limited_buses: vec![], n_q_limit_switches: 0,
2029 gen_slack_contribution_mw: vec![],
2030 convergence_history: vec![],
2031 worst_mismatch_bus: None,
2032 area_interchange: None,
2033 }
2034}
2035
2036#[cfg(test)]
2037mod tests {
2038 use super::*;
2039 use crate::test_util::*;
2040 use surge_network::network::{Branch, Bus, BusType, Generator, Load};
2041 use surge_network::{AngleReference, DistributedAngleWeight};
2042
2043 fn check_kcl(net: &Network, result: &DcPfSolution, tol: f64) {
2044 let bus_map = net.bus_index_map();
2045 let p_inj = net.bus_p_injection_pu();
2046 let slack_idx = net.slack_bus_index().unwrap();
2047 let mut bus_flow_sum = vec![0.0; net.n_buses()];
2048
2049 for (br_idx, branch) in net.branches.iter().enumerate() {
2050 if !branch.in_service {
2051 continue;
2052 }
2053 let from = bus_map[&branch.from_bus];
2054 let to = bus_map[&branch.to_bus];
2055 bus_flow_sum[from] += result.branch_p_flow[br_idx];
2056 bus_flow_sum[to] -= result.branch_p_flow[br_idx];
2057 }
2058
2059 for i in 0..net.n_buses() {
2060 if i == slack_idx {
2061 continue;
2062 }
2063 assert!(
2064 (bus_flow_sum[i] - p_inj[i]).abs() < tol,
2065 "KCL violated at bus {} (idx {}): flow_sum={:.6e}, p_inj={:.6e}",
2066 net.buses[i].number,
2067 i,
2068 bus_flow_sum[i],
2069 p_inj[i]
2070 );
2071 }
2072 }
2073
2074 #[test]
2075 fn test_dc_case9() {
2076 skip_if_no_data!();
2077 let net = load_net("case9");
2078 let result = solve_dc(&net).expect("DC PF should converge");
2079
2080 assert_eq!(result.theta.len(), 9);
2081 assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2082 for &angle in &result.theta {
2083 assert!(angle.abs() < 1.0, "unreasonable angle: {angle} rad");
2084 }
2085 check_kcl(&net, &result, 1e-8);
2086 }
2087
2088 #[test]
2089 fn test_dc_case14() {
2090 skip_if_no_data!();
2091 let net = load_net("case14");
2092 let result = solve_dc(&net).expect("DC PF should converge");
2093
2094 assert_eq!(result.theta.len(), 14);
2095 assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2096 check_kcl(&net, &result, 1e-8);
2097 }
2098
2099 #[test]
2100 fn test_dc_case118() {
2101 skip_if_no_data!();
2102 let net = load_net("case118");
2103 let result = solve_dc(&net).expect("DC PF should converge");
2104
2105 assert_eq!(result.theta.len(), 118);
2106 check_kcl(&net, &result, 1e-6);
2107 }
2108
2109 #[test]
2110 fn test_dc_case2383wp() {
2111 skip_if_no_data!();
2112 let net = load_net("case2383wp");
2113 let result = solve_dc(&net).expect("DC PF should converge on case2383wp");
2114
2115 assert_eq!(result.theta.len(), 2383);
2116 assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2117 check_kcl(&net, &result, 1e-5);
2118 }
2119
2120 #[test]
2122 fn test_dc_multi_island_case16ci() {
2123 skip_if_no_data!();
2124 let net = load_net("case16ci");
2125 let result = solve_dc(&net).expect("DC PF should converge on multi-island case16ci");
2126 assert_eq!(result.theta.len(), 16);
2127 for &angle in &result.theta {
2129 assert!(angle.abs() < 1.0, "unreasonable angle: {angle} rad");
2130 }
2131 }
2132
2133 #[test]
2134 fn test_dc_solver_rejects_missing_slack_bus() {
2135 let mut net = Network::new("dc_missing_slack");
2136 net.base_mva = 100.0;
2137
2138 net.buses.push(Bus::new(1, BusType::PQ, 230.0));
2139 net.buses.push(Bus::new(2, BusType::PQ, 230.0));
2140 net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2141 net.loads.push(Load::new(2, 50.0, 0.0));
2142
2143 let err = solve_dc_opts(&net, &DcPfOptions::default()).unwrap_err();
2144 match err {
2145 DcError::InvalidNetwork(msg) => {
2146 assert!(
2147 msg.contains("slack"),
2148 "validation error should mention slack placement, got: {msg}"
2149 );
2150 }
2151 other => panic!("expected invalid-network error from DC validation, got {other:?}"),
2152 }
2153 }
2154
2155 #[test]
2156 fn test_multi_island_dc_line_injections_are_preserved() {
2157 use surge_network::network::{
2158 Branch, Bus, Generator, LccConverterTerminal, LccHvdcLink, Load,
2159 };
2160
2161 let mut net = Network::new("multi_island_dc_line");
2162 net.base_mva = 100.0;
2163
2164 let mut bus1 = Bus::new(1, BusType::Slack, 230.0);
2165 bus1.voltage_angle_rad = 0.05;
2166 net.buses.push(bus1);
2167
2168 let bus2 = Bus::new(2, BusType::PQ, 230.0);
2169 net.buses.push(bus2);
2170 net.loads.push(Load::new(2, 100.0, 0.0));
2171
2172 net.buses.push(Bus::new(3, BusType::Slack, 230.0));
2173 net.buses.push(Bus::new(4, BusType::PQ, 230.0));
2174 net.generators.push(Generator::new(1, 100.0, 1.0));
2175 net.generators.push(Generator::new(3, 0.0, 1.0));
2176
2177 net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2178 net.branches.push(Branch::new_line(3, 4, 0.0, 0.1, 0.0));
2179
2180 let base = solve_dc(&net).expect("base multi-island DC solve");
2181
2182 let dc_line = LccHvdcLink {
2183 name: "L12".to_string(),
2184 scheduled_setpoint: 40.0,
2185 scheduled_voltage_kv: 500.0,
2186 rectifier: LccConverterTerminal {
2187 bus: 2,
2188 in_service: true,
2189 ..LccConverterTerminal::default()
2190 },
2191 inverter: LccConverterTerminal {
2192 bus: 4,
2193 in_service: true,
2194 ..LccConverterTerminal::default()
2195 },
2196 ..LccHvdcLink::default()
2197 };
2198 net.hvdc.push_lcc_link(dc_line);
2199
2200 let with_dc = solve_dc(&net).expect("multi-island DC solve with DC line");
2201
2202 assert!(
2203 (with_dc.branch_p_flow[0] - base.branch_p_flow[0] - 0.4).abs() < 1e-9,
2204 "island-A flow should include the rectifier withdrawal"
2205 );
2206 assert!(
2207 (with_dc.branch_p_flow[1] - base.branch_p_flow[1] + 0.4).abs() < 1e-9,
2208 "island-B flow should include the inverter injection"
2209 );
2210 assert!(
2211 (with_dc.p_inject_pu[1] + 1.4).abs() < 1e-9,
2212 "bus 2 should include the extra DC-line withdrawal"
2213 );
2214 assert!(
2215 (with_dc.p_inject_pu[3] - 0.4).abs() < 1e-9,
2216 "bus 4 should include the DC-line injection"
2217 );
2218 }
2219
2220 fn build_two_island_prepared_test_network() -> Network {
2221 use surge_network::network::{Branch, Bus, Generator, Load};
2222
2223 let mut net = Network::new("two_island_prepared_dc");
2224 net.base_mva = 100.0;
2225
2226 net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2227 net.buses.push(Bus::new(2, BusType::PQ, 230.0));
2228 net.loads.push(Load::new(2, 50.0, 0.0));
2229 net.buses.push(Bus::new(3, BusType::PQ, 230.0));
2230 net.loads.push(Load::new(3, 50.0, 0.0));
2231
2232 net.buses.push(Bus::new(4, BusType::Slack, 230.0));
2233 net.buses.push(Bus::new(5, BusType::PQ, 230.0));
2234 net.loads.push(Load::new(5, 50.0, 0.0));
2235 net.buses.push(Bus::new(6, BusType::PQ, 230.0));
2236 net.loads.push(Load::new(6, 50.0, 0.0));
2237
2238 net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2239 net.branches.push(Branch::new_line(2, 3, 0.0, 0.1, 0.0));
2240 net.branches.push(Branch::new_line(1, 3, 0.0, 0.2, 0.0));
2241
2242 net.branches.push(Branch::new_line(4, 5, 0.0, 0.1, 0.0));
2243 net.branches.push(Branch::new_line(5, 6, 0.0, 0.1, 0.0));
2244 net.branches.push(Branch::new_line(4, 6, 0.0, 0.2, 0.0));
2245
2246 net.generators.push(Generator::new(1, 100.0, 1.0));
2247 net.generators.push(Generator::new(4, 100.0, 1.0));
2248
2249 net
2250 }
2251
2252 fn build_two_island_headroom_test_network() -> Network {
2253 use surge_network::network::{Branch, Bus, Generator, Load};
2254
2255 let mut net = Network::new("two_island_headroom_dc");
2256 net.base_mva = 100.0;
2257
2258 net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2259 net.buses.push(Bus::new(2, BusType::PV, 230.0));
2260 net.buses.push(Bus::new(3, BusType::PQ, 230.0));
2261 net.loads.push(Load::new(3, 90.0, 0.0));
2262
2263 net.buses.push(Bus::new(4, BusType::Slack, 230.0));
2264 net.buses.push(Bus::new(5, BusType::PV, 230.0));
2265 net.buses.push(Bus::new(6, BusType::PQ, 230.0));
2266 net.loads.push(Load::new(6, 80.0, 0.0));
2267
2268 net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2269 net.branches.push(Branch::new_line(2, 3, 0.0, 0.1, 0.0));
2270 net.branches.push(Branch::new_line(1, 3, 0.0, 0.2, 0.0));
2271 net.branches.push(Branch::new_line(4, 5, 0.0, 0.1, 0.0));
2272 net.branches.push(Branch::new_line(5, 6, 0.0, 0.1, 0.0));
2273 net.branches.push(Branch::new_line(4, 6, 0.0, 0.2, 0.0));
2274
2275 let mut g1 = Generator::new(1, 25.0, 1.0);
2276 g1.pmax = 120.0;
2277 net.generators.push(g1);
2278
2279 let mut g2 = Generator::new(2, 25.0, 1.0);
2280 g2.pmax = 90.0;
2281 net.generators.push(g2);
2282
2283 let mut g3 = Generator::new(4, 20.0, 1.0);
2284 g3.pmax = 110.0;
2285 net.generators.push(g3);
2286
2287 let mut g4 = Generator::new(5, 20.0, 1.0);
2288 g4.pmax = 85.0;
2289 net.generators.push(g4);
2290
2291 net
2292 }
2293
2294 #[test]
2295 fn test_prepared_model_supports_multi_island_networks() {
2296 let net = build_two_island_prepared_test_network();
2297 let mut model = PreparedDcStudy::new(&net).expect("prepared multi-island model");
2298
2299 let prepared = model
2300 .solve(&DcPfOptions::default())
2301 .expect("prepared solve");
2302 let wrapped = solve_dc(&net).expect("wrapped solve");
2303 assert_eq!(prepared.theta, wrapped.theta);
2304 assert_eq!(prepared.branch_p_flow, wrapped.branch_p_flow);
2305 assert_eq!(prepared.p_inject_pu, wrapped.p_inject_pu);
2306
2307 let monitored = vec![0usize, 3usize];
2308 let ptdf = model
2309 .compute_ptdf_request(
2310 &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2311 .with_bus_indices(&[0, 1, 2, 3, 4, 5]),
2312 )
2313 .expect("prepared ptdf");
2314 let wrapped_ptdf = crate::sensitivity::compute_ptdf(
2315 &net,
2316 &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2317 .with_bus_indices(&[0, 1, 2, 3, 4, 5]),
2318 )
2319 .expect("wrapped ptdf");
2320 assert_eq!(ptdf, wrapped_ptdf);
2321 let row0 = ptdf.row(0).expect("row 0");
2322 let row3 = ptdf.row(3).expect("row 3");
2323 assert!(row0[3].abs() < 1e-12);
2324 assert!(row0[4].abs() < 1e-12);
2325 assert!(row0[5].abs() < 1e-12);
2326 assert!(row3[0].abs() < 1e-12);
2327 assert!(row3[1].abs() < 1e-12);
2328 assert!(row3[2].abs() < 1e-12);
2329
2330 let lodf = model
2331 .compute_lodf(&monitored, &monitored)
2332 .expect("prepared lodf");
2333 let wrapped_lodf = crate::sensitivity::compute_lodf(
2334 &net,
2335 &crate::sensitivity::LodfRequest::for_branches(&monitored, &monitored),
2336 )
2337 .expect("wrapped lodf");
2338 assert_eq!(lodf, wrapped_lodf);
2339 assert!(lodf[(0, 1)].abs() < 1e-12);
2340 assert!(lodf[(1, 0)].abs() < 1e-12);
2341
2342 let n2 = model
2343 .compute_n2_lodf((0, 3), &monitored)
2344 .expect("prepared n2");
2345 let wrapped_n2 = crate::sensitivity::compute_n2_lodf(
2346 &net,
2347 &crate::sensitivity::N2LodfRequest::new((0, 3)).with_monitored_branches(&monitored),
2348 )
2349 .expect("wrapped n2");
2350 assert_eq!(n2, wrapped_n2);
2351 }
2352
2353 #[test]
2354 fn test_multi_island_headroom_metadata_matches_prepared_study() {
2355 let net = build_two_island_headroom_test_network();
2356 let opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4]);
2357
2358 let one_shot = solve_dc_opts(&net, &opts).expect("one-shot multi-island solve");
2359 let mut study = PreparedDcStudy::new(&net).expect("prepared multi-island study");
2360 let prepared = study.solve(&opts).expect("prepared multi-island solve");
2361
2362 assert_eq!(one_shot.theta, prepared.theta);
2363 assert_eq!(one_shot.branch_p_flow, prepared.branch_p_flow);
2364 assert_eq!(one_shot.p_inject_pu, prepared.p_inject_pu);
2365 assert!((one_shot.slack_p_injection - prepared.slack_p_injection).abs() < 1e-12);
2366 assert!((one_shot.total_generation_mw - prepared.total_generation_mw).abs() < 1e-9);
2367 assert_eq!(one_shot.slack_distribution, prepared.slack_distribution);
2368 }
2369
2370 #[test]
2371 fn test_unchecked_singleton_island_keeps_island_id_and_slack_accounting() {
2372 let mut net = Network::new("singleton_island");
2373 net.base_mva = 100.0;
2374 net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2375 net.buses.push(Bus::new(2, BusType::PQ, 230.0));
2376 net.buses.push(Bus::new(3, BusType::PQ, 230.0));
2377 net.branches.push(Branch::new_line(1, 2, 0.0, 0.1, 0.0));
2378 net.loads.push(Load::new(2, 40.0, 0.0));
2379 net.loads.push(Load::new(3, 15.0, 0.0));
2380
2381 let mut g1 = Generator::new(1, 40.0, 1.0);
2382 g1.pmax = 100.0;
2383 net.generators.push(g1);
2384
2385 let mut study = PreparedDcStudy::new_unchecked(&net).expect("prepared study");
2386 let result = study.solve(&DcPfOptions::default()).expect("dc solve");
2387
2388 assert_eq!(result.island_ids, vec![0, 0, 1]);
2389 assert!(
2390 (result.slack_p_injection - 0.55).abs() < 1e-12,
2391 "total slack accounting should include both the main island and the singleton island, got {:.6}",
2392 result.slack_p_injection
2393 );
2394 assert!(
2395 (result.p_inject_pu[2] - 0.15).abs() < 1e-12,
2396 "singleton island bus should keep its local slack injection, got {:.6}",
2397 result.p_inject_pu[2]
2398 );
2399 }
2400
2401 #[test]
2402 fn test_singleton_island_applies_external_injections() {
2403 let mut net = Network::new("singleton_external_injection");
2404 net.base_mva = 100.0;
2405 net.buses.push(Bus::new(1, BusType::Slack, 230.0));
2406 net.loads.push(Load::new(1, 10.0, 0.0));
2407
2408 let mut study = PreparedDcStudy::new_unchecked(&net).expect("prepared study");
2409 let opts = DcPfOptions {
2410 external_p_injections_mw: vec![(1, 5.0)],
2411 ..DcPfOptions::default()
2412 };
2413 let result = study.solve(&opts).expect("dc solve");
2414
2415 assert!(
2416 (result.p_inject_pu[0] - 0.05).abs() < 1e-12,
2417 "singleton island should include external injections in slack accounting, got {:.6}",
2418 result.p_inject_pu[0]
2419 );
2420 }
2421
2422 fn build_3bus_pst_network(phi_deg: f64) -> Network {
2435 use surge_network::network::{Branch, Bus, BusType, Generator, Load};
2436
2437 let mut net = Network::new("3bus_pst");
2438 net.base_mva = 100.0;
2439
2440 net.buses.push(Bus::new(1, BusType::Slack, 345.0));
2441 net.generators.push(Generator::new(1, 0.0, 1.0));
2442
2443 net.buses.push(Bus::new(2, BusType::PQ, 345.0));
2444 net.loads.push(Load::new(2, 100.0, 0.0));
2445
2446 net.buses.push(Bus::new(3, BusType::PV, 345.0));
2447
2448 net.generators.push(Generator::new(3, 100.0, 1.0));
2449
2450 let mut br_pst = Branch::new_line(1, 2, 0.0, 0.1, 0.0);
2452 br_pst.phase_shift_rad = phi_deg.to_radians();
2453 net.branches.push(br_pst);
2454
2455 net.branches.push(Branch::new_line(2, 3, 0.0, 0.1, 0.0));
2457 net.branches.push(Branch::new_line(1, 3, 0.0, 0.2, 0.0));
2458
2459 net
2460 }
2461
2462 #[test]
2464 fn test_pst_dc_flow_changes_with_shift() {
2465 skip_if_no_data!();
2466 let net_no_shift = build_3bus_pst_network(0.0);
2467 let net_with_shift = build_3bus_pst_network(5.0);
2468
2469 let r0 = solve_dc(&net_no_shift).expect("DC PF should converge");
2470 let r5 = solve_dc(&net_with_shift).expect("DC PF should converge");
2471
2472 let all_same = r0
2474 .branch_p_flow
2475 .iter()
2476 .zip(r5.branch_p_flow.iter())
2477 .all(|(a, b)| (a - b).abs() < 1e-10);
2478
2479 assert!(
2480 !all_same,
2481 "DC flows should change when PST shift is non-zero; \
2482 no-shift flows: {:?}, with-shift flows: {:?}",
2483 r0.branch_p_flow, r5.branch_p_flow
2484 );
2485
2486 let delta = (r0.branch_p_flow[0] - r5.branch_p_flow[0]).abs();
2488 assert!(
2489 delta > 1e-4,
2490 "PST branch flow should change noticeably: no-shift={:.4}, with-shift={:.4}",
2491 r0.branch_p_flow[0],
2492 r5.branch_p_flow[0]
2493 );
2494 }
2495
2496 #[test]
2498 fn test_pst_kcl_satisfied() {
2499 skip_if_no_data!();
2500 let net = build_3bus_pst_network(10.0);
2501 let result = solve_dc(&net).expect("DC PF should converge");
2502 check_kcl(&net, &result, 1e-8);
2503 }
2504
2505 #[test]
2507 fn test_no_pst_case9_unchanged() {
2508 skip_if_no_data!();
2509 let net = load_net("case9");
2510 for br in &net.branches {
2511 assert!(
2512 br.phase_shift_rad.abs() < 1e-12,
2513 "case9 branch {}->{} has unexpected shift={}",
2514 br.from_bus,
2515 br.to_bus,
2516 br.phase_shift_rad
2517 );
2518 }
2519 let result = solve_dc(&net).expect("DC PF should converge");
2520 check_kcl(&net, &result, 1e-8);
2521 assert_eq!(result.theta[net.slack_bus_index().unwrap()], 0.0);
2522 }
2523
2524 #[test]
2526 fn test_no_pst_case14_unchanged() {
2527 skip_if_no_data!();
2528 let net = load_net("case14");
2529 for br in &net.branches {
2530 assert!(
2531 br.phase_shift_rad.abs() < 1e-12,
2532 "case14 branch {}->{} has unexpected shift={}",
2533 br.from_bus,
2534 br.to_bus,
2535 br.phase_shift_rad
2536 );
2537 }
2538 let result = solve_dc(&net).expect("DC PF should converge");
2539 check_kcl(&net, &result, 1e-8);
2540 }
2541
2542 #[test]
2548 fn test_default_opts_identical_to_solve_dc() {
2549 skip_if_no_data!();
2550 let net = load_net("case9");
2551 let r1 = solve_dc(&net).expect("solve_dc");
2552 let r2 = solve_dc_opts(&net, &DcPfOptions::default()).expect("solve_dc_opts default");
2553
2554 assert_eq!(r1.theta.len(), r2.theta.len());
2555 for (a, b) in r1.theta.iter().zip(r2.theta.iter()) {
2556 assert!((a - b).abs() < 1e-12, "theta differs: {a:.8e} vs {b:.8e}");
2557 }
2558 for (a, b) in r1.branch_p_flow.iter().zip(r2.branch_p_flow.iter()) {
2559 assert!((a - b).abs() < 1e-12, "flow differs: {a:.8e} vs {b:.8e}");
2560 }
2561 }
2562
2563 #[test]
2564 fn test_angle_reference_changes_only_reported_theta() {
2565 let mut net = build_3bus_pst_network(5.0);
2566 net.buses[0].voltage_angle_rad = 0.12;
2567
2568 let preserve = solve_dc_opts(&net, &DcPfOptions::default()).expect("preserve solve");
2569 let zero = solve_dc_opts(
2570 &net,
2571 &DcPfOptions::default().with_angle_reference(AngleReference::Zero),
2572 )
2573 .expect("zero-reference solve");
2574 let distributed = solve_dc_opts(
2575 &net,
2576 &DcPfOptions::default().with_angle_reference(AngleReference::Distributed(
2577 DistributedAngleWeight::LoadWeighted,
2578 )),
2579 )
2580 .expect("distributed-reference solve");
2581
2582 assert!((preserve.theta[0] - 0.12).abs() < 1e-12);
2583 assert!(zero.theta[0].abs() < 1e-12);
2584
2585 let bus2_idx = net.bus_index_map()[&2];
2586 assert!(distributed.theta[bus2_idx].abs() < 1e-12);
2587
2588 assert!(
2589 preserve
2590 .theta
2591 .iter()
2592 .zip(zero.theta.iter())
2593 .any(|(a, b)| (a - b).abs() > 1e-9),
2594 "angle reference should change the reported theta vector"
2595 );
2596
2597 for ((flow_p, flow_z), flow_d) in preserve
2598 .branch_p_flow
2599 .iter()
2600 .zip(zero.branch_p_flow.iter())
2601 .zip(distributed.branch_p_flow.iter())
2602 {
2603 assert!((flow_p - flow_z).abs() < 1e-12);
2604 assert!((flow_p - flow_d).abs() < 1e-12);
2605 }
2606 for ((inj_p, inj_z), inj_d) in preserve
2607 .p_inject_pu
2608 .iter()
2609 .zip(zero.p_inject_pu.iter())
2610 .zip(distributed.p_inject_pu.iter())
2611 {
2612 assert!((inj_p - inj_z).abs() < 1e-12);
2613 assert!((inj_p - inj_d).abs() < 1e-12);
2614 }
2615 assert!((preserve.slack_p_injection - zero.slack_p_injection).abs() < 1e-12);
2616 assert!((preserve.slack_p_injection - distributed.slack_p_injection).abs() < 1e-12);
2617 }
2618
2619 #[test]
2620 fn test_multi_island_angle_reference_is_applied_per_island() {
2621 let mut net = build_two_island_prepared_test_network();
2622 net.buses[0].voltage_angle_rad = 0.15;
2623 net.buses[3].voltage_angle_rad = -0.25;
2624
2625 let preserve = solve_dc_opts(&net, &DcPfOptions::default()).expect("preserve solve");
2626 let zero = solve_dc_opts(
2627 &net,
2628 &DcPfOptions::default().with_angle_reference(AngleReference::Zero),
2629 )
2630 .expect("zero-reference solve");
2631 let distributed = solve_dc_opts(
2632 &net,
2633 &DcPfOptions::default().with_angle_reference(AngleReference::Distributed(
2634 DistributedAngleWeight::LoadWeighted,
2635 )),
2636 )
2637 .expect("distributed-reference solve");
2638
2639 assert!((preserve.theta[0] - 0.15).abs() < 1e-12);
2640 assert!((preserve.theta[3] + 0.25).abs() < 1e-12);
2641 assert!(zero.theta[0].abs() < 1e-12);
2642 assert!(zero.theta[3].abs() < 1e-12);
2643
2644 let island_a_load_center = 0.5 * distributed.theta[1] + 0.5 * distributed.theta[2];
2645 let island_b_load_center = 0.5 * distributed.theta[4] + 0.5 * distributed.theta[5];
2646 assert!(island_a_load_center.abs() < 1e-12);
2647 assert!(island_b_load_center.abs() < 1e-12);
2648
2649 for (flow_p, flow_d) in preserve
2650 .branch_p_flow
2651 .iter()
2652 .zip(distributed.branch_p_flow.iter())
2653 {
2654 assert!((flow_p - flow_d).abs() < 1e-12);
2655 }
2656 }
2657
2658 #[test]
2659 fn test_angle_reference_remains_orthogonal_to_headroom_slack() {
2660 let mut net = build_two_island_headroom_test_network();
2661 net.buses[0].voltage_angle_rad = 0.08;
2662 net.buses[3].voltage_angle_rad = -0.11;
2663
2664 let preserve_opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4])
2665 .with_angle_reference(AngleReference::PreserveInitial);
2666 let zero_opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4])
2667 .with_angle_reference(AngleReference::Zero);
2668 let distributed_opts = DcPfOptions::with_headroom_slack(&[0, 1, 3, 4])
2669 .with_angle_reference(AngleReference::Distributed(
2670 DistributedAngleWeight::LoadWeighted,
2671 ));
2672
2673 let preserve = solve_dc_opts(&net, &preserve_opts).expect("preserve solve");
2674 let zero = solve_dc_opts(&net, &zero_opts).expect("zero-reference solve");
2675 let distributed =
2676 solve_dc_opts(&net, &distributed_opts).expect("distributed-reference solve");
2677
2678 for ((flow_p, flow_z), flow_d) in preserve
2679 .branch_p_flow
2680 .iter()
2681 .zip(zero.branch_p_flow.iter())
2682 .zip(distributed.branch_p_flow.iter())
2683 {
2684 assert!((flow_p - flow_z).abs() < 1e-12);
2685 assert!((flow_p - flow_d).abs() < 1e-12);
2686 }
2687
2688 assert_eq!(
2689 preserve.slack_distribution.len(),
2690 zero.slack_distribution.len()
2691 );
2692 assert_eq!(
2693 preserve.slack_distribution.len(),
2694 distributed.slack_distribution.len()
2695 );
2696 for (bus_idx, preserve_share) in &preserve.slack_distribution {
2697 let zero_share = zero
2698 .slack_distribution
2699 .get(bus_idx)
2700 .copied()
2701 .expect("zero-reference slack distribution key");
2702 let distributed_share = distributed
2703 .slack_distribution
2704 .get(bus_idx)
2705 .copied()
2706 .expect("distributed-reference slack distribution key");
2707 assert!((preserve_share - zero_share).abs() < 1e-12);
2708 assert!((preserve_share - distributed_share).abs() < 1e-12);
2709 }
2710 }
2711
2712 #[test]
2713 fn test_prepared_model_matches_wrapper_apis() {
2714 skip_if_no_data!();
2715
2716 let net = load_net("case14");
2717 let mut model = PreparedDcStudy::new(&net).expect("prepared model");
2718
2719 let prepared = model
2720 .solve(&DcPfOptions::default())
2721 .expect("prepared solve");
2722 let wrapped = solve_dc(&net).expect("wrapper solve");
2723 assert_eq!(prepared.theta, wrapped.theta);
2724 assert_eq!(prepared.branch_p_flow, wrapped.branch_p_flow);
2725 assert_eq!(prepared.p_inject_pu, wrapped.p_inject_pu);
2726
2727 let all_branches: Vec<usize> = (0..net.n_branches()).collect();
2728 let prepared_ptdf_rows = model
2729 .compute_ptdf(&all_branches)
2730 .expect("prepared ptdf rows");
2731 let prepared_ptdf = model.compute_ptdf(&all_branches).expect("prepared ptdf");
2732 let wrapped_ptdf = crate::sensitivity::compute_ptdf(
2733 &net,
2734 &crate::sensitivity::PtdfRequest::for_branches(&all_branches),
2735 )
2736 .expect("wrapper ptdf");
2737 assert_eq!(
2738 prepared_ptdf_rows.monitored_branches(),
2739 all_branches.as_slice()
2740 );
2741 assert_eq!(prepared_ptdf, wrapped_ptdf);
2742
2743 let prepared_lodf = model
2744 .compute_lodf_matrix(&all_branches)
2745 .expect("prepared lodf");
2746 let wrapped_lodf = crate::sensitivity::compute_lodf_matrix(
2747 &net,
2748 &crate::sensitivity::LodfMatrixRequest::for_branches(&all_branches),
2749 )
2750 .expect("wrapper lodf");
2751 assert_eq!(prepared_lodf.n_rows(), wrapped_lodf.n_rows());
2752 assert_eq!(prepared_lodf.n_cols(), wrapped_lodf.n_cols());
2753 for i in 0..prepared_lodf.n_rows() {
2754 for j in 0..prepared_lodf.n_cols() {
2755 assert!(
2756 (prepared_lodf[(i, j)] - wrapped_lodf[(i, j)]).abs() < 1e-12
2757 || (prepared_lodf[(i, j)].is_infinite()
2758 && wrapped_lodf[(i, j)].is_infinite())
2759 );
2760 }
2761 }
2762
2763 let monitored = vec![0, 2, 5];
2764 let outages = vec![1, 6];
2765 let prepared_subset = model
2766 .compute_lodf(&monitored, &outages)
2767 .expect("prepared subset lodf");
2768 let wrapped_subset = crate::sensitivity::compute_lodf(
2769 &net,
2770 &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
2771 )
2772 .expect("wrapper subset lodf");
2773 let prepared_otdf = model
2774 .compute_otdf(&monitored, &outages)
2775 .expect("prepared subset otdf");
2776 let wrapped_otdf = crate::sensitivity::compute_otdf(
2777 &net,
2778 &crate::sensitivity::OtdfRequest::new(&monitored, &outages),
2779 )
2780 .expect("wrapper subset otdf");
2781 assert_eq!(prepared_subset.n_rows(), monitored.len());
2782 assert_eq!(prepared_subset.n_cols(), outages.len());
2783 assert_eq!(prepared_subset.n_rows(), wrapped_subset.n_rows());
2784 assert_eq!(prepared_subset.n_cols(), wrapped_subset.n_cols());
2785 for i in 0..prepared_subset.n_rows() {
2786 for j in 0..prepared_subset.n_cols() {
2787 assert!(
2788 (prepared_subset[(i, j)] - wrapped_subset[(i, j)]).abs() < 1e-12
2789 || (prepared_subset[(i, j)].is_infinite()
2790 && wrapped_subset[(i, j)].is_infinite())
2791 );
2792 }
2793 }
2794 assert_eq!(prepared_otdf, wrapped_otdf);
2795
2796 let prepared_pairs = model
2797 .compute_lodf_pairs(&monitored, &outages)
2798 .expect("prepared subset pairs");
2799 let wrapped_pairs = crate::sensitivity::compute_lodf_pairs(&net, &monitored, &outages)
2800 .expect("wrapper pairs");
2801 assert_eq!(prepared_pairs, wrapped_pairs);
2802
2803 let mut lodf_columns = model.lodf_columns();
2804 let prepared_column = lodf_columns
2805 .compute_column(&monitored, outages[0])
2806 .expect("prepared outage column");
2807 for (row, &value) in prepared_column.iter().enumerate() {
2808 let matrix_value = prepared_subset[(row, 0)];
2809 assert!(
2810 (value - matrix_value).abs() < 1e-12
2811 || (value.is_infinite() && matrix_value.is_infinite())
2812 );
2813 }
2814
2815 let mut prepared_n2_columns = model
2816 .n2_lodf_columns(&all_branches, &all_branches)
2817 .expect("prepared N-2 columns");
2818 let prepared_n2 = prepared_n2_columns
2819 .compute_column(0, 2)
2820 .expect("prepared N-2 column");
2821 let wrapped_n2 = crate::sensitivity::compute_n2_lodf(
2822 &net,
2823 &crate::sensitivity::N2LodfRequest::new((0, 2)).with_monitored_branches(&all_branches),
2824 )
2825 .expect("wrapper N-2");
2826 assert_eq!(prepared_n2.len(), wrapped_n2.len());
2827 for (prepared, wrapped) in prepared_n2.iter().zip(wrapped_n2.iter()) {
2828 assert!(
2829 (prepared - wrapped).abs() < 1e-12
2830 || (prepared.is_infinite() && wrapped.is_infinite())
2831 );
2832 }
2833
2834 let outage_pairs = vec![(0, 2), (2, 0)];
2835 let prepared_n2_batch = model
2836 .compute_n2_lodf_batch(&outage_pairs, &all_branches)
2837 .expect("prepared N-2 batch");
2838 let wrapped_n2_batch = crate::sensitivity::compute_n2_lodf_batch(
2839 &net,
2840 &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
2841 .with_monitored_branches(&all_branches),
2842 )
2843 .expect("wrapper N-2 batch");
2844 assert_eq!(prepared_n2_batch.n_rows(), wrapped_n2_batch.n_rows());
2845 assert_eq!(prepared_n2_batch.n_cols(), wrapped_n2_batch.n_cols());
2846 for i in 0..prepared_n2_batch.n_rows() {
2847 for j in 0..prepared_n2_batch.n_cols() {
2848 assert!(
2849 (prepared_n2_batch[(i, j)] - wrapped_n2_batch[(i, j)]).abs() < 1e-12
2850 || (prepared_n2_batch[(i, j)].is_infinite()
2851 && wrapped_n2_batch[(i, j)].is_infinite())
2852 );
2853 }
2854 }
2855 }
2856
2857 #[test]
2858 fn test_dc_sensitivity_workflow_matches_individual_calls() {
2859 skip_if_no_data!();
2860
2861 let net = load_net("case14");
2862 let monitored = vec![0, 3, 7];
2863 let ptdf_buses = vec![0, 4, 9];
2864 let outages = vec![1, 5];
2865 let otdf_buses = vec![0, 4, 9];
2866 let outage_pairs = vec![(0, 2), (2, 0)];
2867 let request = DcAnalysisRequest::with_monitored_branches(&monitored)
2868 .with_ptdf_buses(&ptdf_buses)
2869 .with_otdf_outages(&outages)
2870 .with_otdf_buses(&otdf_buses)
2871 .with_lodf_outages(&outages)
2872 .with_n2_outage_pairs(&outage_pairs);
2873
2874 let workflow = run_dc_analysis(&net, &request).expect("workflow");
2875 let standalone_pf = solve_dc(&net).expect("standalone solve");
2876 let standalone_ptdf = crate::sensitivity::compute_ptdf(
2877 &net,
2878 &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2879 .with_bus_indices(&ptdf_buses),
2880 )
2881 .expect("standalone ptdf");
2882 let standalone_otdf = crate::sensitivity::compute_otdf(
2883 &net,
2884 &crate::sensitivity::OtdfRequest::new(&monitored, &outages)
2885 .with_bus_indices(&otdf_buses),
2886 )
2887 .expect("standalone otdf");
2888 let standalone_lodf = crate::sensitivity::compute_lodf(
2889 &net,
2890 &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
2891 )
2892 .expect("standalone lodf");
2893 let standalone_n2 = crate::sensitivity::compute_n2_lodf_batch(
2894 &net,
2895 &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
2896 .with_monitored_branches(&monitored),
2897 )
2898 .expect("standalone n2");
2899
2900 assert_eq!(workflow.monitored_branch_indices, monitored);
2901 assert_eq!(workflow.ptdf_bus_indices, ptdf_buses);
2902 assert_eq!(workflow.power_flow.theta, standalone_pf.theta);
2903 assert_eq!(
2904 workflow.power_flow.branch_p_flow,
2905 standalone_pf.branch_p_flow
2906 );
2907 assert_eq!(workflow.ptdf, standalone_ptdf);
2908 assert_eq!(
2909 workflow.otdf_outage_branch_indices, outages,
2910 "OTDF outage metadata should preserve request order"
2911 );
2912 assert_eq!(
2913 workflow.otdf_bus_indices, otdf_buses,
2914 "OTDF bus metadata should preserve request order"
2915 );
2916 assert_eq!(
2917 workflow.lodf_outage_branch_indices, outages,
2918 "LODF outage metadata should preserve request order"
2919 );
2920 assert_eq!(
2921 workflow.n2_outage_pairs, outage_pairs,
2922 "N-2 outage-pair metadata should preserve request order"
2923 );
2924
2925 let workflow_otdf = workflow.otdf.expect("workflow otdf");
2926 assert_eq!(workflow_otdf, standalone_otdf);
2927
2928 let workflow_lodf = workflow.lodf.expect("workflow lodf");
2929 assert_eq!(workflow_lodf.n_rows(), standalone_lodf.n_rows());
2930 assert_eq!(workflow_lodf.n_cols(), standalone_lodf.n_cols());
2931 for i in 0..workflow_lodf.n_rows() {
2932 for j in 0..workflow_lodf.n_cols() {
2933 assert!(
2934 (workflow_lodf[(i, j)] - standalone_lodf[(i, j)]).abs() < 1e-12
2935 || (workflow_lodf[(i, j)].is_infinite()
2936 && standalone_lodf[(i, j)].is_infinite())
2937 );
2938 }
2939 }
2940
2941 let workflow_n2 = workflow.n2_lodf.expect("workflow n2");
2942 assert_eq!(workflow_n2.n_rows(), standalone_n2.n_rows());
2943 assert_eq!(workflow_n2.n_cols(), standalone_n2.n_cols());
2944 for i in 0..workflow_n2.n_rows() {
2945 for j in 0..workflow_n2.n_cols() {
2946 assert!(
2947 (workflow_n2[(i, j)] - standalone_n2[(i, j)]).abs() < 1e-12
2948 || (workflow_n2[(i, j)].is_infinite()
2949 && standalone_n2[(i, j)].is_infinite())
2950 );
2951 }
2952 }
2953 }
2954
2955 #[test]
2956 fn test_dc_sensitivity_workflow_headroom_slack_matches_individual_calls() {
2957 skip_if_no_data!();
2958
2959 let net = load_net("case14");
2960 let monitored = vec![0, 3, 7];
2961 let outages = vec![1, 5];
2962 let otdf_buses = vec![0, 4, 9];
2963 let outage_pairs = vec![(0, 2), (2, 0)];
2964 let participating_buses: Vec<usize> = {
2965 let bus_map = net.bus_index_map();
2966 net.generators
2967 .iter()
2968 .filter(|g| g.in_service)
2969 .filter_map(|g| bus_map.get(&g.bus).copied())
2970 .collect()
2971 };
2972 let pf_options = DcPfOptions::with_headroom_slack(&participating_buses);
2973 let sensitivity_options =
2974 crate::sensitivity::DcSensitivityOptions::with_headroom_slack(&participating_buses);
2975
2976 let request = DcAnalysisRequest::with_monitored_branches(&monitored)
2977 .with_otdf_outages(&outages)
2978 .with_otdf_buses(&otdf_buses)
2979 .with_lodf_outages(&outages)
2980 .with_n2_outage_pairs(&outage_pairs)
2981 .with_pf_options(pf_options.clone());
2982
2983 let workflow = run_dc_analysis(&net, &request).expect("workflow");
2984 let standalone_pf = solve_dc_opts(&net, &pf_options).expect("standalone solve");
2985 let standalone_ptdf = crate::sensitivity::compute_ptdf(
2986 &net,
2987 &crate::sensitivity::PtdfRequest::for_branches(&monitored)
2988 .with_options(sensitivity_options.clone()),
2989 )
2990 .expect("standalone ptdf");
2991 let standalone_otdf = crate::sensitivity::compute_otdf(
2992 &net,
2993 &crate::sensitivity::OtdfRequest::new(&monitored, &outages)
2994 .with_bus_indices(&otdf_buses)
2995 .with_options(sensitivity_options.clone()),
2996 )
2997 .expect("standalone otdf");
2998 let standalone_lodf = crate::sensitivity::compute_lodf(
2999 &net,
3000 &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
3001 )
3002 .expect("standalone lodf");
3003 let standalone_n2 = crate::sensitivity::compute_n2_lodf_batch(
3004 &net,
3005 &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
3006 .with_monitored_branches(&monitored),
3007 )
3008 .expect("standalone n2");
3009
3010 assert_eq!(workflow.power_flow.theta, standalone_pf.theta);
3011 assert_eq!(
3012 workflow.power_flow.branch_p_flow,
3013 standalone_pf.branch_p_flow
3014 );
3015 assert_eq!(workflow.ptdf, standalone_ptdf);
3016 let workflow_otdf = workflow.otdf.as_ref().expect("workflow otdf");
3017 assert_eq!(workflow_otdf, &standalone_otdf);
3018
3019 let workflow_lodf = workflow.lodf.as_ref().expect("workflow lodf");
3020 assert_eq!(workflow_lodf.n_rows(), standalone_lodf.n_rows());
3021 assert_eq!(workflow_lodf.n_cols(), standalone_lodf.n_cols());
3022 for i in 0..workflow_lodf.n_rows() {
3023 for j in 0..workflow_lodf.n_cols() {
3024 assert!(
3025 (workflow_lodf[(i, j)] - standalone_lodf[(i, j)]).abs() < 1e-12
3026 || (workflow_lodf[(i, j)].is_infinite()
3027 && standalone_lodf[(i, j)].is_infinite())
3028 );
3029 }
3030 }
3031
3032 let workflow_n2 = workflow.n2_lodf.as_ref().expect("workflow n2");
3033 assert_eq!(workflow_n2.n_rows(), standalone_n2.n_rows());
3034 assert_eq!(workflow_n2.n_cols(), standalone_n2.n_cols());
3035 for i in 0..workflow_n2.n_rows() {
3036 for j in 0..workflow_n2.n_cols() {
3037 assert!(
3038 (workflow_n2[(i, j)] - standalone_n2[(i, j)]).abs() < 1e-12
3039 || (workflow_n2[(i, j)].is_infinite()
3040 && standalone_n2[(i, j)].is_infinite())
3041 );
3042 }
3043 }
3044
3045 let single_slack_ptdf = crate::sensitivity::compute_ptdf(
3046 &net,
3047 &crate::sensitivity::PtdfRequest::for_branches(&monitored),
3048 )
3049 .expect("single-slack ptdf");
3050 let workflow_row = workflow.ptdf.row(monitored[0]).expect("workflow ptdf row");
3051 let single_row = single_slack_ptdf
3052 .row(monitored[0])
3053 .expect("single-slack ptdf row");
3054 assert!(
3055 workflow_row
3056 .iter()
3057 .zip(single_row.iter())
3058 .any(|(workflow_value, single_value)| (workflow_value - single_value).abs() > 1e-9),
3059 "headroom-slack workflow PTDF should differ from single-slack PTDF"
3060 );
3061 }
3062
3063 #[test]
3064 fn test_dc_sensitivity_workflow_slack_weights_matches_individual_calls() {
3065 skip_if_no_data!();
3066
3067 let net = load_net("case14");
3068 let monitored = vec![0, 3, 7];
3069 let outages = vec![1, 5];
3070 let otdf_buses = vec![0, 4, 9];
3071 let outage_pairs = vec![(0, 2), (2, 0)];
3072 let sensitivity_options = crate::sensitivity::DcSensitivityOptions::with_slack_weights(&[
3073 (1usize, 3.0),
3074 (2usize, 1.0),
3075 ]);
3076
3077 let request = DcAnalysisRequest::with_monitored_branches(&monitored)
3078 .with_otdf_outages(&outages)
3079 .with_otdf_buses(&otdf_buses)
3080 .with_lodf_outages(&outages)
3081 .with_n2_outage_pairs(&outage_pairs)
3082 .with_sensitivity_options(sensitivity_options.clone());
3083
3084 let workflow = run_dc_analysis(&net, &request).expect("workflow");
3085 let standalone_pf = solve_dc(&net).expect("standalone solve");
3086 let standalone_ptdf = crate::sensitivity::compute_ptdf(
3087 &net,
3088 &crate::sensitivity::PtdfRequest::for_branches(&monitored)
3089 .with_options(sensitivity_options.clone()),
3090 )
3091 .expect("standalone ptdf");
3092 let standalone_otdf = crate::sensitivity::compute_otdf(
3093 &net,
3094 &crate::sensitivity::OtdfRequest::new(&monitored, &outages)
3095 .with_bus_indices(&otdf_buses)
3096 .with_options(sensitivity_options.clone()),
3097 )
3098 .expect("standalone otdf");
3099 let standalone_lodf = crate::sensitivity::compute_lodf(
3100 &net,
3101 &crate::sensitivity::LodfRequest::for_branches(&monitored, &outages),
3102 )
3103 .expect("standalone lodf");
3104 let standalone_n2 = crate::sensitivity::compute_n2_lodf_batch(
3105 &net,
3106 &crate::sensitivity::N2LodfBatchRequest::new(&outage_pairs)
3107 .with_monitored_branches(&monitored),
3108 )
3109 .expect("standalone n2");
3110
3111 assert_eq!(workflow.power_flow.theta, standalone_pf.theta);
3112 assert_eq!(
3113 workflow.power_flow.branch_p_flow,
3114 standalone_pf.branch_p_flow
3115 );
3116 assert_eq!(workflow.ptdf, standalone_ptdf);
3117 assert_eq!(
3118 workflow.otdf.as_ref().expect("workflow otdf"),
3119 &standalone_otdf
3120 );
3121
3122 let workflow_lodf = workflow.lodf.as_ref().expect("workflow lodf");
3123 assert_eq!(workflow_lodf.n_rows(), standalone_lodf.n_rows());
3124 assert_eq!(workflow_lodf.n_cols(), standalone_lodf.n_cols());
3125 for i in 0..workflow_lodf.n_rows() {
3126 for j in 0..workflow_lodf.n_cols() {
3127 assert!(
3128 (workflow_lodf[(i, j)] - standalone_lodf[(i, j)]).abs() < 1e-12
3129 || (workflow_lodf[(i, j)].is_infinite()
3130 && standalone_lodf[(i, j)].is_infinite())
3131 );
3132 }
3133 }
3134
3135 let workflow_n2 = workflow.n2_lodf.as_ref().expect("workflow n2");
3136 assert_eq!(workflow_n2.n_rows(), standalone_n2.n_rows());
3137 assert_eq!(workflow_n2.n_cols(), standalone_n2.n_cols());
3138 for i in 0..workflow_n2.n_rows() {
3139 for j in 0..workflow_n2.n_cols() {
3140 assert!(
3141 (workflow_n2[(i, j)] - standalone_n2[(i, j)]).abs() < 1e-12
3142 || (workflow_n2[(i, j)].is_infinite()
3143 && standalone_n2[(i, j)].is_infinite())
3144 );
3145 }
3146 }
3147
3148 let single_slack_ptdf = crate::sensitivity::compute_ptdf(
3149 &net,
3150 &crate::sensitivity::PtdfRequest::for_branches(&monitored),
3151 )
3152 .expect("single-slack ptdf");
3153 let workflow_row = workflow.ptdf.row(monitored[0]).expect("workflow ptdf row");
3154 let single_row = single_slack_ptdf
3155 .row(monitored[0])
3156 .expect("single-slack ptdf row");
3157 assert!(
3158 workflow_row
3159 .iter()
3160 .zip(single_row.iter())
3161 .any(|(workflow_value, single_value)| (workflow_value - single_value).abs() > 1e-9),
3162 "weighted workflow PTDF should differ from single-slack PTDF"
3163 );
3164 }
3165
3166 #[test]
3178 fn test_headroom_slack_case14() {
3179 skip_if_no_data!();
3180 let net = load_net("case14");
3181
3182 let bus_map = net.bus_index_map();
3183 let idx1 = bus_map[&1]; let idx2 = bus_map[&2]; let idx8 = bus_map[&8]; let opts = DcPfOptions::with_headroom_slack(&[idx1, idx2, idx8]);
3188 let result = solve_dc_opts(&net, &opts).expect("headroom slack DC PF should converge");
3189
3190 let single = solve_dc(&net).expect("single-slack DC PF");
3191 let single_mismatch_mw = single.slack_p_injection * net.base_mva;
3192
3193 assert_eq!(
3196 result.slack_distribution.len(),
3197 3,
3198 "Expected 3 entries in slack_distribution (one per declared participant)"
3199 );
3200
3201 for (&bidx, &share_mw) in &result.slack_distribution {
3203 if bidx == idx1 {
3204 continue; }
3206 let headroom_mw: f64 = net
3207 .generators
3208 .iter()
3209 .filter(|g| g.in_service && bus_map.get(&g.bus) == Some(&bidx))
3210 .map(|g| (g.pmax - g.p).max(0.0))
3211 .sum();
3212 assert!(
3213 share_mw.abs() <= headroom_mw + 1e-6,
3214 "Bus {bidx}: share {share_mw:.4} MW exceeds headroom {headroom_mw:.4} MW"
3215 );
3216 }
3217
3218 let total_absorbed: f64 = result.slack_distribution.values().sum();
3221 assert!(
3222 (total_absorbed - single_mismatch_mw).abs() < 1e-4,
3223 "Total distributed ({total_absorbed:.4} MW) should equal \
3224 single-slack mismatch ({single_mismatch_mw:.4} MW)"
3225 );
3226
3227 let total_load = net.total_load_mw();
3229 let remaining_slack_mw = result.slack_p_injection * net.base_mva;
3230 assert!(
3231 (result.total_generation_mw - (total_load + remaining_slack_mw)).abs() < 1e-4,
3232 "total_generation_mw ({:.4}) should equal total_load + remaining_slack ({:.4})",
3233 result.total_generation_mw,
3234 total_load + remaining_slack_mw
3235 );
3236 }
3237
3238 #[test]
3247 fn test_participation_factor_slack_case14() {
3248 skip_if_no_data!();
3249 let mut net = load_net("case14");
3250 let bus_map = net.bus_index_map();
3251 let idx2 = bus_map[&2];
3252 let idx3 = bus_map[&3];
3253
3254 for g in &mut net.generators {
3256 if let Some(&bidx) = bus_map.get(&g.bus) {
3257 if bidx == idx2 {
3258 g.agc_participation_factor = Some(0.75);
3259 } else if bidx == idx3 {
3260 g.agc_participation_factor = Some(0.25);
3261 }
3262 }
3263 }
3264
3265 let weights = net.agc_participation_by_bus();
3266 assert!(
3267 !weights.is_empty(),
3268 "Should have participation factors from generators"
3269 );
3270
3271 let opts = DcPfOptions::with_participation_factors(&weights);
3272 let result = solve_dc_opts(&net, &opts).expect("participation factor DC PF");
3273 let single = solve_dc(&net).expect("single-slack DC PF");
3274 let single_mismatch_mw = single.slack_p_injection * net.base_mva;
3275
3276 let total_absorbed: f64 = result.slack_distribution.values().sum();
3280 assert!(
3281 (total_absorbed - single_mismatch_mw).abs() < 1e-4,
3282 "Total distributed ({total_absorbed:.4} MW) should equal \
3283 single-slack mismatch ({single_mismatch_mw:.4} MW)"
3284 );
3285
3286 let bus2_share = result.slack_distribution.get(&idx2).copied().unwrap_or(0.0);
3288 let bus3_share = result.slack_distribution.get(&idx3).copied().unwrap_or(0.0);
3289 let non_slack_total = bus2_share + bus3_share;
3290 if non_slack_total.abs() > 1e-6 {
3291 let ratio = bus2_share / non_slack_total;
3292 assert!(
3293 (ratio - 0.75).abs() < 0.02,
3294 "Bus 2 should get ~75% of distributed slack, got {:.1}%",
3295 ratio * 100.0
3296 );
3297 }
3298
3299 let max_diff: f64 = result
3301 .branch_p_flow
3302 .iter()
3303 .zip(single.branch_p_flow.iter())
3304 .map(|(a, b)| (a - b).abs())
3305 .fold(0.0f64, f64::max);
3306 assert!(
3307 max_diff > 1e-6,
3308 "Participation-factor flows should differ from single-slack flows"
3309 );
3310
3311 let sens_opts = crate::sensitivity::DcSensitivityOptions::with_slack_weights(&weights);
3314 let all_branches: Vec<usize> = (0..net.n_branches()).collect();
3315 let mut study = PreparedDcStudy::new(&net).expect("prepared study");
3316 let dptdf = study
3317 .compute_ptdf_with_options(&all_branches, None, &sens_opts)
3318 .expect("D-PTDF");
3319 for (br_idx, &actual_flow) in result.branch_p_flow.iter().enumerate() {
3321 if let Some(row) = dptdf.row(br_idx) {
3322 let predicted: f64 = row
3323 .iter()
3324 .enumerate()
3325 .map(|(col_pos, &ptdf_val)| {
3326 let bus_idx = dptdf.bus_indices()[col_pos];
3327 ptdf_val * result.p_inject_pu[bus_idx]
3328 })
3329 .sum();
3330 assert!(
3331 (predicted - actual_flow).abs() < 1e-6,
3332 "D-PTDF flow reconstruction mismatch on branch {br_idx}: \
3333 predicted={predicted:.8}, actual={actual_flow:.8}"
3334 );
3335 }
3336 }
3337 }
3338
3339 #[test]
3346 fn test_dc_case9_matpower_reference() {
3347 skip_if_no_data!();
3348 let net = load_net("case9");
3349 let result = solve_dc(&net).expect("case9 DC PF should converge");
3350
3351 let ref_theta_deg: &[f64] = &[
3354 0.0000000000, 9.7960188551, 5.0605600451, -2.2111587230, -3.7380912470, 2.2066572676, 0.8224410570, 3.9590113172, -4.0634004908, ];
3364
3365 assert_eq!(
3366 result.theta.len(),
3367 ref_theta_deg.len(),
3368 "case9: expected {} buses, got {}",
3369 ref_theta_deg.len(),
3370 result.theta.len()
3371 );
3372
3373 for (i, &theta) in result.theta.iter().enumerate() {
3374 let theta_deg = theta.to_degrees();
3375 assert!(
3376 (theta_deg - ref_theta_deg[i]).abs() < 1e-2,
3377 "case9 bus {} theta: got {:.4} deg, expected {:.4} deg",
3378 net.buses[i].number,
3379 theta_deg,
3380 ref_theta_deg[i]
3381 );
3382 }
3383
3384 let ref_flows: &[f64] = &[
3387 0.6700000000, 0.2896739130, -0.6103260870, 0.8500000000, 0.2396739130, -0.7603260870, -1.6300000000, 0.8696739130, -0.3803260870, ];
3397
3398 assert_eq!(
3399 result.branch_p_flow.len(),
3400 ref_flows.len(),
3401 "case9: expected {} branches, got {}",
3402 ref_flows.len(),
3403 result.branch_p_flow.len()
3404 );
3405
3406 for (i, &flow) in result.branch_p_flow.iter().enumerate() {
3407 assert!(
3408 (flow - ref_flows[i]).abs() < 1e-4,
3409 "case9 branch {} ({}->{}) flow: got {:.6}, expected {:.6}",
3410 i,
3411 net.branches[i].from_bus,
3412 net.branches[i].to_bus,
3413 flow,
3414 ref_flows[i]
3415 );
3416 }
3417 }
3418
3419 #[test]
3422 fn test_dc_case14_matpower_reference() {
3423 skip_if_no_data!();
3424 let net = load_net("case14");
3425 let result = solve_dc(&net).expect("case14 DC PF should converge");
3426
3427 let ref_theta_deg: &[f64] = &[
3430 0.0000000000, -5.0120111659, -12.9536631292, -10.5836674351, -9.0938942486, -14.8520790526, -13.9070545899, -13.9070545899, -15.6946888799, -15.9741231351, -15.6188501240, -15.9670768584, -16.1397037396, -17.1882875703, ];
3445
3446 assert_eq!(
3447 result.theta.len(),
3448 ref_theta_deg.len(),
3449 "case14: expected {} buses, got {}",
3450 ref_theta_deg.len(),
3451 result.theta.len()
3452 );
3453
3454 for (i, &theta) in result.theta.iter().enumerate() {
3455 let theta_deg = theta.to_degrees();
3456 assert!(
3457 (theta_deg - ref_theta_deg[i]).abs() < 1e-2,
3458 "case14 bus {} theta: got {:.4} deg, expected {:.4} deg",
3459 net.buses[i].number,
3460 theta_deg,
3461 ref_theta_deg[i]
3462 );
3463 }
3464
3465 let ref_flows: &[f64] = &[
3468 1.4783859556, 0.7116140444, 0.7001463596, 0.5515185270, 0.4097210690, -0.2418536404, -0.6174649065, 0.2836115279, 0.1655182652, 0.4278702069, 0.0672834580, 0.0760735814, 0.1725131674, 0.0000000000, 0.2836115279, 0.0577165420, 0.0964132512, -0.0322834580, 0.0150735814, 0.0525867488, ];
3489
3490 assert_eq!(
3491 result.branch_p_flow.len(),
3492 ref_flows.len(),
3493 "case14: expected {} branches, got {}",
3494 ref_flows.len(),
3495 result.branch_p_flow.len()
3496 );
3497
3498 for (i, &flow) in result.branch_p_flow.iter().enumerate() {
3499 assert!(
3500 (flow - ref_flows[i]).abs() < 1e-4,
3501 "case14 branch {} ({}->{}) flow: got {:.6}, expected {:.6}",
3502 i,
3503 net.branches[i].from_bus,
3504 net.branches[i].to_bus,
3505 flow,
3506 ref_flows[i]
3507 );
3508 }
3509 }
3510}