compas-sandbox-native 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- compas_sandbox_native-0.1.0/CMakeLists.txt +109 -0
- compas_sandbox_native-0.1.0/PKG-INFO +39 -0
- compas_sandbox_native-0.1.0/README.md +26 -0
- compas_sandbox_native-0.1.0/pyproject.toml +27 -0
- compas_sandbox_native-0.1.0/python/compas_sandbox_native/__init__.py +10 -0
- compas_sandbox_native-0.1.0/src/ipopt_nb.cpp +310 -0
- compas_sandbox_native-0.1.0/tests/smoke.py +37 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# compas_sandbox_native: IPOPT bound into a Python extension module.
|
|
2
|
+
#
|
|
3
|
+
# IPOPT (and its MUMPS linear solver) are consumed as the static libraries produced by
|
|
4
|
+
# packaging/build_ipopt.sh; point IPOPT_PREFIX (env or cache var) at that stage tree.
|
|
5
|
+
# The result is one self-contained extension module: no ipopt executable, no DLLs.
|
|
6
|
+
|
|
7
|
+
cmake_minimum_required(VERSION 3.18...3.30)
|
|
8
|
+
project(compas_sandbox_native LANGUAGES CXX)
|
|
9
|
+
|
|
10
|
+
set(CMAKE_CXX_STANDARD 17)
|
|
11
|
+
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
|
12
|
+
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
|
13
|
+
|
|
14
|
+
if(NOT DEFINED IPOPT_PREFIX)
|
|
15
|
+
if(DEFINED ENV{IPOPT_PREFIX})
|
|
16
|
+
set(IPOPT_PREFIX "$ENV{IPOPT_PREFIX}")
|
|
17
|
+
else()
|
|
18
|
+
# the default location packaging/build_ipopt.sh stages into
|
|
19
|
+
get_filename_component(_repo "${CMAKE_CURRENT_SOURCE_DIR}/.." ABSOLUTE)
|
|
20
|
+
set(IPOPT_PREFIX "${_repo}/build/ipopt/stage")
|
|
21
|
+
endif()
|
|
22
|
+
endif()
|
|
23
|
+
if(NOT EXISTS "${IPOPT_PREFIX}/include/coin-or/IpTNLP.hpp")
|
|
24
|
+
message(FATAL_ERROR "No IPOPT build at IPOPT_PREFIX=${IPOPT_PREFIX}; run packaging/build_ipopt.sh first")
|
|
25
|
+
endif()
|
|
26
|
+
message(STATUS "IPOPT_PREFIX: ${IPOPT_PREFIX}")
|
|
27
|
+
|
|
28
|
+
find_package(Python REQUIRED COMPONENTS Interpreter Development.Module)
|
|
29
|
+
|
|
30
|
+
# nanobind is a build requirement; locate its CMake config through the interpreter
|
|
31
|
+
execute_process(
|
|
32
|
+
COMMAND "${Python_EXECUTABLE}" -m nanobind --cmake_dir
|
|
33
|
+
OUTPUT_VARIABLE nanobind_ROOT OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY)
|
|
34
|
+
find_package(nanobind CONFIG REQUIRED)
|
|
35
|
+
|
|
36
|
+
# IPOPT + MUMPS themselves are linked as the static archives from the stage tree. The
|
|
37
|
+
# Fortran/BLAS runtimes are linked dynamically instead: static Fortran runtimes do not
|
|
38
|
+
# link cleanly into shared objects (local symbols, non-PIC objects), and the ecosystem
|
|
39
|
+
# answer — what numpy/scipy wheels do — is to link the shared runtime and have the
|
|
40
|
+
# platform wheel-repair tool (delocate / auditwheel / delvewheel) graft it into the
|
|
41
|
+
# wheel afterwards.
|
|
42
|
+
# everything needed is derivable from the stage tree itself; a pkg-config executable
|
|
43
|
+
# is not reliably present on all builders (Windows in particular)
|
|
44
|
+
set(IPOPT_INCLUDE_DIRS "${IPOPT_PREFIX}/include/coin-or")
|
|
45
|
+
file(STRINGS "${IPOPT_PREFIX}/lib/pkgconfig/ipopt.pc" _ipopt_ver_line REGEX "^Version:")
|
|
46
|
+
string(REGEX REPLACE "^Version: *" "" IPOPT_VERSION "${_ipopt_ver_line}")
|
|
47
|
+
message(STATUS "IPOPT version: ${IPOPT_VERSION}")
|
|
48
|
+
|
|
49
|
+
nanobind_add_module(_core src/ipopt_nb.cpp)
|
|
50
|
+
|
|
51
|
+
target_include_directories(_core PRIVATE ${IPOPT_INCLUDE_DIRS})
|
|
52
|
+
target_link_libraries(_core PRIVATE
|
|
53
|
+
"${IPOPT_PREFIX}/lib/libipopt${CMAKE_STATIC_LIBRARY_SUFFIX}"
|
|
54
|
+
"${IPOPT_PREFIX}/lib/libcoinmumps${CMAKE_STATIC_LIBRARY_SUFFIX}")
|
|
55
|
+
target_compile_definitions(_core PRIVATE IPOPT_VERSION="${IPOPT_VERSION}")
|
|
56
|
+
|
|
57
|
+
# BLAS/LAPACK: Accelerate on macOS, OpenBLAS elsewhere (matching build_ipopt.sh)
|
|
58
|
+
if(APPLE)
|
|
59
|
+
target_link_options(_core PRIVATE "-Wl,-framework,Accelerate")
|
|
60
|
+
else()
|
|
61
|
+
target_link_libraries(_core PRIVATE openblas)
|
|
62
|
+
endif()
|
|
63
|
+
|
|
64
|
+
# the shared Fortran runtime, located through the Fortran compiler itself
|
|
65
|
+
if(NOT DEFINED FORTRAN_COMPILER)
|
|
66
|
+
set(FORTRAN_COMPILER gfortran)
|
|
67
|
+
endif()
|
|
68
|
+
if(APPLE)
|
|
69
|
+
set(_frt libgfortran.dylib)
|
|
70
|
+
elseif(WIN32)
|
|
71
|
+
set(_frt libgfortran.dll.a)
|
|
72
|
+
else()
|
|
73
|
+
set(_frt libgfortran.so)
|
|
74
|
+
endif()
|
|
75
|
+
execute_process(
|
|
76
|
+
COMMAND ${FORTRAN_COMPILER} -print-file-name=${_frt}
|
|
77
|
+
OUTPUT_VARIABLE _frt_path OUTPUT_STRIP_TRAILING_WHITESPACE COMMAND_ERROR_IS_FATAL ANY)
|
|
78
|
+
if(NOT EXISTS "${_frt_path}")
|
|
79
|
+
message(FATAL_ERROR "no shared Fortran runtime found (${FORTRAN_COMPILER} -print-file-name=${_frt} -> ${_frt_path})")
|
|
80
|
+
endif()
|
|
81
|
+
get_filename_component(_frt_dir "${_frt_path}" DIRECTORY)
|
|
82
|
+
message(STATUS "Fortran runtime: ${_frt_path}")
|
|
83
|
+
target_link_directories(_core PRIVATE "${_frt_dir}")
|
|
84
|
+
target_link_libraries(_core PRIVATE gfortran)
|
|
85
|
+
if(NOT WIN32)
|
|
86
|
+
target_link_libraries(_core PRIVATE ${CMAKE_DL_LIBS})
|
|
87
|
+
endif()
|
|
88
|
+
if(WIN32)
|
|
89
|
+
target_link_libraries(_core PRIVATE quadmath)
|
|
90
|
+
endif()
|
|
91
|
+
|
|
92
|
+
# extra link directories/flags for special setups (e.g. the msys2 lib dir on Windows)
|
|
93
|
+
if(DEFINED ENV{EXTRA_LINK_DIRS})
|
|
94
|
+
target_link_directories(_core PRIVATE "$ENV{EXTRA_LINK_DIRS}")
|
|
95
|
+
endif()
|
|
96
|
+
if(DEFINED ENV{IPOPT_EXTRA_LINK})
|
|
97
|
+
separate_arguments(_extra NATIVE_COMMAND "$ENV{IPOPT_EXTRA_LINK}")
|
|
98
|
+
target_link_options(_core PRIVATE ${_extra})
|
|
99
|
+
endif()
|
|
100
|
+
|
|
101
|
+
# arm64 macOS kills processes that load unsigned code, so make sure the module keeps a
|
|
102
|
+
# valid ad-hoc signature whatever earlier steps did to it
|
|
103
|
+
if(APPLE)
|
|
104
|
+
add_custom_command(TARGET _core POST_BUILD
|
|
105
|
+
COMMAND codesign --force -s - $<TARGET_FILE:_core>
|
|
106
|
+
COMMENT "ad-hoc signing $<TARGET_FILE:_core>")
|
|
107
|
+
endif()
|
|
108
|
+
|
|
109
|
+
install(TARGETS _core LIBRARY DESTINATION compas_sandbox_native)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: compas_sandbox_native
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: In-process IPOPT solver for compas_sandbox (nanobind binding, statically linked)
|
|
5
|
+
License: EPL-2.0
|
|
6
|
+
Classifier: Development Status :: 3 - Alpha
|
|
7
|
+
Classifier: Topic :: Scientific/Engineering
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Project-URL: Repository, https://github.com/petrasvestartas/compas_sandbox
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Requires-Dist: numpy>=1.24
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
|
|
14
|
+
# compas_sandbox_native
|
|
15
|
+
|
|
16
|
+
IPOPT (with the MUMPS linear solver) compiled into a Python extension module with
|
|
17
|
+
[nanobind](https://github.com/wjakob/nanobind), so `compas_sandbox` can solve CRA
|
|
18
|
+
problems in-process: no bundled executable, no subprocess, no `.nl` files.
|
|
19
|
+
|
|
20
|
+
## Building
|
|
21
|
+
|
|
22
|
+
The extension links the static IPOPT tree produced by `packaging/build_ipopt.sh`:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
packaging/build_ipopt.sh # stages into build/ipopt/stage
|
|
26
|
+
pip install ./native # picks the stage tree up automatically
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Point `IPOPT_PREFIX` at a different stage tree to override. Extra link flags (e.g. a
|
|
30
|
+
static Fortran runtime) go in `IPOPT_EXTRA_LINK`.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Installed alongside `compas_sandbox`, the `nlp` backend discovers it automatically:
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from compas_sandbox.equilibrium import cra_solve_native
|
|
38
|
+
cra_solve_native(assembly) # same inputs and results as cra_solve
|
|
39
|
+
```
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# compas_sandbox_native
|
|
2
|
+
|
|
3
|
+
IPOPT (with the MUMPS linear solver) compiled into a Python extension module with
|
|
4
|
+
[nanobind](https://github.com/wjakob/nanobind), so `compas_sandbox` can solve CRA
|
|
5
|
+
problems in-process: no bundled executable, no subprocess, no `.nl` files.
|
|
6
|
+
|
|
7
|
+
## Building
|
|
8
|
+
|
|
9
|
+
The extension links the static IPOPT tree produced by `packaging/build_ipopt.sh`:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
packaging/build_ipopt.sh # stages into build/ipopt/stage
|
|
13
|
+
pip install ./native # picks the stage tree up automatically
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Point `IPOPT_PREFIX` at a different stage tree to override. Extra link flags (e.g. a
|
|
17
|
+
static Fortran runtime) go in `IPOPT_EXTRA_LINK`.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
Installed alongside `compas_sandbox`, the `nlp` backend discovers it automatically:
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
from compas_sandbox.equilibrium import cra_solve_native
|
|
25
|
+
cra_solve_native(assembly) # same inputs and results as cra_solve
|
|
26
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
# nanobind 3.x dropped Python 3.9, which Rhino 8 still ships — stay on 2.x
|
|
3
|
+
requires = ["scikit-build-core>=0.10", "nanobind>=2.2,<3"]
|
|
4
|
+
build-backend = "scikit_build_core.build"
|
|
5
|
+
|
|
6
|
+
[project]
|
|
7
|
+
name = "compas_sandbox_native"
|
|
8
|
+
version = "0.1.0"
|
|
9
|
+
description = "In-process IPOPT solver for compas_sandbox (nanobind binding, statically linked)"
|
|
10
|
+
readme = "README.md"
|
|
11
|
+
requires-python = ">=3.9"
|
|
12
|
+
license = { text = "EPL-2.0" }
|
|
13
|
+
dependencies = ["numpy>=1.24"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Topic :: Scientific/Engineering",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[project.urls]
|
|
21
|
+
Repository = "https://github.com/petrasvestartas/compas_sandbox"
|
|
22
|
+
|
|
23
|
+
[tool.scikit-build]
|
|
24
|
+
minimum-version = "0.10"
|
|
25
|
+
wheel.packages = ["python/compas_sandbox_native"]
|
|
26
|
+
# stripping would invalidate the macOS ad-hoc code signature; arm64 kills unsigned code
|
|
27
|
+
install.strip = false
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""In-process IPOPT solver for compas_sandbox.
|
|
2
|
+
|
|
3
|
+
The heavy lifting happens in the compiled ``_core`` module, which links IPOPT and the
|
|
4
|
+
MUMPS linear solver statically. See ``compas_sandbox.nlp`` for the high-level API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from ._core import IPOPT_VERSION
|
|
8
|
+
from ._core import solve_nlp
|
|
9
|
+
|
|
10
|
+
__all__ = ["solve_nlp", "IPOPT_VERSION"]
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// In-process IPOPT solver exposed to Python through nanobind.
|
|
2
|
+
//
|
|
3
|
+
// One function is exported: solve_nlp(...). It receives the problem data (bounds,
|
|
4
|
+
// starting point, Jacobian/Hessian sparsity) as numpy arrays and the evaluation
|
|
5
|
+
// callbacks as Python callables, drives Ipopt::IpoptApplication over a TNLP that
|
|
6
|
+
// trampolines every evaluation back into Python, and returns the solution as a dict.
|
|
7
|
+
//
|
|
8
|
+
// Threading: everything runs on the calling thread and the GIL is held throughout,
|
|
9
|
+
// so the Python callbacks need no locking. A Python exception raised inside a
|
|
10
|
+
// callback is captured, the evaluation reports failure to IPOPT, and the exception
|
|
11
|
+
// is re-raised to the caller once IPOPT returns.
|
|
12
|
+
|
|
13
|
+
#include <nanobind/nanobind.h>
|
|
14
|
+
#include <nanobind/ndarray.h>
|
|
15
|
+
#include <nanobind/stl/string.h>
|
|
16
|
+
|
|
17
|
+
#include <IpIpoptApplication.hpp>
|
|
18
|
+
#include <IpIpoptData.hpp>
|
|
19
|
+
#include <IpSolveStatistics.hpp>
|
|
20
|
+
#include <IpTNLP.hpp>
|
|
21
|
+
|
|
22
|
+
#include <cstring>
|
|
23
|
+
#include <exception>
|
|
24
|
+
#include <string>
|
|
25
|
+
#include <vector>
|
|
26
|
+
|
|
27
|
+
namespace nb = nanobind;
|
|
28
|
+
|
|
29
|
+
using DArray = nb::ndarray<double, nb::ndim<1>, nb::c_contig, nb::device::cpu>;
|
|
30
|
+
using IArray = nb::ndarray<int32_t, nb::ndim<1>, nb::c_contig, nb::device::cpu>;
|
|
31
|
+
|
|
32
|
+
namespace {
|
|
33
|
+
|
|
34
|
+
std::vector<double> to_vector(const DArray &a) {
|
|
35
|
+
return std::vector<double>(a.data(), a.data() + a.size());
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
std::vector<int32_t> to_vector_i(const IArray &a) {
|
|
39
|
+
return std::vector<int32_t>(a.data(), a.data() + a.size());
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A fresh numpy array holding a copy of `x`, handed to the Python callbacks.
|
|
43
|
+
nb::object make_numpy(const double *x, size_t k) {
|
|
44
|
+
double *buf = new double[k];
|
|
45
|
+
std::memcpy(buf, x, k * sizeof(double));
|
|
46
|
+
nb::capsule owner(buf, [](void *p) noexcept { delete[] static_cast<double *>(p); });
|
|
47
|
+
return nb::cast(nb::ndarray<nb::numpy, double, nb::ndim<1>>(buf, {k}, owner));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Copy a callback's numpy result into an IPOPT buffer, checking the length.
|
|
51
|
+
void copy_result(const nb::object &res, double *out, size_t expected, const char *what) {
|
|
52
|
+
DArray arr = nb::cast<DArray>(res);
|
|
53
|
+
if (arr.size() != expected)
|
|
54
|
+
throw std::runtime_error(std::string(what) + ": expected " + std::to_string(expected) +
|
|
55
|
+
" values, got " + std::to_string(arr.size()));
|
|
56
|
+
std::memcpy(out, arr.data(), expected * sizeof(double));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const char *status_name(Ipopt::ApplicationReturnStatus s) {
|
|
60
|
+
using S = Ipopt::ApplicationReturnStatus;
|
|
61
|
+
switch (s) {
|
|
62
|
+
case S::Solve_Succeeded: return "Solve_Succeeded";
|
|
63
|
+
case S::Solved_To_Acceptable_Level: return "Solved_To_Acceptable_Level";
|
|
64
|
+
case S::Infeasible_Problem_Detected: return "Infeasible_Problem_Detected";
|
|
65
|
+
case S::Search_Direction_Becomes_Too_Small: return "Search_Direction_Becomes_Too_Small";
|
|
66
|
+
case S::Diverging_Iterates: return "Diverging_Iterates";
|
|
67
|
+
case S::User_Requested_Stop: return "User_Requested_Stop";
|
|
68
|
+
case S::Feasible_Point_Found: return "Feasible_Point_Found";
|
|
69
|
+
case S::Maximum_Iterations_Exceeded: return "Maximum_Iterations_Exceeded";
|
|
70
|
+
case S::Restoration_Failed: return "Restoration_Failed";
|
|
71
|
+
case S::Error_In_Step_Computation: return "Error_In_Step_Computation";
|
|
72
|
+
case S::Maximum_CpuTime_Exceeded: return "Maximum_CpuTime_Exceeded";
|
|
73
|
+
case S::Not_Enough_Degrees_Of_Freedom: return "Not_Enough_Degrees_Of_Freedom";
|
|
74
|
+
case S::Invalid_Problem_Definition: return "Invalid_Problem_Definition";
|
|
75
|
+
case S::Invalid_Option: return "Invalid_Option";
|
|
76
|
+
case S::Invalid_Number_Detected: return "Invalid_Number_Detected";
|
|
77
|
+
case S::Unrecoverable_Exception: return "Unrecoverable_Exception";
|
|
78
|
+
case S::NonIpopt_Exception_Thrown: return "NonIpopt_Exception_Thrown";
|
|
79
|
+
case S::Insufficient_Memory: return "Insufficient_Memory";
|
|
80
|
+
case S::Internal_Error: return "Internal_Error";
|
|
81
|
+
default: return "Unknown";
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
class CallbackNLP : public Ipopt::TNLP {
|
|
86
|
+
public:
|
|
87
|
+
// problem data
|
|
88
|
+
Ipopt::Index n_ = 0, m_ = 0;
|
|
89
|
+
std::vector<double> x_l_, x_u_, g_l_, g_u_, x0_;
|
|
90
|
+
std::vector<int32_t> jac_rows_, jac_cols_, hess_rows_, hess_cols_;
|
|
91
|
+
nb::object eval_f_, eval_grad_, eval_g_, eval_jac_, eval_h_;
|
|
92
|
+
bool have_hess_ = false;
|
|
93
|
+
|
|
94
|
+
// captured Python error from a callback
|
|
95
|
+
std::exception_ptr error_;
|
|
96
|
+
|
|
97
|
+
// solution
|
|
98
|
+
bool solved_ = false;
|
|
99
|
+
double obj_ = 0.0;
|
|
100
|
+
std::vector<double> sol_x_, sol_g_, sol_mult_g_, sol_z_l_, sol_z_u_;
|
|
101
|
+
|
|
102
|
+
bool get_nlp_info(Ipopt::Index &n, Ipopt::Index &m, Ipopt::Index &nnz_jac_g,
|
|
103
|
+
Ipopt::Index &nnz_h_lag, IndexStyleEnum &index_style) override {
|
|
104
|
+
n = n_;
|
|
105
|
+
m = m_;
|
|
106
|
+
nnz_jac_g = static_cast<Ipopt::Index>(jac_rows_.size());
|
|
107
|
+
nnz_h_lag = static_cast<Ipopt::Index>(hess_rows_.size());
|
|
108
|
+
index_style = C_STYLE;
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
bool get_bounds_info(Ipopt::Index n, Ipopt::Number *x_l, Ipopt::Number *x_u,
|
|
113
|
+
Ipopt::Index m, Ipopt::Number *g_l, Ipopt::Number *g_u) override {
|
|
114
|
+
std::memcpy(x_l, x_l_.data(), n * sizeof(double));
|
|
115
|
+
std::memcpy(x_u, x_u_.data(), n * sizeof(double));
|
|
116
|
+
std::memcpy(g_l, g_l_.data(), m * sizeof(double));
|
|
117
|
+
std::memcpy(g_u, g_u_.data(), m * sizeof(double));
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
bool get_starting_point(Ipopt::Index n, bool init_x, Ipopt::Number *x, bool init_z,
|
|
122
|
+
Ipopt::Number * /*z_L*/, Ipopt::Number * /*z_U*/, Ipopt::Index /*m*/,
|
|
123
|
+
bool init_lambda, Ipopt::Number * /*lambda*/) override {
|
|
124
|
+
if (init_z || init_lambda)
|
|
125
|
+
return false;
|
|
126
|
+
if (init_x)
|
|
127
|
+
std::memcpy(x, x0_.data(), n * sizeof(double));
|
|
128
|
+
return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
bool eval_f(Ipopt::Index n, const Ipopt::Number *x, bool /*new_x*/, Ipopt::Number &obj_value) override {
|
|
132
|
+
try {
|
|
133
|
+
obj_value = nb::cast<double>(eval_f_(make_numpy(x, n)));
|
|
134
|
+
return true;
|
|
135
|
+
} catch (...) {
|
|
136
|
+
error_ = std::current_exception();
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
bool eval_grad_f(Ipopt::Index n, const Ipopt::Number *x, bool /*new_x*/, Ipopt::Number *grad_f) override {
|
|
142
|
+
try {
|
|
143
|
+
copy_result(eval_grad_(make_numpy(x, n)), grad_f, n, "gradient");
|
|
144
|
+
return true;
|
|
145
|
+
} catch (...) {
|
|
146
|
+
error_ = std::current_exception();
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
bool eval_g(Ipopt::Index n, const Ipopt::Number *x, bool /*new_x*/, Ipopt::Index m, Ipopt::Number *g) override {
|
|
152
|
+
try {
|
|
153
|
+
copy_result(eval_g_(make_numpy(x, n)), g, m, "constraints");
|
|
154
|
+
return true;
|
|
155
|
+
} catch (...) {
|
|
156
|
+
error_ = std::current_exception();
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
bool eval_jac_g(Ipopt::Index n, const Ipopt::Number *x, bool /*new_x*/, Ipopt::Index /*m*/,
|
|
162
|
+
Ipopt::Index nele_jac, Ipopt::Index *iRow, Ipopt::Index *jCol,
|
|
163
|
+
Ipopt::Number *values) override {
|
|
164
|
+
if (values == nullptr) {
|
|
165
|
+
for (Ipopt::Index k = 0; k < nele_jac; ++k) {
|
|
166
|
+
iRow[k] = jac_rows_[k];
|
|
167
|
+
jCol[k] = jac_cols_[k];
|
|
168
|
+
}
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
copy_result(eval_jac_(make_numpy(x, n)), values, nele_jac, "jacobian");
|
|
173
|
+
return true;
|
|
174
|
+
} catch (...) {
|
|
175
|
+
error_ = std::current_exception();
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
bool eval_h(Ipopt::Index n, const Ipopt::Number *x, bool /*new_x*/, Ipopt::Number obj_factor,
|
|
181
|
+
Ipopt::Index m, const Ipopt::Number *lambda, bool /*new_lambda*/, Ipopt::Index nele_hess,
|
|
182
|
+
Ipopt::Index *iRow, Ipopt::Index *jCol, Ipopt::Number *values) override {
|
|
183
|
+
if (!have_hess_)
|
|
184
|
+
return false;
|
|
185
|
+
if (values == nullptr) {
|
|
186
|
+
for (Ipopt::Index k = 0; k < nele_hess; ++k) {
|
|
187
|
+
iRow[k] = hess_rows_[k];
|
|
188
|
+
jCol[k] = hess_cols_[k];
|
|
189
|
+
}
|
|
190
|
+
return true;
|
|
191
|
+
}
|
|
192
|
+
try {
|
|
193
|
+
copy_result(eval_h_(make_numpy(x, n), obj_factor, make_numpy(lambda, m)), values, nele_hess,
|
|
194
|
+
"hessian");
|
|
195
|
+
return true;
|
|
196
|
+
} catch (...) {
|
|
197
|
+
error_ = std::current_exception();
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
void finalize_solution(Ipopt::SolverReturn /*status*/, Ipopt::Index n, const Ipopt::Number *x,
|
|
203
|
+
const Ipopt::Number *z_L, const Ipopt::Number *z_U, Ipopt::Index m,
|
|
204
|
+
const Ipopt::Number *g, const Ipopt::Number *lambda, Ipopt::Number obj_value,
|
|
205
|
+
const Ipopt::IpoptData * /*ip_data*/,
|
|
206
|
+
Ipopt::IpoptCalculatedQuantities * /*ip_cq*/) override {
|
|
207
|
+
solved_ = true;
|
|
208
|
+
obj_ = obj_value;
|
|
209
|
+
sol_x_.assign(x, x + n);
|
|
210
|
+
sol_g_.assign(g, g + m);
|
|
211
|
+
sol_mult_g_.assign(lambda, lambda + m);
|
|
212
|
+
sol_z_l_.assign(z_L, z_L + n);
|
|
213
|
+
sol_z_u_.assign(z_U, z_U + n);
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
nb::object vec_to_numpy(const std::vector<double> &v) {
|
|
218
|
+
return make_numpy(v.data(), v.size());
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
nb::dict solve_nlp(int n, int m, DArray x_l, DArray x_u, DArray g_l, DArray g_u, DArray x0,
|
|
222
|
+
IArray jac_rows, IArray jac_cols, nb::object eval_f, nb::object eval_grad,
|
|
223
|
+
nb::object eval_g, nb::object eval_jac, nb::object hess_rows, nb::object hess_cols,
|
|
224
|
+
nb::object eval_h, nb::dict options) {
|
|
225
|
+
if ((int)x_l.size() != n || (int)x_u.size() != n || (int)x0.size() != n)
|
|
226
|
+
throw std::invalid_argument("variable arrays must have length n");
|
|
227
|
+
if ((int)g_l.size() != m || (int)g_u.size() != m)
|
|
228
|
+
throw std::invalid_argument("constraint bound arrays must have length m");
|
|
229
|
+
if (jac_rows.size() != jac_cols.size())
|
|
230
|
+
throw std::invalid_argument("jac_rows and jac_cols must have the same length");
|
|
231
|
+
|
|
232
|
+
Ipopt::SmartPtr<CallbackNLP> nlp = new CallbackNLP();
|
|
233
|
+
nlp->n_ = n;
|
|
234
|
+
nlp->m_ = m;
|
|
235
|
+
nlp->x_l_ = to_vector(x_l);
|
|
236
|
+
nlp->x_u_ = to_vector(x_u);
|
|
237
|
+
nlp->g_l_ = to_vector(g_l);
|
|
238
|
+
nlp->g_u_ = to_vector(g_u);
|
|
239
|
+
nlp->x0_ = to_vector(x0);
|
|
240
|
+
nlp->jac_rows_ = to_vector_i(jac_rows);
|
|
241
|
+
nlp->jac_cols_ = to_vector_i(jac_cols);
|
|
242
|
+
nlp->eval_f_ = eval_f;
|
|
243
|
+
nlp->eval_grad_ = eval_grad;
|
|
244
|
+
nlp->eval_g_ = eval_g;
|
|
245
|
+
nlp->eval_jac_ = eval_jac;
|
|
246
|
+
|
|
247
|
+
if (!eval_h.is_none()) {
|
|
248
|
+
IArray hr = nb::cast<IArray>(hess_rows);
|
|
249
|
+
IArray hc = nb::cast<IArray>(hess_cols);
|
|
250
|
+
if (hr.size() != hc.size())
|
|
251
|
+
throw std::invalid_argument("hess_rows and hess_cols must have the same length");
|
|
252
|
+
nlp->hess_rows_ = to_vector_i(hr);
|
|
253
|
+
nlp->hess_cols_ = to_vector_i(hc);
|
|
254
|
+
nlp->eval_h_ = eval_h;
|
|
255
|
+
nlp->have_hess_ = true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
Ipopt::SmartPtr<Ipopt::IpoptApplication> app = IpoptApplicationFactory();
|
|
259
|
+
app->RethrowNonIpoptException(false);
|
|
260
|
+
|
|
261
|
+
for (auto item : options) {
|
|
262
|
+
std::string key = nb::cast<std::string>(item.first);
|
|
263
|
+
nb::handle value = item.second;
|
|
264
|
+
if (nb::isinstance<nb::str>(value)) {
|
|
265
|
+
app->Options()->SetStringValue(key, nb::cast<std::string>(value));
|
|
266
|
+
} else if (nb::isinstance<nb::bool_>(value)) {
|
|
267
|
+
app->Options()->SetStringValue(key, nb::cast<bool>(value) ? "yes" : "no");
|
|
268
|
+
} else if (nb::isinstance<nb::int_>(value)) {
|
|
269
|
+
app->Options()->SetIntegerValue(key, nb::cast<int>(value));
|
|
270
|
+
} else if (nb::isinstance<nb::float_>(value)) {
|
|
271
|
+
app->Options()->SetNumericValue(key, nb::cast<double>(value));
|
|
272
|
+
} else {
|
|
273
|
+
throw std::invalid_argument("option '" + key + "' must be str, bool, int or float");
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
Ipopt::ApplicationReturnStatus status = app->Initialize();
|
|
278
|
+
if (status == Ipopt::Solve_Succeeded)
|
|
279
|
+
status = app->OptimizeTNLP(Ipopt::SmartPtr<Ipopt::TNLP>(Ipopt::GetRawPtr(nlp)));
|
|
280
|
+
|
|
281
|
+
// a Python exception inside a callback beats whatever IPOPT made of the failure
|
|
282
|
+
if (nlp->error_)
|
|
283
|
+
std::rethrow_exception(nlp->error_);
|
|
284
|
+
|
|
285
|
+
nb::dict out;
|
|
286
|
+
out["status"] = static_cast<int>(status);
|
|
287
|
+
out["status_name"] = status_name(status);
|
|
288
|
+
out["obj"] = nlp->obj_;
|
|
289
|
+
out["x"] = vec_to_numpy(nlp->solved_ ? nlp->sol_x_ : nlp->x0_);
|
|
290
|
+
out["g"] = vec_to_numpy(nlp->sol_g_);
|
|
291
|
+
out["mult_g"] = vec_to_numpy(nlp->sol_mult_g_);
|
|
292
|
+
out["mult_x_l"] = vec_to_numpy(nlp->sol_z_l_);
|
|
293
|
+
out["mult_x_u"] = vec_to_numpy(nlp->sol_z_u_);
|
|
294
|
+
if (Ipopt::IsValid(app->Statistics()))
|
|
295
|
+
out["iterations"] = app->Statistics()->IterationCount();
|
|
296
|
+
return out;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
} // namespace
|
|
300
|
+
|
|
301
|
+
NB_MODULE(_core, mod) {
|
|
302
|
+
mod.doc() = "In-process IPOPT solver for compas_sandbox";
|
|
303
|
+
mod.def("solve_nlp", &solve_nlp, nb::arg("n"), nb::arg("m"), nb::arg("x_l"), nb::arg("x_u"),
|
|
304
|
+
nb::arg("g_l"), nb::arg("g_u"), nb::arg("x0"), nb::arg("jac_rows"), nb::arg("jac_cols"),
|
|
305
|
+
nb::arg("eval_f"), nb::arg("eval_grad"), nb::arg("eval_g"), nb::arg("eval_jac"),
|
|
306
|
+
nb::arg("hess_rows").none(), nb::arg("hess_cols").none(), nb::arg("eval_h").none(),
|
|
307
|
+
nb::arg("options"),
|
|
308
|
+
"Solve a sparse NLP with IPOPT; see compas_sandbox.nlp for the friendly wrapper.");
|
|
309
|
+
mod.attr("IPOPT_VERSION") = IPOPT_VERSION;
|
|
310
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Minimal smoke test for a built compas_sandbox_native wheel.
|
|
2
|
+
|
|
3
|
+
Solves min x^2 s.t. x >= 1 — enough to prove the extension loads, IPOPT runs, the
|
|
4
|
+
MUMPS linear solver works and callbacks round-trip. Needs only numpy, so it can run
|
|
5
|
+
in the bare test environments of the wheel-building CI.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
import compas_sandbox_native as csn
|
|
11
|
+
|
|
12
|
+
print("compas_sandbox_native, IPOPT", csn.IPOPT_VERSION)
|
|
13
|
+
|
|
14
|
+
res = csn.solve_nlp(
|
|
15
|
+
n=1,
|
|
16
|
+
m=1,
|
|
17
|
+
x_l=np.array([-10.0]),
|
|
18
|
+
x_u=np.array([10.0]),
|
|
19
|
+
g_l=np.array([1.0]),
|
|
20
|
+
g_u=np.array([1e19]),
|
|
21
|
+
x0=np.array([5.0]),
|
|
22
|
+
jac_rows=np.array([0], dtype=np.int32),
|
|
23
|
+
jac_cols=np.array([0], dtype=np.int32),
|
|
24
|
+
eval_f=lambda x: float(x[0] ** 2),
|
|
25
|
+
eval_grad=lambda x: np.array([2.0 * x[0]]),
|
|
26
|
+
eval_g=lambda x: np.array([x[0]]),
|
|
27
|
+
eval_jac=lambda x: np.array([1.0]),
|
|
28
|
+
hess_rows=np.array([0], dtype=np.int32),
|
|
29
|
+
hess_cols=np.array([0], dtype=np.int32),
|
|
30
|
+
eval_h=lambda x, sigma, lam: np.array([2.0 * sigma]),
|
|
31
|
+
options={"print_level": 0, "sb": "yes"},
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
assert res["status"] == 0, res["status_name"]
|
|
35
|
+
assert abs(res["x"][0] - 1.0) < 1e-6, res["x"]
|
|
36
|
+
assert abs(res["obj"] - 1.0) < 1e-6, res["obj"]
|
|
37
|
+
print("smoke test OK: x =", res["x"][0])
|