Skip to content

Commit b86f3f2

Browse files
authored
Merge pull request #411 from alphaville/feature/409-pybindings-error-handling
Handling errors via py bindings
2 parents 0bd29d4 + daa1f88 commit b86f3f2

13 files changed

Lines changed: 590 additions & 76 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ homepage = "https://alphaville.github.io/optimization-engine/"
4242
repository = "https://github.com/alphaville/optimization-engine"
4343

4444
# Version of this crate (SemVer)
45-
version = "0.12.0-alpha.1"
45+
version = "0.12.0-alpha.2"
4646

4747
edition = "2018"
4848

docs/example_navigation_py.mdx

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,9 @@ sys.path.insert(1, './my_optimizers/navigation')
154154
import navigation
155155

156156
solver = navigation.solver()
157-
result = solver.run(p=[-1.0, 2.0, 0.0],
158-
initial_guess=[1.0] * (nu*N))
159-
u_star = result.solution
157+
response = solver.run(p=[-1.0, 2.0, 0.0],
158+
initial_guess=[1.0] * (nu*N))
159+
u_star = response.get().solution
160160

161161

162162
# Plot solution
@@ -289,5 +289,5 @@ problem = og.builder.Problem(u, p, cost).with_constraints(bounds)
289289
Then, when we use the optimiser we to provide the vector `p`. For example, if `z0 = (-1, 2, 0)` and `xref = 1`, `yref = 0.6`, `thetaref = 0.05` we use
290290

291291
```python
292-
result = solver.run(p=[-1.0, 2.0, 0.0, 1.0, 0.6, 0.05])
292+
result = solver.run(p=[-1.0, 2.0, 0.0, 1.0, 0.6, 0.05]).get()
293293
```

docs/python-bindings.md

Lines changed: 56 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,8 @@ so you will have to add it before you can import the optimizer.
7070
This can be done very easily:
7171

7272
```python
73+
import sys
74+
7375
sys.path.insert(1, './my_optimizers/rosenbrock')
7476
import rosenbrock
7577
```
@@ -78,22 +80,50 @@ Then you will be able to use it as follows:
7880

7981
```python
8082
solver = rosenbrock.solver()
81-
result = solver.run(p=[20., 1.])
83+
response = solver.run(p=[20., 1.])
84+
if not response.is_ok():
85+
raise RuntimeError(response.get().message)
86+
87+
result = response.get()
8288
u_star = result.solution
8389
```
8490

8591
In the first line, `solver = rosenbrock.solver()`, we obtain an instance of
8692
`Solver`, which can be used to solve parametric optimization problems.
87-
In the second line, `result = solver.run(p=[20., 1.])`, we call the solver
93+
In the second line, `response = solver.run(p=[20., 1.])`, we call the solver
8894
with parameter $p=(20, 1)$. Method `run` accepts another three optional
8995
arguments, namely:
9096

9197
- `initial_guess` (can be either a list or a numpy array),
9298
- `initial_lagrange_multipliers`, and
9399
- `initial_penalty`
94100

95-
The solver returns an object of type `OptimizerSolution` with the following
96-
properties:
101+
The solver returns an object of type `SolverResponse`, similar to the TCP
102+
interface. First call `response.is_ok()` to determine whether the call
103+
succeeded, then call `response.get()` to obtain either a `SolverStatus`
104+
object or a `SolverError`. This mirrors the Python TCP interface, but without
105+
the socket transport layer.
106+
107+
```python
108+
response = solver.run(p=[20., 1.])
109+
if response.is_ok():
110+
result = response.get()
111+
u_star = result.solution
112+
else:
113+
error = response.get()
114+
print(error.code, error.message)
115+
```
116+
117+
The returned objects also implement `__repr__`, which makes them convenient to
118+
inspect in a Python REPL or notebook:
119+
120+
```python
121+
response = solver.run(p=[20., 1.])
122+
print(response)
123+
print(response.get())
124+
```
125+
126+
The `SolverStatus` object exposes the following properties:
97127

98128

99129
| Property | Explanation |
@@ -112,6 +142,25 @@ properties:
112142

113143
These are the same properties as those of `opengen.tcp.SolverStatus`.
114144

145+
For backward compatibility, the generated module also exposes
146+
`OptimizerSolution` as an alias of `SolverStatus`.
147+
148+
If the call fails, `response.get()` returns a `SolverError` with:
149+
150+
| Property | Explanation |
151+
|-----------|-------------|
152+
| `code` | Error code, aligned with the TCP interface |
153+
| `message` | Detailed error message |
154+
155+
The most common error codes are:
156+
157+
| Code | Meaning |
158+
|------|---------|
159+
| `1600` | Initial guess has incompatible dimensions |
160+
| `1700` | Wrong dimension of initial Lagrange multipliers |
161+
| `2000` | Problem solution failed; the message includes the solver-side reason |
162+
| `3003` | Wrong number of parameters |
163+
115164

116165
## Importing optimizer with variable name
117166

@@ -122,6 +171,9 @@ The limitation of this syntax is that it makes it difficult to change the name o
122171
A better syntax would be:
123172

124173
```python
174+
import os
175+
import sys
176+
125177
optimizers_dir = "my_optimizers"
126178
optimizer_name = "rosenbrock"
127179
sys.path.insert(1, os.path.join(optimizers_dir, optimizer_name))

open-codegen/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Note: This is the Changelog file of `opengen` - the Python interface of OpEn
1919
### Changed
2020

2121
- Extended `RosConfiguration` so it can be used for both ROS and ROS2 package generation
22+
- Breaking change: the direct interface (Python bindings) now has an API which mirrors that of the TCP interface: the method `solve` returns either a solution or an error object. Website documentation is updated. New unit tests are implemented. Note that `solver.run()` does not return the solution object directly, but rather works in the same way as the TCP interface: it returns a response object (instance of `SolverResponse`), on which the method `.get()` returns either a `SolverStatus` or `SolverError`.
23+
- Added helpful `__repr__` methods to generated Python binding response/status/error objects, TCP solver response/error objects, and `GeneratedOptimizer` for easier inspection and debugging
2224
- Updated generated TCP server and C interface templates to work with the richer Rust solver error model and expose better failure information to clients
2325

2426

open-codegen/VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.11.0a1
1+
0.11.0a2

open-codegen/main.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -44,23 +44,21 @@ def get_open_local_absolute_path():
4444
meta,
4545
build_config,
4646
solver_cfg)
47-
builder.build()
47+
# builder.build()
4848

4949
sys.path.insert(1, os.path.join(optimizers_dir, optimizer_name))
5050
rosenbrock = __import__(optimizer_name)
5151

5252
solver = rosenbrock.solver()
53-
result = solver.run(p=[0.5, 8.5], initial_guess=[1, 2, 3, 4, 0])
54-
print(" ")
55-
print(f"solution = {result.solution}")
56-
print(f"time = {result.solve_time_ms} ms")
57-
print(f"penalty = {result.penalty}")
58-
print(f"infeasibility f1 = {result.f1_infeasibility}")
59-
print(f"infeasibility f2 = {result.f2_norm}")
60-
print(f"status = {result.exit_status}")
61-
print(f"inner = {result.num_inner_iterations}")
62-
print(f"outer = {result.num_outer_iterations}")
63-
print(f"cost = {result.cost}")
53+
response = solver.run(p=[0.5, 8.5], initial_guess=[1, 2, 3, 4, 0]) # SolverResponse
54+
55+
if response.is_ok():
56+
result = response.get() # SolverStatus
57+
print(type(result))
58+
else:
59+
error = response.get() # SolverError
60+
print(type(error))
61+
6462

6563
# Preconditioned Non-preconditioned
6664
# -------------------------------------

open-codegen/opengen/ocp/builder.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ def backend_kind(self):
113113
"""Backend kind used by this optimizer wrapper."""
114114
return self.__backend_kind
115115

116+
def __repr__(self):
117+
"""Return a concise summary of the generated optimizer wrapper."""
118+
return (
119+
"GeneratedOptimizer("
120+
f"optimizer_name={self.__optimizer_name!r}, "
121+
f"backend_kind={self.__backend_kind!r}, "
122+
f"shooting={self.__shooting.value!r}, "
123+
f"nx={self.__nx}, "
124+
f"nu={self.__nu}, "
125+
f"horizon={self.__horizon}, "
126+
f"target_dir={self.__target_dir!r})"
127+
)
128+
116129
def start(self):
117130
"""Start the backend if it is a local TCP server.
118131
@@ -229,6 +242,10 @@ def __casadi_version():
229242
return casadi_version
230243
return GeneratedOptimizer.__safe_package_version("casadi")
231244

245+
@staticmethod
246+
def __format_backend_error(error):
247+
return getattr(error, "message", str(error))
248+
232249
def save(self, json_path=None):
233250
"""Save a manifest that can later recreate this optimizer.
234251
@@ -327,8 +344,10 @@ def solve(
327344
:param initial_penalty: optional initial penalty parameter
328345
:param parameter_values: named parameter values
329346
:return: :class:`OcpSolution`
347+
:raises ValueError: if required named parameters are missing or have
348+
incompatible dimensions
330349
:raises RuntimeError: if the backend is unavailable or the low-level
331-
solve call fails
350+
solve call fails; backend-specific error messages are propagated
332351
"""
333352
packed_parameters = self.__pack_parameters(parameter_values)
334353

@@ -341,6 +360,10 @@ def solve(
341360
)
342361
if raw is None:
343362
raise RuntimeError("solver failed")
363+
if hasattr(raw, "is_ok") and hasattr(raw, "get"):
364+
if not raw.is_ok():
365+
raise RuntimeError(self.__format_backend_error(raw.get()))
366+
raw = raw.get()
344367
elif self.__backend_kind == "tcp":
345368
self.start()
346369
response = self.__backend.call(
@@ -350,7 +373,7 @@ def solve(
350373
initial_penalty=initial_penalty,
351374
)
352375
if not response.is_ok():
353-
raise RuntimeError(str(response.get()))
376+
raise RuntimeError(self.__format_backend_error(response.get()))
354377
raw = response.get()
355378
else:
356379
raise RuntimeError("optimizer backend is not available")

open-codegen/opengen/tcp/solver_error.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
class SolverError:
2-
"""Class for storing solver status in the event of an error."""
2+
"""Structured solver error returned by TCP or direct Python bindings."""
33

44
def __init__(self, error):
55
"""Constructs instance of :class:`~opengen.tcp.solver_error.SolverError`
@@ -38,3 +38,7 @@ def message(self):
3838
:rtype: str
3939
"""
4040
return self.__dict__["__message"]
41+
42+
def __repr__(self):
43+
"""Return a concise one-line representation of the error."""
44+
return f"SolverError(code={self.code}, message={self.message!r})"

open-codegen/opengen/tcp/solver_response.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33

44

55
class SolverResponse:
6-
"""Stores a solver response of type SolverStatus or SolverError."""
6+
"""Stores a solver response of type SolverStatus or SolverError.
7+
8+
This wrapper is used by both the TCP interface and the direct Python
9+
bindings generated by OpEn. Call :meth:`is_ok` first, then
10+
:meth:`get` to obtain either a :class:`SolverStatus` or a
11+
:class:`SolverError`.
12+
"""
713

814
def __init__(self, d):
915
"""Constructs instance of :class:`~opengen.tcp.solver_response.SolverResponse`
@@ -38,4 +44,22 @@ def get(self):
3844
return self.__response
3945

4046
def __getitem__(self, key):
47+
"""Proxy attribute access to the wrapped status or error object."""
4148
return getattr(self.__response, key)
49+
50+
def __repr__(self):
51+
"""Return a concise one-line summary suitable for debugging."""
52+
if self.is_ok():
53+
status = self.get()
54+
return (
55+
"SolverResponse(ok=True, "
56+
f"exit_status={status.exit_status!r}, "
57+
f"num_outer_iterations={status.num_outer_iterations}, "
58+
f"num_inner_iterations={status.num_inner_iterations})"
59+
)
60+
error = self.get()
61+
return (
62+
"SolverResponse(ok=False, "
63+
f"code={error.code}, "
64+
f"message={error.message!r})"
65+
)

0 commit comments

Comments
 (0)