diff --git a/Cargo.lock b/Cargo.lock
index 193706e..9e80e43 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -432,7 +432,6 @@ dependencies = [
"oximo-core",
"oximo-expr",
"oximo-solver",
- "rayon",
"rustc-hash",
"thiserror",
]
diff --git a/README.md b/README.md
index 173bd12..67b623d 100644
--- a/README.md
+++ b/README.md
@@ -11,9 +11,7 @@
-oximo is a Rust algebraic modeling library for mathematical optimization. Build LP and MILP models with a clean builder API, then solve them with bundled or commercial solvers.
-
-> Support for nonlinear programming (NLP) and mixed-integer nonlinear programming (MINLP) is planned.
+oximo is a Rust algebraic modeling library for mathematical optimization. Build LP, MILP, QP/MIQP, NLP, and MINLP models with a clean builder API, then solve them with bundled or commercial solvers.
```rust,no_run
use oximo::prelude::*;
@@ -37,12 +35,12 @@ println!("y = {:?}", result.value_of(y)); // 4.0
## Features
-| Feature | What it adds | Default |
-|----------|---------------------------------------------------|---------|
-| `highs` | HiGHS LP/MILP solver (bundled, no install) | yes |
-| `io` | MPS and LP file writers | yes |
-| `gurobi` | Gurobi LP/MILP solver (requires licensed install) | no |
-| `gams` | GAMS solver bridge (requires GAMS on PATH) | no |
+| Feature | What it adds | Default |
+|----------|-----------------------------------------------------------------------|---------|
+| `highs` | HiGHS - LP/MILP solver (bundled, no install) | yes |
+| `io` | MPS and LP file writers | yes |
+| `gurobi` | Gurobi - LP/MILP/QP/MIQP/NLP/MINLP solver (requires licensed install) | no |
+| `gams` | GAMS bridge - LP/MILP/QP/MIQP/NLP/MINLP depending on solver | no |
```toml
[dependencies]
@@ -170,6 +168,23 @@ m.add_constraints_over("supply", &plants, |p: String| {
m.add_constraints_over("c", &set, |k: IndexKey| x[&k].le(1.0));
```
+### Nonlinear expressions
+
+`Pow`, `Sin`, `Cos`, `Exp`, `Log`, and bilinear products are first-class. The
+model's kind (`LP`/`MILP`/`QP`/`MIQP`/`NLP`/`MINLP`) is inferred from the
+expressions.
+
+```rust,ignore
+// Rosenbrock NLP
+m.minimize((1.0 - x).powi(2) + 100.0 * (y - x.powi(2)).powi(2));
+
+// Quadratic constraint
+m.constraint("disk", (x.powi(2) + y.powi(2)).le(1.0));
+
+// Transcendental utility (MINLP when any variable is integer/binary)
+m.maximize(sum_over(&items, |i: usize| u[i] * (1.0 + w[i] * x[i]).log()));
+```
+
## Solving
All backends implement the `Solver` trait:
diff --git a/crates/oximo-core/README.md b/crates/oximo-core/README.md
index b772d7c..a436aef 100644
--- a/crates/oximo-core/README.md
+++ b/crates/oximo-core/README.md
@@ -181,8 +181,6 @@ Inferred automatically from variables and expressions, cached and invalidated on
| `NLP` | All continuous, `Pow`/`Sin`/`Cos`/`Exp`/`Log` |
| `MINLP` | Any integer/binary + nonlinear |
-For now, we only support linear constraints, so `QP` and `NLP` are not possible. But the API is designed to allow nonlinear constraints in the future without breaking changes.
-
## License
MIT OR Apache-2.0
diff --git a/crates/oximo-core/src/model.rs b/crates/oximo-core/src/model.rs
index 7fabf2a..a59ab42 100644
--- a/crates/oximo-core/src/model.rs
+++ b/crates/oximo-core/src/model.rs
@@ -1,6 +1,6 @@
use std::cell::{Ref, RefCell};
-use oximo_expr::{Expr, ExprArena, VarId};
+use oximo_expr::{Expr, ExprArena, ExprClass, VarId, classify};
use rustc_hash::FxHashMap;
use smol_str::SmolStr;
@@ -269,38 +269,30 @@ impl Model {
}
let arena = self.arena.borrow();
let has_int = self.variables.borrow().iter().any(|v| v.domain.is_integer());
- let nonlinear = self.constraints.borrow().iter().any(|c| has_nonlinear(&arena, c.lhs))
- || self.objective.borrow().as_ref().is_some_and(|o| has_nonlinear(&arena, o.expr));
- let k = match (has_int, nonlinear) {
- (false, false) => ModelKind::LP,
- (true, false) => ModelKind::MILP,
- (false, true) => ModelKind::NLP,
- (true, true) => ModelKind::MINLP,
- };
- *self.cached_kind.borrow_mut() = Some(k);
- k
- }
-}
-fn has_nonlinear(arena: &ExprArena, id: oximo_expr::ExprId) -> bool {
- use oximo_expr::ExprNode as N;
- match arena.get(id) {
- N::Const(_) | N::Var(_) | N::Param(_) | N::Linear { .. } => false,
- N::Neg(inner) => has_nonlinear(arena, *inner),
- N::Add(children) => children.iter().any(|c| has_nonlinear(arena, *c)),
- N::Mul(children) => {
- let mut nonconst = 0;
- for c in children {
- if !matches!(arena.get(*c), N::Const(_)) {
- nonconst += 1;
- }
- if has_nonlinear(arena, *c) {
- return true;
- }
+ // Highest expression class across the objective and every constraint
+ // determines the model class
+ let mut class = ExprClass::Linear;
+ if let Some(o) = self.objective.borrow().as_ref() {
+ class = class.max(classify(&arena, o.expr));
+ }
+ for c in self.constraints.borrow().iter() {
+ if class == ExprClass::Nonlinear {
+ break;
}
- nonconst >= 2
+ class = class.max(classify(&arena, c.lhs));
}
- N::Pow(_, _) | N::Sin(_) | N::Cos(_) | N::Exp(_) | N::Log(_) => true,
+
+ let k = match (has_int, class) {
+ (false, ExprClass::Linear) => ModelKind::LP,
+ (true, ExprClass::Linear) => ModelKind::MILP,
+ (false, ExprClass::Quadratic) => ModelKind::QP,
+ (true, ExprClass::Quadratic) => ModelKind::MIQP,
+ (false, ExprClass::Nonlinear) => ModelKind::NLP,
+ (true, ExprClass::Nonlinear) => ModelKind::MINLP,
+ };
+ *self.cached_kind.borrow_mut() = Some(k);
+ k
}
}
diff --git a/crates/oximo-core/tests/model.rs b/crates/oximo-core/tests/model.rs
index dab88db..a8bd569 100644
--- a/crates/oximo-core/tests/model.rs
+++ b/crates/oximo-core/tests/model.rs
@@ -20,14 +20,53 @@ fn classifies_milp() {
assert_eq!(m.kind(), ModelKind::MILP);
}
+#[test]
+fn classifies_qp() {
+ let m = Model::new("qp");
+ let x = m.var("x").lb(0.0).build();
+ m.minimize(x.powi(2));
+ assert_eq!(m.kind(), ModelKind::QP);
+}
+
+#[test]
+fn classifies_miqp() {
+ let m = Model::new("miqp");
+ let x = m.var("x").lb(0.0).build();
+ let y = m.var("y").lb(0.0).ub(1.0).integer().build();
+ // Bilinear term keeps it quadratic, the integer var makes QP -> MIQP.
+ m.minimize(x * y);
+ assert_eq!(m.kind(), ModelKind::MIQP);
+}
+
+#[test]
+fn quadratic_constraint_classifies_qp() {
+ let m = Model::new("qp_con");
+ let x = m.var("x").lb(0.0).build();
+ // Linear objective but a quadratic constraint still makes the model a QP.
+ m.constraint("c", x.powi(2).le(4.0));
+ m.minimize(x);
+ assert_eq!(m.kind(), ModelKind::QP);
+}
+
#[test]
fn classifies_nlp() {
let m = Model::new("nlp");
let x = m.var("x").lb(0.0).build();
- m.minimize(x.powi(2));
+ // Degree-3, so it falls through to the nonlinear path.
+ m.minimize(x.powi(3));
assert_eq!(m.kind(), ModelKind::NLP);
}
+#[test]
+fn classifies_minlp_with_division() {
+ let m = Model::new("minlp_div");
+ let x = m.var("x").lb(1.0).build();
+ let y = m.var("y").lb(0.0).ub(1.0).integer().build();
+ // x / y is nonlinear (non-constant denominator)
+ m.minimize(x / y);
+ assert_eq!(m.kind(), ModelKind::MINLP);
+}
+
#[test]
fn variable_count_matches_register() {
let m = Model::new("vars");
diff --git a/crates/oximo-expr/README.md b/crates/oximo-expr/README.md
index 7421780..5dea422 100644
--- a/crates/oximo-expr/README.md
+++ b/crates/oximo-expr/README.md
@@ -28,6 +28,7 @@ Add(Children) // generic n-ary add
Mul(Children) // generic n-ary mul
Neg(ExprId)
Pow(ExprId, ExprId)
+Div(ExprId, ExprId) // numerator / denominator
Sin(ExprId) / Cos(ExprId) / Exp(ExprId) / Log(ExprId)
Linear { coeffs: Vec<(VarId, f64)>, constant: f64 } // LP fast-path
```
@@ -36,13 +37,14 @@ Linear { coeffs: Vec<(VarId, f64)>, constant: f64 } // LP fast-path
## Operator overloads
-`Expr` implements `Add`, `Sub`, `Mul`, `Neg` against other `Expr` values and against `f64`. All operations that stay linear produce a `Linear` node. For example:
+`Expr` implements `Add`, `Sub`, `Mul`, `Div`, `Neg` against other `Expr` values and against `f64`. All operations that stay linear produce a `Linear` node. For example:
```rust,ignore
// All of these produce a single Linear node, not an Add/Mul tree:
let e = 2.0 * x + 3.0 * y - 1.0;
let e = x + y;
let e = -x;
+let e = x / 2.0; // constant denominator: stays linear (x*0.5)
```
## Nonlinear methods on `Expr`
@@ -55,6 +57,7 @@ expr.sin()
expr.cos()
expr.exp()
expr.log()
+expr/expr
```
## Utilities
diff --git a/crates/oximo-expr/src/arena.rs b/crates/oximo-expr/src/arena.rs
index b56b74e..730999d 100644
--- a/crates/oximo-expr/src/arena.rs
+++ b/crates/oximo-expr/src/arena.rs
@@ -45,6 +45,7 @@ pub enum ExprNode {
Mul(Children),
Neg(ExprId),
Pow(ExprId, ExprId),
+ Div(ExprId, ExprId),
Sin(ExprId),
Cos(ExprId),
Exp(ExprId),
diff --git a/crates/oximo-expr/src/classify.rs b/crates/oximo-expr/src/classify.rs
new file mode 100644
index 0000000..ae72d8c
--- /dev/null
+++ b/crates/oximo-expr/src/classify.rs
@@ -0,0 +1,199 @@
+use crate::arena::{ExprArena, ExprId, ExprNode};
+
+/// Highest-degree polynomial class an expression belongs to, ignoring constant
+/// folding. Used by backends to pick between linear, quadratic, and general
+/// nonlinear translation paths.
+///
+/// Variants are ordered by increasing degree, so `max` of two classes yields the
+/// dominating one (e.g. a model with a quadratic objective and a nonlinear
+/// constraint is `Nonlinear`).
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub enum ExprClass {
+ Linear,
+ Quadratic,
+ Nonlinear,
+}
+
+/// Polynomial-degree bucket. `Higher` is a saturating sentinel for "anything
+/// above quadratic". Both polynomial degree > 2 and transcendentals collapse
+/// into it, since neither fits a QP solver's quadratic API.
+#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
+enum Degree {
+ Zero,
+ One,
+ Two,
+ Higher,
+}
+
+impl Degree {
+ /// `+` on a sum: take the maximum, saturating at `Higher`.
+ fn add(self, other: Degree) -> Degree {
+ self.max(other)
+ }
+
+ /// `*` on a product: add ordinal degrees, saturating at `Higher`.
+ fn mul(self, other: Degree) -> Degree {
+ match (self, other) {
+ (Degree::Higher, _) | (_, Degree::Higher) => Degree::Higher,
+ (Degree::Zero, x) | (x, Degree::Zero) => x,
+ (Degree::One, Degree::One) => Degree::Two,
+ _ => Degree::Higher,
+ }
+ }
+
+ /// `^n` on a power: multiply by `n`, saturating at `Higher`.
+ fn pow(self, n: u32) -> Degree {
+ match (self, n) {
+ (_, 0) | (Degree::Zero, _) => Degree::Zero,
+ (d, 1) => d,
+ (Degree::One, 2) => Degree::Two,
+ _ => Degree::Higher,
+ }
+ }
+}
+
+fn degree(arena: &ExprArena, id: ExprId) -> Degree {
+ match arena.get(id) {
+ ExprNode::Const(_) => Degree::Zero,
+ ExprNode::Var(_) | ExprNode::Param(_) | ExprNode::Linear { .. } => Degree::One,
+ ExprNode::Neg(inner) => degree(arena, *inner),
+ ExprNode::Add(children) => {
+ let mut d = Degree::Zero;
+ for c in children {
+ d = d.add(degree(arena, *c));
+ if d == Degree::Higher {
+ return d;
+ }
+ }
+ d
+ }
+ ExprNode::Mul(children) => {
+ let mut d = Degree::Zero;
+ for c in children {
+ d = d.mul(degree(arena, *c));
+ if d == Degree::Higher {
+ return d;
+ }
+ }
+ d
+ }
+ ExprNode::Pow(base, exp) => {
+ let ExprNode::Const(e) = arena.get(*exp) else { return Degree::Higher };
+ if (*e - e.round()).abs() >= f64::EPSILON || *e < 0.0 {
+ return Degree::Higher;
+ }
+ // Bucket the exponent into the only values `Degree::pow` treats
+ // distinctly.
+ let n = match e.round() {
+ v if v < 0.5 => 0,
+ v if v < 1.5 => 1,
+ v if v < 2.5 => 2,
+ _ => 3,
+ };
+ degree(arena, *base).pow(n)
+ }
+ // Transcendentals are always > quadratic. Division is too: `div_into`
+ // folds the only degree-preserving case (constant denominator) before a
+ // `Div` node is created, so any other `Div` has a non-constant
+ // denominator.
+ ExprNode::Div(_, _)
+ | ExprNode::Sin(_)
+ | ExprNode::Cos(_)
+ | ExprNode::Exp(_)
+ | ExprNode::Log(_) => Degree::Higher,
+ }
+}
+
+/// Classify an expression as Linear, Quadratic (polynomial degree <= 2 with at
+/// least one degree-2 term), or Nonlinear (transcendentals, non-integer powers,
+/// or polynomial degree > 2).
+pub fn classify(arena: &ExprArena, id: ExprId) -> ExprClass {
+ match degree(arena, id) {
+ Degree::Zero | Degree::One => ExprClass::Linear,
+ Degree::Two => ExprClass::Quadratic,
+ Degree::Higher => ExprClass::Nonlinear,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::arena::{ExprArena, ExprNode, VarId};
+ use smallvec::smallvec;
+
+ fn var(arena: &mut ExprArena, i: u32) -> ExprId {
+ arena.push(ExprNode::Var(VarId(i)))
+ }
+
+ #[test]
+ fn linear_var_sum() {
+ let mut a = ExprArena::new();
+ let x = var(&mut a, 0);
+ let y = var(&mut a, 1);
+ let sum = a.push(ExprNode::Add(smallvec![x, y]));
+ assert_eq!(classify(&a, sum), ExprClass::Linear);
+ }
+
+ #[test]
+ fn quadratic_mul_two_vars() {
+ let mut a = ExprArena::new();
+ let x = var(&mut a, 0);
+ let y = var(&mut a, 1);
+ let xy = a.push(ExprNode::Mul(smallvec![x, y]));
+ assert_eq!(classify(&a, xy), ExprClass::Quadratic);
+ }
+
+ #[test]
+ fn quadratic_pow_two() {
+ let mut a = ExprArena::new();
+ let x = var(&mut a, 0);
+ let two = a.push(ExprNode::Const(2.0));
+ let sq = a.push(ExprNode::Pow(x, two));
+ assert_eq!(classify(&a, sq), ExprClass::Quadratic);
+ }
+
+ #[test]
+ fn nonlinear_pow_three() {
+ let mut a = ExprArena::new();
+ let x = var(&mut a, 0);
+ let three = a.push(ExprNode::Const(3.0));
+ let cube = a.push(ExprNode::Pow(x, three));
+ assert_eq!(classify(&a, cube), ExprClass::Nonlinear);
+ }
+
+ #[test]
+ fn nonlinear_div() {
+ let mut a = ExprArena::new();
+ let x = var(&mut a, 0);
+ let y = var(&mut a, 1);
+ let q = a.push(ExprNode::Div(x, y));
+ assert_eq!(classify(&a, q), ExprClass::Nonlinear);
+ }
+
+ #[test]
+ fn nonlinear_sin() {
+ let mut a = ExprArena::new();
+ let x = var(&mut a, 0);
+ let s = a.push(ExprNode::Sin(x));
+ assert_eq!(classify(&a, s), ExprClass::Nonlinear);
+ }
+
+ #[test]
+ fn nonlinear_triple_mul() {
+ let mut arena = ExprArena::new();
+ let x = var(&mut arena, 0);
+ let y = var(&mut arena, 1);
+ let z = var(&mut arena, 2);
+ let prod = arena.push(ExprNode::Mul(smallvec![x, y, z]));
+ assert_eq!(classify(&arena, prod), ExprClass::Nonlinear);
+ }
+
+ #[test]
+ fn linear_promoted_by_const_mul() {
+ let mut a = ExprArena::new();
+ let x = var(&mut a, 0);
+ let c = a.push(ExprNode::Const(3.0));
+ let m = a.push(ExprNode::Mul(smallvec![c, x]));
+ assert_eq!(classify(&a, m), ExprClass::Linear);
+ }
+}
diff --git a/crates/oximo-expr/src/eval.rs b/crates/oximo-expr/src/eval.rs
index 900608d..ec26676 100644
--- a/crates/oximo-expr/src/eval.rs
+++ b/crates/oximo-expr/src/eval.rs
@@ -43,6 +43,7 @@ pub fn evaluate(arena: &ExprArena, id: ExprId, ctx: &C) -> Resul
.try_fold(1.0, |acc, c| Ok::<_, EvalError>(acc * evaluate(arena, *c, ctx)?))?,
ExprNode::Neg(inner) => -evaluate(arena, *inner, ctx)?,
ExprNode::Pow(base, exp) => evaluate(arena, *base, ctx)?.powf(evaluate(arena, *exp, ctx)?),
+ ExprNode::Div(num, den) => evaluate(arena, *num, ctx)? / evaluate(arena, *den, ctx)?,
ExprNode::Sin(inner) => evaluate(arena, *inner, ctx)?.sin(),
ExprNode::Cos(inner) => evaluate(arena, *inner, ctx)?.cos(),
ExprNode::Exp(inner) => evaluate(arena, *inner, ctx)?.exp(),
diff --git a/crates/oximo-expr/src/lib.rs b/crates/oximo-expr/src/lib.rs
index 9731104..dd34f52 100644
--- a/crates/oximo-expr/src/lib.rs
+++ b/crates/oximo-expr/src/lib.rs
@@ -2,6 +2,7 @@
#![forbid(unsafe_code)]
mod arena;
+mod classify;
mod eval;
mod handle;
mod linear;
@@ -10,6 +11,7 @@ mod simplify;
mod visit;
pub use arena::{ExprArena, ExprId, ExprNode, ParamId, VarId};
+pub use classify::{ExprClass, classify};
pub use eval::{EvalContext, EvalError, evaluate};
pub use handle::Expr;
pub use linear::{LinearTerms, extract_linear};
diff --git a/crates/oximo-expr/src/linear.rs b/crates/oximo-expr/src/linear.rs
index 5c48ca8..853859b 100644
--- a/crates/oximo-expr/src/linear.rs
+++ b/crates/oximo-expr/src/linear.rs
@@ -118,6 +118,25 @@ pub(crate) fn mul_into(arena: &mut ExprArena, lhs: ExprId, rhs: ExprId) -> ExprI
arena.push(ExprNode::Mul(smallvec![lhs, rhs]))
}
+/// Build `num / den`. If `den` is a nonzero constant `c`, fold to `num * (1/c)`
+/// so a constant-denominator division stays on the linear fast-path. Otherwise
+/// produce a `Div` node (always nonlinear, even when the numerator is linear).
+pub(crate) fn div_into(arena: &mut ExprArena, num: ExprId, den: ExprId) -> ExprId {
+ if let ExprNode::Const(c) = *arena.get(den) {
+ if c != 0.0 {
+ if let Some(mut t) = as_linear(arena, num) {
+ let inv = 1.0 / c;
+ t.coeffs.iter_mut().for_each(|(_, co)| *co *= inv);
+ t.constant *= inv;
+ return push_linear(arena, t);
+ }
+ let inv = arena.push(ExprNode::Const(1.0 / c));
+ return mul_into(arena, num, inv);
+ }
+ }
+ arena.push(ExprNode::Div(num, den))
+}
+
/// Build `-rhs`, preserving linearity.
pub(crate) fn neg_into(arena: &mut ExprArena, rhs: ExprId) -> ExprId {
if let Some(mut t) = as_linear(arena, rhs) {
diff --git a/crates/oximo-expr/src/ops.rs b/crates/oximo-expr/src/ops.rs
index b5d85ba..880d931 100644
--- a/crates/oximo-expr/src/ops.rs
+++ b/crates/oximo-expr/src/ops.rs
@@ -1,7 +1,7 @@
-use std::ops::{Add, Mul, Neg, Sub};
+use std::ops::{Add, Div, Mul, Neg, Sub};
use crate::handle::Expr;
-use crate::linear::{add_into, mul_into, neg_into, sub_into};
+use crate::linear::{add_into, div_into, mul_into, neg_into, sub_into};
// -----------------------------------------------------------------------------
// Expr Expr
@@ -31,6 +31,14 @@ impl<'a> Mul for Expr<'a> {
}
}
+impl<'a> Div for Expr<'a> {
+ type Output = Self;
+ fn div(self, rhs: Self) -> Self {
+ let id = div_into(&mut self.arena.borrow_mut(), self.id, rhs.id);
+ Self::new(id, self.arena)
+ }
+}
+
impl<'a> Neg for Expr<'a> {
type Output = Self;
fn neg(self) -> Self {
@@ -45,14 +53,13 @@ impl<'a> Neg for Expr<'a> {
// -----------------------------------------------------------------------------
macro_rules! impl_scalar_ops {
- ($scalar:ty) => {
+ ($scalar:ty, $to_f64:expr) => {
impl<'a> Add<$scalar> for Expr<'a> {
type Output = Self;
fn add(self, rhs: $scalar) -> Self {
- #[allow(clippy::cast_lossless)]
let id = {
let mut a = self.arena.borrow_mut();
- let rhs_id = a.constant(rhs as f64);
+ let rhs_id = a.constant($to_f64(rhs));
add_into(&mut a, self.id, rhs_id)
};
Self::new(id, self.arena)
@@ -69,10 +76,9 @@ macro_rules! impl_scalar_ops {
impl<'a> Sub<$scalar> for Expr<'a> {
type Output = Self;
fn sub(self, rhs: $scalar) -> Self {
- #[allow(clippy::cast_lossless)]
let id = {
let mut a = self.arena.borrow_mut();
- let rhs_id = a.constant(rhs as f64);
+ let rhs_id = a.constant($to_f64(rhs));
sub_into(&mut a, self.id, rhs_id)
};
Self::new(id, self.arena)
@@ -82,10 +88,9 @@ macro_rules! impl_scalar_ops {
impl<'a> Sub> for $scalar {
type Output = Expr<'a>;
fn sub(self, rhs: Expr<'a>) -> Expr<'a> {
- #[allow(clippy::cast_lossless)]
let id = {
let mut a = rhs.arena.borrow_mut();
- let lhs_id = a.constant(self as f64);
+ let lhs_id = a.constant($to_f64(self));
sub_into(&mut a, lhs_id, rhs.id)
};
Expr::new(id, rhs.arena)
@@ -95,10 +100,9 @@ macro_rules! impl_scalar_ops {
impl<'a> Mul<$scalar> for Expr<'a> {
type Output = Self;
fn mul(self, rhs: $scalar) -> Self {
- #[allow(clippy::cast_lossless)]
let id = {
let mut a = self.arena.borrow_mut();
- let rhs_id = a.constant(rhs as f64);
+ let rhs_id = a.constant($to_f64(rhs));
mul_into(&mut a, self.id, rhs_id)
};
Self::new(id, self.arena)
@@ -111,11 +115,35 @@ macro_rules! impl_scalar_ops {
rhs * self
}
}
+
+ impl<'a> Div<$scalar> for Expr<'a> {
+ type Output = Self;
+ fn div(self, rhs: $scalar) -> Self {
+ let id = {
+ let mut a = self.arena.borrow_mut();
+ let rhs_id = a.constant($to_f64(rhs));
+ div_into(&mut a, self.id, rhs_id)
+ };
+ Self::new(id, self.arena)
+ }
+ }
+
+ impl<'a> Div> for $scalar {
+ type Output = Expr<'a>;
+ fn div(self, rhs: Expr<'a>) -> Expr<'a> {
+ let id = {
+ let mut a = rhs.arena.borrow_mut();
+ let lhs_id = a.constant($to_f64(self));
+ div_into(&mut a, lhs_id, rhs.id)
+ };
+ Expr::new(id, rhs.arena)
+ }
+ }
};
}
-impl_scalar_ops!(f64);
-impl_scalar_ops!(i32);
+impl_scalar_ops!(f64, core::convert::identity);
+impl_scalar_ops!(i32, f64::from);
// -----------------------------------------------------------------------------
// std::iter::Sum: the first element of the iterator carries the arena handle,
diff --git a/crates/oximo-expr/src/simplify.rs b/crates/oximo-expr/src/simplify.rs
index d39cb34..19742bf 100644
--- a/crates/oximo-expr/src/simplify.rs
+++ b/crates/oximo-expr/src/simplify.rs
@@ -18,6 +18,10 @@ pub fn simplify(arena: &mut ExprArena, id: ExprId) -> ExprId {
(ExprNode::Const(b), ExprNode::Const(e)) => Some(ExprNode::Const(b.powf(*e))),
_ => None,
},
+ ExprNode::Div(num, den) => match (arena.get(num), arena.get(den)) {
+ (ExprNode::Const(n), ExprNode::Const(d)) => Some(ExprNode::Const(n / d)),
+ _ => None,
+ },
ExprNode::Sin(inner)
| ExprNode::Cos(inner)
| ExprNode::Exp(inner)
diff --git a/crates/oximo-expr/src/visit.rs b/crates/oximo-expr/src/visit.rs
index 690e294..aae04d2 100644
--- a/crates/oximo-expr/src/visit.rs
+++ b/crates/oximo-expr/src/visit.rs
@@ -31,6 +31,12 @@ pub fn walk(arena: &ExprArena, id: ExprId, visitor: &mut V) {
walk(arena, base, visitor);
walk(arena, exp, visitor);
}
+ ExprNode::Div(num, den) => {
+ let num = *num;
+ let den = *den;
+ walk(arena, num, visitor);
+ walk(arena, den, visitor);
+ }
ExprNode::Const(_) | ExprNode::Var(_) | ExprNode::Param(_) | ExprNode::Linear { .. } => {}
}
}
diff --git a/crates/oximo-expr/tests/arithmetic.rs b/crates/oximo-expr/tests/arithmetic.rs
index 5278a46..e4a7409 100644
--- a/crates/oximo-expr/tests/arithmetic.rs
+++ b/crates/oximo-expr/tests/arithmetic.rs
@@ -2,7 +2,9 @@
use std::cell::RefCell;
-use oximo_expr::{Expr, ExprArena, ExprNode, VarId, dot, evaluate, extract_linear};
+use oximo_expr::{
+ Expr, ExprArena, ExprClass, ExprNode, VarId, classify, dot, evaluate, extract_linear,
+};
fn make_var(arena: &RefCell, idx: u32) -> Expr<'_> {
Expr::from_var(arena, VarId(idx))
@@ -105,6 +107,66 @@ fn dot_panics_on_empty() {
let _ = dot(&xs, &coeffs);
}
+#[test]
+fn div_by_constant_stays_linear() {
+ let arena = RefCell::new(ExprArena::new());
+ let x = make_var(&arena, 0);
+ let combo = x / 2.0;
+
+ let snapshot = arena.borrow().get(combo.id).clone();
+ match snapshot {
+ ExprNode::Linear { coeffs, constant } => {
+ assert_eq!(constant, 0.0);
+ assert_eq!(coeffs, vec![(VarId(0), 0.5)]);
+ }
+ n => panic!("expected Linear node, got {n:?}"),
+ }
+ assert_eq!(classify(&arena.borrow(), combo.id), ExprClass::Linear);
+}
+
+#[test]
+fn div_two_vars_is_nonlinear() {
+ let arena = RefCell::new(ExprArena::new());
+ let a = make_var(&arena, 0);
+ let b = make_var(&arena, 1);
+ let q = a / b;
+
+ assert!(matches!(arena.borrow().get(q.id), ExprNode::Div(_, _)));
+ assert_eq!(classify(&arena.borrow(), q.id), ExprClass::Nonlinear);
+ assert!(extract_linear(&arena.borrow(), q.id).is_none());
+}
+
+#[test]
+fn scalar_over_var_is_nonlinear() {
+ let arena = RefCell::new(ExprArena::new());
+ let x = make_var(&arena, 0);
+ let recip = 1.0 / x;
+ assert!(matches!(arena.borrow().get(recip.id), ExprNode::Div(_, _)));
+ assert_eq!(classify(&arena.borrow(), recip.id), ExprClass::Nonlinear);
+}
+
+#[test]
+fn evaluate_division() {
+ let arena = RefCell::new(ExprArena::new());
+ let a = make_var(&arena, 0);
+ let b = make_var(&arena, 1);
+ let q = a / b;
+ let values: &[f64] = &[12.0, 4.0];
+ let arena_ref = arena.borrow();
+ assert_eq!(evaluate(&arena_ref, q.id, &values).unwrap(), 3.0);
+}
+
+#[test]
+fn evaluate_division_by_zero_is_infinite() {
+ let arena = RefCell::new(ExprArena::new());
+ let a = make_var(&arena, 0);
+ let b = make_var(&arena, 1);
+ let q = a / b;
+ let values: &[f64] = &[1.0, 0.0];
+ let arena_ref = arena.borrow();
+ assert!(evaluate(&arena_ref, q.id, &values).unwrap().is_infinite());
+}
+
#[test]
fn large_sum_extracts_correctly() {
let arena = RefCell::new(ExprArena::new());
diff --git a/crates/oximo-gams/README.md b/crates/oximo-gams/README.md
index 8b25798..3c3dc49 100644
--- a/crates/oximo-gams/README.md
+++ b/crates/oximo-gams/README.md
@@ -1,8 +1,8 @@
# oximo-gams
-GAMS LP/MILP backend for [oximo](https://github.com/germanheim/oximo).
+GAMS backend for [oximo](https://github.com/germanheim/oximo).
-Writes an oximo`Model`] to a temporary `.gms` file, invokes the GAMS executable via `std::process::Command`, and parses the solution from a PUT-generated text file. Supports `LP` and `MILP` model kinds. **QP/NLP/MINLP return `SolverError::UnsupportedKind` for now**.
+Writes an oximo `Model` to a temporary `.gms` file, invokes the GAMS executable via `std::process::Command`, and parses the solution from a PUT-generated text file. Supports `LP`, `MILP`, `QP`, `MIQP`, `NLP`, and `MINLP` model kinds.
The sub-solver is determined by the GAMS installation (default) or set explicitly via `GamsOptions::solver`. Any solver available in your GAMS distribution can be targeted, see [Sub-solver selection](#sub-solver-selection) below.
@@ -100,7 +100,8 @@ let opts = GamsOptions::default()
## Sub-solver selection
Pass a `GamsSolverConfig` to `.solver(...)` to select a GAMS sub-solver. This emits
-`option {LP|MIP} = ;` in the generated `.gms` file.
+`option {LP|MIP|NLP|MINLP|QCP|MIQCP} = ;` in the generated `.gms` file, scoped to
+the solve type resolved from `Model::kind()` (`QP` -> `QCP`, `MIQP` -> `MIQCP`).
```rust
use oximo_gams::{GamsOptions, GamsSolver, GamsSolverConfig};
diff --git a/crates/oximo-gams/src/lib.rs b/crates/oximo-gams/src/lib.rs
index d39737e..7026ccb 100644
--- a/crates/oximo-gams/src/lib.rs
+++ b/crates/oximo-gams/src/lib.rs
@@ -50,7 +50,15 @@ impl Solver for Gams {
}
fn supports(&self, kind: ModelKind) -> bool {
- matches!(kind, ModelKind::LP | ModelKind::MILP)
+ matches!(
+ kind,
+ ModelKind::LP
+ | ModelKind::MILP
+ | ModelKind::QP
+ | ModelKind::MIQP
+ | ModelKind::NLP
+ | ModelKind::MINLP
+ )
}
fn solve(&mut self, model: &Model, opts: &GamsOptions) -> Result {
diff --git a/crates/oximo-gams/src/options.rs b/crates/oximo-gams/src/options.rs
index 5cc093e..fe8a25f 100644
--- a/crates/oximo-gams/src/options.rs
+++ b/crates/oximo-gams/src/options.rs
@@ -25,15 +25,15 @@ pub struct GamsOptions {
/// Reference:
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum GamsSolver {
- /// ALPHAECP: MINLP
+ /// ALPHAECP: MINLP, MIQCP
AlphaEcp,
- /// ANTIGONE: LP, MIP, NLP, MINLP, QCP, MIQCP, Global
+ /// ANTIGONE: NLP, CNS, DNLP, MINLP, QCP, MIQCP, Global
Antigone,
- /// BARON: LP, MIP, NLP, MCP, MPEC, CNS, MINLP, QCP, MIQCP, Global
+ /// BARON: LP, MIP, NLP, CNS, DNLP, MINLP, QCP, MIQCP, Global
Baron,
/// CBC: LP, MIP
Cbc,
- /// CONOPT: NLP, DNLP, CNS, MPEC
+ /// CONOPT: LP, NLP, CNS, DNLP, QCP
Conopt,
/// COPT: LP, MIP, QCP, MIQCP
Copt,
@@ -41,55 +41,55 @@ pub enum GamsSolver {
Cplex,
/// DECIS: LP, Stochastic
Decis,
- /// DICOPT: MINLP
+ /// DICOPT: MINLP, MIQCP
Dicopt,
/// GLPK: LP, MIP (not in GAMS docs but recognized)
Glpk,
- /// GUROBI: LP, MIP, NLP, MINLP, QCP, MIQCP, Global
+ /// GUROBI: LP, MIP, NLP, DNLP, MINLP, QCP, MIQCP, Global
Gurobi,
- /// GUSS: LP, MIP, NLP, MCP, MPEC, CNS, DNLP, MINLP, QCP, MIQCP
+ /// GUSS: LP, MIP, NLP, MCP, CNS, DNLP, MINLP, QCP, MIQCP
Guss,
/// HiGHS: LP, MIP
Highs,
- /// IPOPT: NLP, DNLP, CNS, MPEC
+ /// IPOPT: LP, NLP, CNS, DNLP, QCP
Ipopt,
- /// JAMS: MPEC
+ /// JAMS: EMP
Jams,
/// KESTREL: all model types (remote solver submission)
Kestrel,
- /// KNITRO: LP, MIP, NLP, MCP, MPEC, CNS, DNLP, MINLP, QCP, MIQCP
+ /// KNITRO: LP, NLP, MCP, MPEC, CNS, DNLP, MINLP, QCP, MIQCP
Knitro,
- /// LINDO: LP, MIP, NLP, MCP, MPEC, CNS, DNLP, MINLP, QCP, Stochastic, Global
+ /// LINDO: LP, MIP, NLP, DNLP, MINLP, QCP, MIQCP, Stochastic, Global
Lindo,
- /// LINDOGLOBAL: LP, MIP, NLP, MINLP, QCP, MIQCP, Global
+ /// LINDOGLOBAL: LP, MIP, NLP, DNLP, MINLP, QCP, MIQCP, Global
LindoGlobal,
/// MILES: MCP
Miles,
- /// MINOS: NLP, DNLP, CNS, MPEC
+ /// MINOS: LP, NLP, CNS, DNLP, QCP
Minos,
- /// MOSEK: LP, MIP, NLP, QCP, MIQCP
+ /// MOSEK: LP, MIP, NLP, DNLP, MINLP, QCP, MIQCP
Mosek,
- /// NLPEC: NLP, MPEC
+ /// NLPEC: MCP, MPEC
Nlpec,
- /// ODHCPLEX: LP, MIP
+ /// ODHCPLEX: MIP, MIQCP
OdhCplex,
- /// PATH: MCP, MPEC
+ /// PATH: MCP, CNS
Path,
- /// QUADMINOS: NLP
+ /// QUADMINOS: LP
QuadMinos,
- /// RESHOP: NLP
+ /// RESHOP: EMP
Reshop,
- /// SBB: NLP, MINLP
+ /// SBB: MINLP, MIQCP
Sbb,
- /// SCIP: LP, MIP, NLP, MINLP, QCP, MIQCP, Global
+ /// SCIP: MIP, NLP, CNS, DNLP, MINLP, QCP, MIQCP, Global
Scip,
- /// SHOT: MINLP
+ /// SHOT: MINLP, MIQCP
Shot,
- /// SNOPT: NLP, DNLP, CNS, MPEC
+ /// SNOPT: LP, NLP, CNS, DNLP, QCP
Snopt,
/// SOPLEX: LP
Soplex,
- /// XPRESS: LP, MIP, NLP, MINLP, QCP, MIQCP, Global
+ /// XPRESS: LP, MIP, NLP, CNS, DNLP, MINLP, QCP, MIQCP, Global
Xpress,
/// Any other GAMS-recognized solver name, emitted verbatim.
Custom(String),
@@ -170,7 +170,8 @@ impl HasUniversal for GamsOptions {
/// Emit GAMS option statements into `gms` before the `Solve` statement.
///
-/// `solve_type` is `"LP"` or `"MIP"`, used to scope the `solver` option.
+/// `solve_type` is the GAMS model type (`"LP"` / `"MIP"` / `"NLP"` / `"MINLP"`
+/// / `"QCP"` / `"MIQCP"`), used to scope the `solver` option.
pub fn write_options(gms: &mut String, o: &GamsOptions, solve_type: &str) {
if let Some(d) = o.universal.time_limit {
writeln!(gms, "option ResLim = {};", d.as_secs_f64()).unwrap();
diff --git a/crates/oximo-gams/src/solver_options.rs b/crates/oximo-gams/src/solver_options.rs
index e924925..5a9239e 100644
--- a/crates/oximo-gams/src/solver_options.rs
+++ b/crates/oximo-gams/src/solver_options.rs
@@ -3,7 +3,10 @@
use std::fmt::Write as FmtWrite;
+use oximo_core::ModelKind;
+
use crate::options::GamsSolver;
+use crate::translate::gams_solve_type;
// - Config enum
@@ -73,6 +76,53 @@ impl GamsSolverConfig {
Self::Named(_) => false,
}
}
+
+ /// Whether this solver can handle `kind` under oximo's GAMS translation,
+ /// which emits `QP` as a `QCP` solve and `MIQP` as a `MIQCP` solve.
+ ///
+ /// [`GamsSolver::Custom`] and any unrecognized name return `true`: their
+ /// capabilities are unknown, so they are left for GAMS to accept or reject.
+ #[must_use]
+ pub fn supports(&self, kind: ModelKind) -> bool {
+ solver_supports_type(self.gams_name(), gams_solve_type(kind))
+ }
+}
+
+/// GAMS solve types a named solver supports, restricted to the six oximo can
+/// emit: `LP` / `MIP` / `NLP` / `MINLP` / `QCP` / `MIQCP`. `None` means the
+/// name is unrecognized and cannot be validated.
+///
+/// Transcribed from the GAMS solver/model-type matrix (other model types —
+/// `MCP`, `MPEC`, `CNS`, `DNLP`, `EMP`, stochastic — are omitted because oximo
+/// never emits them):
+/// - "GAMS Solver Manuals," GAMS Development Corporation.
+/// (accessed May 14, 2026).
+fn supported_solve_types(gams_name: &str) -> Option<&'static [&'static str]> {
+ Some(match gams_name {
+ "ALPHAECP" | "DICOPT" | "SBB" | "SHOT" => &["MINLP", "MIQCP"],
+ "CONOPT" | "CONOPT3" | "CONOPT4" | "IPOPT" | "MINOS" | "SNOPT" => &["LP", "NLP", "QCP"],
+ "DECIS" | "SOPLEX" | "QUADMINOS" => &["LP"],
+ "CBC" | "GLPK" | "HIGHS" => &["LP", "MIP"],
+ "ODHCPLEX" => &["MIP", "MIQCP"],
+ "COPT" | "CPLEX" => &["LP", "MIP", "QCP", "MIQCP"],
+ "ANTIGONE" => &["NLP", "MINLP", "QCP", "MIQCP"],
+ "KNITRO" => &["LP", "NLP", "MINLP", "QCP", "MIQCP"],
+ "SCIP" => &["MIP", "NLP", "MINLP", "QCP", "MIQCP"],
+ "BARON" | "GUROBI" | "GUSS" | "KESTREL" | "LINDO" | "LINDOGLOBAL" | "MOSEK" | "XPRESS" => {
+ &["LP", "MIP", "NLP", "MINLP", "QCP", "MIQCP"]
+ }
+ // JAMS (EMP), MILES (MCP), NLPEC (MCP/MPEC), PATH (MCP/MPEC/CNS),
+ // RESHOP (EMP) support none of the model types oximo emits.
+ "JAMS" | "MILES" | "NLPEC" | "PATH" | "RESHOP" => &[],
+ _ => return None,
+ })
+}
+
+/// Whether the GAMS solver named `gams_name` supports `gams_type`
+/// (`"LP"` / `"MIP"` / `"NLP"` / `"MINLP"` / `"QCP"` / `"MIQCP"`). Unrecognized
+/// names return `true`.
+pub(crate) fn solver_supports_type(gams_name: &str, gams_type: &str) -> bool {
+ supported_solve_types(gams_name).is_none_or(|types| types.contains(&gams_type))
}
impl From for GamsSolverConfig {
@@ -1017,4 +1067,51 @@ mod tests {
assert!(matches!(cfg, GamsSolverConfig::Named(GamsSolver::Gurobi)));
assert_eq!(cfg.gams_name(), "GUROBI");
}
+
+ #[test]
+ fn supports_matches_solver_capabilities() {
+ // CPLEX: LP, MIP, QCP, MIQCP. QP/MIQP route through QCP/MIQCP, so the
+ // quadratic kinds pass while the general (MI)NLP kinds fail.
+ let cplex = GamsSolverConfig::Cplex(GamsCplexOptions::default());
+ assert!(cplex.supports(ModelKind::LP));
+ assert!(cplex.supports(ModelKind::MILP));
+ assert!(cplex.supports(ModelKind::QP), "QP routes through QCP");
+ assert!(cplex.supports(ModelKind::MIQP), "MIQP routes through MIQCP");
+ assert!(!cplex.supports(ModelKind::NLP));
+ assert!(!cplex.supports(ModelKind::MINLP));
+
+ // IPOPT: LP, NLP, QCP. LP, NLP, and QP pass, the integer kinds fail.
+ let ipopt = GamsSolverConfig::Ipopt(GamsIpoptOptions::default());
+ assert!(ipopt.supports(ModelKind::LP));
+ assert!(ipopt.supports(ModelKind::NLP));
+ assert!(ipopt.supports(ModelKind::QP), "QP routes through QCP");
+ assert!(!ipopt.supports(ModelKind::MIQP), "MIQP routes through MIQCP");
+ assert!(!ipopt.supports(ModelKind::MINLP));
+
+ // BARON handles all six oximo solve types.
+ let baron = GamsSolverConfig::Named(GamsSolver::Baron);
+ for k in [
+ ModelKind::LP,
+ ModelKind::MILP,
+ ModelKind::QP,
+ ModelKind::MIQP,
+ ModelKind::NLP,
+ ModelKind::MINLP,
+ ] {
+ assert!(baron.supports(k), "BARON should support {k:?}");
+ }
+
+ // HiGHS: LP/MIP only, no quadratic or nonlinear support THROUGH GAMS.
+ let highs = GamsSolverConfig::Highs(GamsHighsOptions::default());
+ assert!(highs.supports(ModelKind::LP));
+ assert!(!highs.supports(ModelKind::QP));
+ assert!(!highs.supports(ModelKind::NLP));
+ }
+
+ #[test]
+ fn supports_is_permissive_for_unknown_names() {
+ let custom = GamsSolverConfig::Named(GamsSolver::Custom("MYSOLVER".into()));
+ assert!(custom.supports(ModelKind::MINLP));
+ assert!(custom.supports(ModelKind::LP));
+ }
}
diff --git a/crates/oximo-gams/src/translate.rs b/crates/oximo-gams/src/translate.rs
index 85bc7f6..a874d97 100644
--- a/crates/oximo-gams/src/translate.rs
+++ b/crates/oximo-gams/src/translate.rs
@@ -6,10 +6,12 @@ use std::{fs, io};
static SOLVE_ID: AtomicU64 = AtomicU64::new(0);
-use oximo_core::{ConstraintId, Domain, Model, ModelKind, ObjectiveSense, Sense, VarId};
-use oximo_expr::{ExprArena, LinearTerms, extract_linear};
+use oximo_core::{
+ Constraint, ConstraintId, Domain, Model, ModelKind, Objective, ObjectiveSense, Sense, VarId,
+ Variable,
+};
+use oximo_expr::{ExprArena, ExprId, ExprNode, LinearTerms, extract_linear};
use oximo_solver::{SolverError, SolverResult, SolverStatus};
-use rayon::prelude::*;
use rustc_hash::FxHashMap;
use crate::GamsOptions;
@@ -36,167 +38,28 @@ pub fn solve(
exec: Option<&str>,
) -> Result {
let kind = model.kind();
- if !matches!(kind, ModelKind::LP | ModelKind::MILP) {
- return Err(SolverError::UnsupportedKind(kind));
- }
-
+ validate_solver(opts, kind)?;
let arena = model.arena();
let vars = model.variables();
let constraints = model.constraints();
let objective = model.try_objective().map_err(SolverError::Core)?;
- let obj_terms = extract_linear(&arena, objective.expr).ok_or(SolverError::Nonlinear)?;
-
- let arena_ref: &ExprArena = &arena;
- let con_terms: Vec = constraints
- .par_iter()
- .map(|c| extract_linear(arena_ref, c.lhs).ok_or(SolverError::Nonlinear))
- .collect::, _>>()?;
-
- let solve_type = if matches!(kind, ModelKind::MILP) { "MIP" } else { "LP" };
let sense_kw = match objective.sense {
ObjectiveSense::Minimize => "minimizing",
ObjectiveSense::Maximize => "maximizing",
};
- // Pre-compute solver opt file content so we know whether to inject optfile=1.
- let solver_opt: Option<(String, String)> = opts.solver.as_ref().and_then(|cfg| {
- let mut buf = String::new();
- if cfg.write_opt_file(&mut buf) {
- let fname = format!("{}.opt", cfg.gams_name().to_ascii_lowercase());
- Some((fname, buf))
- } else {
- None
- }
- });
-
- // - Build the .gms file
let mut gms = String::with_capacity(4096);
-
- writeln!(gms, "$title oximo_model").unwrap();
- writeln!(gms, "$offSymList").unwrap();
- writeln!(gms, "$offSymXRef").unwrap();
- writeln!(gms, "option solprint = off;").unwrap();
- writeln!(gms, "option limrow = 0;").unwrap();
- writeln!(gms, "option limcol = 0;").unwrap();
- writeln!(gms).unwrap();
-
- // Variable declarations, split by domain
- let (mut cont_vars, mut bin_vars, mut int_vars) = (Vec::new(), Vec::new(), Vec::new());
- for v in vars.iter() {
- match v.domain {
- Domain::Binary => bin_vars.push(v),
- Domain::Integer | Domain::SemiInteger { .. } => int_vars.push(v),
- _ => cont_vars.push(v),
- }
- }
-
- // Continuous + objective variable
- write!(gms, "Variables\n v_obj").unwrap();
- for v in &cont_vars {
- write!(gms, ", v{}", v.id.index()).unwrap();
- }
- writeln!(gms, ";").unwrap();
-
- if !bin_vars.is_empty() {
- write!(gms, "Binary Variables").unwrap();
- for (k, v) in bin_vars.iter().enumerate() {
- if k == 0 {
- write!(gms, "\n v{}", v.id.index()).unwrap();
- } else {
- write!(gms, ", v{}", v.id.index()).unwrap();
- }
- }
- writeln!(gms, ";").unwrap();
- }
-
- if !int_vars.is_empty() {
- write!(gms, "Integer Variables").unwrap();
- for (k, v) in int_vars.iter().enumerate() {
- if k == 0 {
- write!(gms, "\n v{}", v.id.index()).unwrap();
- } else {
- write!(gms, ", v{}", v.id.index()).unwrap();
- }
- }
- writeln!(gms, ";").unwrap();
- }
- writeln!(gms).unwrap();
-
- // Bounds
- for v in vars.iter() {
- let i = v.id.index();
- if matches!(v.domain, Domain::Binary) {
- // Default binary bounds are [0, 1], only emit when overridden.
- if (v.lb - v.ub).abs() < f64::EPSILON {
- writeln!(gms, "v{i}.fx = {};", fmt(v.lb)).unwrap();
- } else {
- if v.lb.abs() > f64::EPSILON {
- writeln!(gms, "v{i}.lo = {};", fmt(v.lb)).unwrap();
- }
- if (v.ub - 1.0).abs() > f64::EPSILON {
- writeln!(gms, "v{i}.up = {};", fmt(v.ub)).unwrap();
- }
- }
- continue;
- }
- // Lower bound
- if v.lb == f64::NEG_INFINITY {
- writeln!(gms, "v{i}.lo = -Inf;").unwrap();
- } else if v.lb.is_finite() {
- writeln!(gms, "v{i}.lo = {};", fmt(v.lb)).unwrap();
- }
- // Upper bound (+Inf is the GAMS default, only write when finite)
- if v.ub.is_finite() {
- writeln!(gms, "v{i}.up = {};", fmt(v.ub)).unwrap();
- }
- }
-
- // Initial levels (warm start)
- for v in vars.iter() {
- if let Some(val) = v.initial {
- writeln!(gms, "v{}.l = {};", v.id.index(), fmt(val)).unwrap();
- }
- }
- writeln!(gms).unwrap();
-
- // Equations declaration
- write!(gms, "Equations\n eq_obj").unwrap();
- for i in 0..constraints.len() {
- write!(gms, ", eq_c{i}").unwrap();
- }
- writeln!(gms, ";").unwrap();
- writeln!(gms).unwrap();
-
- // Objective equation: v_obj =e=
- write!(gms, "eq_obj.. v_obj =e=").unwrap();
- write_expr(&mut gms, &obj_terms, true);
- writeln!(gms, ";").unwrap();
-
- // Constraint equations: variable terms only, constant folded into RHS
- for (ci, (c, t)) in constraints.iter().zip(con_terms.iter()).enumerate() {
- let adjusted_rhs = c.rhs - t.constant;
- let sense_str = match c.sense {
- Sense::Le => "=l=",
- Sense::Ge => "=g=",
- Sense::Eq => "=e=",
- };
- write!(gms, "eq_c{ci}..").unwrap();
- write_expr(&mut gms, t, false);
- writeln!(gms, " {sense_str} {};", fmt(adjusted_rhs)).unwrap();
- }
- writeln!(gms).unwrap();
-
- // Options (time limit, MIP gap, sub-solver, etc.)
- write_options(&mut gms, opts, solve_type);
-
- // Model and solve statements
- writeln!(gms, "Model oximo_m / all /;").unwrap();
- if solver_opt.is_some() {
- writeln!(gms, "oximo_m.optfile = 1;").unwrap();
- }
- writeln!(gms, "Solve oximo_m using {solve_type} {sense_kw} v_obj;").unwrap();
- writeln!(gms).unwrap();
+ let (solve_type, solver_opt) = build_model_section(
+ &mut gms,
+ kind,
+ &arena,
+ &vars,
+ &constraints,
+ &objective,
+ sense_kw,
+ opts,
+ );
// - Temp directory
// Combine timestamp with a per-process atomic counter so concurrent
@@ -432,11 +295,232 @@ fn map_status(modelstat: i32, solvestat: i32) -> SolverStatus {
// - Helpers
+/// Write the formulation portion of the `.gms` file: title, variables, bounds,
+/// equations, options, model, and solve statement. Returns the solve type
+/// (`"LP"` / `"MIP"` / `"NLP"` / `"MINLP"` / `"QCP"` / `"MIQCP"`) and any
+/// solver-options file pair `(filename, content)` the caller should also
+/// persist alongside the `.gms`.
+#[allow(clippy::too_many_arguments)]
+fn build_model_section(
+ gms: &mut String,
+ kind: ModelKind,
+ arena: &ExprArena,
+ vars: &[Variable],
+ constraints: &[Constraint],
+ objective: &Objective,
+ sense_kw: &str,
+ opts: &GamsOptions,
+) -> (&'static str, Option<(String, String)>) {
+ let solve_type = gams_solve_type(kind);
+ let solver_opt = build_solver_opt(opts);
+
+ write_preamble(gms);
+ write_var_declarations(gms, vars);
+ write_bounds_and_initials(gms, vars);
+ write_equations(gms, arena, constraints, objective);
+ write_options(gms, opts, solve_type);
+ write_model_and_solve(gms, solve_type, sense_kw, solver_opt.is_some());
+
+ (solve_type, solver_opt)
+}
+
+pub(crate) fn gams_solve_type(kind: ModelKind) -> &'static str {
+ match kind {
+ ModelKind::LP => "LP",
+ ModelKind::MILP => "MIP",
+ ModelKind::QP => "QCP",
+ ModelKind::MIQP => "MIQCP",
+ ModelKind::NLP => "NLP",
+ ModelKind::MINLP => "MINLP",
+ }
+}
+
+/// Reject an explicitly selected sub-solver that cannot handle `kind` before
+/// invoking GAMS, so the caller gets a clear error naming the solver and model
+/// type instead of a downstream GAMS compilation failure.
+fn validate_solver(opts: &GamsOptions, kind: ModelKind) -> Result<(), SolverError> {
+ if let Some(cfg) = &opts.solver {
+ if !cfg.supports(kind) {
+ let solve_type = gams_solve_type(kind);
+ return Err(SolverError::Backend(format!(
+ "GAMS solver {} does not support {solve_type} models (model kind {kind:?}); \
+ select a solver that supports {solve_type}",
+ cfg.gams_name()
+ )));
+ }
+ }
+ Ok(())
+}
+
+fn build_solver_opt(opts: &GamsOptions) -> Option<(String, String)> {
+ opts.solver.as_ref().and_then(|cfg| {
+ let mut buf = String::new();
+ cfg.write_opt_file(&mut buf)
+ .then(|| (format!("{}.opt", cfg.gams_name().to_ascii_lowercase()), buf))
+ })
+}
+
+fn write_preamble(gms: &mut String) {
+ writeln!(gms, "$title oximo_model").unwrap();
+ writeln!(gms, "$offSymList").unwrap();
+ writeln!(gms, "$offSymXRef").unwrap();
+ writeln!(gms, "option solprint = off;").unwrap();
+ writeln!(gms, "option limrow = 0;").unwrap();
+ writeln!(gms, "option limcol = 0;").unwrap();
+ writeln!(gms).unwrap();
+}
+
+/// Emit `Variables`, `Binary Variables`, `Integer Variables` sections.
+fn write_var_declarations(gms: &mut String, vars: &[Variable]) {
+ let (mut cont, mut bin, mut int) = (Vec::new(), Vec::new(), Vec::new());
+ for v in vars {
+ match v.domain {
+ Domain::Binary => bin.push(v),
+ Domain::Integer | Domain::SemiInteger { .. } => int.push(v),
+ _ => cont.push(v),
+ }
+ }
+
+ write!(gms, "Variables\n v_obj").unwrap();
+ for v in &cont {
+ write!(gms, ", v{}", v.id.index()).unwrap();
+ }
+ writeln!(gms, ";").unwrap();
+
+ write_typed_var_section(gms, "Binary Variables", &bin);
+ write_typed_var_section(gms, "Integer Variables", &int);
+ writeln!(gms).unwrap();
+}
+
+fn write_typed_var_section(gms: &mut String, header: &str, vars: &[&Variable]) {
+ if vars.is_empty() {
+ return;
+ }
+ write!(gms, "{header}\n ").unwrap();
+ for (k, v) in vars.iter().enumerate() {
+ if k > 0 {
+ write!(gms, ", ").unwrap();
+ }
+ write!(gms, "v{}", v.id.index()).unwrap();
+ }
+ writeln!(gms, ";").unwrap();
+}
+
+fn write_bounds_and_initials(gms: &mut String, vars: &[Variable]) {
+ for v in vars {
+ write_var_bounds(gms, v);
+ }
+ for v in vars {
+ if let Some(val) = v.initial {
+ writeln!(gms, "v{}.l = {};", v.id.index(), fmt(val)).unwrap();
+ }
+ }
+ writeln!(gms).unwrap();
+}
+
+fn write_var_bounds(gms: &mut String, v: &Variable) {
+ let i = v.id.index();
+ if matches!(v.domain, Domain::Binary) {
+ // Default binary bounds are [0, 1], only emit when overridden or fixed.
+ if (v.lb - v.ub).abs() < f64::EPSILON {
+ writeln!(gms, "v{i}.fx = {};", fmt(v.lb)).unwrap();
+ return;
+ }
+ if v.lb.abs() > f64::EPSILON {
+ writeln!(gms, "v{i}.lo = {};", fmt(v.lb)).unwrap();
+ }
+ if (v.ub - 1.0).abs() > f64::EPSILON {
+ writeln!(gms, "v{i}.up = {};", fmt(v.ub)).unwrap();
+ }
+ return;
+ }
+ if v.lb == f64::NEG_INFINITY {
+ writeln!(gms, "v{i}.lo = -Inf;").unwrap();
+ } else if v.lb.is_finite() {
+ writeln!(gms, "v{i}.lo = {};", fmt(v.lb)).unwrap();
+ }
+ if v.ub.is_finite() {
+ writeln!(gms, "v{i}.up = {};", fmt(v.ub)).unwrap();
+ }
+}
+
+fn write_equations(
+ gms: &mut String,
+ arena: &ExprArena,
+ constraints: &[Constraint],
+ objective: &Objective,
+) {
+ write!(gms, "Equations\n eq_obj").unwrap();
+ for i in 0..constraints.len() {
+ write!(gms, ", eq_c{i}").unwrap();
+ }
+ writeln!(gms, ";").unwrap();
+ writeln!(gms).unwrap();
+
+ let obj_form = ExprForm::from(arena, objective.expr);
+ write!(gms, "eq_obj.. v_obj =e=").unwrap();
+ write_form(gms, arena, &obj_form, true);
+ writeln!(gms, ";").unwrap();
+
+ for (ci, c) in constraints.iter().enumerate() {
+ let sense_str = match c.sense {
+ Sense::Le => "=l=",
+ Sense::Ge => "=g=",
+ Sense::Eq => "=e=",
+ };
+ write!(gms, "eq_c{ci}..").unwrap();
+ match ExprForm::from(arena, c.lhs) {
+ ExprForm::Linear(t) => {
+ let adjusted_rhs = c.rhs - t.constant;
+ write_linear(gms, &t, false);
+ writeln!(gms, " {sense_str} {};", fmt(adjusted_rhs)).unwrap();
+ }
+ ExprForm::Nonlinear(id) => {
+ write_gams_expr(gms, arena, id, true);
+ writeln!(gms, " {sense_str} {};", fmt(c.rhs)).unwrap();
+ }
+ }
+ }
+ writeln!(gms).unwrap();
+}
+
+fn write_model_and_solve(gms: &mut String, solve_type: &str, sense_kw: &str, has_opt: bool) {
+ writeln!(gms, "Model oximo_m / all /;").unwrap();
+ if has_opt {
+ writeln!(gms, "oximo_m.optfile = 1;").unwrap();
+ }
+ writeln!(gms, "Solve oximo_m using {solve_type} {sense_kw} v_obj;").unwrap();
+ writeln!(gms).unwrap();
+}
+
+/// Captured form of an expression for GAMS emission.
+enum ExprForm {
+ Linear(LinearTerms),
+ Nonlinear(ExprId),
+}
+
+impl ExprForm {
+ fn from(arena: &ExprArena, id: ExprId) -> Self {
+ match extract_linear(arena, id) {
+ Some(t) => ExprForm::Linear(t),
+ None => ExprForm::Nonlinear(id),
+ }
+ }
+}
+
+/// Append a captured expression form to `gms`.
+fn write_form(gms: &mut String, arena: &ExprArena, form: &ExprForm, include_constant: bool) {
+ match form {
+ ExprForm::Linear(t) => write_linear(gms, t, include_constant),
+ ExprForm::Nonlinear(id) => write_gams_expr(gms, arena, *id, true),
+ }
+}
+
/// Append the linear expression `t` to `gms`.
/// When `include_constant` is true, the constant term is included; otherwise
/// only variable terms are emitted (used for constraints where the constant is
/// folded into the RHS).
-fn write_expr(gms: &mut String, t: &LinearTerms, include_constant: bool) {
+fn write_linear(gms: &mut String, t: &LinearTerms, include_constant: bool) {
let mut first = true;
for (v, coef) in &t.coeffs {
if *coef == 0.0 {
@@ -467,6 +551,101 @@ fn write_expr(gms: &mut String, t: &LinearTerms, include_constant: bool) {
}
}
+/// Recursive infix printer for a GAMS-compatible expression.
+fn write_gams_expr(gms: &mut String, arena: &ExprArena, id: ExprId, leading_space: bool) {
+ if leading_space {
+ write!(gms, " ").unwrap();
+ }
+ match arena.get(id) {
+ ExprNode::Const(c) => write!(gms, "{}", fmt(*c)).unwrap(),
+ ExprNode::Var(v) => write!(gms, "v{}", v.index()).unwrap(),
+ ExprNode::Param(_) => {
+ // Since params not yet passed into GAMS emission, we emit a placeholder
+ // so downstream errors are clear.
+ write!(gms, "0 /* unsupported: param */").unwrap();
+ }
+ ExprNode::Linear { coeffs, constant } => {
+ let t = LinearTerms { coeffs: coeffs.clone(), constant: *constant };
+ write!(gms, "(").unwrap();
+ write_linear(gms, &t, true);
+ write!(gms, " )").unwrap();
+ }
+ ExprNode::Neg(inner) => {
+ write!(gms, "(-").unwrap();
+ write_gams_expr(gms, arena, *inner, true);
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Add(children) => {
+ write!(gms, "(").unwrap();
+ for (i, c) in children.iter().enumerate() {
+ if i > 0 {
+ write!(gms, " +").unwrap();
+ }
+ write_gams_expr(gms, arena, *c, true);
+ }
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Mul(children) => {
+ write!(gms, "(").unwrap();
+ for (i, c) in children.iter().enumerate() {
+ if i > 0 {
+ write!(gms, " *").unwrap();
+ }
+ write_gams_expr(gms, arena, *c, true);
+ }
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Pow(base, exp) => {
+ // GAMS's `**` lowers to `rPower(x, r)`, which rejects negative
+ // bases. For small integer constant exponents emit `power(x, n)`
+ // (accepts any real base), otherwise fall back to `**`.
+ //
+ // The 1e9 cap keeps the cast safe and rejects nonsense huge exponents
+ // that would still satisfy the integer check after f64 rounding.
+ if let ExprNode::Const(c) = arena.get(*exp) {
+ if (c - c.round()).abs() < f64::EPSILON && c.abs() <= 1e9 {
+ write!(gms, "power(").unwrap();
+ write_gams_expr(gms, arena, *base, false);
+ write!(gms, ", {:.0})", c.round()).unwrap();
+ return;
+ }
+ }
+ write!(gms, "(").unwrap();
+ write_gams_expr(gms, arena, *base, false);
+ write!(gms, " **").unwrap();
+ write_gams_expr(gms, arena, *exp, true);
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Div(num, den) => {
+ write!(gms, "(").unwrap();
+ write_gams_expr(gms, arena, *num, false);
+ write!(gms, " /").unwrap();
+ write_gams_expr(gms, arena, *den, true);
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Sin(a) => {
+ write!(gms, "sin(").unwrap();
+ write_gams_expr(gms, arena, *a, false);
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Cos(a) => {
+ write!(gms, "cos(").unwrap();
+ write_gams_expr(gms, arena, *a, false);
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Exp(a) => {
+ write!(gms, "exp(").unwrap();
+ write_gams_expr(gms, arena, *a, false);
+ write!(gms, ")").unwrap();
+ }
+ ExprNode::Log(a) => {
+ write!(gms, "log(").unwrap();
+ write_gams_expr(gms, arena, *a, false);
+ write!(gms, ")").unwrap();
+ }
+ }
+}
+
/// Format an `f64` for use in a GAMS file.
fn fmt(v: f64) -> String {
if v == f64::INFINITY {
@@ -481,12 +660,10 @@ fn fmt(v: f64) -> String {
/// Parse a GAMS-formatted integer (may be written as `"1"` or `"1.000"`).
fn parse_gams_int(s: &str) -> Option {
let trimmed = s.trim();
- if let Ok(n) = trimmed.parse::() {
- return Some(n);
- }
- // Fall back through f64 for GAMS formats like "1.000".
- #[allow(clippy::cast_possible_truncation)]
- trimmed.parse::().ok().map(|f| f.round() as i32)
+ // GAMS writes modelstat/solvestat with the `:0:0` PUT format, so we
+ // normally see a bare integer.
+ let head = trimmed.split_once('.').map_or(trimmed, |(int, _)| int);
+ head.parse::().ok()
}
/// Parse a GAMS-formatted float, tolerating GAMS special tokens.
@@ -498,3 +675,101 @@ fn parse_gams_float(s: &str) -> Option {
other => other.parse().ok(),
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use oximo_core::prelude::*;
+
+ fn render(model: &Model, opts: &GamsOptions) -> String {
+ let arena = model.arena();
+ let vars = model.variables();
+ let constraints = model.constraints();
+ let objective = model.try_objective().expect("objective set");
+ let sense_kw = match objective.sense {
+ ObjectiveSense::Minimize => "minimizing",
+ ObjectiveSense::Maximize => "maximizing",
+ };
+ let mut gms = String::new();
+ build_model_section(
+ &mut gms,
+ model.kind(),
+ &arena,
+ &vars,
+ &constraints,
+ &objective,
+ sense_kw,
+ opts,
+ );
+ gms
+ }
+
+ #[test]
+ fn linear_objective_uses_lp_solve_type() {
+ let m = Model::new("lp");
+ let x = m.var("x").lb(0.0).ub(10.0).build();
+ let y = m.var("y").lb(0.0).ub(10.0).build();
+ m.constraint("c", (x + y).le(5.0));
+ m.minimize(x + 2.0 * y);
+ let gms = render(&m, &GamsOptions::default());
+ assert!(gms.contains("Solve oximo_m using LP minimizing v_obj;"), "got:\n{gms}");
+ }
+
+ #[test]
+ fn nlp_uses_transcendental_and_picks_nlp_solve_type() {
+ let m = Model::new("nlp");
+ let x = m.var("x").lb(-std::f64::consts::PI).ub(std::f64::consts::PI).build();
+ m.minimize(x.sin() + x.exp());
+ let gms = render(&m, &GamsOptions::default());
+ assert!(gms.contains("Solve oximo_m using NLP minimizing v_obj;"), "got:\n{gms}");
+ assert!(gms.contains("sin("), "expected sin(...) in objective:\n{gms}");
+ assert!(gms.contains("exp("), "expected exp(...) in objective:\n{gms}");
+ }
+
+ #[test]
+ fn minlp_nonlinear_knapsack_routes_to_minlp_solve_type() {
+ let m = Model::new("minlp");
+ let x = m.var("x").binary().build();
+ let y = m.var("y").lb(0.0).ub(10.0).build();
+ m.constraint("budget", (x + y).le(8.0));
+ let one = Expr::constant(x.arena, 1.0);
+ m.maximize((one + y).log() + 2.0 * x);
+ let gms = render(&m, &GamsOptions::default());
+ assert!(gms.contains("Solve oximo_m using MINLP maximizing v_obj;"), "got:\n{gms}");
+ assert!(gms.contains("log("), "expected log(...) in objective:\n{gms}");
+ }
+
+ #[test]
+ fn quadratic_constraint_emits_full_expression_against_rhs() {
+ let m = Model::new("qcp");
+ let x = m.var("x").lb(0.0).ub(5.0).build();
+ let y = m.var("y").lb(0.0).ub(5.0).build();
+ m.constraint("xy", (x * y).le(4.0));
+ m.minimize(x + y);
+ let gms = render(&m, &GamsOptions::default());
+ assert!(gms.contains("Solve oximo_m using QCP minimizing v_obj;"), "got:\n{gms}");
+ // The product term must appear on the LHS, the user RHS untouched.
+ assert!(gms.contains("v0") && gms.contains("v1"), "vars missing:\n{gms}");
+ assert!(gms.contains("=l= 4"), "expected =l= 4 on the right:\n{gms}");
+ }
+
+ #[test]
+ fn integer_power_uses_power_func() {
+ let m = Model::new("pow");
+ let x = m.var("x").lb(-10.0).ub(10.0).build();
+ m.minimize(x.powi(3));
+ let gms = render(&m, &GamsOptions::default());
+ assert!(gms.contains("power("), "expected power(...) for int Pow:\n{gms}");
+ assert!(gms.contains(", 3)"), "expected exponent 3:\n{gms}");
+ assert!(gms.contains("Solve oximo_m using NLP minimizing v_obj;"), "got:\n{gms}");
+ }
+
+ #[test]
+ fn real_power_falls_back_to_double_star() {
+ let m = Model::new("rpow");
+ let x = m.var("x").lb(0.1).ub(10.0).build();
+ m.minimize(x.powf(0.5));
+ let gms = render(&m, &GamsOptions::default());
+ assert!(gms.contains(" **"), "expected ** for real Pow:\n{gms}");
+ }
+}
diff --git a/crates/oximo-gurobi/Cargo.toml b/crates/oximo-gurobi/Cargo.toml
index d0b524d..260feca 100644
--- a/crates/oximo-gurobi/Cargo.toml
+++ b/crates/oximo-gurobi/Cargo.toml
@@ -14,7 +14,6 @@ oximo-expr.workspace = true
oximo-core.workspace = true
oximo-solver.workspace = true
grb = { workspace = true, features = ["gurobi12"] }
-rayon.workspace = true
rustc-hash.workspace = true
thiserror.workspace = true
diff --git a/crates/oximo-gurobi/README.md b/crates/oximo-gurobi/README.md
index 8a4eb7e..6f6b3e1 100644
--- a/crates/oximo-gurobi/README.md
+++ b/crates/oximo-gurobi/README.md
@@ -1,8 +1,8 @@
# oximo-gurobi
-Gurobi LP/MILP backend for [oximo](https://github.com/germanheim/oximo).
+Gurobi backend for [oximo](https://github.com/germanheim/oximo).
-Wraps the [`grb`](https://crates.io/crates/grb) crate (`gurobi12` feature). Supports `LP` and `MILP` model kinds. **QP/NLP return `SolverError::UnsupportedKind` for now**.
+Wraps the [`grb`](https://crates.io/crates/grb) crate (`gurobi12` feature). Supports `LP`, `MILP`, `QP`, `MIQP`, `NLP`, and `MINLP` model kinds. Nonlinear expressions (`Pow`, `Sin`, `Cos`, `Exp`, `Log`, bilinear `Mul`) are lowered to auxiliary variables wired together with Gurobi's native `add_genconstr_*` and `add_qconstr` APIs. `NonConvex = 2` is enabled automatically when needed.
Gurobi v13.0+ works with the `gurobi12` feature, which is the default for this crate. For more information see [grb/issue#31](https://github.com/ykrist/rust-grb/issues/31). To use older versions, enable the `gurobi11` feature instead (see [grb docs](https://docs.rs/grb/latest/grb/#features) for details).
diff --git a/crates/oximo-gurobi/src/lib.rs b/crates/oximo-gurobi/src/lib.rs
index c0b3ea2..f25e6ae 100644
--- a/crates/oximo-gurobi/src/lib.rs
+++ b/crates/oximo-gurobi/src/lib.rs
@@ -1,6 +1,7 @@
#![doc = include_str!("../README.md")]
#![forbid(unsafe_code)]
+mod nonlinear;
mod options;
mod translate;
@@ -21,7 +22,15 @@ impl Solver for Gurobi {
}
fn supports(&self, kind: ModelKind) -> bool {
- matches!(kind, ModelKind::LP | ModelKind::MILP)
+ matches!(
+ kind,
+ ModelKind::LP
+ | ModelKind::MILP
+ | ModelKind::QP
+ | ModelKind::MIQP
+ | ModelKind::NLP
+ | ModelKind::MINLP
+ )
}
fn solve(&mut self, model: &Model, opts: &GurobiOptions) -> Result {
diff --git a/crates/oximo-gurobi/src/nonlinear.rs b/crates/oximo-gurobi/src/nonlinear.rs
new file mode 100644
index 0000000..31de1ed
--- /dev/null
+++ b/crates/oximo-gurobi/src/nonlinear.rs
@@ -0,0 +1,440 @@
+//! Lower an oximo expression tree onto Gurobi's native types.
+//!
+//! Gurobi 12 supports linear, quadratic, and a fixed set of general nonlinear
+//! function constraints (exp/log/sin/cos/pow/...) where each one takes the form
+//! `y = f(x)` between two variables. Anything more complex must be flattened
+//! into a DAG of auxiliary variables connected by those primitive equalities.
+//! This module is that flattening pass.
+
+// TODO: This will change once we can get support for V13 in the grb crate
+// since we will be able to use generic expresion trees
+
+use grb::expr::{LinExpr, QuadExpr};
+use grb::prelude::*;
+use oximo_expr::{ExprArena, ExprId, ExprNode, VarId};
+use oximo_solver::SolverError;
+
+pub(crate) enum LoweredExpr {
+ Linear(LinExpr),
+ Quadratic(QuadExpr),
+ Var(Var),
+}
+
+pub(crate) struct LoweringCtx<'a> {
+ pub model: &'a mut Model,
+ pub gurobi_vars: &'a [Var],
+ pub aux_counter: u32,
+}
+
+impl LoweringCtx<'_> {
+ fn next_name(&mut self, tag: &str) -> String {
+ let n = self.aux_counter;
+ self.aux_counter += 1;
+ format!("aux_{tag}_{n}")
+ }
+
+ fn new_aux(&mut self, tag: &str, lb: f64, ub: f64) -> Result {
+ let name = self.next_name(tag);
+ let m = &mut *self.model;
+ #[allow(clippy::unnecessary_cast)]
+ let v = add_ctsvar!(m, name: &name, bounds: lb..ub)?;
+ Ok(v)
+ }
+}
+
+fn map_grb(e: grb::Error) -> SolverError {
+ SolverError::Backend(e.to_string())
+}
+
+fn linear_from_var(v: Var) -> LinExpr {
+ let mut e = LinExpr::new();
+ e.add_term(1.0, v);
+ e
+}
+
+fn linear_constant(c: f64) -> LinExpr {
+ let mut e = LinExpr::new();
+ e.add_constant(c);
+ e
+}
+
+fn quad_from_linear(e: LinExpr) -> QuadExpr {
+ let mut q = QuadExpr::new();
+ q.add_constant(e.get_offset());
+ for (v, c) in e.into_parts().0 {
+ q.add_term(c, v);
+ }
+ q
+}
+
+fn add_linears(mut a: LinExpr, b: LinExpr) -> LinExpr {
+ let (coeffs, offset) = b.into_parts();
+ a.add_constant(offset);
+ for (v, c) in coeffs {
+ a.add_term(c, v);
+ }
+ a
+}
+
+fn add_quads(mut a: QuadExpr, b: QuadExpr) -> QuadExpr {
+ let (qcoeffs, linexpr) = b.into_parts();
+ a.add_constant(linexpr.get_offset());
+ for (v, c) in linexpr.into_parts().0 {
+ a.add_term(c, v);
+ }
+ for ((x, y), c) in qcoeffs {
+ a.add_qterm(c, x, y);
+ }
+ a
+}
+
+fn scale_linear(mut e: LinExpr, k: f64) -> LinExpr {
+ e.mul_scalar(k);
+ e
+}
+
+fn scale_quad(mut e: QuadExpr, k: f64) -> QuadExpr {
+ e.mul_scalar(k);
+ e
+}
+
+/// Convert any lowered form to a `LinExpr`.
+///
+/// Panics if quadratic, callers must only invoke when the value
+/// is known linear (i.e. degree <= 1).
+fn into_linexpr(l: LoweredExpr) -> LinExpr {
+ match l {
+ LoweredExpr::Linear(e) => e,
+ LoweredExpr::Var(v) => linear_from_var(v),
+ LoweredExpr::Quadratic(_) => {
+ panic!("internal: into_linexpr called on Quadratic LoweredExpr")
+ }
+ }
+}
+
+/// Combine two lowered values additively, promoting to the highest order.
+fn lowered_add(a: LoweredExpr, b: LoweredExpr) -> LoweredExpr {
+ use LoweredExpr::{Linear, Quadratic, Var};
+ match (a, b) {
+ (Linear(x), Linear(y)) => Linear(add_linears(x, y)),
+ (Var(v), Linear(y)) | (Linear(y), Var(v)) => Linear(add_linears(y, linear_from_var(v))),
+ (Var(v), Var(w)) => Linear(add_linears(linear_from_var(v), linear_from_var(w))),
+ (Quadratic(x), Quadratic(y)) => Quadratic(add_quads(x, y)),
+ (Quadratic(x), other) | (other, Quadratic(x)) => {
+ let y = into_linexpr(other);
+ Quadratic(add_quads(x, quad_from_linear(y)))
+ }
+ }
+}
+
+fn lowered_scale(l: LoweredExpr, k: f64) -> LoweredExpr {
+ use LoweredExpr::{Linear, Quadratic, Var};
+ if k == 0.0 {
+ return Linear(linear_constant(0.0));
+ }
+ match l {
+ Linear(e) => Linear(scale_linear(e, k)),
+ Quadratic(e) => Quadratic(scale_quad(e, k)),
+ Var(v) => Linear(scale_linear(linear_from_var(v), k)),
+ }
+}
+
+fn lowered_neg(l: LoweredExpr) -> LoweredExpr {
+ lowered_scale(l, -1.0)
+}
+
+/// Materialize `lowered` into a single Gurobi variable, returning the existing
+/// one if it is already opaque. Bounds default to +-inf, callers should tighten
+/// when they know more about the value's range.
+fn as_aux_var(lowered: LoweredExpr, ctx: &mut LoweringCtx<'_>) -> Result {
+ match lowered {
+ LoweredExpr::Var(v) => Ok(v),
+ LoweredExpr::Linear(e) => {
+ let aux = ctx.new_aux("v", f64::NEG_INFINITY, f64::INFINITY).map_err(map_grb)?;
+ let name = ctx.next_name("eq");
+ ctx.model.add_constr(&name, c!(aux == e)).map_err(map_grb)?;
+ Ok(aux)
+ }
+ LoweredExpr::Quadratic(e) => {
+ let aux = ctx.new_aux("v", f64::NEG_INFINITY, f64::INFINITY).map_err(map_grb)?;
+ let name = ctx.next_name("qeq");
+ ctx.model.add_qconstr(&name, c!(aux == e)).map_err(map_grb)?;
+ Ok(aux)
+ }
+ }
+}
+
+/// True if `id` is a literal constant, returns the value if so.
+fn as_const(arena: &ExprArena, id: ExprId) -> Option {
+ match arena.get(id) {
+ ExprNode::Const(c) => Some(*c),
+ _ => None,
+ }
+}
+
+pub(crate) fn lower(
+ arena: &ExprArena,
+ id: ExprId,
+ ctx: &mut LoweringCtx<'_>,
+) -> Result {
+ match arena.get(id) {
+ ExprNode::Const(c) => Ok(LoweredExpr::Linear(linear_constant(*c))),
+ ExprNode::Var(v) => Ok(LoweredExpr::Linear(linear_from_var(grb_var(ctx, *v)))),
+ ExprNode::Param(_) => Err(SolverError::Backend(
+ "Param nodes are not supported by oximo-gurobi nonlinear lowering".into(),
+ )),
+ ExprNode::Linear { coeffs, constant } => {
+ let mut e = linear_constant(*constant);
+ for (v, c) in coeffs {
+ e.add_term(*c, grb_var(ctx, *v));
+ }
+ Ok(LoweredExpr::Linear(e))
+ }
+ ExprNode::Neg(inner) => {
+ let l = lower(arena, *inner, ctx)?;
+ Ok(lowered_neg(l))
+ }
+ ExprNode::Add(children) => {
+ let mut acc = LoweredExpr::Linear(linear_constant(0.0));
+ for c in children {
+ let l = lower(arena, *c, ctx)?;
+ acc = lowered_add(acc, l);
+ }
+ Ok(acc)
+ }
+ ExprNode::Mul(children) => lower_mul(arena, children, ctx),
+ ExprNode::Pow(base, exp) => lower_pow(arena, *base, *exp, ctx),
+ ExprNode::Div(num, den) => lower_div(arena, *num, *den, ctx),
+ ExprNode::Sin(a) => lower_unary(arena, *a, ctx, UnaryFn::Sin),
+ ExprNode::Cos(a) => lower_unary(arena, *a, ctx, UnaryFn::Cos),
+ ExprNode::Exp(a) => lower_unary(arena, *a, ctx, UnaryFn::Exp),
+ ExprNode::Log(a) => lower_unary(arena, *a, ctx, UnaryFn::Log),
+ }
+}
+
+fn grb_var(ctx: &LoweringCtx<'_>, v: VarId) -> Var {
+ ctx.gurobi_vars[v.index()]
+}
+
+fn lower_mul(
+ arena: &ExprArena,
+ children: &[ExprId],
+ ctx: &mut LoweringCtx<'_>,
+) -> Result {
+ let mut scalar = 1.0_f64;
+ let mut non_consts: Vec = Vec::new();
+ for c in children {
+ if let Some(k) = as_const(arena, *c) {
+ scalar *= k;
+ } else {
+ non_consts.push(*c);
+ }
+ }
+ if non_consts.is_empty() {
+ return Ok(LoweredExpr::Linear(linear_constant(scalar)));
+ }
+ if non_consts.len() == 1 {
+ let l = lower(arena, non_consts[0], ctx)?;
+ return Ok(lowered_scale(l, scalar));
+ }
+ if non_consts.len() == 2 {
+ let a = lower(arena, non_consts[0], ctx)?;
+ let b = lower(arena, non_consts[1], ctx)?;
+ // Linear * Linear -> quadratic, if either side has variable terms.
+ if let (LoweredExpr::Linear(la), LoweredExpr::Linear(lb)) = (&a, &b) {
+ let q = multiply_linears(la, lb, scalar);
+ return Ok(LoweredExpr::Quadratic(q));
+ }
+ // Mixed with Quadratic or Var -> materialize aux vars and recompose.
+ let va = as_aux_var(a, ctx)?;
+ let vb = as_aux_var(b, ctx)?;
+ let mut q = QuadExpr::new();
+ q.add_qterm(scalar, va, vb);
+ return Ok(LoweredExpr::Quadratic(q));
+ }
+ // 3+ non-constant factors: degree > 2. Fold left, materializing aux vars.
+ let mut acc_var = {
+ let a = lower(arena, non_consts[0], ctx)?;
+ let b = lower(arena, non_consts[1], ctx)?;
+ let va = as_aux_var(a, ctx)?;
+ let vb = as_aux_var(b, ctx)?;
+ let mut q = QuadExpr::new();
+ q.add_qterm(1.0, va, vb);
+ as_aux_var(LoweredExpr::Quadratic(q), ctx)?
+ };
+ for c in &non_consts[2..] {
+ let next = lower(arena, *c, ctx)?;
+ let vn = as_aux_var(next, ctx)?;
+ let mut q = QuadExpr::new();
+ q.add_qterm(1.0, acc_var, vn);
+ acc_var = as_aux_var(LoweredExpr::Quadratic(q), ctx)?;
+ }
+ Ok(lowered_scale(LoweredExpr::Var(acc_var), scalar))
+}
+
+fn multiply_linears(a: &LinExpr, b: &LinExpr, scalar: f64) -> QuadExpr {
+ let a_off = a.get_offset();
+ let b_off = b.get_offset();
+ let mut q = QuadExpr::new();
+ q.add_constant(scalar * a_off * b_off);
+ for (va, ca) in a.iter_terms() {
+ q.add_term(scalar * ca * b_off, *va);
+ }
+ for (vb, cb) in b.iter_terms() {
+ q.add_term(scalar * a_off * cb, *vb);
+ }
+ for (va, ca) in a.iter_terms() {
+ for (vb, cb) in b.iter_terms() {
+ q.add_qterm(scalar * ca * cb, *va, *vb);
+ }
+ }
+ q
+}
+
+fn lower_pow(
+ arena: &ExprArena,
+ base: ExprId,
+ exp: ExprId,
+ ctx: &mut LoweringCtx<'_>,
+) -> Result {
+ // Constant exponent -> either expand to a Mul chain (small ints) or use
+ // Gurobi's add_genconstr_pow. Non-constant exponent -> exp(b*log(a)).
+ if let Some(alpha) = as_const(arena, exp) {
+ if alpha == 0.0 {
+ return Ok(LoweredExpr::Linear(linear_constant(1.0)));
+ }
+ if (alpha - 1.0).abs() < f64::EPSILON {
+ return lower(arena, base, ctx);
+ }
+ if (alpha - alpha.round()).abs() < f64::EPSILON && alpha > 0.0 && alpha <= 4.0 {
+ // Pre-checks guarantee alpha in {1.0, 2.0, 3.0, 4.0}, alpha == 1 was
+ // already handled above, so bucket the remaining three values.
+ let n: u32 = match alpha.round() {
+ v if v < 2.5 => 2,
+ v if v < 3.5 => 3,
+ _ => 4,
+ };
+ let base_lowered = lower(arena, base, ctx)?;
+ let v = as_aux_var(base_lowered, ctx)?;
+ let mut q = QuadExpr::new();
+ q.add_qterm(1.0, v, v);
+ let mut acc = LoweredExpr::Quadratic(q);
+ for _ in 2..n {
+ let aux = as_aux_var(acc, ctx)?;
+ let mut next = QuadExpr::new();
+ next.add_qterm(1.0, aux, v);
+ acc = LoweredExpr::Quadratic(next);
+ }
+ return Ok(acc);
+ }
+ // General constant exponent via Gurobi's genconstr_pow
+ let base_lowered = lower(arena, base, ctx)?;
+ let x = as_aux_var(base_lowered, ctx)?;
+ let y = ctx.new_aux("pow", f64::NEG_INFINITY, f64::INFINITY).map_err(map_grb)?;
+ let name = ctx.next_name("pow");
+ ctx.model.add_genconstr_pow(&name, x, y, alpha, "").map_err(map_grb)?;
+ return Ok(LoweredExpr::Var(y));
+ }
+ // exp(b*log(a))
+ let base_lowered = lower(arena, base, ctx)?;
+ let x = as_aux_var(base_lowered, ctx)?;
+ let log_y = ctx.new_aux("log", f64::NEG_INFINITY, f64::INFINITY).map_err(map_grb)?;
+ let log_name = ctx.next_name("log");
+ ctx.model.add_genconstr_natural_log(&log_name, x, log_y, "").map_err(map_grb)?;
+ let b_lowered = lower(arena, exp, ctx)?;
+ let b_var = as_aux_var(b_lowered, ctx)?;
+ let prod = ctx.new_aux("mul", f64::NEG_INFINITY, f64::INFINITY).map_err(map_grb)?;
+ let mut q = QuadExpr::new();
+ q.add_qterm(1.0, b_var, log_y);
+ let prod_eq = ctx.next_name("mul_eq");
+ ctx.model.add_qconstr(&prod_eq, c!(prod == q)).map_err(map_grb)?;
+ let result = ctx.new_aux("exp", 0.0, f64::INFINITY).map_err(map_grb)?;
+ let exp_name = ctx.next_name("exp");
+ ctx.model.add_genconstr_natural_exp(&exp_name, prod, result, "").map_err(map_grb)?;
+ Ok(LoweredExpr::Var(result))
+}
+
+/// Lower `num / den` as `num * recip`, introducing a reciprocal variable
+/// `recip` pinned by the bilinear equality `den * recip == 1`.
+///
+/// This avoids a `pow(den, -1)` lowering. Gurobi's `genconstr_pow` has a pole
+/// at 0 and so requires its base to stay in a strictly positive domain,
+/// which rules out negative or zero-straddling denominators. The bilinear pin
+/// carries no such restriction: it is a plain non-convex quadratic
+/// (handled via `NonConvex = 2`) valid for `den` of either sign,
+/// and is infeasible only at `den == 0`.
+fn lower_div(
+ arena: &ExprArena,
+ num: ExprId,
+ den: ExprId,
+ ctx: &mut LoweringCtx<'_>,
+) -> Result {
+ // `div_into` folds every nonzero constant denominator into the linear path
+ // at construction, so the only constant `den` that reaches here is a literal
+ // zero.
+ if as_const(arena, den) == Some(0.0) {
+ return Err(SolverError::Backend("division by zero: constant denominator is 0".into()));
+ }
+
+ // recip = 1 / den, pinned by `den * recip == 1`.
+ let den_lowered = lower(arena, den, ctx)?;
+ let x = as_aux_var(den_lowered, ctx)?;
+ let recip = ctx.new_aux("recip", f64::NEG_INFINITY, f64::INFINITY).map_err(map_grb)?;
+ let mut pin = QuadExpr::new();
+ pin.add_qterm(1.0, x, recip);
+ let name = ctx.next_name("recip_eq");
+ ctx.model.add_qconstr(&name, c!(pin == 1.0)).map_err(map_grb)?;
+
+ // Constant numerator stays linear in `recip`, otherwise form `num * recip`.
+ if let Some(k) = as_const(arena, num) {
+ return Ok(lowered_scale(LoweredExpr::Var(recip), k));
+ }
+ let num_lowered = lower(arena, num, ctx)?;
+ let vn = as_aux_var(num_lowered, ctx)?;
+ let mut q = QuadExpr::new();
+ q.add_qterm(1.0, vn, recip);
+ Ok(LoweredExpr::Quadratic(q))
+}
+
+enum UnaryFn {
+ Sin,
+ Cos,
+ Exp,
+ Log,
+}
+
+fn lower_unary(
+ arena: &ExprArena,
+ inner: ExprId,
+ ctx: &mut LoweringCtx<'_>,
+ f: UnaryFn,
+) -> Result {
+ let lowered = lower(arena, inner, ctx)?;
+ let x = as_aux_var(lowered, ctx)?;
+ let (lb, ub, tag) = match f {
+ UnaryFn::Sin | UnaryFn::Cos => (-1.0, 1.0, "trig"),
+ UnaryFn::Exp => (0.0, f64::INFINITY, "exp"),
+ UnaryFn::Log => (f64::NEG_INFINITY, f64::INFINITY, "log"),
+ };
+ let y = ctx.new_aux(tag, lb, ub).map_err(map_grb)?;
+ let name = ctx.next_name(tag);
+ match f {
+ UnaryFn::Sin => ctx.model.add_genconstr_sin(&name, x, y, "").map_err(map_grb)?,
+ UnaryFn::Cos => ctx.model.add_genconstr_cos(&name, x, y, "").map_err(map_grb)?,
+ UnaryFn::Exp => ctx.model.add_genconstr_natural_exp(&name, x, y, "").map_err(map_grb)?,
+ UnaryFn::Log => ctx.model.add_genconstr_natural_log(&name, x, y, "").map_err(map_grb)?,
+ };
+ Ok(LoweredExpr::Var(y))
+}
+
+/// Helpers used by translate.rs to materialize a lowered constraint or
+/// objective expression.
+impl LoweredExpr {
+ pub(crate) fn into_expr_for_objective(self) -> grb::expr::Expr {
+ match self {
+ LoweredExpr::Linear(e) => grb::expr::Expr::from(e),
+ LoweredExpr::Quadratic(e) => grb::expr::Expr::from(e),
+ LoweredExpr::Var(v) => grb::expr::Expr::from(v),
+ }
+ }
+}
diff --git a/crates/oximo-gurobi/src/translate.rs b/crates/oximo-gurobi/src/translate.rs
index f84cf4e..3de03d9 100644
--- a/crates/oximo-gurobi/src/translate.rs
+++ b/crates/oximo-gurobi/src/translate.rs
@@ -3,12 +3,12 @@ use std::time::Instant;
use grb::expr::LinExpr;
use grb::prelude::*;
use oximo_core::{ConstraintId, Domain, Model, ModelKind, ObjectiveSense, Sense, VarId};
-use oximo_expr::{ExprArena, LinearTerms, extract_linear};
+use oximo_expr::{ExprArena, ExprId, LinearTerms, extract_linear};
use oximo_solver::{SolverError, SolverResult, SolverStatus};
-use rayon::prelude::*;
use rustc_hash::FxHashMap;
use crate::GurobiOptions;
+use crate::nonlinear::{LoweredExpr, LoweringCtx, lower};
use crate::options::apply as apply_options;
fn map_grb_err(e: grb::Error) -> SolverError {
@@ -21,34 +21,22 @@ fn map_grb_err(e: grb::Error) -> SolverError {
/// # Errors
///
/// Returns a [`SolverError`] if the model is unsupported, contains nonlinear
-/// expressions, or if Gurobi reports an error during setup or optimization.
+/// expressions Gurobi cannot represent, or if Gurobi reports an error during
+/// setup or optimization.
///
/// # Panics
///
/// Panics if model variable or constraint indices overflow `u32`.
-#[allow(clippy::unnecessary_cast, clippy::cast_possible_truncation, clippy::cast_sign_loss)]
pub fn solve(model: &Model, opts: &GurobiOptions) -> Result {
let kind = model.kind();
- if !matches!(kind, ModelKind::LP | ModelKind::MILP) {
- return Err(SolverError::UnsupportedKind(kind));
- }
+ let nonlinear_kind =
+ matches!(kind, ModelKind::QP | ModelKind::MIQP | ModelKind::NLP | ModelKind::MINLP);
let arena = model.arena();
let vars = model.variables();
let constraints = model.constraints();
let objective = model.try_objective().map_err(SolverError::Core)?;
- let mut obj_coeffs = vec![0.0; vars.len()];
- let obj_constant = match extract_linear(&arena, objective.expr) {
- Some(t) => {
- for (v, c) in t.coeffs {
- obj_coeffs[v.index()] = c;
- }
- t.constant
- }
- None => return Err(SolverError::Nonlinear),
- };
-
let env = Env::new("").map_err(|e| SolverError::Backend(format!("Gurobi env: {e}")))?;
let mut grb_model = grb::Model::with_env("oximo", &env).map_err(map_grb_err)?;
@@ -61,7 +49,9 @@ pub fn solve(model: &Model, opts: &GurobiOptions) -> Result VarType::SemiCont,
Domain::SemiInteger { .. } => VarType::SemiInt,
};
- let gvar = add_var!(grb_model, vtype, obj: obj_coeffs[i], bounds: v.lb..v.ub, name: &format!("x{i}"))
+ // `add_var!` expands the f64 bounds with an `as f64` cast.
+ #[allow(clippy::unnecessary_cast)]
+ let gvar = add_var!(grb_model, vtype, bounds: v.lb..v.ub, name: &format!("x{i}"))
.map_err(map_grb_err)?;
gurobi_vars.push(gvar);
if let Some(val) = v.initial {
@@ -70,46 +60,69 @@ pub fn solve(model: &Model, opts: &GurobiOptions) -> Result = constraints
- .par_iter()
- .map(|c| extract_linear(arena_ref, c.lhs).ok_or(SolverError::Nonlinear))
- .collect::, _>>()?;
-
- let mut gurobi_constrs = Vec::with_capacity(constraints.len());
- for (c_id, (c, t)) in constraints.iter().zip(con_terms).enumerate() {
- let adjusted_rhs = c.rhs - t.constant;
-
- let mut expr = LinExpr::new();
- for (v, co) in t.coeffs {
- expr.add_term(co, gurobi_vars[v.index()]);
- }
- let name = format!("c{c_id}");
- let constr = match c.sense {
- Sense::Le => {
- grb_model.add_constr(&name, c!(expr <= adjusted_rhs)).map_err(map_grb_err)?
- }
- Sense::Ge => {
- grb_model.add_constr(&name, c!(expr >= adjusted_rhs)).map_err(map_grb_err)?
- }
- Sense::Eq => {
- grb_model.add_constr(&name, c!(expr == adjusted_rhs)).map_err(map_grb_err)?
+ // Linear fast path per constraint. We fall back to the general lowering only
+ // for those that need it. Keep linear constraints tracked so duals/Pi can
+ // still be reported in the LP/MILP case.
+ let con_lin_terms: Vec