1use std::cell::RefCell;
39use std::rc::Rc;
40
41use crate::{
42 ApplicationReturnStatus, BoundsInfo, IndexStyle, IpoptApplication, IpoptCq, IpoptData, NlpInfo,
43 Solution as TnlpSolution, SolveStatistics, SparsityRequest, StartingPoint, TNLP,
44};
45
46const FD: f64 = 1.4901161193847656e-8; const INF: f64 = 2.0e19; pub trait Problem {
55 fn objective(&self, x: &[f64]) -> f64;
57
58 fn n_constraints(&self) -> usize {
60 0
61 }
62
63 fn constraints(&self, _x: &[f64], _out: &mut [f64]) {}
65
66 fn gradient(&self, _x: &[f64], _grad: &mut [f64]) -> bool {
69 false
70 }
71
72 fn jacobian(&self, _x: &[f64], _jac: &mut [f64]) -> bool {
75 false
76 }
77}
78
79#[derive(Debug, Clone)]
86#[non_exhaustive]
87pub struct Solution {
88 pub status: ApplicationReturnStatus,
90 pub success: bool,
92 pub x: Vec<f64>,
94 pub objective: f64,
96 pub multipliers: Vec<f64>,
98 pub g: Vec<f64>,
100 pub z_l: Vec<f64>,
102 pub z_u: Vec<f64>,
104 pub stats: SolveStatistics,
110}
111
112pub struct Nlp<P: Problem> {
115 problem: P,
116 n: Option<usize>, x_l: Option<Vec<f64>>,
118 x_u: Option<Vec<f64>>,
119 g_l: Vec<f64>,
120 g_u: Vec<f64>,
121 x0: Option<Vec<f64>>,
122 num: Vec<(String, f64)>,
123 int: Vec<(String, i32)>,
124 string: Vec<(String, String)>,
125 capture_iterations: bool,
126}
127
128impl<P: Problem + 'static> Nlp<P> {
129 pub fn new(problem: P) -> Self {
135 let m = problem.n_constraints();
136 Nlp {
137 problem,
138 n: None,
139 x_l: None,
140 x_u: None,
141 g_l: vec![0.0; m],
142 g_u: vec![0.0; m],
143 x0: None,
144 num: Vec::new(),
145 int: Vec::new(),
146 string: Vec::new(),
147 capture_iterations: false,
148 }
149 }
150
151 fn set_n(&mut self, len: usize, what: &str) {
154 match self.n {
155 Some(n) if n != len => panic!(
156 "pounce_rs::Nlp: {what} has length {len}, but the problem was \
157 already sized to {n} variables",
158 ),
159 _ => self.n = Some(len),
160 }
161 }
162
163 pub fn var_bounds(mut self, lo: &[f64], hi: &[f64]) -> Self {
166 assert_eq!(lo.len(), hi.len(), "var_bounds: lo and hi differ in length");
167 self.set_n(lo.len(), "var_bounds");
168 self.x_l = Some(lo.to_vec());
169 self.x_u = Some(hi.to_vec());
170 self
171 }
172
173 pub fn constraint_bounds(mut self, lo: &[f64], hi: &[f64]) -> Self {
175 self.g_l = lo.to_vec();
176 self.g_u = hi.to_vec();
177 self
178 }
179
180 pub fn x0(mut self, x0: &[f64]) -> Self {
182 self.set_n(x0.len(), "x0");
183 self.x0 = Some(x0.to_vec());
184 self
185 }
186
187 pub fn option_num(mut self, tag: &str, value: f64) -> Self {
189 self.num.push((tag.to_string(), value));
190 self
191 }
192
193 pub fn option_int(mut self, tag: &str, value: i32) -> Self {
195 self.int.push((tag.to_string(), value));
196 self
197 }
198
199 pub fn option_str(mut self, tag: &str, value: &str) -> Self {
201 self.string.push((tag.to_string(), value.to_string()));
202 self
203 }
204
205 pub fn capture_iterations(mut self) -> Self {
215 self.capture_iterations = true;
216 self
217 }
218
219 pub fn solve(self) -> Solution {
224 let n = self.n.expect(
225 "pounce_rs::Nlp: number of variables unknown — call .var_bounds(..) \
226 or .x0(..) to set it",
227 );
228 let m = self.problem.n_constraints();
229 let adapter = Rc::new(RefCell::new(Adapter {
230 problem: self.problem,
231 n,
232 m,
233 x_l: self.x_l.unwrap_or_else(|| vec![-INF; n]),
234 x_u: self.x_u.unwrap_or_else(|| vec![INF; n]),
235 g_l: self.g_l,
236 g_u: self.g_u,
237 x0: self.x0.unwrap_or_else(|| vec![0.0; n]),
238 sol_x: Vec::new(),
239 sol_obj: 0.0,
240 sol_lambda: Vec::new(),
241 sol_g: Vec::new(),
242 sol_z_l: Vec::new(),
243 sol_z_u: Vec::new(),
244 }));
245
246 let mut app = IpoptApplication::new();
247 app.initialize().expect("IpoptApplication::initialize");
248 let _ = app.options_mut().set_string_value(
250 "hessian_approximation",
251 "limited-memory",
252 true,
253 true,
254 );
255 let _ = app
261 .options_mut()
262 .set_string_value("sqp_hessian", "lbfgs", true, true);
263 for (k, v) in &self.string {
264 let _ = app.options_mut().set_string_value(k, v, true, true);
265 }
266 for (k, v) in &self.num {
267 let _ = app.options_mut().set_numeric_value(k, *v, true, true);
268 }
269 for (k, v) in &self.int {
270 let _ = app.options_mut().set_integer_value(k, *v, true, true);
271 }
272
273 let scope = self.capture_iterations.then(|| {
274 app.enable_iter_history();
275 crate::collector_scope()
276 });
277 let tnlp: Rc<RefCell<dyn TNLP>> = Rc::clone(&adapter) as _;
278 let status = app.optimize_tnlp(tnlp);
279 drop(scope);
280 let stats = app.statistics();
281 let a = adapter.borrow();
282 Solution {
283 status,
284 success: matches!(
285 status,
286 ApplicationReturnStatus::SolveSucceeded
287 | ApplicationReturnStatus::SolvedToAcceptableLevel
288 ),
289 x: a.sol_x.clone(),
290 objective: a.sol_obj,
291 multipliers: a.sol_lambda.clone(),
292 g: a.sol_g.clone(),
293 z_l: a.sol_z_l.clone(),
294 z_u: a.sol_z_u.clone(),
295 stats,
296 }
297 }
298}
299
300struct Adapter<P: Problem> {
303 problem: P,
304 n: usize,
305 m: usize,
306 x_l: Vec<f64>,
307 x_u: Vec<f64>,
308 g_l: Vec<f64>,
309 g_u: Vec<f64>,
310 x0: Vec<f64>,
311 sol_x: Vec<f64>,
312 sol_obj: f64,
313 sol_lambda: Vec<f64>,
314 sol_g: Vec<f64>,
315 sol_z_l: Vec<f64>,
316 sol_z_u: Vec<f64>,
317}
318
319impl<P: Problem> TNLP for Adapter<P> {
320 fn get_nlp_info(&mut self) -> Option<NlpInfo> {
321 Some(NlpInfo {
322 n: self.n as i32,
323 m: self.m as i32,
324 nnz_jac_g: (self.m * self.n) as i32, nnz_h_lag: 0, index_style: IndexStyle::C,
327 })
328 }
329
330 fn get_bounds_info(&mut self, b: BoundsInfo<'_>) -> bool {
331 b.x_l.copy_from_slice(&self.x_l);
332 b.x_u.copy_from_slice(&self.x_u);
333 b.g_l.copy_from_slice(&self.g_l);
334 b.g_u.copy_from_slice(&self.g_u);
335 true
336 }
337
338 fn get_starting_point(&mut self, sp: StartingPoint<'_>) -> bool {
339 sp.x.copy_from_slice(&self.x0);
340 true
341 }
342
343 fn eval_f(&mut self, x: &[f64], _new_x: bool) -> Option<f64> {
344 Some(self.problem.objective(x))
345 }
346
347 fn eval_grad_f(&mut self, x: &[f64], _new_x: bool, grad: &mut [f64]) -> bool {
348 if self.problem.gradient(x, grad) {
349 return true;
350 }
351 let f0 = self.problem.objective(x);
353 let mut xp = x.to_vec();
354 for j in 0..self.n {
355 let h = FD * x[j].abs().max(1.0);
356 xp[j] = x[j] + h;
357 grad[j] = (self.problem.objective(&xp) - f0) / h;
358 xp[j] = x[j];
359 }
360 true
361 }
362
363 fn eval_g(&mut self, x: &[f64], _new_x: bool, g: &mut [f64]) -> bool {
364 self.problem.constraints(x, g);
365 true
366 }
367
368 fn eval_jac_g(&mut self, x: Option<&[f64]>, _new_x: bool, mode: SparsityRequest<'_>) -> bool {
369 match mode {
370 SparsityRequest::Structure { irow, jcol } => {
371 let mut k = 0;
372 for i in 0..self.m {
373 for j in 0..self.n {
374 irow[k] = i as i32;
375 jcol[k] = j as i32;
376 k += 1;
377 }
378 }
379 }
380 SparsityRequest::Values { values } => {
381 let x = x.expect("eval_jac_g(Values) without x");
382 if self.problem.jacobian(x, values) {
383 return true;
384 }
385 let mut g0 = vec![0.0; self.m];
387 self.problem.constraints(x, &mut g0);
388 let mut xp = x.to_vec();
389 let mut gp = vec![0.0; self.m];
390 for j in 0..self.n {
391 let h = FD * x[j].abs().max(1.0);
392 xp[j] = x[j] + h;
393 self.problem.constraints(&xp, &mut gp);
394 for i in 0..self.m {
395 values[i * self.n + j] = (gp[i] - g0[i]) / h;
396 }
397 xp[j] = x[j];
398 }
399 }
400 }
401 true
402 }
403
404 fn eval_h(
405 &mut self,
406 _x: Option<&[f64]>,
407 _new_x: bool,
408 _obj_factor: f64,
409 _lambda: Option<&[f64]>,
410 _new_lambda: bool,
411 _mode: SparsityRequest<'_>,
412 ) -> bool {
413 false }
415
416 fn finalize_solution(&mut self, sol: TnlpSolution<'_>, _d: &IpoptData, _q: &IpoptCq) {
417 self.sol_x = sol.x.to_vec();
418 self.sol_obj = sol.obj_value;
419 self.sol_lambda = sol.lambda.to_vec();
420 self.sol_g = sol.g.to_vec();
421 self.sol_z_l = sol.z_l.to_vec();
422 self.sol_z_u = sol.z_u.to_vec();
423 }
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 struct Quad; impl Problem for Quad {
432 fn objective(&self, x: &[f64]) -> f64 {
433 (x[0] - 1.0).powi(2) + (x[1] - 2.0).powi(2)
434 }
435 fn n_constraints(&self) -> usize {
436 1
437 }
438 fn constraints(&self, x: &[f64], g: &mut [f64]) {
439 g[0] = x[0] + x[1];
440 }
441 }
442
443 #[test]
444 fn infers_n_from_bounds_and_solves() {
445 let sol = Nlp::new(Quad)
446 .var_bounds(&[0.0, 0.0], &[5.0, 5.0]) .constraint_bounds(&[3.0], &[3.0])
448 .option_num("tol", 1e-10)
449 .solve();
450 assert!(sol.success);
451 assert!((sol.x[0] - 1.0).abs() < 1e-5 && (sol.x[1] - 2.0).abs() < 1e-5);
452 }
453
454 #[test]
455 fn infers_n_from_x0() {
456 let sol = Nlp::new(Quad)
457 .constraint_bounds(&[3.0], &[3.0])
458 .x0(&[0.0, 0.0]) .solve();
460 assert!(sol.success);
461 }
462
463 #[test]
464 fn solve_populates_stats_and_duals() {
465 let sol = Nlp::new(Quad)
466 .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
467 .constraint_bounds(&[3.0], &[3.0])
468 .solve();
469 assert!(sol.success);
470 assert!(sol.stats.iteration_count > 0);
471 assert!(sol.stats.total_wallclock_time_secs > 0.0);
472 assert!(sol.stats.num_obj_evals > 0);
473 assert!(sol.stats.final_constr_viol < 1e-6);
474 assert_eq!(sol.g.len(), 1);
475 assert!((sol.g[0] - 3.0).abs() < 1e-6, "g at solution: {:?}", sol.g);
476 assert_eq!(sol.z_l.len(), 2);
477 assert_eq!(sol.z_u.len(), 2);
478 assert!(sol.stats.iterations.is_empty());
479 }
480
481 #[test]
482 fn capture_iterations_fills_trajectory() {
483 let sol = Nlp::new(Quad)
484 .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
485 .constraint_bounds(&[3.0], &[3.0])
486 .capture_iterations()
487 .solve();
488 assert!(sol.success);
489 let iters = &sol.stats.iterations;
490 assert!(!iters.is_empty(), "no iteration records captured");
491 assert_eq!(iters[0].iter, 0, "trajectory must start at iteration 0");
492 assert!(
493 iters.windows(2).all(|w| w[0].iter < w[1].iter),
494 "iteration counter must be strictly increasing"
495 );
496 }
497
498 #[test]
499 fn capture_iterations_is_empty_on_sqp_engine() {
500 let sol = Nlp::new(Quad)
501 .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
502 .constraint_bounds(&[3.0], &[3.0])
503 .option_str("solver_selection", "qp-active-set")
504 .capture_iterations()
505 .solve();
506 assert!(sol.success, "status = {:?}", sol.status);
507 assert!(sol.stats.iteration_count > 0);
508 assert!(sol.stats.iterations.is_empty());
509 }
510
511 #[test]
512 fn qp_active_set_selection_solves() {
513 let sol = Nlp::new(Quad)
514 .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
515 .constraint_bounds(&[3.0], &[3.0])
516 .option_str("solver_selection", "qp-active-set")
517 .solve();
518 assert!(sol.success, "status = {:?}", sol.status);
519 assert!((sol.x[0] - 1.0).abs() < 1e-4 && (sol.x[1] - 2.0).abs() < 1e-4);
520 }
521
522 #[test]
523 fn forced_convex_selection_fails_in_builder() {
524 let sol = Nlp::new(Quad)
525 .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
526 .constraint_bounds(&[3.0], &[3.0])
527 .option_str("solver_selection", "qp-ipm")
528 .solve();
529 assert!(
530 !sol.success,
531 "forced qp-ipm must not silently succeed via NLP"
532 );
533 assert_eq!(sol.status, ApplicationReturnStatus::InvalidOption);
534 }
535
536 #[test]
537 #[should_panic(expected = "already sized to 2")]
538 fn mismatched_sizes_panic() {
539 let _ = Nlp::new(Quad)
540 .var_bounds(&[0.0, 0.0], &[5.0, 5.0])
541 .x0(&[0.0, 0.0, 0.0]) .solve();
543 }
544
545 #[test]
546 #[should_panic(expected = "number of variables unknown")]
547 fn missing_size_panics() {
548 let _ = Nlp::new(Quad).constraint_bounds(&[3.0], &[3.0]).solve();
549 }
550}