use std::cell::RefCell;
use std::rc::Rc;
use crate::{
ApplicationReturnStatus, BoundsInfo, IndexStyle, IpoptApplication, IpoptCq, IpoptData, NlpInfo,
Solution as TnlpSolution, SolveStatistics, SparsityRequest, StartingPoint, TNLP,
};
const FD: f64 = 1.4901161193847656e-8; const INF: f64 = 2.0e19;
pub trait Problem {
fn objective(&self, x: &[f64]) -> f64;
fn n_constraints(&self) -> usize {
0
}
fn constraints(&self, _x: &[f64], _out: &mut [f64]) {}
fn gradient(&self, _x: &[f64], _grad: &mut [f64]) -> bool {
false
}
fn jacobian(&self, _x: &[f64], _jac: &mut [f64]) -> bool {
false
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Solution {
pub status: ApplicationReturnStatus,
pub success: bool,
pub x: Vec<f64>,
pub objective: f64,
pub multipliers: Vec<f64>,
pub g: Vec<f64>,
pub z_l: Vec<f64>,
pub z_u: Vec<f64>,
pub stats: SolveStatistics,
}
pub struct Nlp<P: Problem> {
problem: P,
n: Option<usize>, x_l: Option<Vec<f64>>,
x_u: Option<Vec<f64>>,
g_l: Vec<f64>,
g_u: Vec<f64>,
x0: Option<Vec<f64>>,
num: Vec<(String, f64)>,
int: Vec<(String, i32)>,
string: Vec<(String, String)>,
capture_iterations: bool,
}
impl<P: Problem + 'static> Nlp<P> {
pub fn new(problem: P) -> Self {
let m = problem.n_constraints();
Nlp {
problem,
n: None,
x_l: None,
x_u: None,
g_l: vec![0.0; m],
g_u: vec![0.0; m],
x0: None,
num: Vec::new(),
int: Vec::new(),
string: Vec::new(),
capture_iterations: false,
}
}
fn set_n(&mut self, len: usize, what: &str) {
match self.n {
Some(n) if n != len => panic!(
"pounce_rs::Nlp: {what} has length {len}, but the problem was \
already sized to {n} variables",
),
_ => self.n = Some(len),
}
}
pub fn var_bounds(mut self, lo: &[f64], hi: &[f64]) -> Self {
assert_eq!(lo.len(), hi.len(), "var_bounds: lo and hi differ in length");
self.set_n(lo.len(), "var_bounds");
self.x_l = Some(lo.to_vec());
self.x_u = Some(hi.to_vec());
self
}
pub fn constraint_bounds(mut self, lo: &[f64], hi: &[f64]) -> Self {
self.g_l = lo.to_vec();
self.g_u = hi.to_vec();
self
}
pub fn x0(mut self, x0: &[f64]) -> Self {
self.set_n(x0.len(), "x0");
self.x0 = Some(x0.to_vec());
self
}
pub fn option_num(mut self, tag: &str, value: f64) -> Self {
self.num.push((tag.to_string(), value));
self
}
pub fn option_int(mut self, tag: &str, value: i32) -> Self {
self.int.push((tag.to_string(), value));
self
}
pub fn option_str(mut self, tag: &str, value: &str) -> Self {
self.string.push((tag.to_string(), value.to_string()));
self
}
pub fn capture_iterations(mut self) -> Self {
self.capture_iterations = true;
self
}
pub fn solve(self) -> Solution {
let n = self.n.expect(
"pounce_rs::Nlp: number of variables unknown — call .var_bounds(..) \
or .x0(..) to set it",
);
let m = self.problem.n_constraints();
let adapter = Rc::new(RefCell::new(Adapter {
problem: self.problem,
n,
m,
x_l: self.x_l.unwrap_or_else(|| vec![-INF; n]),
x_u: self.x_u.unwrap_or_else(|| vec![INF; n]),
g_l: self.g_l,
g_u: self.g_u,
x0: self.x0.unwrap_or_else(|| vec![0.0; n]),
sol_x: Vec::new(),
sol_obj: 0.0,
sol_lambda: Vec::new(),
sol_g: Vec::new(),
sol_z_l: Vec::new(),
sol_z_u: Vec::new(),
}));
let mut app = IpoptApplication::new();
app.initialize().expect("IpoptApplication::initialize");
let _ = app.options_mut().set_string_value(
"hessian_approximation",
"limited-memory",
true,
true,
);
let _ = app
.options_mut()
.set_string_value("sqp_hessian", "lbfgs", true, true);
for (k, v) in &self.string {
let _ = app.options_mut().set_string_value(k, v, true, true);
}
for (k, v) in &self.num {
let _ = app.options_mut().set_numeric_value(k, *v, true, true);
}
for (k, v) in &self.int {
let _ = app.options_mut().set_integer_value(k, *v, true, true);
}
let scope = self.capture_iterations.then(|| {
app.enable_iter_history();
crate::collector_scope()
});
let tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&adapter) as _;
let status = app.optimize_tnlp(tnlp);
drop(scope);
let stats = app.statistics();
let a = adapter.borrow();
Solution {
status,
success: matches!(
status,
ApplicationReturnStatus::SolveSucceeded
| ApplicationReturnStatus::SolvedToAcceptableLevel
),
x: a.sol_x.clone(),
objective: a.sol_obj,
multipliers: a.sol_lambda.clone(),
g: a.sol_g.clone(),
z_l: a.sol_z_l.clone(),
z_u: a.sol_z_u.clone(),
stats,
}
}
}
struct Adapter<P: Problem> {
problem: P,
n: usize,
m: usize,
x_l: Vec<f64>,
x_u: Vec<f64>,
g_l: Vec<f64>,
g_u: Vec<f64>,
x0: Vec<f64>,
sol_x: Vec<f64>,
sol_obj: f64,
sol_lambda: Vec<f64>,
sol_g: Vec<f64>,
sol_z_l: Vec<f64>,
sol_z_u: Vec<f64>,
}
impl<P: Problem> TNLP for Adapter<P> {
fn get_nlp_info(&mut self) -> Option<NlpInfo> {
Some(NlpInfo {
n: self.n as i32,
m: self.m as i32,
nnz_jac_g: (self.m * self.n) as i32, nnz_h_lag: 0, index_style: IndexStyle::C,
})
}
fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
b.x_l.copy_from_slice(&self.x_l);
b.x_u.copy_from_slice(&self.x_u);
b.g_l.copy_from_slice(&self.g_l);
b.g_u.copy_from_slice(&self.g_u);
true
}
fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
sp.x.copy_from_slice(&self.x0);
true
}
fn eval_f(&mut self, x: &[f64], _new_x: bool) -> Option<f64> {
Some(self.problem.objective(x))
}
fn eval_grad_f(&mut self, x: &[f64], _new_x: bool, grad: &mut [f64]) -> bool {
if self.problem.gradient(x, grad) {
return true;
}
let f0 = self.problem.objective(x);
let mut xp = x.to_vec();
for j in 0..self.n {
let h = FD * x[j].abs().max(1.0);
xp[j] = x[j] + h;
grad[j] = (self.problem.objective(&xp) - f0) / h;
xp[j] = x[j];
}
true
}
fn eval_g(&mut self, x: &[f64], _new_x: bool, g: &mut [f64]) -> bool {
self.problem.constraints(x, g);
true
}
fn eval_jac_g(&mut self, x: Option<&[f64]>, _new_x: bool, mode: SparsityRequest<'_>) -> bool {
match mode {
SparsityRequest::Structure { irow, jcol } => {
let mut k = 0;
for i in 0..self.m {
for j in 0..self.n {
irow[k] = i as i32;
jcol[k] = j as i32;
k += 1;
}
}
}
SparsityRequest::Values { values } => {
let x = x.expect("eval_jac_g(Values) without x");
if self.problem.jacobian(x, values) {
return true;
}
let mut g0 = vec![0.0; self.m];
self.problem.constraints(x, &mut g0);
let mut xp = x.to_vec();
let mut gp = vec![0.0; self.m];
for j in 0..self.n {
let h = FD * x[j].abs().max(1.0);
xp[j] = x[j] + h;
self.problem.constraints(&xp, &mut gp);
for i in 0..self.m {
values[i * self.n + j] = (gp[i] - g0[i]) / h;
}
xp[j] = x[j];
}
}
}
true
}
fn eval_h(
&mut self,
_x: Option<&[f64]>,
_new_x: bool,
_obj_factor: f64,
_lambda: Option<&[f64]>,
_new_lambda: bool,
_mode: SparsityRequest<'_>,
) -> bool {
false }
fn finalize_solution(&mut self, sol: TnlpSolution<'_>, _d: &IpoptData, _q: &IpoptCq) {
self.sol_x = sol.x.to_vec();
self.sol_obj = sol.obj_value;
self.sol_lambda = sol.lambda.to_vec();
self.sol_g = sol.g.to_vec();
self.sol_z_l = sol.z_l.to_vec();
self.sol_z_u = sol.z_u.to_vec();
}
}
#[cfg(test)]
mod tests {
use super::*;
struct Quad; impl Problem for Quad {
fn objective(&self, x: &[f64]) -> f64 {
(x[0] - 1.0).powi(2) + (x[1] - 2.0).powi(2)
}
fn n_constraints(&self) -> usize {
1
}
fn constraints(&self, x: &[f64], g: &mut [f64]) {
g[0] = x[0] + x[1];
}
}
#[test]
fn infers_n_from_bounds_and_solves() {
let sol = Nlp::new(Quad)
.var_bounds(&[0.0, 0.0], &[5.0, 5.0]) .constraint_bounds(&[3.0], &[3.0])
.option_num("tol", 1e-10)
.solve();
assert!(sol.success);
assert!((sol.x[0] - 1.0).abs() < 1e-5 && (sol.x[1] - 2.0).abs() < 1e-5);
}
#[test]
fn infers_n_from_x0() {
let sol = Nlp::new(Quad)
.constraint_bounds(&[3.0], &[3.0])
.x0(&[0.0, 0.0]) .solve();
assert!(sol.success);
}
#[test]
fn solve_populates_stats_and_duals() {
let sol = Nlp::new(Quad)
.var_bounds(&[0.0, 0.0], &[5.0, 5.0])
.constraint_bounds(&[3.0], &[3.0])
.solve();
assert!(sol.success);
assert!(sol.stats.iteration_count > 0);
assert!(sol.stats.total_wallclock_time_secs > 0.0);
assert!(sol.stats.num_obj_evals > 0);
assert!(sol.stats.final_constr_viol < 1e-6);
assert_eq!(sol.g.len(), 1);
assert!((sol.g[0] - 3.0).abs() < 1e-6, "g at solution: {:?}", sol.g);
assert_eq!(sol.z_l.len(), 2);
assert_eq!(sol.z_u.len(), 2);
assert!(sol.stats.iterations.is_empty());
}
#[test]
fn capture_iterations_fills_trajectory() {
let sol = Nlp::new(Quad)
.var_bounds(&[0.0, 0.0], &[5.0, 5.0])
.constraint_bounds(&[3.0], &[3.0])
.capture_iterations()
.solve();
assert!(sol.success);
let iters = &sol.stats.iterations;
assert!(!iters.is_empty(), "no iteration records captured");
assert_eq!(iters[0].iter, 0, "trajectory must start at iteration 0");
assert!(
iters.windows(2).all(|w| w[0].iter < w[1].iter),
"iteration counter must be strictly increasing"
);
}
#[test]
fn capture_iterations_is_empty_on_sqp_engine() {
let sol = Nlp::new(Quad)
.var_bounds(&[0.0, 0.0], &[5.0, 5.0])
.constraint_bounds(&[3.0], &[3.0])
.option_str("solver_selection", "qp-active-set")
.capture_iterations()
.solve();
assert!(sol.success, "status = {:?}", sol.status);
assert!(sol.stats.iteration_count > 0);
assert!(sol.stats.iterations.is_empty());
}
#[test]
fn qp_active_set_selection_solves() {
let sol = Nlp::new(Quad)
.var_bounds(&[0.0, 0.0], &[5.0, 5.0])
.constraint_bounds(&[3.0], &[3.0])
.option_str("solver_selection", "qp-active-set")
.solve();
assert!(sol.success, "status = {:?}", sol.status);
assert!((sol.x[0] - 1.0).abs() < 1e-4 && (sol.x[1] - 2.0).abs() < 1e-4);
}
#[test]
fn forced_convex_selection_fails_in_builder() {
let sol = Nlp::new(Quad)
.var_bounds(&[0.0, 0.0], &[5.0, 5.0])
.constraint_bounds(&[3.0], &[3.0])
.option_str("solver_selection", "qp-ipm")
.solve();
assert!(
!sol.success,
"forced qp-ipm must not silently succeed via NLP"
);
assert_eq!(sol.status, ApplicationReturnStatus::InvalidOption);
}
#[test]
#[should_panic(expected = "already sized to 2")]
fn mismatched_sizes_panic() {
let _ = Nlp::new(Quad)
.var_bounds(&[0.0, 0.0], &[5.0, 5.0])
.x0(&[0.0, 0.0, 0.0]) .solve();
}
#[test]
#[should_panic(expected = "number of variables unknown")]
fn missing_size_panics() {
let _ = Nlp::new(Quad).constraint_bounds(&[3.0], &[3.0]).solve();
}
}