Skip to main content

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}