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.
@@ -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])