scirs2_optimize/constrained/mod.rs
1//! Constrained optimization algorithms
2//!
3//! This module provides methods for constrained optimization of scalar
4//! functions of one or more variables.
5//!
6//! ## Example
7//!
8//! ```no_run
9//! use scirs2_core::ndarray::{array, Array1};
10//! use scirs2_optimize::constrained::{minimize_constrained, Method, Constraint};
11//!
12//! // Define a simple function to minimize: f(x) = (x[0] - 1)² + (x[1] - 2.5)²
13//! // Unconstrained minimum is at (1.0, 2.5), but we add a constraint.
14//! fn objective(x: &[f64]) -> f64 {
15//! (x[0] - 1.0).powi(2) + (x[1] - 2.5).powi(2)
16//! }
17//!
18//! // Define a constraint: x[0] + x[1] <= 3
19//! // Written as g(x) >= 0, so: g(x) = 3 - x[0] - x[1]
20//! fn constraint(x: &[f64]) -> f64 {
21//! 3.0 - x[0] - x[1] // Should be >= 0
22//! }
23//!
24//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
25//! // Minimize the function starting at [1.0, 1.0]
26//! // Note: Initial point should be feasible (satisfy constraints) for best convergence
27//! let initial_point = array![1.0, 1.0];
28//! let constraints = vec![Constraint::new(constraint, Constraint::INEQUALITY)];
29//!
30//! let result = minimize_constrained(
31//! objective,
32//! &initial_point,
33//! &constraints,
34//! Method::SLSQP,
35//! None
36//! )?;
37//!
38//! // The constrained minimum is at [0.75, 2.25] with f(x) = 0.125
39//! // This is where the gradient of f is parallel to the constraint boundary,
40//! // solved via Lagrange multipliers on x[0] + x[1] = 3.
41//! # Ok(())
42//! # }
43//! ```
44//!
45//! Note: This function requires LAPACK libraries to be linked for certain optimization methods.
46
47use crate::error::OptimizeResult;
48use crate::result::OptimizeResults;
49use scirs2_core::ndarray::{Array1, ArrayBase, Data, Ix1};
50use std::fmt;
51
52// Re-export optimization methods
53pub mod augmented_lagrangian;
54pub mod cobyla;
55pub mod enhanced_sqp;
56pub mod epsilon_constraint;
57pub mod feasibility_rules;
58pub mod interior_point;
59pub mod lp_qp_interior;
60pub mod penalty;
61pub mod slsqp;
62pub mod sqp;
63pub mod sqp_advanced;
64pub mod trust_constr;
65pub mod trust_constr_advanced;
66
67// Re-export main functions
68pub use augmented_lagrangian::{
69 minimize_augmented_lagrangian, minimize_equality_constrained, minimize_inequality_constrained,
70 AugmentedLagrangianOptions, AugmentedLagrangianResult,
71};
72pub use cobyla::minimize_cobyla;
73pub use interior_point::{
74 minimize_interior_point, minimize_interior_point_constrained, InteriorPointOptions,
75 InteriorPointResult,
76};
77pub use slsqp::minimize_slsqp;
78pub use sqp::{minimize_sqp, SqpOptions, SqpResult};
79pub use trust_constr::{
80 minimize_trust_constr, minimize_trust_constr_with_derivatives, GradientFn, HessianFn,
81 HessianUpdate,
82};
83
84#[cfg(test)]
85mod tests;
86
87/// Type alias for constraint functions that take a slice of f64 and return f64
88pub type ConstraintFn = fn(&[f64]) -> f64;
89
90/// Optimization methods for constrained minimization.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Method {
93 /// Sequential Least SQuares Programming
94 SLSQP,
95
96 /// Trust-region constrained algorithm
97 TrustConstr,
98
99 /// Linear programming using the simplex algorithm
100 COBYLA,
101
102 /// Interior point method
103 InteriorPoint,
104
105 /// Augmented Lagrangian method
106 AugmentedLagrangian,
107
108 /// Sequential Quadratic Programming
109 SQP,
110}
111
112impl fmt::Display for Method {
113 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
114 match self {
115 Method::SLSQP => write!(f, "SLSQP"),
116 Method::TrustConstr => write!(f, "trust-constr"),
117 Method::COBYLA => write!(f, "COBYLA"),
118 Method::InteriorPoint => write!(f, "interior-point"),
119 Method::AugmentedLagrangian => write!(f, "augmented-lagrangian"),
120 Method::SQP => write!(f, "SQP"),
121 }
122 }
123}
124
125/// Options for the constrained optimizer.
126#[derive(Debug, Clone)]
127pub struct Options {
128 /// Maximum number of iterations to perform
129 pub maxiter: Option<usize>,
130
131 /// Precision goal for the value in the stopping criterion
132 pub ftol: Option<f64>,
133
134 /// Precision goal for the gradient in the stopping criterion (relative)
135 pub gtol: Option<f64>,
136
137 /// Precision goal for constraint violation
138 pub ctol: Option<f64>,
139
140 /// Step size used for numerical approximation of the jacobian
141 pub eps: Option<f64>,
142
143 /// Whether to print convergence messages
144 pub disp: bool,
145
146 /// Return the optimization result after each iteration
147 pub return_all: bool,
148}
149
150impl Default for Options {
151 fn default() -> Self {
152 Options {
153 maxiter: None,
154 ftol: Some(1e-8),
155 gtol: Some(1e-8),
156 ctol: Some(1e-8),
157 eps: Some(1e-8),
158 disp: false,
159 return_all: false,
160 }
161 }
162}
163
164/// Constraint type for constrained optimization.
165///
166/// The constraint callable is stored as a boxed trait object so that a
167/// `Vec<Constraint>` can hold heterogeneous closures (issue #126). Closures
168/// that capture outer variables (for example a `threshold`) are accepted via
169/// [`Constraint::new`]. An optional analytical Jacobian (gradient of the
170/// constraint with respect to each variable) can be attached via
171/// [`Constraint::with_jacobian`] (issue #127); when present the solvers use it
172/// instead of finite differences.
173pub struct Constraint {
174 /// The constraint function
175 pub fun: Box<dyn Fn(&[f64]) -> f64 + Send + Sync>,
176
177 /// Optional analytical Jacobian (gradient) of the constraint, returning a
178 /// length-`n` vector of partial derivatives. `None` selects finite
179 /// differences.
180 pub jac: Option<Box<dyn Fn(&[f64]) -> Array1<f64> + Send + Sync>>,
181
182 /// The type of constraint (equality or inequality)
183 pub kind: ConstraintKind,
184
185 /// Lower bound for a box constraint
186 pub lb: Option<f64>,
187
188 /// Upper bound for a box constraint
189 pub ub: Option<f64>,
190}
191
192impl fmt::Debug for Constraint {
193 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194 // The boxed closures are not `Debug`; elide them.
195 f.debug_struct("Constraint")
196 .field("kind", &self.kind)
197 .field("lb", &self.lb)
198 .field("ub", &self.ub)
199 .field("has_jac", &self.jac.is_some())
200 .finish_non_exhaustive()
201 }
202}
203
204/// The kind of constraint
205#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206pub enum ConstraintKind {
207 /// Equality constraint: fun(x) = 0
208 Equality,
209
210 /// Inequality constraint: fun(x) >= 0
211 Inequality,
212}
213
214impl Constraint {
215 /// Constant for equality constraint
216 pub const EQUALITY: ConstraintKind = ConstraintKind::Equality;
217
218 /// Constant for inequality constraint
219 pub const INEQUALITY: ConstraintKind = ConstraintKind::Inequality;
220
221 /// Create a new constraint.
222 ///
223 /// Accepts any callable implementing `Fn(&[f64]) -> f64`, including
224 /// closures that capture outer variables and plain `fn` pointers.
225 pub fn new<F>(fun: F, kind: ConstraintKind) -> Self
226 where
227 F: Fn(&[f64]) -> f64 + Send + Sync + 'static,
228 {
229 Constraint {
230 fun: Box::new(fun),
231 jac: None,
232 kind,
233 lb: None,
234 ub: None,
235 }
236 }
237
238 /// Create a new box constraint
239 pub fn new_bounds(lb: Option<f64>, ub: Option<f64>) -> Self {
240 Constraint {
241 fun: Box::new(|_| 0.0), // Dummy function for box constraints
242 jac: None,
243 kind: ConstraintKind::Inequality,
244 lb,
245 ub,
246 }
247 }
248
249 /// Attach an analytical Jacobian (gradient) for this constraint (issue #127).
250 ///
251 /// The supplied callable returns the length-`n` gradient of the constraint
252 /// with respect to each variable, which fills one row of the constraint
253 /// Jacobian matrix. When present, the solvers use it instead of finite
254 /// differences.
255 pub fn with_jacobian<J>(mut self, jac: J) -> Self
256 where
257 J: Fn(&[f64]) -> Array1<f64> + Send + Sync + 'static,
258 {
259 self.jac = Some(Box::new(jac));
260 self
261 }
262
263 /// Check if this is a box constraint
264 pub fn is_bounds(&self) -> bool {
265 self.lb.is_some() || self.ub.is_some()
266 }
267}
268
269/// Minimizes a scalar function of one or more variables with constraints.
270///
271/// # Arguments
272///
273/// * `func` - A function that takes a slice of values and returns a scalar
274/// * `x0` - The initial guess
275/// * `constraints` - Vector of constraints
276/// * `method` - The optimization method to use
277/// * `options` - Options for the optimizer
278///
279/// # Returns
280///
281/// * `OptimizeResults` containing the optimization results
282///
283/// # Example
284///
285/// ```no_run
286/// use scirs2_core::ndarray::array;
287/// use scirs2_optimize::constrained::{minimize_constrained, Method, Constraint};
288///
289/// // Function to minimize
290/// fn objective(x: &[f64]) -> f64 {
291/// (x[0] - 1.0).powi(2) + (x[1] - 2.5).powi(2)
292/// }
293///
294/// // Constraint: x[0] + x[1] <= 3
295/// fn constraint(x: &[f64]) -> f64 {
296/// 3.0 - x[0] - x[1] // Should be >= 0
297/// }
298///
299/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
300/// let initial_point = array![0.0, 0.0];
301/// let constraints = vec![Constraint::new(constraint, Constraint::INEQUALITY)];
302///
303/// let result = minimize_constrained(
304/// objective,
305/// &initial_point,
306/// &constraints,
307/// Method::SLSQP,
308/// None
309/// )?;
310/// # Ok(())
311/// # }
312/// ```
313#[allow(dead_code)]
314pub fn minimize_constrained<F, S>(
315 func: F,
316 x0: &ArrayBase<S, Ix1>,
317 constraints: &[Constraint],
318 method: Method,
319 options: Option<Options>,
320) -> OptimizeResult<OptimizeResults<f64>>
321where
322 F: Fn(&[f64]) -> f64 + Clone,
323 S: Data<Elem = f64>,
324{
325 // Delegate to the gradient-aware variant with no analytical objective gradient
326 // (finite differences are used for the objective; per-constraint analytical
327 // Jacobians, if attached via `Constraint::with_jacobian`, are still honoured).
328 minimize_constrained_with_jac(
329 func,
330 None::<fn(&[f64]) -> Array1<f64>>,
331 x0,
332 constraints,
333 method,
334 options,
335 )
336}
337
338/// Minimizes a scalar function of one or more variables with constraints,
339/// optionally using an analytical objective gradient (issue #127).
340///
341/// This is the gradient-aware counterpart of [`minimize_constrained`]. When
342/// `jac` is `Some`, the supplied objective gradient is used in place of finite
343/// differences by the gradient-based methods (SLSQP and Trust-Constr).
344/// Per-constraint analytical Jacobians attached via
345/// [`Constraint::with_jacobian`] are honoured regardless of `jac`.
346///
347/// # Arguments
348///
349/// * `func` - The objective function to minimize
350/// * `jac` - Optional analytical gradient of the objective, returning a
351/// length-`n` vector. `None` selects finite differences.
352/// * `x0` - The initial guess
353/// * `constraints` - Vector of constraints
354/// * `method` - The optimization method to use
355/// * `options` - Options for the optimizer
356///
357/// # Example
358///
359/// ```no_run
360/// use scirs2_core::ndarray::{array, Array1};
361/// use scirs2_optimize::constrained::{minimize_constrained_with_jac, Method, Constraint};
362///
363/// fn objective(x: &[f64]) -> f64 {
364/// (x[0] - 1.0).powi(2) + (x[1] - 2.5).powi(2)
365/// }
366///
367/// fn objective_grad(x: &[f64]) -> Array1<f64> {
368/// array![2.0 * (x[0] - 1.0), 2.0 * (x[1] - 2.5)]
369/// }
370///
371/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
372/// let x0 = array![0.0, 0.0];
373/// let constraints = vec![Constraint::new(|x: &[f64]| 3.0 - x[0] - x[1], Constraint::INEQUALITY)];
374///
375/// let result = minimize_constrained_with_jac(
376/// objective,
377/// Some(objective_grad),
378/// &x0,
379/// &constraints,
380/// Method::SLSQP,
381/// None,
382/// )?;
383/// # Ok(())
384/// # }
385/// ```
386#[allow(dead_code)]
387pub fn minimize_constrained_with_jac<F, G, S>(
388 func: F,
389 jac: Option<G>,
390 x0: &ArrayBase<S, Ix1>,
391 constraints: &[Constraint],
392 method: Method,
393 options: Option<Options>,
394) -> OptimizeResult<OptimizeResults<f64>>
395where
396 F: Fn(&[f64]) -> f64 + Clone,
397 G: Fn(&[f64]) -> Array1<f64> + Clone,
398 S: Data<Elem = f64>,
399{
400 let options = options.unwrap_or_default();
401
402 // Box the optional objective gradient so it can be threaded into the
403 // internal solvers as a trait object (`&dyn Fn(&[f64]) -> Array1<f64>`).
404 let obj_jac: Option<Box<dyn Fn(&[f64]) -> Array1<f64>>> =
405 jac.map(|g| Box::new(g) as Box<dyn Fn(&[f64]) -> Array1<f64>>);
406 let obj_jac_ref: Option<&dyn Fn(&[f64]) -> Array1<f64>> = obj_jac.as_ref().map(|b| b.as_ref());
407
408 // Implementation of various methods will go here
409 match method {
410 Method::SLSQP => minimize_slsqp(func, x0, constraints, obj_jac_ref, &options),
411 Method::TrustConstr => minimize_trust_constr(func, x0, constraints, obj_jac_ref, &options),
412 // COBYLA is derivative-free by design; the analytical objective gradient
413 // is intentionally not used here.
414 Method::COBYLA => minimize_cobyla(func, x0, constraints, &options),
415 Method::InteriorPoint => {
416 // Convert constraints to interior point format
417 let x0_arr = Array1::from_vec(x0.to_vec());
418
419 // Create interior point options from general options
420 let ip_options = InteriorPointOptions {
421 max_iter: options.maxiter.unwrap_or(100),
422 tol: options.gtol.unwrap_or(1e-8),
423 feas_tol: options.ctol.unwrap_or(1e-8),
424 ..Default::default()
425 };
426
427 // Convert to OptimizeResults format
428 match minimize_interior_point_constrained(func, x0_arr, constraints, Some(ip_options)) {
429 Ok(result) => {
430 let opt_result = OptimizeResults::<f64> {
431 x: result.x,
432 fun: result.fun,
433 nit: result.nit,
434 nfev: result.nfev,
435 success: result.success,
436 message: result.message,
437 jac: None,
438 hess: None,
439 constr: None,
440 njev: 0, // Not tracked by interior point method
441 nhev: 0, // Not tracked by interior point method
442 maxcv: 0, // Not applicable for interior point
443 status: if result.success { 0 } else { 1 },
444 };
445 Ok(opt_result)
446 }
447 Err(e) => Err(e),
448 }
449 }
450 Method::AugmentedLagrangian => {
451 use scirs2_core::ndarray::{Array1, ArrayView1};
452 use std::sync::Arc;
453
454 let x0_arr = Array1::from_vec(x0.to_vec());
455
456 // Partition constraints into equality and inequality index groups.
457 // The boxed closures cannot be copied out of the `Constraint` slice,
458 // so instead we capture the indices (cheaply `Clone`-able via `Arc`)
459 // and a shared reference to the original `constraints` slice, then
460 // evaluate the constraints in place. `Send + Sync` on the boxed
461 // closures preserves the threaded augmented-Lagrangian behaviour.
462 let eq_idx: Arc<Vec<usize>> = Arc::new(
463 constraints
464 .iter()
465 .enumerate()
466 .filter(|(_, c)| c.kind == ConstraintKind::Equality)
467 .map(|(i, _)| i)
468 .collect(),
469 );
470
471 let ineq_idx: Arc<Vec<usize>> = Arc::new(
472 constraints
473 .iter()
474 .enumerate()
475 .filter(|(_, c)| c.kind == ConstraintKind::Inequality)
476 .map(|(i, _)| i)
477 .collect(),
478 );
479
480 let has_eq = !eq_idx.is_empty();
481 let has_ineq = !ineq_idx.is_empty();
482
483 // Wrap the objective to accept an ArrayView
484 let func_clone = func.clone();
485 let al_fun = move |x: &ArrayView1<f64>| func_clone(x.as_slice().unwrap_or(&[]));
486
487 // Build combined equality constraint closure (Clone via Arc)
488 let al_options = AugmentedLagrangianOptions {
489 max_iter: options.maxiter.unwrap_or(100),
490 constraint_tol: options.ctol.unwrap_or(1e-8),
491 optimality_tol: options.gtol.unwrap_or(1e-8),
492 ..Default::default()
493 };
494
495 // Helper: emit a slice-backed value for a contiguous ArrayView1
496 #[inline]
497 fn view_to_slice(x: &ArrayView1<f64>) -> Vec<f64> {
498 x.iter().copied().collect()
499 }
500
501 let result = if has_eq && has_ineq {
502 let eq_arc = Arc::clone(&eq_idx);
503 let eq_closure = move |x: &ArrayView1<f64>| {
504 let xs = view_to_slice(x);
505 Array1::from_vec(eq_arc.iter().map(|&i| (constraints[i].fun)(&xs)).collect())
506 };
507 let ineq_arc = Arc::clone(&ineq_idx);
508 let ineq_closure = move |x: &ArrayView1<f64>| {
509 let xs = view_to_slice(x);
510 Array1::from_vec(
511 ineq_arc
512 .iter()
513 .map(|&i| (constraints[i].fun)(&xs))
514 .collect(),
515 )
516 };
517 minimize_augmented_lagrangian(
518 al_fun,
519 x0_arr,
520 Some(eq_closure),
521 Some(ineq_closure),
522 Some(al_options),
523 )?
524 } else if has_eq {
525 let eq_arc = Arc::clone(&eq_idx);
526 let eq_closure = move |x: &ArrayView1<f64>| {
527 let xs = view_to_slice(x);
528 Array1::from_vec(eq_arc.iter().map(|&i| (constraints[i].fun)(&xs)).collect())
529 };
530 minimize_augmented_lagrangian(
531 al_fun,
532 x0_arr,
533 Some(eq_closure),
534 None::<fn(&ArrayView1<f64>) -> Array1<f64>>,
535 Some(al_options),
536 )?
537 } else {
538 let ineq_arc = Arc::clone(&ineq_idx);
539 let ineq_closure = move |x: &ArrayView1<f64>| {
540 let xs = view_to_slice(x);
541 Array1::from_vec(
542 ineq_arc
543 .iter()
544 .map(|&i| (constraints[i].fun)(&xs))
545 .collect(),
546 )
547 };
548 minimize_augmented_lagrangian(
549 al_fun,
550 x0_arr,
551 None::<fn(&ArrayView1<f64>) -> Array1<f64>>,
552 Some(ineq_closure),
553 Some(al_options),
554 )?
555 };
556
557 Ok(OptimizeResults::<f64> {
558 x: result.x,
559 fun: result.fun,
560 nit: result.nit,
561 nfev: result.nfev,
562 success: result.success,
563 message: result.message,
564 jac: None,
565 hess: None,
566 constr: None,
567 njev: 0,
568 nhev: 0,
569 maxcv: 0,
570 status: if result.success { 0 } else { 1 },
571 })
572 }
573 Method::SQP => sqp::minimize_sqp_compat(func, x0, constraints, &options),
574 }
575}