1use oximo_core::ModelKind;
18
19use crate::options::GamsSolver;
20use crate::translate::gams_solve_type;
21
22macro_rules! gams_setter {
27 (int, $method:ident, $key:literal) => {
28 #[doc = concat!("Set GAMS option `", $key, "` (integer).")]
29 #[must_use]
30 pub fn $method(mut self, v: i64) -> Self {
31 self.opts.push(($key, v.to_string()));
32 self
33 }
34 };
35 (dbl, $method:ident, $key:literal) => {
36 #[doc = concat!("Set GAMS option `", $key, "` (real).")]
37 #[must_use]
38 pub fn $method(mut self, v: f64) -> Self {
39 self.opts.push(($key, v.to_string()));
40 self
41 }
42 };
43 (bool, $method:ident, $key:literal) => {
44 #[doc = concat!("Set GAMS option `", $key, "` (boolean, written as `1`/`0`).")]
45 #[must_use]
46 pub fn $method(mut self, v: bool) -> Self {
47 self.opts.push(($key, if v { "1" } else { "0" }.to_owned()));
48 self
49 }
50 };
51 (str, $method:ident, $key:literal) => {
52 #[doc = concat!("Set GAMS option `", $key, "` (string).")]
53 #[must_use]
54 pub fn $method(mut self, v: impl ::std::fmt::Display) -> Self {
55 self.opts.push(($key, v.to_string()));
56 self
57 }
58 };
59 (any, $method:ident, $key:literal) => {
60 #[doc = concat!("Set GAMS option `", $key, "`. This solver's manual \
61 declares no type for it, so any `Display` value is accepted.")]
62 #[must_use]
63 pub fn $method(mut self, v: impl ::std::fmt::Display) -> Self {
64 self.opts.push(($key, v.to_string()));
65 self
66 }
67 };
68}
69
70macro_rules! gams_params {
74 ($(#[$meta:meta])* $struct:ident, $sep:literal,
75 options: [ $( ($kind:ident, $method:ident, $key:literal) ),* $(,)? ],
76 enums: [ $( ($ename:ident, $emethod:ident, $ekey:literal,
77 [ $( $variant:ident = $value:literal ),* $(,)? ]) ),* $(,)? ] $(,)?) => {
78 $(
79 #[doc = concat!("Allowed values for GAMS option `", $ekey, "`.")]
80 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
81 pub enum $ename {
82 $( #[doc = concat!("`", $value, "`")] $variant, )*
83 }
84
85 impl $ename {
86 #[must_use]
88 pub fn as_str(self) -> &'static str {
89 match self { $( Self::$variant => $value, )* }
90 }
91 }
92 )*
93
94 $(#[$meta])*
95 #[derive(Clone, Debug, Default)]
96 pub struct $struct {
97 pub raw: Vec<String>,
100 #[doc(hidden)]
104 pub opts: Vec<(&'static str, String)>,
105 }
106
107 #[allow(clippy::pedantic)]
108 impl $struct {
109 $( gams_setter!($kind, $method, $key); )*
110
111 $(
112 #[doc = concat!("Set GAMS option `", $ekey, "`, restricted to its \
113 documented values.")]
114 #[must_use]
115 pub fn $emethod(mut self, v: $ename) -> Self {
116 self.opts.push(($ekey, v.as_str().to_owned()));
117 self
118 }
119 )*
120
121 fn render(&self, buf: &mut String) -> bool {
124 use ::std::fmt::Write as _;
125 for (k, v) in &self.opts {
126 let _ = writeln!(buf, concat!("{}", $sep, "{}"), k, v);
127 }
128 for line in &self.raw {
129 let _ = writeln!(buf, "{line}");
130 }
131 !self.opts.is_empty() || !self.raw.is_empty()
132 }
133 }
134 };
135}
136
137include!(concat!(env!("OUT_DIR"), "/gams_generated.rs"));
140
141impl GamsSolverConfig {
142 #[must_use]
147 pub fn supports(&self, kind: ModelKind) -> bool {
148 solver_supports_type(self.gams_name(), gams_solve_type(kind))
149 }
150}
151
152fn supported_solve_types(gams_name: &str) -> Option<&'static [&'static str]> {
162 Some(match gams_name {
163 "ALPHAECP" | "DICOPT" | "SBB" | "SHOT" => &["MINLP", "MIQCP"],
164 "CONOPT" | "CONOPT3" | "CONOPT4" | "IPOPT" | "MINOS" | "SNOPT" => &["LP", "NLP", "QCP"],
165 "DECIS" | "SOPLEX" | "QUADMINOS" => &["LP"],
166 "CBC" | "GLPK" | "HIGHS" => &["LP", "MIP"],
167 "ODHCPLEX" => &["MIP", "MIQCP"],
168 "COPT" | "CPLEX" => &["LP", "MIP", "QCP", "MIQCP"],
169 "ANTIGONE" => &["NLP", "MINLP", "QCP", "MIQCP"],
170 "KNITRO" => &["LP", "NLP", "MINLP", "QCP", "MIQCP"],
171 "SCIP" => &["MIP", "NLP", "MINLP", "QCP", "MIQCP"],
172 "BARON" | "GUROBI" | "GUSS" | "KESTREL" | "LINDO" | "LINDOGLOBAL" | "MOSEK" | "XPRESS" => {
173 &["LP", "MIP", "NLP", "MINLP", "QCP", "MIQCP"]
174 }
175 "JAMS" | "MILES" | "NLPEC" | "PATH" | "RESHOP" => &[],
178 _ => return None,
179 })
180}
181
182pub(crate) fn solver_supports_type(gams_name: &str, gams_type: &str) -> bool {
186 supported_solve_types(gams_name).is_none_or(|types| types.contains(&gams_type))
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::options::GamsSolver;
193
194 #[test]
195 fn setter_and_render_space_separated() {
196 let cfg = GamsSolverConfig::Gurobi(GamsGurobiOptions::default().threads(8).mipgap(0.01));
197 let mut buf = String::new();
198 assert!(cfg.write_opt_file(&mut buf));
199 assert!(buf.contains("threads 8\n"), "got: {buf}");
200 assert!(buf.contains("mipgap 0.01\n"), "got: {buf}");
201 }
202
203 #[test]
204 fn highs_and_scip_render_eq_separated() {
205 let mut buf = String::new();
206 assert!(
207 GamsSolverConfig::Highs(GamsHighsOptions::default().mip_rel_gap(0.05))
208 .write_opt_file(&mut buf)
209 );
210 assert!(buf.contains("mip_rel_gap = 0.05\n"), "got: {buf}");
211
212 let mut buf = String::new();
213 assert!(
214 GamsSolverConfig::Scip(GamsScipOptions::default().limits_gap(0.01))
215 .write_opt_file(&mut buf)
216 );
217 assert!(buf.contains("limits/gap = 0.01\n"), "got: {buf}");
218 }
219
220 #[test]
221 fn untyped_setter_accepts_any_display_value() {
222 let cfg = GamsSolverConfig::Highs(
228 GamsHighsOptions::default()
229 .mip_rel_gap(1e-6)
230 .simplex_max_concurrency(4)
231 .solution_file("sol.txt"),
232 );
233 let mut buf = String::new();
234 assert!(cfg.write_opt_file(&mut buf));
235 assert!(buf.contains("mip_rel_gap = 0.000001\n"), "got: {buf}");
236 assert!(buf.contains("simplex_max_concurrency = 4\n"), "got: {buf}");
237 assert!(buf.contains("solution_file = sol.txt\n"), "got: {buf}");
238 }
239
240 #[test]
241 fn time_limit_comes_from_the_universal_layer_as_a_duration() {
242 use oximo_solver::UniversalOptionsExt;
243 let opts = crate::GamsOptions::default()
244 .time_limit(std::time::Duration::from_secs(3600))
245 .solver(GamsSolverConfig::Highs(GamsHighsOptions::default()));
246 let mut gms = String::new();
247 crate::options::write_options(&mut gms, &opts, "LP");
248 assert!(gms.contains("option ResLim = 3600;"), "got: {gms}");
249 }
250
251 #[test]
252 fn enumerated_option_takes_a_generated_enum() {
253 let cfg =
254 GamsSolverConfig::Highs(GamsHighsOptions::default().solver(GamsHighsSolver::Simplex));
255 let mut buf = String::new();
256 assert!(cfg.write_opt_file(&mut buf));
257 assert!(buf.contains("solver = simplex\n"), "got: {buf}");
258 assert_eq!(GamsHighsSolver::Ipm.as_str(), "ipm");
259 }
260
261 #[test]
262 fn declared_types_produce_typed_setters() {
263 let cfg = GamsSolverConfig::Cplex(
264 GamsCplexOptions::default().preind(false).threads(4).epgap(0.01),
265 );
266 let mut buf = String::new();
267 assert!(cfg.write_opt_file(&mut buf));
268 assert!(buf.contains("preind 0\n"), "bool renders as 0/1: {buf}");
269 assert!(buf.contains("threads 4\n"), "got: {buf}");
270 assert!(buf.contains("epgap 0.01\n"), "got: {buf}");
271 }
272
273 #[test]
274 fn raw_lines_written_after_typed() {
275 let cfg = GamsSolverConfig::Cplex(GamsCplexOptions {
276 raw: vec!["solnpool out.gdx".into(), "solnpoolpop 2".into()],
277 ..Default::default()
278 });
279 let mut buf = String::new();
280 assert!(cfg.write_opt_file(&mut buf));
281 assert!(buf.contains("solnpool out.gdx\n"), "got: {buf}");
282 assert!(buf.contains("solnpoolpop 2\n"), "got: {buf}");
283 }
284
285 #[test]
286 fn empty_options_write_nothing() {
287 let mut buf = String::new();
288 assert!(!GamsSolverConfig::Baron(GamsBaronOptions::default()).write_opt_file(&mut buf));
289 assert!(buf.is_empty());
290 }
291
292 #[test]
293 fn named_writes_nothing_but_raw_variant_does() {
294 let mut buf = String::new();
295 assert!(!GamsSolverConfig::Named(GamsSolver::Baron).write_opt_file(&mut buf));
296 assert!(buf.is_empty());
297
298 let cfg = GamsSolverConfig::Raw(GamsSolver::Xpress, vec!["miptol 1e-6".into()]);
299 let mut buf = String::new();
300 assert!(cfg.write_opt_file(&mut buf));
301 assert_eq!(buf, "miptol 1e-6\n");
302 }
303
304 #[test]
305 fn gams_name_matches_variant() {
306 assert_eq!(GamsSolverConfig::Baron(GamsBaronOptions::default()).gams_name(), "BARON");
307 assert_eq!(GamsSolverConfig::Cplex(GamsCplexOptions::default()).gams_name(), "CPLEX");
308 assert_eq!(GamsSolverConfig::Scip(GamsScipOptions::default()).gams_name(), "SCIP");
309 assert_eq!(
310 GamsSolverConfig::Odhcplex(GamsOdhcplexOptions::default()).gams_name(),
311 "ODHCPLEX"
312 );
313 assert_eq!(GamsSolverConfig::Named(GamsSolver::Cplex).gams_name(), "CPLEX");
314 assert_eq!(
315 GamsSolverConfig::Named(GamsSolver::Custom("MYMIP".into())).gams_name(),
316 "MYMIP"
317 );
318 }
319
320 #[test]
321 fn from_gams_solver_becomes_named() {
322 let cfg: GamsSolverConfig = GamsSolver::Gurobi.into();
323 assert!(matches!(cfg, GamsSolverConfig::Named(GamsSolver::Gurobi)));
324 assert_eq!(cfg.gams_name(), "GUROBI");
325 }
326
327 #[test]
328 fn supports_matches_solver_capabilities() {
329 let cplex = GamsSolverConfig::Cplex(GamsCplexOptions::default());
331 assert!(cplex.supports(ModelKind::LP));
332 assert!(cplex.supports(ModelKind::MILP));
333 assert!(cplex.supports(ModelKind::QP), "QP routes through QCP");
334 assert!(cplex.supports(ModelKind::MIQP), "MIQP routes through MIQCP");
335 assert!(!cplex.supports(ModelKind::NLP));
336 assert!(!cplex.supports(ModelKind::MINLP));
337
338 let ipopt = GamsSolverConfig::Ipopt(GamsIpoptOptions::default());
340 assert!(ipopt.supports(ModelKind::LP));
341 assert!(ipopt.supports(ModelKind::NLP));
342 assert!(ipopt.supports(ModelKind::QP), "QP routes through QCP");
343 assert!(!ipopt.supports(ModelKind::MIQP));
344 assert!(!ipopt.supports(ModelKind::MINLP));
345
346 let highs = GamsSolverConfig::Highs(GamsHighsOptions::default());
348 assert!(highs.supports(ModelKind::LP));
349 assert!(!highs.supports(ModelKind::QP));
350 assert!(!highs.supports(ModelKind::NLP));
351 }
352
353 #[test]
354 fn every_snapshot_option_is_generated() {
355 let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/option-snapshots");
356 let mut seen = 0;
357 for entry in std::fs::read_dir(dir).expect("option-snapshots dir") {
358 let path = entry.expect("dir entry").path();
359 if path.extension().and_then(|e| e.to_str()) != Some("txt") {
360 continue;
361 }
362 let text = std::fs::read_to_string(&path).expect("read snapshot");
363 let mut lines = text.lines();
364 let name = lines.next().unwrap_or("").trim_start_matches("//").trim().to_string();
365 let on_disk = lines.filter(|l| l.trim_start().starts_with('(')).count();
366
367 let (_, _, generated) = GENERATED_SOLVERS
368 .iter()
369 .find(|(n, _, _)| *n == name)
370 .unwrap_or_else(|| panic!("snapshot {name} produced no generated solver"));
371 assert_eq!(
372 *generated, on_disk,
373 "{name}: generated {generated} setters but snapshot has {on_disk} options"
374 );
375 seen += 1;
376 }
377 assert_eq!(seen, GENERATED_SOLVERS.len(), "generated solvers without a snapshot file");
378 assert!(seen >= 24, "expected at least 24 solvers, found {seen}");
379 }
380
381 #[test]
382 fn separator_matches_documented_solver_family() {
383 for (name, sep, _) in GENERATED_SOLVERS {
384 let expected = match *name {
385 "SCIP" | "SOPLEX" | "SHOT" | "HIGHS" => " = ",
386 _ => " ",
387 };
388 assert_eq!(sep, &expected, "{name} has separator {sep:?}, expected {expected:?}");
389 }
390 }
391
392 #[test]
393 fn soplex_and_shot_render_eq_separated() {
394 let mut buf = String::new();
395 assert!(
396 GamsSolverConfig::Soplex(GamsSoplexOptions::default().real_feastol(1e-5))
397 .write_opt_file(&mut buf)
398 );
399 assert!(buf.contains("real:feastol = 0.00001\n"), "got: {buf}");
400
401 let mut buf = String::new();
402 assert!(
403 GamsSolverConfig::Shot(GamsShotOptions::default().dual_mip_solver(2))
404 .write_opt_file(&mut buf)
405 );
406 assert!(buf.contains("Dual.MIP.Solver = 2\n"), "got: {buf}");
407 }
408
409 #[test]
410 fn supports_is_permissive_for_unknown_names() {
411 let custom = GamsSolverConfig::Named(GamsSolver::Custom("MYSOLVER".into()));
412 assert!(custom.supports(ModelKind::MINLP));
413 assert!(custom.supports(ModelKind::LP));
414 }
415}