1use core::f64;
2use good_lp::{
3 constraint, variable, Constraint as LinearConstraint, Expression, ProblemVariables, Solution,
4 Solver, SolverModel, Variable,
5};
6use nalgebra::{DVector, SMatrix, SVector};
7use num_dual::DualNum;
8use std::collections::{HashMap, HashSet};
9use std::fmt::Debug;
10
11mod dual_vec_multiple;
12mod nlp;
13
14#[derive(Clone)]
16pub struct OptimizationResult<const N_X: usize, const N_Y1: usize, const N_Y2: usize> {
17 pub key: String,
18 pub objective: Gradient<N_X, N_Y1, N_Y2>,
19 pub constraints: Vec<Gradient<N_X, N_Y1, N_Y2>>,
20 pub x: SVector<f64, N_X>,
21 pub y: SMatrix<f64, N_Y1, N_Y2>,
22 pub s: Vec<f64>,
23 pub lambda: DVector<f64>,
24}
25
26impl<const N_X: usize, const N_Y1: usize, const N_Y2: usize> OptimizationResult<N_X, N_Y1, N_Y2> {
27 pub fn new(
28 key: String,
29 y: SMatrix<f64, N_Y1, N_Y2>,
30 s: Vec<f64>,
31 objective: Gradient<N_X, N_Y1, N_Y2>,
32 constraints: Vec<Gradient<N_X, N_Y1, N_Y2>>,
33 x: SVector<f64, N_X>,
34 lambda: DVector<f64>,
35 ) -> Self {
36 Self {
37 key,
38 objective,
39 constraints,
40 x,
41 y,
42 s,
43 lambda,
44 }
45 }
46}
47
48type Gradient<const N_X: usize, const N_Y1: usize, const N_Y2: usize> =
49 (f64, SMatrix<f64, N_Y1, N_Y2>, SVector<f64, N_X>);
50
51#[derive(Clone)]
52pub struct OptimizationOptions {
53 pub min_iter: usize,
54 pub max_iter: usize,
55 pub zero_tol: f64,
56 #[cfg(feature = "ipopt")]
57 pub nlp_options: Vec<(&'static str, ipopt::IpoptOption<'static>)>,
58 #[cfg(feature = "ripopt")]
59 pub nlp_options: ripopt::SolverOptions,
60}
61
62impl Default for OptimizationOptions {
63 fn default() -> Self {
64 Self {
65 min_iter: 5,
66 max_iter: 50,
67 zero_tol: 1e-6,
68 #[cfg(feature = "ipopt")]
69 nlp_options: vec![("print_level", ipopt::IpoptOption::Int(0))],
70 #[cfg(feature = "ripopt")]
71 nlp_options: ripopt::SolverOptions {
72 print_level: 0,
73 ..Default::default()
74 },
75 }
76 }
77}
78
79#[derive(Clone, Copy)]
81pub enum GeneralConstraint {
82 Equality(f64),
83 Inequality(Option<f64>, Option<f64>),
84}
85
86impl GeneralConstraint {
87 pub fn lower_bound(&self) -> f64 {
88 match self {
89 Self::Equality(e) => *e,
90 Self::Inequality(l, _) => l.unwrap_or(f64::NEG_INFINITY),
91 }
92 }
93
94 pub fn upper_bound(&self) -> f64 {
95 match self {
96 Self::Equality(e) => *e,
97 Self::Inequality(_, u) => u.unwrap_or(f64::INFINITY),
98 }
99 }
100}
101
102pub trait MixedIntegerNonLinearProgram<const N_X: usize, const N_Y1: usize, const N_Y2: usize> {
104 type Error;
105
106 fn x_variables(&self) -> SVector<(f64, f64, f64), N_X>;
107
108 fn y_variables(&self) -> SMatrix<(i32, i32), N_Y1, N_Y2>;
109
110 fn linear_constraints(&self, y: SMatrix<Variable, N_Y1, N_Y2>) -> Vec<LinearConstraint>;
111
112 fn constraints(&self) -> Vec<GeneralConstraint>;
113
114 fn evaluate<D: DualNum<f64> + Copy>(
115 &self,
116 x: SVector<D, N_X>,
117 y: SMatrix<D, N_Y1, N_Y2>,
118 ) -> Result<(D, Vec<D>), Self::Error>;
119
120 fn y_to_string(&self, y: &SMatrix<f64, N_Y1, N_Y2>) -> String;
121
122 fn exclude_solutions(&self, s: &[f64]) -> Vec<Vec<f64>> {
123 vec![s.to_vec()]
124 }
125}
126
127pub struct OuterApproximation<
129 'a,
130 M: MixedIntegerNonLinearProgram<N_X, N_Y1, N_Y2>,
131 const N_X: usize,
132 const N_Y1: usize,
133 const N_Y2: usize,
134> {
135 minlp: &'a M,
136 known_solutions: HashMap<String, OptimizationResult<N_X, N_Y1, N_Y2>>,
137 excluded_solutions: Vec<Vec<f64>>,
138}
139
140impl<
141 'a,
142 M: MixedIntegerNonLinearProgram<N_X, N_Y1, N_Y2>,
143 const N_X: usize,
144 const N_Y1: usize,
145 const N_Y2: usize,
146 > OuterApproximation<'a, M, N_X, N_Y1, N_Y2>
147where
148 M::Error: Debug,
149{
150 pub fn new(minlp: &'a M) -> Self {
151 Self {
152 minlp,
153 known_solutions: HashMap::new(),
154 excluded_solutions: vec![],
155 }
156 }
157
158 fn add_oa_cuts(
159 &self,
160 constraints: &mut Vec<LinearConstraint>,
161 x: SVector<Variable, N_X>,
162 y: SMatrix<Variable, N_Y1, N_Y2>,
163 mu: Variable,
164 result: &OptimizationResult<N_X, N_Y1, N_Y2>,
165 zero_tol: f64,
166 ) {
167 let (f, grad_x, grad_y) = &result.objective;
168 let con = &result.constraints;
169
170 let y_expr = y.iter().zip(result.y.data.0[0]).zip(grad_y.data.0[0]);
171 let x_expr = x.iter().zip(result.x.data.0[0]).zip(grad_x.data.0[0]);
172 let expr: Expression = y_expr.chain(x_expr).map(|((&x, x0), j)| (x - x0) * j).sum();
173 constraints.push(constraint!(expr + *f <= mu));
174
175 for ((constraint, (c, jac_y, jac_x)), &l) in self
176 .minlp
177 .constraints()
178 .into_iter()
179 .zip(con)
180 .zip(result.lambda.iter())
181 {
182 match constraint {
183 GeneralConstraint::Equality(eq) => {
184 let sign = 1f64.copysign(-l);
185 let y_expr = y.iter().zip(result.y.data.0[0]).zip(jac_y.data.0[0]);
186 let x_expr = x.iter().zip(result.x.data.0[0]).zip(jac_x.data.0[0]);
187 let expr: Expression =
188 y_expr.chain(x_expr).map(|((&x, x0), j)| (x - x0) * j).sum();
189 constraints.push(constraint!(expr * sign + *c <= eq));
190 }
191 GeneralConstraint::Inequality(lo, up) => {
192 if let Some(up) = up {
193 if up - c < zero_tol {
195 let y_expr = y.iter().zip(result.y.data.0[0]).zip(jac_y.data.0[0]);
196 let x_expr = x.iter().zip(result.x.data.0[0]).zip(jac_x.data.0[0]);
197 let expr: Expression =
198 y_expr.chain(x_expr).map(|((&x, x0), j)| (x - x0) * j).sum();
199 constraints.push(constraint!(expr + *c <= up));
200 }
201 }
202 if let Some(lo) = lo {
203 if c - lo < zero_tol {
205 let y_expr = y.iter().zip(result.y.data.0[0]).zip(jac_y.data.0[0]);
206 let x_expr = x.iter().zip(result.x.data.0[0]).zip(jac_x.data.0[0]);
207 let expr: Expression =
208 y_expr.chain(x_expr).map(|((&x, x0), j)| (x - x0) * j).sum();
209 constraints.push(constraint!(expr + *c >= lo));
210 }
211 }
212 }
213 }
214 }
215 }
216
217 fn add_integer_cut(constraints: &mut Vec<LinearConstraint>, s: &[Variable], s0: &[f64]) {
218 let expr = s.iter().zip(s0).map(|(&s, &s0)| s - 2.0 * s0 * s + s0);
219 constraints.push(constraint!(expr.sum::<Expression>() >= 1.0));
220 }
221
222 #[expect(clippy::type_complexity)]
223 pub fn solve_milp<S: Solver>(
224 &mut self,
225 solver: S,
226 oa_cuts: &[String],
227 zero_tol: f64,
228 ) -> Result<(SMatrix<f64, N_Y1, N_Y2>, Vec<f64>), <S::Model as SolverModel>::Error> {
229 let mut model = ProblemVariables::new();
230 let mut constraints = Vec::new();
231
232 let mut s = Vec::new();
234
235 let y = self.minlp.y_variables().map(|(l, u)| {
237 let y = model.add(variable().integer().bounds(l..u));
238
239 let vars = model.add_vector(variable().binary(), (u - l) as usize);
241 for vars in vars.windows(2) {
242 constraints.push(constraint!(vars[0] >= vars[1]));
243 }
244 constraints.push(constraint!(l + vars.iter().sum::<Expression>() == y));
245 s.extend_from_slice(&vars);
246
247 y
248 });
249
250 constraints.append(&mut self.minlp.linear_constraints(y));
252
253 let x = self
255 .minlp
256 .x_variables()
257 .map(|(l, u, _)| model.add(variable().bounds(l..u)));
258
259 let mu = model.add_variable();
261
262 for solution in &self.excluded_solutions {
264 Self::add_integer_cut(&mut constraints, &s, solution);
265 }
266
267 for key in oa_cuts {
269 let solution = &self.known_solutions[key];
270 self.add_oa_cuts(&mut constraints, x, y, mu, solution, zero_tol);
271 }
272
273 let mut model = model.minimise(mu).using(solver);
275
276 constraints.into_iter().for_each(|c| {
278 model.add_constraint(c);
279 });
280
281 model.solve().map(|solution| {
283 let y = y.map(|y| solution.value(y).round());
284 let s: Vec<_> = s.iter().map(|s| solution.value(*s).round()).collect();
285
286 (y, s)
287 })
288 }
289
290 fn calculate_s(&self, y: SMatrix<f64, N_Y1, N_Y2>) -> Vec<f64> {
291 let mut s = Vec::new();
292 self.minlp
293 .y_variables()
294 .iter()
295 .zip(y.iter())
296 .for_each(|(&(l, u), &y)| {
297 s.extend_from_slice(&vec![1.0; y as usize - l as usize]);
298 s.extend_from_slice(&vec![0.0; u as usize - y as usize]);
299 });
300 s
301 }
302
303 pub fn solve<S>(
304 &mut self,
305 y_init: SMatrix<f64, N_Y1, N_Y2>,
306 solver: &S,
307 options: &OptimizationOptions,
308 ) -> Vec<String>
309 where
310 for<'b> &'b S: Solver,
311 {
312 let s_init = self.calculate_s(y_init);
314 self.excluded_solutions
315 .extend(self.minlp.exclude_solutions(&s_init));
316 let result = self
317 .solve_nlp_with_options(y_init, s_init, &options.nlp_options)
318 .expect("The optimization did not converge for the initial structure!");
319 println!(
320 "{:8.5} {:.5?} {}",
321 result.objective.0, result.x.data.0[0], result.key
322 );
323
324 let mut new_solutions = vec![result.key.clone()];
326
327 let mut last = result.objective.0;
329
330 for k in 0..options.max_iter {
331 let (y, s) = match self.solve_milp(solver, &new_solutions, options.zero_tol) {
333 Ok(result) => result,
334 Err(e) => {
335 println!("{e}");
337 return new_solutions;
338 }
339 };
340 self.excluded_solutions
342 .extend(self.minlp.exclude_solutions(&s));
343
344 if let Some(result) = self.solve_nlp_with_options(y, s, &options.nlp_options) {
346 println!(
347 "{:8.5} {:.5?} {}",
348 result.objective.0, result.x.data.0[0], result.key
349 );
350 let obj = result.objective.0;
351 new_solutions.push(result.key.clone());
352
353 if obj > last && k >= options.min_iter {
355 return new_solutions;
356 }
357 last = obj;
358 } else {
359 println!("{} not converged!", self.minlp.y_to_string(&y));
360 }
361 }
362
363 new_solutions
364 }
365
366 pub fn solve_ranking<S>(
367 mut self,
368 y_init: SMatrix<f64, N_Y1, N_Y2>,
369 solver: S,
370 runs: usize,
371 options: &OptimizationOptions,
372 ) -> Vec<OptimizationResult<N_X, N_Y1, N_Y2>>
373 where
374 for<'b> &'b S: Solver,
375 {
376 let key_init = self.minlp.y_to_string(&y_init);
378 for k in 0..runs {
379 println!("\nStarting run {}", k + 1);
380
381 let new_solutions = self.solve(y_init, &solver, options);
383 let new_solutions: HashSet<_> = new_solutions.into_iter().collect();
384
385 let mut all_solutions: Vec<_> = self.known_solutions.values().collect();
387 all_solutions.sort_by(|&s1, &s2| s1.objective.0.total_cmp(&s2.objective.0));
388
389 println!("\nRanking after run {}", k + 1);
391 for (k, solution) in all_solutions.into_iter().enumerate() {
392 let s = self.minlp.y_to_string(&solution.y);
393 let known = if solution.key == key_init {
394 "+"
395 } else if new_solutions.contains(&solution.key) {
396 "*"
397 } else {
398 " "
399 };
400 println!(
401 "{:3}{known} {:10.7} {:.5?} {s}",
402 k + 1,
403 solution.objective.0,
404 solution.x.data.0[0]
405 );
406 }
407 }
408
409 self.known_solutions.into_values().collect()
410 }
411}
412
413#[cfg(test)]
414mod test {
415 use std::convert::Infallible;
416
417 use good_lp::highs;
418
419 use super::*;
420
421 struct TestMINLP;
422
423 impl MixedIntegerNonLinearProgram<2, 3, 1> for TestMINLP {
424 type Error = Infallible;
425
426 fn x_variables(&self) -> SVector<(f64, f64, f64), 2> {
427 SVector::from([(0.0, 10.0, 5.0), (0.0, 10.0, 5.0)])
428 }
429
430 fn y_variables(&self) -> SVector<(i32, i32), 3> {
431 SVector::from([(0, 1); 3])
432 }
433
434 fn linear_constraints(&self, y: SVector<Variable, 3>) -> Vec<LinearConstraint> {
435 vec![constraint!(-y[0] - y[1] + y[2] <= 0.0)]
436 }
437
438 fn constraints(&self) -> Vec<GeneralConstraint> {
439 vec![
440 GeneralConstraint::Equality(1.25),
441 GeneralConstraint::Equality(3.0),
442 GeneralConstraint::Inequality(None, Some(1.6)),
443 GeneralConstraint::Inequality(None, Some(3.0)),
444 ]
445 }
446
447 fn evaluate<D: DualNum<f64> + Copy>(
448 &self,
449 x: SVector<D, 2>,
450 y: SVector<D, 3>,
451 ) -> Result<(D, Vec<D>), Self::Error> {
452 let [x1, x2] = x.data.0[0];
453 let [y1, y2, y3] = y.data.0[0];
454 let c1 = x1 * x1 + y1;
455 let c2 = x2.powf(1.5) + y2 * 1.5;
456 let c4 = x1 + y1;
457 let c5 = x2 * 1.333 + y2;
458 let obj = x1 * 2.0 + x2 * 3.0 + y1 * 1.5 + y2 * 2.0 - y3 * 0.5;
459 Ok((obj, vec![c1, c2, c4, c5]))
460 }
461
462 fn y_to_string(&self, y: &SMatrix<f64, 3, 1>) -> String {
463 format!("{:.1?}", y.data.0[0])
464 }
465 }
466
467 #[test]
468 fn test_minlp_highs() {
469 let minlp = OuterApproximation::new(&TestMINLP);
470 minlp.solve_ranking(SVector::from([0.0; 3]), &highs, 1, &Default::default());
471 }
472}