lp_solvers/solvers/
gurobi.rs

1//! The proprietary gurobi solver
2use std::collections::HashMap;
3use std::ffi::OsString;
4use std::fs::File;
5use std::io::{BufRead, BufReader, Write};
6use std::path::{Path, PathBuf};
7
8use crate::lp_format::*;
9use crate::solvers::{
10    Solution, SolverProgram, SolverWithSolutionParsing, Status, WithMaxSeconds, WithMipGap,
11    WithMipStart,
12};
13use crate::util::buf_contains;
14
15/// The proprietary gurobi solver
16#[derive(Debug, Clone)]
17pub struct GurobiSolver {
18    name: String,
19    command_name: String,
20    temp_solution_file: Option<PathBuf>,
21    temp_mip_start_file: Option<PathBuf>,
22    seconds: Option<u32>,
23    mipgap: Option<f32>,
24}
25
26impl Default for GurobiSolver {
27    fn default() -> Self {
28        Self::new()
29    }
30}
31
32impl GurobiSolver {
33    /// create a solver instance
34    pub fn new() -> GurobiSolver {
35        GurobiSolver {
36            name: "Gurobi".to_string(),
37            command_name: "gurobi_cl".to_string(),
38            temp_solution_file: None,
39            temp_mip_start_file: None,
40            seconds: None,
41            mipgap: None,
42        }
43    }
44    /// set the name of the commandline gurobi executable to use
45    pub fn command_name(&self, command_name: String) -> GurobiSolver {
46        GurobiSolver {
47            name: self.name.clone(),
48            command_name,
49            temp_solution_file: self.temp_solution_file.clone(),
50            temp_mip_start_file: self.temp_mip_start_file.clone(),
51            seconds: None,
52            mipgap: self.mipgap,
53        }
54    }
55}
56
57impl SolverWithSolutionParsing for GurobiSolver {
58    fn read_specific_solution<'a, P: LpProblem<'a>>(
59        &self,
60        f: &File,
61        _problem: Option<&'a P>,
62    ) -> Result<Solution, String> {
63        let mut vars_value: HashMap<_, _> = HashMap::new();
64        let mut file = BufReader::new(f);
65        let mut buffer = String::new();
66        let _ = file.read_line(&mut buffer);
67
68        if buffer.split(' ').next().is_some() {
69            for line in file.lines() {
70                let l = line.unwrap();
71
72                // Gurobi version 7 add comments on the header file
73                if let Some('#') = l.chars().next() {
74                    continue;
75                }
76
77                let result_line: Vec<_> = l.split_whitespace().collect();
78                if result_line.len() == 2 {
79                    match result_line[1].parse::<f32>() {
80                        Ok(n) => {
81                            vars_value.insert(result_line[0].to_string(), n);
82                        }
83                        Err(e) => return Err(e.to_string()),
84                    }
85                } else {
86                    return Err("Incorrect solution format".to_string());
87                }
88            }
89        } else {
90            return Err("Incorrect solution format".to_string());
91        }
92        Ok(Solution::new(Status::Optimal, vars_value))
93    }
94}
95
96impl WithMaxSeconds<GurobiSolver> for GurobiSolver {
97    fn max_seconds(&self) -> Option<u32> {
98        self.seconds
99    }
100
101    fn with_max_seconds(&self, seconds: u32) -> GurobiSolver {
102        GurobiSolver {
103            seconds: Some(seconds),
104            ..(*self).clone()
105        }
106    }
107}
108
109impl WithMipGap<GurobiSolver> for GurobiSolver {
110    fn mip_gap(&self) -> Option<f32> {
111        self.mipgap
112    }
113
114    fn with_mip_gap(&self, mipgap: f32) -> Result<GurobiSolver, String> {
115        if mipgap.is_sign_positive() && mipgap.is_finite() {
116            Ok(GurobiSolver {
117                mipgap: Some(mipgap),
118                ..(*self).clone()
119            })
120        } else {
121            Err("Invalid MIP gap: must be positive and finite".to_string())
122        }
123    }
124}
125
126impl WithMipStart<GurobiSolver> for GurobiSolver {
127    /// create a (temporary) mip start file (.mst) and store the path reference in the solver struct.
128    /// file is persisted; caller may want to delete
129    fn with_mip_start(&self, assignments: &HashMap<String, f32>) -> Result<GurobiSolver, String> {
130        let mut tmp = tempfile::Builder::new()
131            .prefix("lp-solvers-gurobi-")
132            .suffix(".mst")
133            .tempfile()
134            .map_err(|e| e.to_string())?;
135
136        writeln!(tmp, "# MIP start (generated by lp-solvers)").map_err(|e| e.to_string())?;
137
138        let mut deterministic_assignments: Vec<_> = assignments.iter().collect();
139        deterministic_assignments.sort_by(|(a, _), (b, _)| a.cmp(b));
140
141        for (var, val) in deterministic_assignments {
142            writeln!(tmp, "{} {}", var, val).map_err(|e| e.to_string())?;
143        }
144        tmp.flush().map_err(|e| e.to_string())?;
145
146        let path = tmp.into_temp_path().keep().map_err(|e| e.to_string())?;
147
148        Ok(GurobiSolver {
149            temp_mip_start_file: Some(path),
150            ..(*self).clone()
151        })
152    }
153}
154
155impl SolverProgram for GurobiSolver {
156    fn command_name(&self) -> &str {
157        &self.command_name
158    }
159
160    fn arguments(&self, lp_file: &Path, solution_file: &Path) -> Vec<OsString> {
161        let mut arg0: OsString = "ResultFile=".into();
162        arg0.push(solution_file.as_os_str());
163
164        let mut args = vec![arg0];
165
166        if let Some(mipgap) = self.mip_gap() {
167            let mut arg_mipgap: OsString = "MIPGap=".into();
168            arg_mipgap.push::<OsString>(mipgap.to_string().into());
169            args.push(arg_mipgap);
170        }
171
172        if let Some(seconds) = self.max_seconds() {
173            let mut arg_timelimit: OsString = "TimeLimit=".into();
174            arg_timelimit.push::<OsString>(seconds.to_string().into());
175            args.push(arg_timelimit);
176        }
177
178        if let Some(mst_file_path) = &self.temp_mip_start_file {
179            let mut arg_inputfile: OsString = "InputFile=".into();
180            arg_inputfile.push::<OsString>(mst_file_path.clone().into_os_string());
181            args.push(arg_inputfile);
182        }
183
184        args.push(lp_file.into());
185
186        args
187    }
188
189    fn preferred_temp_solution_file(&self) -> Option<&Path> {
190        self.temp_solution_file.as_deref()
191    }
192
193    fn solution_suffix(&self) -> Option<&str> {
194        Some(".sol")
195    }
196
197    fn parse_stdout_status(&self, stdout: &[u8]) -> Option<Status> {
198        if buf_contains(stdout, "Optimal solution found (tolerance 1.00e-04)") {
199            Some(Status::Optimal)
200        } else if buf_contains(stdout, "Optimal solution found (tolerance ") {
201            Some(Status::MipGap)
202        } else if buf_contains(stdout, "Time limit reached") {
203            if buf_contains(stdout, "Best objective -,") {
204                Some(Status::NotSolved)
205            } else {
206                Some(Status::TimeLimit)
207            }
208        } else if buf_contains(stdout, "infeasible") || buf_contains(stdout, "Infeasible model") {
209            Some(Status::Infeasible)
210        } else {
211            None
212        }
213    }
214}
215
216#[cfg(test)]
217mod tests {
218    use crate::solvers::{GurobiSolver, SolverProgram, WithMaxSeconds, WithMipGap, WithMipStart};
219    use std::collections::HashMap;
220    use std::env::temp_dir;
221    use std::ffi::{OsStr, OsString};
222    use std::path::Path;
223
224    #[test]
225    fn cli_args_default() {
226        let solver = GurobiSolver::new();
227        let args = solver.arguments(Path::new("test.lp"), Path::new("test.sol"));
228
229        let expected: Vec<OsString> = vec!["ResultFile=test.sol".into(), "test.lp".into()];
230
231        assert_eq!(args, expected);
232    }
233
234    #[test]
235    fn cli_args_seconds() {
236        let solver = GurobiSolver::new().with_max_seconds(10);
237        let args = solver.arguments(Path::new("test.lp"), Path::new("test.sol"));
238
239        let expected: Vec<OsString> = vec![
240            "ResultFile=test.sol".into(),
241            "TimeLimit=10".into(),
242            "test.lp".into(),
243        ];
244
245        assert_eq!(args, expected);
246    }
247
248    #[test]
249    fn cli_args_mipgap() {
250        let solver = GurobiSolver::new()
251            .with_mip_gap(0.05)
252            .expect("mipgap should be valid");
253
254        let args = solver.arguments(Path::new("test.lp"), Path::new("test.sol"));
255
256        let expected: Vec<OsString> = vec![
257            "ResultFile=test.sol".into(),
258            "MIPGap=0.05".into(),
259            "test.lp".into(),
260        ];
261
262        assert_eq!(args, expected);
263    }
264
265    #[test]
266    fn cli_args_input_file() {
267        let solver = GurobiSolver::new()
268            .with_mip_start(&HashMap::from([
269                ("x".to_owned(), 1.0_f32),
270                ("y".to_owned(), -2.5_f32),
271            ]))
272            .expect("mip start should be valid");
273
274        let args = solver.arguments(Path::new("test.lp"), Path::new("test.sol"));
275
276        let input_file_argument = args
277            .iter()
278            .find(|a| a.to_string_lossy().starts_with("InputFile="))
279            .expect("expected an InputFile=... argument")
280            .to_string_lossy()
281            .to_string();
282
283        let input_file_path = Path::new(input_file_argument.strip_prefix("InputFile=").unwrap());
284
285        assert!(
286            input_file_path.exists(),
287            "MIP start file does not exist: {:?}",
288            input_file_path
289        );
290
291        assert_eq!(
292            input_file_path.extension(),
293            Some(OsStr::new("mst")),
294            "InputFile not an .mst: {:?}",
295            input_file_path
296        );
297
298        assert!(
299            input_file_path.starts_with(&temp_dir()),
300            "InputFile not under temp dir.\n  temp: {:?}\n  file: {:?}",
301            temp_dir(),
302            input_file_path
303        );
304    }
305
306    #[test]
307    fn cli_args_mipgap_negative() {
308        let solver = GurobiSolver::new().with_mip_gap(-0.05);
309        assert!(solver.is_err());
310    }
311
312    #[test]
313    fn cli_args_mipgap_infinite() {
314        let solver = GurobiSolver::new().with_mip_gap(f32::INFINITY);
315        assert!(solver.is_err());
316    }
317}