1use crate::error::{OptimizeError, OptimizeResult};
40use scirs2_core::ndarray::{Array1, Array2, ArrayView1, ArrayView2};
41
42#[derive(Debug, Clone)]
53pub struct RobustLP {
54 pub c: Array1<f64>,
56 pub a_matrix: Array2<f64>,
58 pub b_rhs: Array1<f64>,
60 pub lb: Option<Array1<f64>>,
62 pub ub: Option<Array1<f64>>,
64 pub c_uncertainty: Option<Array1<f64>>,
66 pub a_uncertainty: Option<Array2<f64>>,
68 pub b_uncertainty: Option<Array1<f64>>,
70}
71
72impl RobustLP {
73 pub fn new(c: Array1<f64>, a_matrix: Array2<f64>, b_rhs: Array1<f64>) -> OptimizeResult<Self> {
81 let n = c.len();
82 let (m, nc) = (a_matrix.shape()[0], a_matrix.shape()[1]);
83 if nc != n {
84 return Err(OptimizeError::ValueError(format!(
85 "A has {} columns but c has length {}",
86 nc, n
87 )));
88 }
89 if b_rhs.len() != m {
90 return Err(OptimizeError::ValueError(format!(
91 "b has length {} but A has {} rows",
92 b_rhs.len(),
93 m
94 )));
95 }
96 Ok(Self {
97 c,
98 a_matrix,
99 b_rhs,
100 lb: None,
101 ub: None,
102 c_uncertainty: None,
103 a_uncertainty: None,
104 b_uncertainty: None,
105 })
106 }
107
108 pub fn with_c_uncertainty(mut self, delta_c: Array1<f64>) -> OptimizeResult<Self> {
110 if delta_c.len() != self.c.len() {
111 return Err(OptimizeError::ValueError(format!(
112 "delta_c has length {} but c has length {}",
113 delta_c.len(),
114 self.c.len()
115 )));
116 }
117 self.c_uncertainty = Some(delta_c);
118 Ok(self)
119 }
120
121 pub fn with_a_uncertainty(mut self, delta_a: Array2<f64>) -> OptimizeResult<Self> {
123 if delta_a.shape() != self.a_matrix.shape() {
124 return Err(OptimizeError::ValueError(format!(
125 "delta_A shape {:?} does not match A shape {:?}",
126 delta_a.shape(),
127 self.a_matrix.shape()
128 )));
129 }
130 self.a_uncertainty = Some(delta_a);
131 Ok(self)
132 }
133
134 pub fn with_b_uncertainty(mut self, delta_b: Array1<f64>) -> OptimizeResult<Self> {
136 if delta_b.len() != self.b_rhs.len() {
137 return Err(OptimizeError::ValueError(format!(
138 "delta_b has length {} but b has length {}",
139 delta_b.len(),
140 self.b_rhs.len()
141 )));
142 }
143 self.b_uncertainty = Some(delta_b);
144 Ok(self)
145 }
146
147 pub fn with_bounds(mut self, lb: Array1<f64>, ub: Array1<f64>) -> OptimizeResult<Self> {
149 let n = self.c.len();
150 if lb.len() != n || ub.len() != n {
151 return Err(OptimizeError::ValueError(format!(
152 "bounds have length {}/{} but n={}",
153 lb.len(),
154 ub.len(),
155 n
156 )));
157 }
158 self.lb = Some(lb);
159 self.ub = Some(ub);
160 Ok(self)
161 }
162
163 pub fn n_vars(&self) -> usize {
165 self.c.len()
166 }
167
168 pub fn n_constraints(&self) -> usize {
170 self.b_rhs.len()
171 }
172}
173
174#[derive(Debug, Clone)]
176pub struct RobustLPResult {
177 pub x: Array1<f64>,
179 pub fun: f64,
181 pub nominal_fun: f64,
183 pub constraint_slack: f64,
185 pub n_iter: usize,
187 pub converged: bool,
189 pub message: String,
191}
192
193#[derive(Debug, Clone)]
195pub struct RobustLPConfig {
196 pub max_iter: usize,
198 pub tol: f64,
200 pub step_size: f64,
202 pub step_reduction: f64,
204 pub constraint_penalty: f64,
206}
207
208impl Default for RobustLPConfig {
209 fn default() -> Self {
210 Self {
211 max_iter: 5_000,
212 tol: 1e-6,
213 step_size: 1e-2,
214 step_reduction: 0.5,
215 constraint_penalty: 100.0,
216 }
217 }
218}
219
220fn project_box(x: &Array1<f64>, lb: &Option<Array1<f64>>, ub: &Option<Array1<f64>>) -> Array1<f64> {
224 x.iter()
225 .enumerate()
226 .map(|(i, &xi)| {
227 let lo = lb.as_ref().map(|b| b[i]).unwrap_or(f64::NEG_INFINITY);
228 let hi = ub.as_ref().map(|b| b[i]).unwrap_or(f64::INFINITY);
229 xi.max(lo).min(hi)
230 })
231 .collect()
232}
233
234fn constraint_residual(
236 a: &ArrayView2<f64>,
237 x: &ArrayView1<f64>,
238 b: &ArrayView1<f64>,
239) -> Array1<f64> {
240 let m = b.len();
241 let n = x.len();
242 let mut r = Array1::<f64>::zeros(m);
243 for i in 0..m {
244 let ax_i: f64 = (0..n).map(|j| a[[i, j]] * x[j]).sum();
245 r[i] = ax_i - b[i];
246 }
247 r
248}
249
250fn penalized_gradient(
254 x: &Array1<f64>,
255 c_worst: &Array1<f64>,
256 a: &Array2<f64>,
257 b: &Array1<f64>,
258 penalty: f64,
259) -> Array1<f64> {
260 let n = x.len();
261 let m = b.len();
262
263 let mut grad = c_worst.clone();
265
266 for i in 0..m {
268 let ax_i: f64 = (0..n).map(|j| a[[i, j]] * x[j]).sum();
269 let viol = ax_i - b[i];
270 if viol > 0.0 {
271 for j in 0..n {
273 grad[j] += 2.0 * penalty * viol * a[[i, j]];
274 }
275 }
276 }
277 grad
278}
279
280pub fn box_robust_lp(
302 problem: &RobustLP,
303 x0: &ArrayView1<f64>,
304 config: &RobustLPConfig,
305) -> OptimizeResult<RobustLPResult> {
306 let n = problem.n_vars();
307 let m = problem.n_constraints();
308 if x0.len() != n {
309 return Err(OptimizeError::ValueError(format!(
310 "x0 has length {} but problem has {} variables",
311 x0.len(),
312 n
313 )));
314 }
315
316 let delta_c = problem
320 .c_uncertainty
321 .clone()
322 .unwrap_or_else(|| Array1::zeros(n));
323
324 let delta_b = problem
326 .b_uncertainty
327 .clone()
328 .unwrap_or_else(|| Array1::zeros(m));
329 let b_robust: Array1<f64> = problem
330 .b_rhs
331 .iter()
332 .zip(delta_b.iter())
333 .map(|(&bi, &dbi)| bi - dbi.abs())
334 .collect();
335
336 let delta_a = problem
339 .a_uncertainty
340 .clone()
341 .unwrap_or_else(|| Array2::zeros((m, n)));
342
343 let mut x = x0.to_owned();
344 let mut converged = false;
345 let mut step = config.step_size;
346
347 for iter in 0..config.max_iter {
348 let c_worst: Array1<f64> = problem
350 .c
351 .iter()
352 .zip(delta_c.iter())
353 .zip(x.iter())
354 .map(|((&ci, &dci), &xi)| ci + dci * xi.signum())
355 .collect();
356
357 let b_effective: Array1<f64> = (0..m)
360 .map(|i| {
361 let reduction: f64 = (0..n).map(|j| delta_a[[i, j]] * x[j].abs()).sum();
362 b_robust[i] - reduction
363 })
364 .collect();
365
366 let grad = penalized_gradient(
367 &x,
368 &c_worst,
369 &problem.a_matrix,
370 &b_effective,
371 config.constraint_penalty,
372 );
373
374 let grad_norm: f64 = grad.iter().map(|g| g * g).sum::<f64>().sqrt();
375 if grad_norm < config.tol {
376 converged = true;
377 break;
378 }
379
380 let obj_curr: f64 = problem
382 .c
383 .iter()
384 .zip(x.iter())
385 .map(|(&ci, &xi)| ci * xi)
386 .sum::<f64>()
387 + delta_c
388 .iter()
389 .zip(x.iter())
390 .map(|(&di, &xi)| di * xi.abs())
391 .sum::<f64>();
392
393 let mut accepted = false;
394 for _ in 0..20 {
395 let x_new: Array1<f64> = x
396 .iter()
397 .zip(grad.iter())
398 .map(|(&xi, &gi)| xi - step * gi)
399 .collect();
400 let x_proj = project_box(&x_new, &problem.lb, &problem.ub);
401
402 let obj_new: f64 = problem
403 .c
404 .iter()
405 .zip(x_proj.iter())
406 .map(|(&ci, &xi)| ci * xi)
407 .sum::<f64>()
408 + delta_c
409 .iter()
410 .zip(x_proj.iter())
411 .map(|(&di, &xi)| di * xi.abs())
412 .sum::<f64>();
413
414 if obj_new <= obj_curr - 1e-4 * step * grad_norm * grad_norm {
415 x = x_proj;
416 accepted = true;
417 break;
418 }
419 step *= config.step_reduction;
420 }
421
422 if !accepted {
423 let x_new: Array1<f64> = x
425 .iter()
426 .zip(grad.iter())
427 .map(|(&xi, &gi)| xi - step * gi)
428 .collect();
429 x = project_box(&x_new, &problem.lb, &problem.ub);
430 }
431
432 if iter % 100 == 99 {
434 step = (step * 1.1).min(config.step_size);
435 }
436 }
437
438 let nominal_fun: f64 = problem
440 .c
441 .iter()
442 .zip(x.iter())
443 .map(|(&ci, &xi)| ci * xi)
444 .sum();
445 let robust_fun: f64 = nominal_fun
446 + delta_c
447 .iter()
448 .zip(x.iter())
449 .map(|(&di, &xi)| di * xi.abs())
450 .sum::<f64>();
451
452 let residual = constraint_residual(&problem.a_matrix.view(), &x.view(), &problem.b_rhs.view());
453 let constraint_slack = -residual.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
454
455 Ok(RobustLPResult {
456 x,
457 fun: robust_fun,
458 nominal_fun,
459 constraint_slack,
460 n_iter: config.max_iter,
461 converged,
462 message: if converged {
463 "Box robust LP converged".to_string()
464 } else {
465 "Box robust LP reached maximum iterations".to_string()
466 },
467 })
468}
469
470pub fn ellipsoidal_robust_lp(
503 problem: &RobustLP,
504 c_covariance: &ArrayView2<f64>,
505 ellipsoid_radius: f64,
506 x0: &ArrayView1<f64>,
507 config: &RobustLPConfig,
508) -> OptimizeResult<RobustLPResult> {
509 let n = problem.n_vars();
510 let m = problem.n_constraints();
511 if x0.len() != n {
512 return Err(OptimizeError::ValueError(format!(
513 "x0 has length {} but problem has {} variables",
514 x0.len(),
515 n
516 )));
517 }
518 if c_covariance.shape() != [n, n] {
519 return Err(OptimizeError::ValueError(format!(
520 "c_covariance shape {:?} != [{n},{n}]",
521 c_covariance.shape()
522 )));
523 }
524 if ellipsoid_radius < 0.0 {
525 return Err(OptimizeError::ValueError(
526 "ellipsoid_radius must be non-negative".to_string(),
527 ));
528 }
529
530 let l_chol = cholesky_lower_triangular(c_covariance)?;
532
533 let mut x = x0.to_owned();
534 let mut converged = false;
535 let h = 1e-7;
536 let step = config.step_size;
537
538 for _ in 0..config.max_iter {
539 let lx = mat_vec_mul_lower(&l_chol, &x.view());
542 let lx_norm = l2_norm_vec(&lx);
543
544 let socp_grad: Array1<f64> = if lx_norm > h {
545 let lx_normalized: Array1<f64> = lx.iter().map(|&v| v / lx_norm).collect();
547 let lt_v = mat_vec_mul_lower_transpose(&l_chol, &lx_normalized.view());
548 problem
549 .c
550 .iter()
551 .zip(lt_v.iter())
552 .map(|(&ci, &li)| ci + ellipsoid_radius * li)
553 .collect()
554 } else {
555 problem.c.clone()
557 };
558
559 let residual =
561 constraint_residual(&problem.a_matrix.view(), &x.view(), &problem.b_rhs.view());
562 let mut full_grad = socp_grad;
563 for i in 0..m {
564 if residual[i] > 0.0 {
565 for j in 0..n {
566 full_grad[j] +=
567 2.0 * config.constraint_penalty * residual[i] * problem.a_matrix[[i, j]];
568 }
569 }
570 }
571
572 let grad_norm = l2_norm_vec(&full_grad);
573 if grad_norm < config.tol {
574 converged = true;
575 break;
576 }
577
578 let x_new: Array1<f64> = x
579 .iter()
580 .zip(full_grad.iter())
581 .map(|(&xi, &gi)| xi - step * gi)
582 .collect();
583 x = project_box(&x_new, &problem.lb, &problem.ub);
584 }
585
586 let nominal_fun: f64 = problem
587 .c
588 .iter()
589 .zip(x.iter())
590 .map(|(&ci, &xi)| ci * xi)
591 .sum();
592 let lx = mat_vec_mul_lower(&l_chol, &x.view());
593 let robust_fun = nominal_fun + ellipsoid_radius * l2_norm_vec(&lx);
594
595 let residual = constraint_residual(&problem.a_matrix.view(), &x.view(), &problem.b_rhs.view());
596 let constraint_slack = -residual.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
597
598 Ok(RobustLPResult {
599 x,
600 fun: robust_fun,
601 nominal_fun,
602 constraint_slack,
603 n_iter: config.max_iter,
604 converged,
605 message: if converged {
606 "Ellipsoidal robust LP converged".to_string()
607 } else {
608 "Ellipsoidal robust LP reached maximum iterations".to_string()
609 },
610 })
611}
612
613pub fn robust_objective(
632 c: &ArrayView1<f64>,
633 x: &ArrayView1<f64>,
634 model: &ObjectiveUncertaintyModel,
635) -> OptimizeResult<f64> {
636 let n = c.len();
637 if x.len() != n {
638 return Err(OptimizeError::ValueError(format!(
639 "c has length {} but x has length {}",
640 n,
641 x.len()
642 )));
643 }
644
645 let nominal: f64 = c.iter().zip(x.iter()).map(|(&ci, &xi)| ci * xi).sum();
646
647 let penalty = match model {
648 ObjectiveUncertaintyModel::Box { delta } => {
649 if delta.len() != n {
650 return Err(OptimizeError::ValueError(format!(
651 "delta has length {} but n={}",
652 delta.len(),
653 n
654 )));
655 }
656 delta
658 .iter()
659 .zip(x.iter())
660 .map(|(&di, &xi)| di * xi.abs())
661 .sum::<f64>()
662 }
663 ObjectiveUncertaintyModel::Ellipsoidal { covariance, radius } => {
664 if covariance.shape() != [n, n] {
665 return Err(OptimizeError::ValueError(format!(
666 "covariance shape {:?} != [{n},{n}]",
667 covariance.shape()
668 )));
669 }
670 let sigma_x: f64 = (0..n)
672 .map(|i| {
673 let row: f64 = (0..n).map(|j| covariance[[i, j]] * x[j]).sum();
674 row * x[i]
675 })
676 .sum();
677 radius * sigma_x.sqrt()
678 }
679 ObjectiveUncertaintyModel::Budget { delta, budget } => {
680 if delta.len() != n {
681 return Err(OptimizeError::ValueError(format!(
682 "delta has length {} but n={}",
683 delta.len(),
684 n
685 )));
686 }
687 let mut perturbations: Vec<f64> = delta
689 .iter()
690 .zip(x.iter())
691 .map(|(&di, &xi)| di * xi.abs())
692 .collect();
693 perturbations.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
694
695 let gamma = (*budget as usize).min(n);
696 let floor_gamma = budget.floor() as usize;
697 let frac = budget - budget.floor();
698
699 let int_sum: f64 = perturbations.iter().take(floor_gamma).sum();
701 let frac_sum = if floor_gamma < perturbations.len() && gamma <= perturbations.len() {
703 frac * perturbations[floor_gamma]
704 } else {
705 0.0
706 };
707 int_sum + frac_sum
708 }
709 };
710
711 Ok(nominal + penalty)
712}
713
714#[derive(Debug, Clone)]
716pub enum ObjectiveUncertaintyModel {
717 Box {
719 delta: Array1<f64>,
721 },
722 Ellipsoidal {
725 covariance: Array2<f64>,
727 radius: f64,
729 },
730 Budget {
733 delta: Array1<f64>,
735 budget: f64,
737 },
738}
739
740fn cholesky_lower_triangular(a: &ArrayView2<f64>) -> OptimizeResult<Array2<f64>> {
744 let n = a.shape()[0];
745 let mut l = Array2::<f64>::zeros((n, n));
746 for i in 0..n {
747 for j in 0..=i {
748 let mut s: f64 = 0.0;
749 for p in 0..j {
750 s += l[[i, p]] * l[[j, p]];
751 }
752 if i == j {
753 let diag = a[[i, i]] - s;
754 l[[i, j]] = if diag < 0.0 {
755 diag.abs().sqrt().max(1e-12)
756 } else {
757 diag.sqrt()
758 };
759 } else {
760 let ljj = l[[j, j]];
761 l[[i, j]] = if ljj.abs() < 1e-14 {
762 0.0
763 } else {
764 (a[[i, j]] - s) / ljj
765 };
766 }
767 }
768 }
769 Ok(l)
770}
771
772fn mat_vec_mul_lower(l: &Array2<f64>, x: &ArrayView1<f64>) -> Array1<f64> {
774 let n = x.len();
775 let mut y = Array1::<f64>::zeros(n);
776 for i in 0..n {
777 for j in 0..=i {
778 y[i] += l[[i, j]] * x[j];
779 }
780 }
781 y
782}
783
784fn mat_vec_mul_lower_transpose(l: &Array2<f64>, x: &ArrayView1<f64>) -> Array1<f64> {
786 let n = x.len();
787 let mut y = Array1::<f64>::zeros(n);
788 for j in 0..n {
789 for i in j..n {
790 y[j] += l[[i, j]] * x[i];
791 }
792 }
793 y
794}
795
796fn l2_norm_vec(v: &Array1<f64>) -> f64 {
798 v.iter().map(|vi| vi * vi).sum::<f64>().sqrt()
799}
800
801#[cfg(test)]
804mod tests {
805 use super::*;
806 use scirs2_core::ndarray::{array, Array2};
807
808 fn simple_lp() -> OptimizeResult<RobustLP> {
809 let c = array![-1.0, -1.0];
812 let a = array![[1.0, 1.0]];
813 let b = array![1.0];
814 RobustLP::new(c, a, b)
815 }
816
817 #[test]
818 fn test_robust_lp_new_dimension_mismatch() {
819 let c = array![1.0, 2.0];
820 let a = array![[1.0, 2.0, 3.0]]; let b = array![1.0];
822 assert!(RobustLP::new(c, a, b).is_err());
823 }
824
825 #[test]
826 fn test_box_robust_lp_no_uncertainty() {
827 let problem = simple_lp()
828 .expect("failed to create problem")
829 .with_bounds(array![0.0, 0.0], array![1.0, 1.0])
830 .expect("unexpected None or Err");
831 let x0 = array![0.0, 0.0];
832 let config = RobustLPConfig {
833 max_iter: 3_000,
834 step_size: 1e-2,
835 constraint_penalty: 50.0,
836 ..Default::default()
837 };
838 let result = box_robust_lp(&problem, &x0.view(), &config).expect("failed to create result");
839 assert!(
841 result.nominal_fun < 0.0,
842 "nominal fun should be negative (minimizing -x)"
843 );
844 }
845
846 #[test]
847 fn test_box_robust_lp_with_uncertainty() {
848 let problem = simple_lp()
849 .expect("unexpected None or Err")
850 .with_c_uncertainty(array![0.1, 0.1])
851 .expect("unexpected None or Err")
852 .with_bounds(array![0.0, 0.0], array![1.0, 1.0])
853 .expect("unexpected None or Err");
854 let x0 = array![0.1, 0.1];
855 let config = RobustLPConfig {
856 max_iter: 2_000,
857 step_size: 5e-3,
858 constraint_penalty: 50.0,
859 ..Default::default()
860 };
861 let result = box_robust_lp(&problem, &x0.view(), &config).expect("failed to create result");
862 assert!(
864 result.fun >= result.nominal_fun - 1e-9,
865 "robust obj {} should be ≥ nominal obj {}",
866 result.fun,
867 result.nominal_fun
868 );
869 }
870
871 #[test]
872 fn test_ellipsoidal_robust_lp() {
873 let problem = simple_lp()
874 .expect("unexpected None or Err")
875 .with_bounds(array![0.0, 0.0], array![1.0, 1.0])
876 .expect("unexpected None or Err");
877 let cov = Array2::<f64>::eye(2) * 0.01;
878 let x0 = array![0.1, 0.1];
879 let config = RobustLPConfig {
880 max_iter: 2_000,
881 step_size: 5e-3,
882 constraint_penalty: 50.0,
883 ..Default::default()
884 };
885 let result = ellipsoidal_robust_lp(&problem, &cov.view(), 0.5, &x0.view(), &config)
886 .expect("unexpected None or Err");
887 assert!(result.x.len() == 2);
889 assert!(result.fun.is_finite());
890 }
891
892 #[test]
893 fn test_robust_objective_box() {
894 let c = array![1.0, 2.0, -1.0];
895 let x = array![1.0, -1.0, 2.0];
896 let model = ObjectiveUncertaintyModel::Box {
897 delta: array![0.1, 0.1, 0.1],
898 };
899 let obj = robust_objective(&c.view(), &x.view(), &model).expect("failed to create obj");
900 assert!((obj - (-3.0 + 0.4)).abs() < 1e-9, "box robust obj={obj}");
903 }
904
905 #[test]
906 fn test_robust_objective_ellipsoidal() {
907 let c = array![1.0, 0.0];
908 let x = array![1.0, 0.0];
909 let cov = Array2::<f64>::eye(2);
910 let model = ObjectiveUncertaintyModel::Ellipsoidal {
911 covariance: cov,
912 radius: 1.0,
913 };
914 let obj = robust_objective(&c.view(), &x.view(), &model).expect("failed to create obj");
915 assert!((obj - 2.0).abs() < 1e-9, "ellipsoidal robust obj={obj}");
917 }
918
919 #[test]
920 fn test_robust_objective_budget() {
921 let c = array![1.0, 1.0, 1.0];
922 let x = array![1.0, 1.0, 1.0]; let model = ObjectiveUncertaintyModel::Budget {
924 delta: array![0.5, 0.3, 0.2],
925 budget: 2.0, };
927 let obj = robust_objective(&c.view(), &x.view(), &model).expect("failed to create obj");
928 assert!((obj - 3.8).abs() < 1e-9, "budget robust obj={obj}");
930 }
931
932 #[test]
933 fn test_robust_objective_dimension_error() {
934 let c = array![1.0, 2.0];
935 let x = array![1.0]; let model = ObjectiveUncertaintyModel::Box {
937 delta: array![0.1, 0.1],
938 };
939 assert!(robust_objective(&c.view(), &x.view(), &model).is_err());
940 }
941}