Skip to main content

feos_campd/solver/
mod.rs

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/// The output of a single optimization consisting of the objective, structure variables, and process variables.
15#[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/// A generalization over equality and inequality constraints.
80#[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
102/// A generic MINLP that can be solved with the [OuterApproximation] algorithm.
103pub 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
127/// Implementation of outer approximation in Rust.
128pub 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                        // Check if constraint is active
194                        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                        // Check if constraint is active
204                        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        // binary variables for integer cuts
233        let mut s = Vec::new();
234
235        // discrete variables
236        let y = self.minlp.y_variables().map(|(l, u)| {
237            let y = model.add(variable().integer().bounds(l..u));
238
239            // add binary variables for integer cuts
240            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        // linear constraints
251        constraints.append(&mut self.minlp.linear_constraints(y));
252
253        // process variables
254        let x = self
255            .minlp
256            .x_variables()
257            .map(|(l, u, _)| model.add(variable().bounds(l..u)));
258
259        // epigraph variable
260        let mu = model.add_variable();
261
262        // integer cuts
263        for solution in &self.excluded_solutions {
264            Self::add_integer_cut(&mut constraints, &s, solution);
265        }
266
267        // OA cuts
268        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        // setup solver
274        let mut model = model.minimise(mu).using(solver);
275
276        // add constraints
277        constraints.into_iter().for_each(|c| {
278            model.add_constraint(c);
279        });
280
281        // solve MILP
282        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        // Solve the process for the initial structure. Has to converge!
313        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        // Initialize the list of found structures in this run
325        let mut new_solutions = vec![result.key.clone()];
326
327        // objective value of the previous structure
328        let mut last = result.objective.0;
329
330        for k in 0..options.max_iter {
331            // Solve for a new structure
332            let (y, s) = match self.solve_milp(solver, &new_solutions, options.zero_tol) {
333                Ok(result) => result,
334                Err(e) => {
335                    // No new structure found -> exit run
336                    println!("{e}");
337                    return new_solutions;
338                }
339            };
340            // Exclude the found structure and all symmetric structures from future runs
341            self.excluded_solutions
342                .extend(self.minlp.exclude_solutions(&s));
343
344            // Solve the process for the current structure
345            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                // Exit after at least min_iter iterations and on non-improving objective
354                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 mut old_solutions = Vec::new();
377        let key_init = self.minlp.y_to_string(&y_init);
378        for k in 0..runs {
379            println!("\nStarting run {}", k + 1);
380
381            // Calculate a new set of solutions
382            let new_solutions = self.solve(y_init, &solver, options);
383            let new_solutions: HashSet<_> = new_solutions.into_iter().collect();
384
385            // Calculate a sorted list of all solutions
386            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            // Print the results
390            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}