Skip to main content

oximo_gams/
solver_options.rs

1//! Per-solver typed option structs and [`GamsSolverConfig`].
2//!
3//! The option structs are generated at build time from the checked-in
4//! `option-snapshots/<solver>.txt` files (see `build.rs`), which are scraped
5//! from the GAMS solver docs.
6//!
7//! Each solver gets a `Gams<Name>Options` struct with one builder setter
8//! per documented option. The setter name is the snake_case of the option
9//! and the exact GAMS key is written to the `.opt` file.
10//!
11//! For any option without a generated setter, push a verbatim line onto
12//! the public `raw` field.
13//!
14//! Reference: "GAMS Solver Manuals," GAMS Development Corporation.
15//! <https://www.gams.com/latest/docs/S_MAIN.html#SOLVERS_MODEL_TYPES>
16
17use oximo_core::ModelKind;
18
19use crate::options::GamsSolver;
20use crate::translate::gams_solve_type;
21
22/// Generates one setter for a scraped option, typed by the kind GAMS declares.
23///
24/// `any` means the solver's manual publishes no declared type, so the setter
25/// stays permissive. Booleans are written as `1`/`0`.
26macro_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
70/// Generates a solver's option struct, including a public `raw` passthrough,
71/// the private key/value store, one setter per option, and `render`
72/// (writes the `.opt` body using `$sep` between key and value: `" "` or `" = "`).
73macro_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                /// The exact token written to the option file.
87                #[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            /// Extra option-file lines written verbatim, for options without a
98            /// generated setter (or values a setter can't express).
99            pub raw: Vec<String>,
100            /// Key/value store behind the generated setters. Public (but hidden)
101            /// only so `Struct { raw, ..Default::default() }` construction works.
102            /// Prefer the setters.
103            #[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            /// Write the `.opt` file body into `buf`. Returns `true` if anything
122            /// was written.
123            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
137// The generated structs, the `GamsSolverConfig` enum, its `gams_name`/
138// `write_opt_file` dispatch, and `From<GamsSolver>`.
139include!(concat!(env!("OUT_DIR"), "/gams_generated.rs"));
140
141impl GamsSolverConfig {
142    /// Whether this solver can handle `kind` under oximo's GAMS translation,
143    /// which emits `QP` as a `QCP` solve and `MIQP` as a `MIQCP` solve.
144    ///
145    /// [`GamsSolver::Custom`] and any unrecognized name return `true`.
146    #[must_use]
147    pub fn supports(&self, kind: ModelKind) -> bool {
148        solver_supports_type(self.gams_name(), gams_solve_type(kind))
149    }
150}
151
152/// GAMS solve types a named solver supports, restricted to the six oximo can
153/// emit: `LP` / `MIP` / `NLP` / `MINLP` / `QCP` / `MIQCP`. `None` means the
154/// name is unrecognized and cannot be validated.
155///
156/// Transcribed from the GAMS solver/model-type matrix (other model types
157/// (`MCP`, `MPEC`, `CNS`, `DNLP`, `EMP`, stochastic) are omitted because oximo
158/// never emits them):
159/// - "GAMS Solver Manuals," GAMS Development Corporation.
160///   <https://www.gams.com/latest/docs/S_MAIN.html#SOLVERS_MODEL_TYPES> (accessed May 14, 2026).
161fn 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 (EMP), MILES (MCP), NLPEC (MCP/MPEC), PATH (MCP/MPEC/CNS),
176        // RESHOP (EMP) support none of the model types oximo emits.
177        "JAMS" | "MILES" | "NLPEC" | "PATH" | "RESHOP" => &[],
178        _ => return None,
179    })
180}
181
182/// Whether the GAMS solver named `gams_name` supports `gams_type`
183/// (`"LP"` / `"MIP"` / `"NLP"` / `"MINLP"` / `"QCP"` / `"MIQCP"`). Unrecognized
184/// names return `true`.
185pub(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        // HiGHS publishes no declared types, so its setters stay permissive and
223        // take floats, ints or strings alike.
224        //
225        // Note these are raw GAMS option keys. Time limits, thread counts and MIP
226        // gaps are better set once on `GamsOptions` via the universal options.
227        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        // CPLEX: LP, MIP, QCP, MIQCP. QP/MIQP route through QCP/MIQCP.
330        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        // IPOPT: LP, NLP, QCP.
339        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        // HiGHS under GAMS is LP/MIP only.
347        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}