diff --git a/.gitignore b/.gitignore index 59f4aa77..59cac5b2 100644 --- a/.gitignore +++ b/.gitignore @@ -73,3 +73,4 @@ open-clib/Cargo.lock *private* .DS_Store /img/ +private/ \ No newline at end of file diff --git a/README.md b/README.md index 364438b8..d4f5bb43 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@

- OpEn logo + OpEn logo

@@ -13,48 +13,23 @@ build status -

- -

+
MIT license Apache v2 license -

- -

- - Gitter - - - Tweet - Chat on Discord

-Optimization Engine (OpEn) is a solver for Fast & Accurate Embedded Optimization for next-generation Robotics and Autonomous Systems. + +Optimization Engine (OpEn) is a solver for Fast & Accurate Embedded Optimization for next-generation Robotics and Autonomous Systems. Read on to see how you can use OpEn on your robot today. **Documentation available at** [**alphaville.github.io/optimization-engine**](https://alphaville.github.io/optimization-engine/) -## Table of contents - -- [Features](#features) -- [Demos](#demos) - - [Code generation](#code-generation) - - [Embedded applications](#embedded-applications) -- [Parametric optimization problems](#parametric-problems) -- [Code generation example](#code-generation-example) -- [Getting started](#getting-started) -- [Contact](#contact-us) -- [Show us your love](#do-you-like-open) -- [Licence](#license) -- [Core team](#core-team) -- [Contributions](#contributions) -- [Cite OpEn](#citing-open) ## Features @@ -67,11 +42,9 @@ Optimization Engine (OpEn) is a solver for Fast & Accurate Embedded Optimization **OpEn** is ideal for: -- Embedded **Nonlinear Model Predictive Control**, -- Embedded **Nonlinear Moving Horizon Estimation** and their applications in -- Robotics and Advanced Manufacturing Systems -- Autonomous vehicles -- Aerial Vehicles and Aerospace +- Embedded **nonlinear nonlinear model predictive control** and **moving horizon estimation** +- Robotics and advanced manufacturing +- Autonomous (aerial/ground) vehicles ## Demos @@ -82,9 +55,7 @@ Code generation? Piece of cake! **OpEn** generates parametric optimizer modules in Rust - it's blazingly fast - it's safe - it can run on embedded devices. -You can use the [MATLAB](https://alphaville.github.io/optimization-engine/docs/matlab-interface) or [Python interface](https://alphaville.github.io/optimization-engine/docs/python-interface) of OpEn to generate Rust code for your parametric optimizer. - -This can then be called directly, using Rust, or, it can be consumed as a service over a socket. +You can use the [Python interface](https://alphaville.github.io/optimization-engine/docs/python-interface) of OpEn, `opengen`, to generate Rust code for your parametric optimizer. The optimizer can then be called from Python, used as a solver in Rust, consumed as a service over TCP (even remotely), or used in ROS on your robot. Easy Code Generation @@ -98,18 +69,71 @@ OpEn can run on embedded devices; here we see it running on an intel Atom for th Fast NMPC of MAV -## Parametric Problems +## Optimal Control + +Optimal control problems can now be set up directly from their natural ingredients: dynamics, prediction horizon, stage cost, terminal cost, and constraints. You can easily choose between a single or multiple shooting formulation. The solver is parametrizable, so you don't have to recompile whenever a parameter changes. + +OpEn allows to solve optimal control problems of the form + +image + +Here is a minimal Python example (try it in [Google Colab](https://colab.research.google.com/drive/17vbVUbqcah9seIg17aN6bW0-T15FWrBo?usp=sharing)): + + + Try it In Colab + + + +```python +import opengen as og +import casadi.casadi as cs + +ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=20) +ocp.add_parameter("x0", 2) +ocp.add_parameter("xref", 2, default=[0.0, 0.0]) + +ocp.with_dynamics( + lambda x, u, p: + cs.vertcat(x[1], -x[0] + u[0])) + +ocp.with_stage_cost( + lambda x, u, p, t: + cs.dot(x - p["xref"], x - p["xref"]) + + 0.1 * cs.dot(u, u)) + +ocp.with_terminal_cost( + lambda x, p: + 10.0 * cs.dot(x - p["xref"], x - p["xref"])) + +ocp.with_input_constraints(og.constraints.BallInf(radius=1.)) + +optimizer = og.ocp.OCPBuilder( + ocp, + meta=..., + build_config=..., + solver_config=...).build() + +result = optimizer.solve(x0=[1.0, 0.0], xref=[0.0, 0.0]) +``` + +See the dedicated [OCP documentation](https://alphaville.github.io/optimization-engine/docs/python-ocp-1) for more details and examples. + +## General Parametric Optimization **OpEn** can solve nonconvex parametric optimization problems of the general form -standard parametric optimziation problem +standard parametric optimziation problem + where *f* is a smooth cost, *U* is a simple - possibly nonconvex - set, *F1* and *F2* are nonlinear smooth mappings and *C* is a convex set ([read more](https://alphaville.github.io/optimization-engine/docs/open-intro)). -## Code Generation Example Code generation in **Python** in just a few lines of code (read the [docs](https://alphaville.github.io/optimization-engine/docs/python-examples) for details) + + Open In Colab + + ```python import opengen as og import casadi.casadi as cs @@ -143,39 +167,44 @@ builder = og.builder.OpEnOptimizerBuilder(problem, meta, builder.build() ``` -Code generation in a few lines of **MATLAB** code (read the [docs](https://alphaville.github.io/optimization-engine/docs/matlab-interface) for details) +## Use the Solver in Rust + +You can also use OpEn directly in Rust by defining a cost function, its gradient, and a set of constraints, then solving the problem. -```matlab -% Define variables -% ------------------------------------ -u = casadi.SX.sym('u', 5); -p = casadi.SX.sym('p', 2); +Here is a minimal Rust example: -% Define cost function and constraints -% ------------------------------------ -phi = rosenbrock(u, p); -f2 = [1.5*u(1) - u(2); - max(0, u(3)-u(4)+0.1)]; +```rust +use optimization_engine::{constraints, panoc::*, Problem, SolverError}; -bounds = OpEnConstraints.make_ball_at_origin(5.0); +let f = |u: &[f64], c: &mut f64| -> Result<(), SolverError> { + *c = (1.0 - u[0]).powi(2) + 200.0 * (u[1] - u[0].powi(2)).powi(2); + Ok(()) +}; -opEnBuilder = OpEnOptimizerBuilder()... - .with_problem(u, p, phi, bounds)... - .with_build_name('penalty_new')... - .with_fpr_tolerance(1e-5)... - .with_constraints_as_penalties(f2); +let df = |u: &[f64], grad: &mut [f64]| -> Result<(), SolverError> { + grad[0] = 2.0 * (u[0] - 1.0) - 800.0 * u[0] * (u[1] - u[0].powi(2)); + grad[1] = 400.0 * (u[1] - u[0].powi(2)); + Ok(()) +}; -opEnOptimizer = opEnBuilder.build(); +let bounds = constraints::Ball2::new(None, 1.0); +let problem = Problem::new(&bounds, df, f); +let mut cache = PANOCCache::new(2, 1e-8, 10); +let mut optimizer = PANOCOptimizer::new(problem, &mut cache).with_max_iter(100); +let mut u = [-1.5, 0.9]; +let status = optimizer.solve(&mut u)?; ``` +See the dedicated [Rust documentation](https://alphaville.github.io/optimization-engine/docs/openrust-basic) for a full introduction and more complete examples. +See more Rust examples [here](examples). -## Getting started +## Check out next... - [More information about OpEn](https://alphaville.github.io/optimization-engine/docs/open-intro) - [Quick installation guide](https://alphaville.github.io/optimization-engine/docs/installation) - [OpEn in Rust](https://alphaville.github.io/optimization-engine/docs/openrust-basic) - [OpEn in Python](https://alphaville.github.io/optimization-engine/docs/python-interface) ([Examples](https://alphaville.github.io/optimization-engine/docs/python-examples)) -- [OpEn in MATLAB](https://alphaville.github.io/optimization-engine/docs/matlab-interface) ([Examples](https://alphaville.github.io/optimization-engine/docs/matlab-examples)) +- [Optimal control](https://alphaville.github.io/optimization-engine/docs/python-ocp-1) - [OpEn+Jupyter in Docker](https://alphaville.github.io/optimization-engine/docs/docker) - [Generation of ROS packages](https://alphaville.github.io/optimization-engine/docs/python-ros) - [Call OpEn in C/C++](https://alphaville.github.io/optimization-engine/docs/python-c) diff --git a/ci/script.sh b/ci/script.sh index f9e68a43..35599d7e 100755 --- a/ci/script.sh +++ b/ci/script.sh @@ -52,6 +52,7 @@ regular_test() { export PYTHONPATH=. python -W ignore test/test_constraints.py -v python -W ignore test/test.py -v + python -W ignore test/test_ocp.py -v python -W ignore test/test_raspberry_pi.py -v diff --git a/icasadi/.gitignore b/design/icasadi_test/.gitignore similarity index 100% rename from icasadi/.gitignore rename to design/icasadi_test/.gitignore diff --git a/test/icasadi_test/Cargo.toml b/design/icasadi_test/Cargo.toml similarity index 100% rename from test/icasadi_test/Cargo.toml rename to design/icasadi_test/Cargo.toml diff --git a/test/icasadi_test/README.md b/design/icasadi_test/README.md similarity index 100% rename from test/icasadi_test/README.md rename to design/icasadi_test/README.md diff --git a/test/icasadi_test/build.rs b/design/icasadi_test/build.rs similarity index 100% rename from test/icasadi_test/build.rs rename to design/icasadi_test/build.rs diff --git a/icasadi/extern/Makefile b/design/icasadi_test/extern/Makefile similarity index 100% rename from icasadi/extern/Makefile rename to design/icasadi_test/extern/Makefile diff --git a/test/icasadi_test/extern/auto_casadi_constraints_type_penalty.c b/design/icasadi_test/extern/auto_casadi_constraints_type_penalty.c similarity index 100% rename from test/icasadi_test/extern/auto_casadi_constraints_type_penalty.c rename to design/icasadi_test/extern/auto_casadi_constraints_type_penalty.c diff --git a/test/icasadi_test/extern/auto_casadi_cost.c b/design/icasadi_test/extern/auto_casadi_cost.c similarity index 100% rename from test/icasadi_test/extern/auto_casadi_cost.c rename to design/icasadi_test/extern/auto_casadi_cost.c diff --git a/test/icasadi_test/extern/auto_casadi_grad.c b/design/icasadi_test/extern/auto_casadi_grad.c similarity index 100% rename from test/icasadi_test/extern/auto_casadi_grad.c rename to design/icasadi_test/extern/auto_casadi_grad.c diff --git a/icasadi/extern/icasadi.c b/design/icasadi_test/extern/icasadi.c similarity index 100% rename from icasadi/extern/icasadi.c rename to design/icasadi_test/extern/icasadi.c diff --git a/icasadi/extern/icasadi.h b/design/icasadi_test/extern/icasadi.h similarity index 100% rename from icasadi/extern/icasadi.h rename to design/icasadi_test/extern/icasadi.h diff --git a/test/icasadi_test/extern/icasadi_config.h b/design/icasadi_test/extern/icasadi_config.h similarity index 100% rename from test/icasadi_test/extern/icasadi_config.h rename to design/icasadi_test/extern/icasadi_config.h diff --git a/icasadi/extern/main.c b/design/icasadi_test/extern/main.c similarity index 100% rename from icasadi/extern/main.c rename to design/icasadi_test/extern/main.c diff --git a/icasadi/src/lib.rs b/design/icasadi_test/src/lib.rs similarity index 100% rename from icasadi/src/lib.rs rename to design/icasadi_test/src/lib.rs diff --git a/docs/python-advanced.md b/docs/python-advanced.md index 9f679158..8b384533 100644 --- a/docs/python-advanced.md +++ b/docs/python-advanced.md @@ -1,6 +1,6 @@ --- id: python-advanced -title: Advanced options +title: Additional options description: Advanced options of opengen, OpEn's Python interface --- diff --git a/docs/python-ocp-1.md b/docs/python-ocp-1.md new file mode 100644 index 00000000..dfc91ac9 --- /dev/null +++ b/docs/python-ocp-1.md @@ -0,0 +1,214 @@ +--- +id: python-ocp-1 +title: Getting started +description: Optimal Control with OpEn/opengen +--- + + + + + + +
+Info: The functionality presented here was introduced in opengen version 0.10.0a1. The API is still young and is likely to change in version 0.11. +
+ +Opegen now comes with a new module that facilitates the construction +of optimal control problems. In an intuitive and straightforward way, +the user defines the problem ingredients (stage/terminal costs, +state/input constraints, dynamics). + + + +## In a nutshell: quick overview + +Suppose you want to solve the optimal control problem +
+\[ + \begin{align} + \mathbb{P}_N(p){}:{}\operatorname*{Minimize}_{u_0, \ldots, u_{N-1}}& + \sum_{t=0}^{N - 1} + \underbrace{q\|x_t-x^{\mathrm{ref}}\|^2 + r \|u_t\|^2}_{\text{stage cost}} + + + \underbrace{10\|x_N-x^{\mathrm{ref}}\|^2}_{\text{terminal cost}} + \\ + \text{subject to: }& x_{t+1} = F(x_t, u_t), t=0,\ldots, N-1 + \\ + &u_{\min} \leq u_t \leq u_{\max}, t=0,\ldots, N-1 + \\ + &x_{2,t} \geq x_{\min}, t=0,\ldots, N + \\ + &x_0=x + \end{align} +\]
+Suppose the state is two-dimensional and the input is one-dimensional. +The dynamics is +
+\[ + \begin{align} + F(x, u; a) = + \begin{bmatrix} + 0.98 \sin(x_1) + x_2 \\ + 0.1x_1^2 - 0.5 x_1 + a x_2 + u + \end{bmatrix}, + \end{align} +\]
+where $a$ is a parameter. Suppose the stage cost +function is +
+\[ + \begin{align} + \ell(x, u; x^{\rm ref}, q, r) + {}={} + q\| x - x^{\rm ref}\|^2 + r \|u\|^2, + \end{align} +\]
+where $x^{\rm ref}$, $q$, and $r$ are parameters. The terminal +cost function is +
+\[ + \begin{align} + V_f(x; x^{\mathrm{ref}}) = 100\|x_N-x^{\mathrm{ref}}\|^2. + \end{align} +\]
+Lastly, we have the state constraint $x_t \geq x_{\rm min}$, where $x_{\rm min}$ is a parameter, +and the hard input constraints $|u_t| \leq 0.2$. + +
+ + +Try it In Colab + +This optimal control problem can be constructed as follows: + + + + +```python +import opengen as og +import casadi.casadi as cs +import numpy as np +import matplotlib.pyplot as plt + +optimizer_name = "ocp_alm" + +# Construct the OCP +ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=20) + +# Define the parameters +ocp.add_parameter("x0", 2) +ocp.add_parameter("xref", 2, default=[0.0, 0.0]) +ocp.add_parameter("q", 1, default=1) +ocp.add_parameter("r", 1, default=0.1) +ocp.add_parameter("a", 1, default=0.8) +ocp.add_parameter("xmin", 1, default=-1) + +# System dynamics +ocp.with_dynamics(lambda x, u, param: + cs.vertcat(0.98 * cs.sin(x[0]) + x[1], + 0.1 * x[0]**2 - 0.5 * x[0] + param["a"] * x[1] + u[0])) +# Stage cost +ocp.with_stage_cost( + lambda x, u, param, _t: + param["q"] * cs.dot(x - param["xref"], x - param["xref"]) + + param["r"] * cs.dot(u, u) +) + +# Terminal cost +ocp.with_terminal_cost( + lambda x, param: 100 * cs.dot(x - param["xref"], x - param["xref"]) +) + +# State constraint: x1 <= xmax, imposed with ALM +ocp.with_path_constraint( + lambda x, u, param, _t: x[1] - param["xmin"], + kind="alm", + set_c=og.constraints.Rectangle([0.], [np.inf]), +) + +# Input constraints +ocp.with_input_constraints(og.constraints.BallInf(radius=0.2)) +``` + +Having defined the above OCP, we can build the optimizer... + + + + +```python +ocp_optimizer = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name(optimizer_name), + build_configuration=og.config.BuildConfiguration() + .with_build_python_bindings().with_rebuild(True), + solver_configuration=og.config.SolverConfiguration() + .with_tolerance(1e-5) + .with_delta_tolerance(1e-5) + .with_preconditioning(True) + .with_penalty_weight_update_factor(1.8) + .with_max_inner_iterations(2000) + .with_max_outer_iterations(40), +).build() +``` + + +```python +ocp_optimizer = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name(optimizer_name), + build_configuration=og.config.BuildConfiguration() + .with_tcp_interface_config( + tcp_interface_config=og.config.TcpServerConfiguration(bind_port=3391) + ).with_rebuild(True), + solver_configuration=og.config.SolverConfiguration() + .with_tolerance(1e-5) + .with_delta_tolerance(1e-5) + .with_preconditioning(True) + .with_penalty_weight_update_factor(1.8) + .with_max_inner_iterations(2000) + .with_max_outer_iterations(40), +).build() +``` + + + +The optimizer can then be called as follows: + +```python +result = ocp_optimizer.solve(x0=[0.4, 0.2], q=30, r=1, xmin=-0.2) +``` + +and note that all parameters except `x0` are optional; if not specified, +their default values will be used (the defaults were set when we constructed the +OCP). We can now plot the optimal sequence of inputs (`result.inputs`) + +Seq. inputs + +and the corresponding sequence of states (`result.states`) + +Seq. states + +The object `result` contains the above sequences of inputs and states and additional +information about the solution, solver time, Lagrange multipliers, etc. diff --git a/docs/python-ocp-2.md b/docs/python-ocp-2.md new file mode 100644 index 00000000..d2c2a0e0 --- /dev/null +++ b/docs/python-ocp-2.md @@ -0,0 +1,508 @@ +--- +id: python-ocp-2 +title: Constructing OCPs +description: Optimal Control with OpEn/opengen +--- + + + + + + +
+Info: The functionality presented here was introduced in opengen version 0.10.0a1. The API is still young and is likely to change in version 0.11. +
+ +Here we will look at how we can construct an optimal control problem (OCP) +by defining its state and terminal cost functions, input and state +constraints, prediction horizon and other options. + + +Generally, we want to solve problems of the form +
+\[ + \begin{align} + \mathbb{P}_N(p){}:{}\operatorname*{Minimize}_{u_0, \ldots, u_{N-1}}& + \sum_{t=0}^{N - 1} \ell_t(x_t, u_t; p) + V_f(x_N; p) + \\ + \text{subject to: }& x_{t+1} = F(x_t, u_t; p), + \\ + &u_t \in U, + \\ + &(x_t, u_t) \in Z_t(p), \text{ for } t=0,\ldots, N - 1 + \\ + &x_N \in X_N, + \\ + &x_0=x + \end{align} +\]
+ + +## OCP Formulations + +### Single shooting + +There are two main ways to formulate an optimal control +problem. The **single shooting** approach consists in +eliminating the sequence of states; the decision variables +are the control actions $u_0, \ldots, u_{N-1}$, i.e., the OCP looks +like (simplified version — additional constraints can be present) + +
+\[ + \begin{align} + \mathbb{P}_N(p){}:{}\operatorname*{Minimize}_{u_0, \ldots, u_{N-1}}& + \sum_{t=0}^{N - 1} \ell_t(x_t, u_t; p) + V_f(x_N; p) + \\ + \text{subject to: }& u_t \in U, + \\ + &x_0=x + \end{align} +\]
+ +where $x_t$ is a function of the initial state, $x_0$, and +the sequence of inputs $u_0, \ldots, u_{t-1}$. For example, +$x_1 = F(x_0, u_0)$, and $x_2 = F(x_1, u_1) = F(F(x_0, u_0), u_1)$. +The single shooting formulation is the default one as we have +observed that it leads to better performance. To construct a +single shooting OCP do + +```python +import opengen as og +import casadi.casadi as cs + +ocp = og.ocp.OptimalControlProblem( + nx=2, nu=1, + horizon=20, + shooting=og.ocp.ShootingMethod.SINGLE) +``` + +Since $\mathbf{u} = (u_0, \ldots, u_{N-1})$ is the decision +variable, we can impose hard constraints of the form +$u_t \in U$ for all $t$. For example, to impose the constraint +$\Vert u_t \Vert_\infty \leq 0.2$ we can do + +```python +set_U = og.constraints.BallInf(radius=0.2) +ocp.with_input_constraints(set_U) +``` + +### Multiple shooting + +Alternatively, we can formulate the problem using the +**multiple shooting** approach, where the sequence of states +is not eliminated. The OCP now has the form (simplified version — additional constraints can be present) + +
+\[ + \begin{align} + \mathbb{P}_N(p){}:{}\operatorname*{Minimize}_{\substack{u_0, \ldots, u_{N-1} \\ x_0, \ldots, x_{N}}}& + \sum_{t=0}^{N - 1} \ell_t(x_t, u_t; p) + V_f(x_N; p) + \\ + \text{subject to: }& + x_{t+1} = F(x_t, u_t; p), + \\ + &(x_t, u_t) \in Z, \text{ for } t=0,\ldots, N - 1 + \\ + &x_N \in X_N, + \\ + &x_0=x + \end{align} +\]
+ + +A multiple shooting problem can be constructed as follows + +```python +ocp = og.ocp.OptimalControlProblem( + nx=2, nu=1, + horizon=20, + shooting=og.ocp.ShootingMethod.MULTIPLE) +``` + +Here the decision variable is the vector $\mathbf{z} = (u_0, x_0, \ldots, u_{N-1}, x_{N-1}, x_N)$ +and the system dynamics become equality constraints, which can be imposed via ALM (default) +or PM; this can be configured using `with_dynamics_constraints` and either `"alm"` or `"penalty"`. + +```python +ocp.with_dynamics_constraints("alm") +``` + +When using the multiple shooting formulation, we can impose constraints of the form $(x_t, u_t)\in Z$ +using `with_hard_stage_state_input_constraints`. + +```python +set_Z = og.constraints.BallInf(radius=1.0) +ocp.with_hard_stage_state_input_constraints(set_Z) +``` + +Likewise, since $x_N$ is part of the decision variable vector, $\mathbf{z}$, +we can impose hard constraints. This can be done as follows + +``` +set_XN = og.constraints.Ball1(radius=0.1) +ocp.with_hard_terminal_state_constraints(set_XN) +``` + +## Parameters + +The OCP API supports **named parameters**. Parameters are packed internally +into the flat vector expected by the low-level OpEn builder, but the user does +not need to keep track of slices manually. Instead, parameters are declared by +name and are later accessed by name inside the callback functions. + +Parameters are added using `add_parameter(name, size, default=None)`. + +```python +ocp.add_parameter("x0", 2) +ocp.add_parameter("xref", 2, default=[0.0, 0.0]) +ocp.add_parameter("q", 1, default=10.0) +``` + +In the above example: + +- `x0` is required because no default value is provided +- `xref` and `q` are optional when calling the optimizer +- scalar defaults such as `10.0` are allowed for one-dimensional parameters + +If the same parameter name is declared twice, `add_parameter` raises a +`ValueError`. Additional checks are in place to validate parameter dimensions. + +Parameters can be used in callback functions (dynamics, cost functions, +constraints); more on this later. + +
+Important: The parameter x0 should always be declared because it defines the initial +state of the OCP. +
+ + + +## Dynamics + +The system dynamics is specified using `with_dynamics`. The callback must +return the next state, that is, it must implement the map + +
+\[ + x^+ = F(x, u; p) +\]
+ +where `x` is the current state, `u` is the current input, and `p` is the +parameter view introduced earlier. In other words, the OCP layer expects +**discrete-time** dynamics. + +A typical discrete-time model can be provided as follows: + +```python +ocp.with_dynamics( + lambda x, u, p: + cs.vertcat( + 0.98 * cs.sin(x[0]) + x[1], + 0.1 * x[0]**2 - 0.5 * x[0] + p["a"] * x[1] + u[0] + ) +) +``` + +Note that the parameter argument behaves like a dictionary of CasADi +symbols, so named parameters can be accessed with expressions such as +`p["a"]`, `p["xref"]`, and so on. + +### Continuous-time dynamics + +If your model is given in continuous time as + +
+\[ + \dot{x} = f(x, u; p), +\]
+ +you can discretize it using `og.ocp.DynamicsDiscretizer`. This helper +constructs a discrete-time callback that is directly compatible with +`with_dynamics`. + +For example, suppose + +
+\[ + \dot{x} = \begin{bmatrix} + x_2 \\ + -x_1 + u + \end{bmatrix}. +\]
+ +Then we can discretize it as follows: + +```python +continuous_dynamics = lambda x, u, p: cs.vertcat( + x[1], + -x[0] + u[0] +) + +discretizer = og.ocp.DynamicsDiscretizer( + continuous_dynamics, + sampling_time=0.1, +) + +ocp.with_dynamics(discretizer.rk4()) +``` + +### Available discretization methods + +The following discretization methods are currently available: + +- `euler()` +- `midpoint()` +- `heun()` +- `rk4()` +- `multistep(method=..., num_steps=...)` + +For example, if a higher-accuracy internal integration is needed over one +sampling interval, we can use a multistep method: + +```python +ocp.with_dynamics( + discretizer.multistep(method="rk4", num_steps=4) +) +``` + +This subdivides the sampling interval into four smaller substeps and applies +RK4 on each one. + +### Choosing a discretization + +As a rule of thumb: + +- `euler()` is the simplest and cheapest option +- `midpoint()` and `heun()` offer better accuracy than Euler at modest cost +- `rk4()` is a good default when accuracy matters +- `multistep(...)` is useful when the controller sampling time is relatively + large compared to the time scale of the plant + +In all cases, the result of the discretizer is a standard discrete-time +callback, so once it is passed to `with_dynamics`, the remainder of the OCP +construction is unchanged. + +## Stage cost function + +The stage cost is specified using `with_stage_cost`. The callback must accept +`(x, u, p, stage_idx)` and return the scalar quantity $\ell_t(x_t, u_t; p)$. +Here `x` is the current state, $x_t$, `u` is the current input, $u_t$, +`p` is the parameter view, `stage_idx` is the stage index, $t$. + +### Typical quadratic stage cost + +A common choice in optimal control is a quadratic tracking term plus a control +effort penalty: + +
+\[ + \ell_t(x_t, u_t; p) + = + q \|x_t - x^{\mathrm{ref}}\|^2 + r \|u_t - u^{\rm ref}\|^2. +\]
+ +This can be written as: + +```python +def my_stage_cost(x, u, p, _t): + xref = p["xref"] + uref = p["uref"] + ex = x - xref + eu = u - uref + return p["q"] * cs.dot(ex, ex) + p["r"] * cs.dot(eu, eu) + +ocp.with_stage_cost(my_stage_cost) +``` + +The stage index is written as `_t` above because it is not used in this +example. + +### Time-varying stage costs + +If the cost depends explicitly on the stage, the argument `stage_idx` can be +used directly. For example, a discounted quadratic stage cost can be written +as + +$$\ell_t(x_t, u_t; p) = \gamma^t \left( q \Vert x_t - x^{\mathrm{ref}} \Vert^2 + r \Vert u_t - u^{\rm ref} \Vert^2 \right),$$ + +where $\gamma \in (0, 1]$ is a discount factor. This can be implemented +as follows (this time using a lambda): + +```python +ocp.with_stage_cost( + lambda x, u, p, t: + (p["gamma"][0] ** t) * ( + p["q"] * cs.dot(x - p["xref"], x - p["xref"]) + + p["r"] * cs.dot(u - p["uref"], u - p["uref"]) + ) +) +``` + +In that case, `gamma`, `q`, and `r` should be declared as parameters, for example: + +```python +ocp.add_parameter("gamma", 1, default=0.95) +ocp.add_parameter("q", 1, default=1.0) +ocp.add_parameter("r", 1, default=0.5) +``` + +More generally, `stage_idx` can be used to encode stage-dependent references, +weights, or economic costs. + +Note that the return value must be a scalar CasADi expression. + +## Terminal cost function + +The terminal cost is specified using `with_terminal_cost`. The callback must +accept `(x, p)` and return the scalar quantity $V_f(x_N; p)$. +Here `x` is the terminal state $x_N$ and `p` is the parameter view. + +A common choice is a quadratic tracking penalty of the form + +
+\[ + V_f(x_N; p) = \|x_N - x^{\mathrm{ref}}\|_{P}^{2}, +\]
+ +where $P$ is a symmetric positive definite matrix. This can be +implemented as follows: + +```python +# Terminal cost matrix +P = cs.DM([ + [10.0, 0.0], + [0.0, 2.0], +]) + +# Terminal cost function +ocp.with_terminal_cost( + lambda x, p: + cs.mtimes([ + (x - p["xref"]).T, + P, + (x - p["xref"]), + ]) +) +``` + +## General symbolic constraints + +In addition to hard constraints on the decision variables, the OCP API supports +general **symbolic constraints**. These are constraints defined by CasADi +expressions and handled either by the penalty method (PM) or by the augmented +Lagrangian method (ALM). + +There are two variants: + +- `with_path_constraint` for stage-wise constraints, evaluated at every + stage $t = 0, \ldots, N-1$ +- `with_terminal_constraint` for terminal constraints, evaluated at $x_N$ + +### Penalty-type symbolic constraints + +Penalty constraints are appended to the mapping $F_2$ (see [PM docs](/optimization-engine/docs/python-interface#penalty-method)). +Such sonstraints are treated through the penalty method. +For example, to impose the state inequality $x_{2,t} \geq x_{\min}$ +we may write + +```python +ocp.with_path_constraint( + lambda x, u, p, _t: cs.fmax(0.0, -x[1] + p["xmin"]), + kind="penalty", +) +``` + +Likewise, a terminal penalty constraint can be used to encode an inequality +such as + +$$ +x_{1,N} + x_{2,N} \leq 0.1. +$$ + +This can be written as + +```python +ocp.with_terminal_constraint( + lambda x, p: cs.fmax(0.0, x[0] + x[1] - 0.1), + kind="penalty", +) +``` + +### ALM-type symbolic constraints + +ALM constraints are appended to the mapping $F_1$ and must be combined +with a set :math:`C`, so that the constraint has the form + +
+\[ + F_1(x_t, u_t; p) \in C +\]
+ +or, for terminal constraints, + +
+\[ + F_1(x_N; p) \in C. +\]
+ +In that case, $C$ (`set_c`) must be provided. +For example, the inequality + +
+\[ + x_{2,t} - x_{\min} \geq 0 +\]
+ +can be written as an ALM constraint by taking $C = [0, \infty)$, that is, + +```python +set_c = og.constraints.Rectangle([0.0], [float("inf")]) +ocp.with_path_constraint( + lambda x, u, p, _t: x[1] - p["xmin"], + kind="alm", + set_c=set_c, +) +``` + +A terminal ALM constraint can be defined similarly: + +```python +set_c = set_c=og.constraints.Rectangle([-0.1], [0.1]) +ocp.with_terminal_constraint( + lambda x, p: x[0], + kind="alm", + set_c=set_c, +) +``` + +An optional dual set `set_y` may also be provided, but in many cases it is +best to leave it unspecified and let the lower-level OpEn machinery choose it +(see the [documentation](/optimization-engine/docs/python-interface#augmented-lagrangian-method)). + +## Next steps + +Once you have defined your optimal control problem, the next step is to +[build an optimizer](/optimization-engine/docs/python-ocp-3). \ No newline at end of file diff --git a/docs/python-ocp-3.md b/docs/python-ocp-3.md new file mode 100644 index 00000000..da5487e9 --- /dev/null +++ b/docs/python-ocp-3.md @@ -0,0 +1,92 @@ +--- +id: python-ocp-3 +title: Building OCPs +description: Optimal Control with OpEn/opengen +--- + + + + + + +
+Info: The functionality presented here was introduced in opengen version 0.10.0a1. The API is still young and is likely to change in version 0.11. +
+ +Once an OCP has been defined, it can be turned into a generated optimizer +using `og.ocp.OCPBuilder`. This builder lowers the high-level OCP to the +standard OpEn problem format and then invokes the usual code-generation +pipeline. + +At a minimum, you need: + +- the OCP definition, +- optimizer metadata, +- a build configuration, and +- a solver configuration. + +For example: + +```python +ocp_optimizer = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("my_ocp"), + build_configuration=og.config.BuildConfiguration() + .with_build_python_bindings(), + solver_configuration=og.config.SolverConfiguration() + .with_tolerance(1e-5) + .with_delta_tolerance(1e-5), +).build() +``` + +The call to `.build()` generates the solver and returns a +`GeneratedOptimizer`, which can then be used directly: + +```python +result = ocp_optimizer.solve(x0=[0.4, 0.2], xref=[0.0, 0.0]) +``` + +If you prefer to access the generated solver over TCP instead of direct Python +bindings, use `with_tcp_interface_config(...)` in the build configuration: + +```python +ocp_optimizer = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("my_ocp_tcp"), + build_configuration=og.config.BuildConfiguration() + .with_tcp_interface_config(), + solver_configuration=og.config.SolverConfiguration(), +).build() +``` + +In both cases, the high-level interface remains the same: + +```python +result = ocp_optimizer.solve(x0=[0.4, 0.2], xref=[0.0, 0.0]) +``` + +For more details on build configurations, TCP servers, Python bindings, and +the low-level code-generation pipeline, see the main +[Python interface documentation](https://alphaville.github.io/optimization-engine/docs/python-interface). diff --git a/docs/python-ocp-4.md b/docs/python-ocp-4.md new file mode 100644 index 00000000..dde21e53 --- /dev/null +++ b/docs/python-ocp-4.md @@ -0,0 +1,138 @@ +--- +id: python-ocp-4 +title: Solving OCPs +description: Optimal Control with OpEn/opengen +--- + + + + + + +
+Info: The functionality presented here was introduced in opengen version 0.10.0a1. The API is still young and is likely to change in version 0.11. +
+ +After an OCP optimizer has been built, it can be called using the method +`solve(...)`. The main difference compared to the low-level interface is that +the OCP optimizer accepts **named parameters** rather than a manually packed +parameter vector. + +For example, if the OCP declares the parameters `x0`, `xref`, `q`, and `r`, +the optimizer can be called as + +```python +result = ocp_optimizer.solve( + x0=[0.4, 0.2], + xref=[0.0, 0.0], + q=30.0, + r=1.0, +) +``` + +Any parameter with a default value may be omitted. For example, if `xref`, +`q`, and `r` have defaults, then the following is also valid: + +```python +result = ocp_optimizer.solve(x0=[0.4, 0.2]) +``` + +Optional warm-start information may also be provided: + +```python +result = ocp_optimizer.solve( + x0=[0.4, 0.2], + initial_guess=[0.0] * 20, + initial_lagrange_multipliers=[0.0] * 5, + initial_penalty=10.0, +) +``` + +## Solution object + +The method `solve(...)` returns an object of type `OcpSolution`. This object +contains both the raw low-level solver status and OCP-oriented views such as +the stage-wise inputs and the state trajectory. + +The most commonly used fields are: + +- `result.inputs`: sequence of optimal inputs +- `result.states`: sequence of states +- `result.cost`: value of the objective at the solution +- `result.exit_status`: solver exit status +- `result.solve_time_ms`: total solver time in milliseconds + +Additional solver diagnostics are also available: + +- `result.penalty` +- `result.num_outer_iterations` +- `result.num_inner_iterations` +- `result.last_problem_norm_fpr` +- `result.f1_infeasibility` +- `result.f2_norm` +- `result.lagrange_multipliers` + +For convenience, the solution object can be printed directly: + +```python +print(result) +``` + +This displays a compact summary of the solution, including inputs, states, and +solver diagnostics. + +## Direct bindings and TCP + +The same high-level `solve(...)` interface is used regardless of whether the +optimizer was built with direct Python bindings or with the TCP interface. +When TCP is used, the underlying server is managed automatically by the +generated optimizer wrapper. + +When the optimizer is accessed over TCP, the server is started automatically +the first time `solve(...)` is called and remains alive for subsequent calls. +When you are done with the optimizer, you should stop the TCP server +explicitly by calling: + +```python +ocp_optimizer.kill() +``` + +This has no effect for optimizers using direct Python bindings. + +## Reloading a generated optimizer + +Generated OCP optimizers save a manifest automatically when they are built. +They can later be reloaded without rebuilding: + +```python +optimizer = og.ocp.GeneratedOptimizer.load( + "path/to/optimizer_manifest.json" +) + +result = optimizer.solve(x0=[0.4, 0.2], xref=[0.0, 0.0]) +``` + +This is useful when you want to reuse a previously generated solver across +Python sessions. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..dca42530 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,6 @@ +## Rust examples + +1. [`panoc_ex1.rs`](panoc_ex1.rs): constrained minimization of the Rosenbrock function +2. [`panoc_ex2.rs`](panoc_ex2.rs): another similar example +3. [`pm.rs`](pm.rs): constrained optimization with the penalty method +4. [`alm.rs`](alm.rs): constrained optimization with the augmented Lagrangian method \ No newline at end of file diff --git a/icasadi/Cargo.toml b/icasadi/Cargo.toml deleted file mode 100644 index 9531df4b..00000000 --- a/icasadi/Cargo.toml +++ /dev/null @@ -1,25 +0,0 @@ -[package] -authors = ["Pantelis Sopasakis"] -name = "icasadi" -categories = ["api-bindings", "no-std", "science", "science::robotics", "simulation"] -keywords = ["casadi", "optimization", "interface"] -description = "Rust interface to CasADi functions" -documentation = "https://docs.rs/icasadi" -license = "MIT" -readme = "README.md" -version = "0.2.0" -edition = "2018" - -[dependencies] -libc = { version = "0.2.0", default-features = false } - -[build-dependencies] -cc = "1.0" -bindgen = "0.49.0" - -[profile.dev] -opt-level = 0 - - -[profile.release] -opt-level = 3 diff --git a/icasadi/README.md b/icasadi/README.md deleted file mode 100644 index 7d751870..00000000 --- a/icasadi/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# MOVED! - -Moved [here](../open-codegen/icasadi/) \ No newline at end of file diff --git a/icasadi/build.rs b/icasadi/build.rs deleted file mode 100644 index b205c50e..00000000 --- a/icasadi/build.rs +++ /dev/null @@ -1,65 +0,0 @@ -use bindgen; -use cc; - -use std::env; -use std::path::{Path, PathBuf}; - -fn main() { - let out_path = PathBuf::from(env::var("OUT_DIR").unwrap()); - - // Sanity checks to get better error messages - assert!( - Path::new("extern/auto_casadi_constraints_type_penalty.c").exists(), - "extern/auto_casadi_constraints_type_penalty.c is missing" - ); - assert!( - Path::new("extern/auto_casadi_cost.c").exists(), - "extern/auto_casadi_cost.c is missing" - ); - assert!( - Path::new("extern/auto_casadi_grad.c").exists(), - "extern/auto_casadi_grad.c is missing" - ); - assert!( - Path::new("extern/icasadi.c").exists(), - "extern/icasadi.c is missing" - ); - assert!( - Path::new("extern/icasadi.h").exists(), - "extern/icasadi.h is missing" - ); - assert!( - Path::new("extern/icasadi_config.h").exists(), - "extern/icasadi_config.h is missing" - ); - - cc::Build::new() - .flag_if_supported("-Wall") - .flag_if_supported("-Wpedantic") - .flag_if_supported("-Wno-long-long") - .flag_if_supported("-Wno-unused-parameter") - .pic(true) - .include("src") - .file("extern/auto_casadi_cost.c") - .file("extern/auto_casadi_grad.c") - .file("extern/auto_casadi_constraints_type_penalty.c") - .file("extern/icasadi.c") - .compile("icasadi"); - - // Extract the problem size parameter size constants from the - // icasadi_config.h file - bindgen::Builder::default() - .header("extern/icasadi_config.h") - .generate() - .expect("Unable to generate bindings") - .write_to_file(out_path.join("bindings.rs")) - .expect("Couldn't write bindings!"); - - // Rerun if these autogenerated files change - println!("cargo:rerun-if-changed=extern/auto_casadi_cost.c"); - println!("cargo:rerun-if-changed=extern/auto_casadi_grad.c"); - println!("cargo:rerun-if-changed=extern/auto_casadi_constraints_type_penalty.c"); - println!("cargo:rerun-if-changed=extern/icasadi.c"); - println!("cargo:rerun-if-changed=extern/icasadi.h"); - println!("cargo:rerun-if-changed=extern/icasadi_config.h"); -} diff --git a/icasadi/extern/auto_casadi_constraints_type_penalty.c b/icasadi/extern/auto_casadi_constraints_type_penalty.c deleted file mode 100644 index e33b003b..00000000 --- a/icasadi/extern/auto_casadi_constraints_type_penalty.c +++ /dev/null @@ -1,112 +0,0 @@ -/* This file was automatically generated by CasADi. - The CasADi copyright holders make no ownership claim of its contents. */ -#ifdef __cplusplus -extern "C" { -#endif - -/* How to prefix internal symbols */ -#ifdef CODEGEN_PREFIX - #define NAMESPACE_CONCAT(NS, ID) _NAMESPACE_CONCAT(NS, ID) - #define _NAMESPACE_CONCAT(NS, ID) NS ## ID - #define CASADI_PREFIX(ID) NAMESPACE_CONCAT(CODEGEN_PREFIX, ID) -#else - #define CASADI_PREFIX(ID) auto_casadi_constraints_type_penalty_ ## ID -#endif - -#include - -#ifndef casadi_real -#define casadi_real double -#endif - -#ifndef casadi_int -#define casadi_int long long int -#endif - -/* Add prefix to internal symbols */ -#define casadi_f0 CASADI_PREFIX(f0) -#define casadi_s0 CASADI_PREFIX(s0) -#define casadi_s1 CASADI_PREFIX(s1) - -/* Symbol visibility in DLLs */ -#ifndef CASADI_SYMBOL_EXPORT - #if defined(_WIN32) || defined(__WIN32__) || defined(__CYGWIN__) - #if defined(STATIC_LINKED) - #define CASADI_SYMBOL_EXPORT - #else - #define CASADI_SYMBOL_EXPORT __declspec(dllexport) - #endif - #elif defined(__GNUC__) && defined(GCC_HASCLASSVISIBILITY) - #define CASADI_SYMBOL_EXPORT __attribute__ ((visibility ("default"))) - #else - #define CASADI_SYMBOL_EXPORT - #endif -#endif - -static const casadi_int casadi_s0[24] = {20, 1, 0, 20, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19}; -static const casadi_int casadi_s1[5] = {1, 1, 0, 1, 0}; - -/* constraints_as_penalty:(i0[20],i1)->(o0) */ -static int casadi_f0(const casadi_real** arg, casadi_real** res, casadi_int* iw, casadi_real* w, void* mem) { - casadi_real a0; - a0=0.; - if (res[0]!=0) res[0][0]=a0; - return 0; -} - -CASADI_SYMBOL_EXPORT int constraints_as_penalty(const casadi_real** arg, casadi_real** res, casadi_int* iw, casadi_real* w, void* mem){ - return casadi_f0(arg, res, iw, w, mem); -} - -CASADI_SYMBOL_EXPORT void constraints_as_penalty_incref(void) { -} - -CASADI_SYMBOL_EXPORT void constraints_as_penalty_decref(void) { -} - -CASADI_SYMBOL_EXPORT casadi_int constraints_as_penalty_n_in(void) { return 2;} - -CASADI_SYMBOL_EXPORT casadi_int constraints_as_penalty_n_out(void) { return 1;} - -CASADI_SYMBOL_EXPORT const char* constraints_as_penalty_name_in(casadi_int i){ - switch (i) { - case 0: return "i0"; - case 1: return "i1"; - default: return 0; - } -} - -CASADI_SYMBOL_EXPORT const char* constraints_as_penalty_name_out(casadi_int i){ - switch (i) { - case 0: return "o0"; - default: return 0; - } -} - -CASADI_SYMBOL_EXPORT const casadi_int* constraints_as_penalty_sparsity_in(casadi_int i) { - switch (i) { - case 0: return casadi_s0; - case 1: return casadi_s1; - default: return 0; - } -} - -CASADI_SYMBOL_EXPORT const casadi_int* constraints_as_penalty_sparsity_out(casadi_int i) { - switch (i) { - case 0: return casadi_s1; - default: return 0; - } -} - -CASADI_SYMBOL_EXPORT int constraints_as_penalty_work(casadi_int *sz_arg, casadi_int* sz_res, casadi_int *sz_iw, casadi_int *sz_w) { - if (sz_arg) *sz_arg = 2; - if (sz_res) *sz_res = 1; - if (sz_iw) *sz_iw = 0; - if (sz_w) *sz_w = 0; - return 0; -} - - -#ifdef __cplusplus -} /* extern "C" */ -#endif diff --git a/open-codegen/CHANGELOG.md b/open-codegen/CHANGELOG.md index 0782a291..751bb5ba 100644 --- a/open-codegen/CHANGELOG.md +++ b/open-codegen/CHANGELOG.md @@ -8,6 +8,31 @@ and this project adheres to [Semantic Versioning](http://semver.org/). Note: This is the Changelog file of `opengen` - the Python interface of OpEn +## [0.10.0] - 2026-03-19 + + +### Added + +- New high-level `opengen.ocp` package for constructing optimal control problems from dynamics, stage/terminal costs, named parameters, and OCP-oriented constraints +- Support for both single-shooting and multiple-shooting formulations, including penalty- and ALM-based constraint lowering +- `GeneratedOptimizer` wrapper with `solve(x0=..., xref=...)` API and persistent optimizer metadata via `optimizer_manifest.json` plus `rollout.casadi` +- Continuous-time dynamics discretization helpers in `opengen.ocp.DynamicsDiscretizer`, including Euler, midpoint, Heun, RK4, and multistep discretizations +- Extended `OcpSolution` with solver diagnostics such as cost, penalty, iteration counts, infeasibility, fixed-point residual, and Lagrange multipliers +- Detailed documentation available [here](https://alphaville.github.io/optimization-engine/docs/python-ocp-1), incl. Google Colab notebooks, and updated [API docs](https://alphaville.github.io/optimization-engine/api-dox/html/index.html) + +### Changed + +- Changed the default penalty weight update factor to `1.75` +- Added and refined OCP documentation, docstrings, notebook examples, and automated tests +- Improved readability of printed OCP solver results + +### Fixed + +- Avoid duplicate builder log messages by disabling propagation to the root logger +- Use reliable package version lookup when recording the installed CasADi version in optimizer manifests +- Check valid version in `with_version` + + ## [0.9.6] - 2026-03-14 ### Fixed @@ -255,7 +280,8 @@ Note: This is the Changelog file of `opengen` - the Python interface of OpEn * Fixed `lbfgs` typo -[0.9.5]: https://github.com/alphaville/optimization-engine/compare/opengen-0.9.5...opengen-0.9.6 +[0.10.0]: https://github.com/alphaville/optimization-engine/compare/opengen-0.9.6...opengen-0.10.0 +[0.9.6]: https://github.com/alphaville/optimization-engine/compare/opengen-0.9.5...opengen-0.9.6 [0.9.5]: https://github.com/alphaville/optimization-engine/compare/opengen-0.9.4...opengen-0.9.5 [0.9.4]: https://github.com/alphaville/optimization-engine/compare/opengen-0.9.3...opengen-0.9.4 [0.9.3]: https://github.com/alphaville/optimization-engine/compare/opengen-0.9.2...opengen-0.9.3 diff --git a/open-codegen/VERSION b/open-codegen/VERSION index 9cf03868..2774f858 100644 --- a/open-codegen/VERSION +++ b/open-codegen/VERSION @@ -1 +1 @@ -0.9.6 \ No newline at end of file +0.10.0 \ No newline at end of file diff --git a/open-codegen/opengen/__init__.py b/open-codegen/opengen/__init__.py index 8e50b150..d7cedb79 100644 --- a/open-codegen/opengen/__init__.py +++ b/open-codegen/opengen/__init__.py @@ -4,3 +4,4 @@ import opengen.functions import opengen.constraints import opengen.tcp +import opengen.ocp diff --git a/open-codegen/opengen/builder/optimizer_builder.py b/open-codegen/opengen/builder/optimizer_builder.py index 0ff153e7..316f5422 100644 --- a/open-codegen/opengen/builder/optimizer_builder.py +++ b/open-codegen/opengen/builder/optimizer_builder.py @@ -86,6 +86,9 @@ def with_verbosity_level(self, verbosity_level): self.__logger.setLevel(verbosity_level) self.__logger.handlers.clear() self.__logger.addHandler(stream_handler) + # Keep logs on this named logger only; otherwise callers that configure + # the root logger can end up seeing every builder message twice. + self.__logger.propagate = False return self @@ -656,7 +659,6 @@ def __check_user_provided_parameters(self): "For now we only support orthans (e.g., F1(u, p) <= 0).") def __generate_code_python_bindings(self): - self.__logger.info("Generating code for Python bindings") target_dir = self.__target_dir() python_bindings_dir_name = _PYTHON_BINDINGS_PREFIX + self.__meta.optimizer_name python_bindings_dir = os.path.join( diff --git a/open-codegen/opengen/config/meta.py b/open-codegen/opengen/config/meta.py index c1400239..5d742a1c 100644 --- a/open-codegen/opengen/config/meta.py +++ b/open-codegen/opengen/config/meta.py @@ -1,6 +1,18 @@ import re # regular expressions +SEMVER_PATTERN = re.compile( + r"^(0|[1-9]\d*)\." + r"(0|[1-9]\d*)\." + r"(0|[1-9]\d*)" + r"(?:-" + r"(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)" + r"(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*" + r")?" + r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$" +) + + class OptimizerMeta: """Metadata of auto-generated parametric optimizer @@ -107,8 +119,20 @@ def with_version(self, optimizer_version): :param optimizer_version: version of auto-generated optimizer + :raises ValueError: if ``optimizer_version`` is not a valid Cargo + package version following Semantic Versioning, for example + ``0.1.0`` or ``1.2.3-alpha.1`` + :returns: The current instance of OptimizerMeta """ + if not isinstance(optimizer_version, str) or not SEMVER_PATTERN.match(optimizer_version): + raise ValueError( + "invalid optimizer version {!r}; expected a valid Cargo package " + "version following Semantic Versioning, for example " + "'0.1.0', '1.2.3-alpha.1', or '1.0.0+build.5'".format( + optimizer_version + ) + ) self.__optimizer_version = optimizer_version return self diff --git a/open-codegen/opengen/config/solver_config.py b/open-codegen/opengen/config/solver_config.py index abed19fb..8958e66f 100644 --- a/open-codegen/opengen/config/solver_config.py +++ b/open-codegen/opengen/config/solver_config.py @@ -19,7 +19,7 @@ def __init__(self): # For the initial penalty, None means that the actual value will be computed # in Rust (see templates/optimizer.rs) self.__initial_penalty = None - self.__penalty_weight_update_factor = 5.0 + self.__penalty_weight_update_factor = 1.75 self.__max_duration_micros = 5000000 self.__inner_tolerance_update_factor = 0.1 self.__sufficient_decrease_coefficient = 0.1 diff --git a/open-codegen/opengen/ocp/__init__.py b/open-codegen/opengen/ocp/__init__.py new file mode 100644 index 00000000..3cdfa0cb --- /dev/null +++ b/open-codegen/opengen/ocp/__init__.py @@ -0,0 +1,19 @@ +"""High-level OCP interface for building optimizers with ``opengen``.""" + +from .builder import GeneratedOptimizer, OCPBuilder +from .dynamics import DynamicsDiscretizer +from .parameter import ParameterDefinition, ParameterPack, ParameterView +from .problem import OptimalControlProblem, ShootingMethod +from .solution import OcpSolution + +__all__ = [ + "GeneratedOptimizer", + "DynamicsDiscretizer", + "OCPBuilder", + "OcpSolution", + "OptimalControlProblem", + "ParameterDefinition", + "ParameterPack", + "ParameterView", + "ShootingMethod", +] diff --git a/open-codegen/opengen/ocp/builder.py b/open-codegen/opengen/ocp/builder.py new file mode 100644 index 00000000..8a44286c --- /dev/null +++ b/open-codegen/opengen/ocp/builder.py @@ -0,0 +1,564 @@ +"""Builders and runtime wrappers for OCP-generated optimizers.""" + +import importlib +import json +import os +import sys +import hashlib +import platform +from datetime import datetime, timezone +import casadi +import casadi.casadi as cs +from importlib.metadata import version, PackageNotFoundError + +from opengen.builder.optimizer_builder import OpEnOptimizerBuilder +from opengen.builder.problem import Problem +from opengen.config.build_config import BuildConfiguration +from opengen.config.solver_config import SolverConfiguration +from opengen.constraints.no_constraints import NoConstraints +from opengen.tcp.optimizer_tcp_manager import OptimizerTcpManager + +from .constraint_utils import make_constraint_product +from .parameter import ParameterPack +from .problem import ShootingMethod +from .solution import OcpSolution + + +class GeneratedOptimizer: + """High-level runtime wrapper around a generated optimizer. + + This class hides whether the underlying solver is consumed through direct + Python bindings or through the TCP interface and exposes a uniform + ``solve(x0=..., xref=...)`` API based on named parameters. + """ + + def __init__( + self, + optimizer_name, + target_dir, + backend, + backend_kind, + ocp=None, + symbolic_model=None, + metadata=None, + ): + """Construct a generated optimizer wrapper. + + :param optimizer_name: generated optimizer name + :param target_dir: generated optimizer directory + :param backend: low-level backend object + :param backend_kind: backend type, e.g. ``"direct"`` or ``"tcp"`` + :param ocp: source OCP definition + :param symbolic_model: precomputed symbolic model of the OCP + :param metadata: serialized optimizer metadata used when reloading an + optimizer from disk + """ + self.__optimizer_name = optimizer_name + self.__target_dir = os.path.abspath(target_dir) + self.__backend = backend + self.__backend_kind = backend_kind + self.__started = False + self.__rollout_function = None + self.__input_slices = None + self.__state_slices = None + + if metadata is not None: + self.__initialize_from_metadata(metadata) + elif ocp is not None: + self.__initialize_from_ocp(ocp, symbolic_model=symbolic_model) + else: + raise ValueError("either ocp or metadata must be provided") + + def __initialize_from_ocp(self, ocp, symbolic_model=None): + """Populate runtime metadata from an in-memory OCP object.""" + self.__shooting = ocp.shooting + self.__nx = ocp.nx + self.__nu = ocp.nu + self.__horizon = ocp.horizon + self.__parameters = ocp.parameters + model = symbolic_model if symbolic_model is not None else ocp.build_symbolic_model() + self.__input_slices = model.get("input_slices") + self.__state_slices = model.get("state_slices") + self.__rollout_function = self.__make_rollout_function(model) + + def __initialize_from_metadata(self, metadata): + """Populate runtime metadata from a saved JSON manifest.""" + self.__shooting = ShootingMethod(metadata["shooting"]) + self.__nx = metadata["nx"] + self.__nu = metadata["nu"] + self.__horizon = metadata["horizon"] + self.__parameters = ParameterPack() + for definition in metadata["parameters"]: + self.__parameters.add( + definition["name"], + definition["size"], + default=definition["default"], + ) + self.__input_slices = metadata.get("input_slices") + self.__state_slices = metadata.get("state_slices") + self.__rollout_function = metadata.get("rollout_function") + + @property + def target_dir(self): + """Directory of the generated optimizer project.""" + return self.__target_dir + + @property + def optimizer_name(self): + """Name of the generated optimizer.""" + return self.__optimizer_name + + @property + def backend_kind(self): + """Backend kind used by this optimizer wrapper.""" + return self.__backend_kind + + def start(self): + """Start the backend if it is a local TCP server. + + :return: current instance + """ + if self.__backend_kind == "tcp" and not self.__started: + self.__backend.start() + self.__started = True + return self + + def kill(self): + """Stop the backend if it is a local TCP server.""" + if self.__backend_kind == "tcp" and self.__started: + self.__backend.kill() + self.__started = False + + def __make_rollout_function(self, symbolic_model): + """ + Create the state rollout function for single shooting + + The function is used in single shooting formualations only; + a function is constructed to compute the sequence of states + from the sequence of inputs. + """ + if self.__shooting == ShootingMethod.MULTIPLE: + return None + states = cs.horzcat(*symbolic_model["state_trajectory"]) + return cs.Function("ocp_rollout", [symbolic_model["u"], symbolic_model["p"]], [states]) + + def __pack_parameters(self, solve_kwargs): + """Pack named keyword arguments into the flat solver parameter vector.""" + return self.__parameters.pack(solve_kwargs) + + def __extract_inputs(self, flat_solution): + r"""Extract stage-wise inputs :math:`u_0, \ldots, u_{N-1}` from the flat solution.""" + if self.__shooting == ShootingMethod.SINGLE: + nu = self.__nu + return [ + list(flat_solution[stage_idx * nu:(stage_idx + 1) * nu]) + for stage_idx in range(self.__horizon) + ] + + return [ + list(flat_solution[start:stop]) + for start, stop in self.__input_slices + ] + + def __extract_states(self, flat_solution, packed_parameters): + r"""Extract or reconstruct the state trajectory :math:`x_0, \ldots, x_N`.""" + if self.__shooting == ShootingMethod.MULTIPLE: + x0_start, x0_stop = self.__parameters.slices()["x0"] + x0 = packed_parameters[x0_start:x0_stop] + states = [x0] + states.extend( + list(flat_solution[start:stop]) + for start, stop in self.__state_slices + ) + return states + + state_matrix = self.__rollout_function(flat_solution, packed_parameters).full() + return [state_matrix[:, idx].reshape((-1,)).tolist() for idx in range(state_matrix.shape[1])] + + def __metadata_dict(self): + """Return the JSON-serializable optimizer manifest.""" + rollout_sha256 = None + if self.__rollout_function is not None: + rollout_sha256 = None + + return { + "manifest_version": 1, + "optimizer_name": self.__optimizer_name, + "target_dir": self.__target_dir, + "backend_kind": self.__backend_kind, + "created_at": datetime.now(timezone.utc).isoformat(), + "platform": platform.platform(), + "python_version": sys.version, + "opengen_version": self.__safe_package_version("opengen"), + "casadi_version": self.__casadi_version(), + "shooting": self.__shooting.value, + "nx": self.__nx, + "nu": self.__nu, + "horizon": self.__horizon, + "decision_dimension": self.__nu * self.__horizon + if self.__shooting == ShootingMethod.SINGLE + else self.__horizon * (self.__nu + self.__nx), + "parameter_dimension": self.__parameters.total_size(), + "parameters": [ + { + "name": definition.name, + "size": definition.size, + "default": definition.default, + } + for definition in self.__parameters.definitions() + ], + "input_slices": self.__input_slices, + "state_slices": self.__state_slices, + "rollout_file": "rollout.casadi" if self.__rollout_function is not None else None, + "rollout_sha256": rollout_sha256, + } + + @staticmethod + def __safe_package_version(package_name): + """Return the installed version of a package if available.""" + try: + return version(package_name) + except PackageNotFoundError: + return None + + @staticmethod + def __casadi_version(): + """Return the installed CasADi version if available.""" + casadi_version = getattr(casadi, "__version__", None) + if casadi_version is not None: + return casadi_version + return GeneratedOptimizer.__safe_package_version("casadi") + + def save(self, json_path=None): + """Save a manifest that can later recreate this optimizer. + + The manifest is stored in the generated optimizer directory by default. + For single-shooting optimizers, the rollout function is stored in a + separate ``rollout.casadi`` file next to the manifest. + + :param json_path: optional destination manifest path + :return: current instance + """ + if json_path is None: + json_path = os.path.join(self.__target_dir, "optimizer_manifest.json") + + json_path = os.path.abspath(json_path) + manifest_dir = os.path.dirname(json_path) + os.makedirs(manifest_dir, exist_ok=True) + + metadata = self.__metadata_dict() + rollout_file = metadata.get("rollout_file") + if rollout_file is not None: + rollout_path = os.path.join(manifest_dir, rollout_file) + self.__rollout_function.save(rollout_path) + with open(rollout_path, "rb") as fh: + metadata["rollout_sha256"] = hashlib.sha256(fh.read()).hexdigest() + + with open(json_path, "w") as fh: + json.dump(metadata, fh, indent=2) + return self + + @staticmethod + def __load_backend(target_dir, optimizer_name, backend_kind): + """Create a backend object from saved metadata.""" + if backend_kind == "direct": + if target_dir not in sys.path: + sys.path.insert(0, target_dir) + module = importlib.import_module(optimizer_name) + return module.solver() + if backend_kind == "tcp": + return OptimizerTcpManager(target_dir) + if backend_kind == "none": + return None + raise ValueError(f"unknown backend kind '{backend_kind}'") + + @classmethod + def load(cls, json_path): + """Load a previously saved optimizer manifest. + + :param json_path: path to a JSON manifest created by :meth:`save` + :return: reconstructed :class:`GeneratedOptimizer` + """ + json_path = os.path.abspath(json_path) + with open(json_path, "r") as fh: + metadata = json.load(fh) + rollout_file = metadata.get("rollout_file") + if rollout_file is not None: + rollout_path = os.path.join(os.path.dirname(json_path), rollout_file) + metadata["rollout_function"] = cs.Function.load(rollout_path) + rollout_sha256 = metadata.get("rollout_sha256") + if rollout_sha256 is not None: + with open(rollout_path, "rb") as fh: + current_sha256 = hashlib.sha256(fh.read()).hexdigest() + if current_sha256 != rollout_sha256: + raise ValueError("rollout.casadi checksum mismatch") + else: + raise ValueError("manifest is missing rollout_sha256") + backend = cls.__load_backend( + metadata["target_dir"], + metadata["optimizer_name"], + metadata["backend_kind"], + ) + return cls( + optimizer_name=metadata["optimizer_name"], + target_dir=metadata["target_dir"], + backend=backend, + backend_kind=metadata["backend_kind"], + metadata=metadata, + ) + + def solve( + self, + initial_guess=None, + initial_lagrange_multipliers=None, + initial_penalty=None, + **parameter_values, + ): + r"""Solve the generated OCP optimizer. + + Named keyword arguments are packed according to the declared OCP + parameters. For example, if the OCP declares ``x0`` and ``xref``, the + solver can be called as ``optimizer.solve(x0=x0, xref=xref)`` to solve + the OCP for a given initial condition :math:`x_0` and reference + :math:`x^{\mathrm{ref}}`. + + :param initial_guess: optional initial decision-variable guess + :param initial_lagrange_multipliers: optional initial ALM multipliers + :param initial_penalty: optional initial penalty parameter + :param parameter_values: named parameter values + :return: :class:`OcpSolution` + :raises RuntimeError: if the backend is unavailable or the low-level + solve call fails + """ + packed_parameters = self.__pack_parameters(parameter_values) + + if self.__backend_kind == "direct": + raw = self.__backend.run( + p=packed_parameters, + initial_guess=initial_guess, + initial_lagrange_multipliers=initial_lagrange_multipliers, + initial_penalty=initial_penalty, + ) + if raw is None: + raise RuntimeError("solver failed") + elif self.__backend_kind == "tcp": + self.start() + response = self.__backend.call( + packed_parameters, + initial_guess=initial_guess, + initial_y=initial_lagrange_multipliers, + initial_penalty=initial_penalty, + ) + if not response.is_ok(): + raise RuntimeError(str(response.get())) + raw = response.get() + else: + raise RuntimeError("optimizer backend is not available") + + inputs = self.__extract_inputs(raw.solution) + states = self.__extract_states(raw.solution, packed_parameters) + return OcpSolution(raw=raw, inputs=inputs, states=states) + + +class OCPBuilder: + """Builder that lowers an OCP to the low-level OpEn builder stack.""" + + def __init__( + self, + problem, + metadata, + build_configuration=BuildConfiguration(), + solver_configuration=None, + ): + """Construct an OCP builder. + + :param problem: instance of :class:`OptimalControlProblem` + :param metadata: optimizer metadata + :param build_configuration: OpEn build configuration + :param solver_configuration: OpEn solver configuration + """ + self.__ocp = problem + self.__metadata = metadata + self.__build_configuration = build_configuration + self.__solver_configuration = ( + solver_configuration + if solver_configuration is not None + else SolverConfiguration() + ) + self.__generate_not_build = False + + def with_generate_not_build_flag(self, flag): + """Generate code without compiling it. + + :param flag: whether to generate only + :return: current instance + """ + self.__generate_not_build = flag + return self + + def __make_input_constraints(self): + r"""Build the hard input constraint set for single shooting. + + This produces a stage-wise product set for the control inputs + :math:`u_0, \ldots, u_{N-1}`. + """ + stage_constraints = self.__ocp.input_constraints + if stage_constraints is None: + return NoConstraints() + + if self.__ocp.horizon == 1: + return stage_constraints + + segments = [((idx + 1) * self.__ocp.nu) - 1 for idx in range(self.__ocp.horizon)] + constraints = [stage_constraints] * self.__ocp.horizon + return make_constraint_product(segments, constraints) + + def __make_multiple_shooting_constraints(self): + """Build the hard decision-variable set for multiple shooting. + + Depending on the selected OCP options, this acts on input blocks + :math:`u_t`, stage-wise state-input blocks :math:`(x_t, u_t)`, or the + terminal state :math:`x_N`. + """ + stage_constraints = self.__ocp.input_constraints + if stage_constraints is None: + stage_constraints = NoConstraints() + + hard_stage_constraints = self.__ocp.hard_stage_state_input_constraints + hard_terminal_constraints = self.__ocp.hard_terminal_state_constraints + + if hard_stage_constraints is not None and self.__ocp.input_constraints is not None: + raise ValueError( + "cannot combine with_input_constraints with " + "with_hard_stage_state_input_constraints automatically; " + "encode the input bounds directly in the stage state-input set" + ) + + if hard_stage_constraints is not None and hard_terminal_constraints is not None: + raise ValueError( + "cannot combine with_hard_stage_state_input_constraints with " + "with_hard_terminal_state_constraints automatically; " + "their intersection on the terminal stage is not supported" + ) + + if hard_stage_constraints is not None: + segments = [ + ((idx + 1) * (self.__ocp.nu + self.__ocp.nx)) - 1 + for idx in range(self.__ocp.horizon) + ] + constraints = [hard_stage_constraints] * self.__ocp.horizon + return make_constraint_product(segments, constraints) + + if hard_terminal_constraints is not None: + segments = [] + constraints = [] + offset = -1 + + for _ in range(self.__ocp.horizon - 1): + offset += self.__ocp.nu + segments.append(offset) + constraints.append(stage_constraints) + + offset += self.__ocp.nx + segments.append(offset) + constraints.append(NoConstraints()) + + offset += self.__ocp.nu + segments.append(offset) + constraints.append(stage_constraints) + + offset += self.__ocp.nx + segments.append(offset) + constraints.append(hard_terminal_constraints) + + return make_constraint_product(segments, constraints) + + segments = [] + constraints = [] + offset = -1 + + for _ in range(self.__ocp.horizon): + offset += self.__ocp.nu + segments.append(offset) + constraints.append(stage_constraints) + + offset += self.__ocp.nx + segments.append(offset) + constraints.append(NoConstraints()) + + return make_constraint_product(segments, constraints) + + def build_problem(self, symbolic_model=None): + """Lower the OCP to a low-level :class:`opengen.builder.problem.Problem`. + + :return: low-level OpEn problem + """ + model = symbolic_model if symbolic_model is not None else self.__ocp.build_symbolic_model() + low_level_problem = Problem(model["u"], model["p"], model["cost"]) + + if self.__ocp.shooting == ShootingMethod.SINGLE: + constraints = self.__make_input_constraints() + elif self.__ocp.shooting == ShootingMethod.MULTIPLE: + constraints = self.__make_multiple_shooting_constraints() + else: + raise NotImplementedError("unsupported shooting method") + + low_level_problem = low_level_problem.with_constraints(constraints) + + if model["alm_mapping"] is not None: + low_level_problem = low_level_problem.with_aug_lagrangian_constraints( + model["alm_mapping"], + model["alm_set_c"], + model["alm_set_y"], + ) + + if model["penalty_mapping"] is not None: + low_level_problem = low_level_problem.with_penalty_constraints( + model["penalty_mapping"] + ) + + return low_level_problem + + def __make_backend(self, target_dir): + """Create the runtime backend associated with the generated optimizer.""" + optimizer_name = self.__metadata.optimizer_name + + if self.__build_configuration.build_python_bindings: + if target_dir not in sys.path: + sys.path.insert(0, target_dir) + module = importlib.import_module(optimizer_name) + return module.solver(), "direct" + + if self.__build_configuration.tcp_interface_config is not None: + return OptimizerTcpManager(target_dir), "tcp" + + return None, "none" + + def build(self): + """Generate, compile, and wrap the optimizer. + + :return: :class:`GeneratedOptimizer` + """ + symbolic_model = self.__ocp.build_symbolic_model() + low_level_problem = self.build_problem(symbolic_model=symbolic_model) + builder = OpEnOptimizerBuilder( + low_level_problem, + metadata=self.__metadata, + build_configuration=self.__build_configuration, + solver_configuration=self.__solver_configuration, + ) + if self.__generate_not_build: + builder.with_generate_not_build_flag(True) + info = builder.build() + target_dir = os.path.abspath(info["paths"]["target"]) + backend, backend_kind = self.__make_backend(target_dir) + optimizer = GeneratedOptimizer( + optimizer_name=self.__metadata.optimizer_name, + target_dir=target_dir, + backend=backend, + backend_kind=backend_kind, + ocp=self.__ocp, + symbolic_model=symbolic_model, + ) + optimizer.save() + return optimizer diff --git a/open-codegen/opengen/ocp/constraint_utils.py b/open-codegen/opengen/ocp/constraint_utils.py new file mode 100644 index 00000000..0a22892c --- /dev/null +++ b/open-codegen/opengen/ocp/constraint_utils.py @@ -0,0 +1,65 @@ +"""Helpers for simplifying products of OCP constraint sets.""" + +from opengen.constraints.cartesian import CartesianProduct +from opengen.constraints.no_constraints import NoConstraints +from opengen.constraints.rectangle import Rectangle +from opengen.constraints.zero import Zero + + +def segment_dimensions(segments): + """Compute segment dimensions from Cartesian-product end indices.""" + dimensions = [] + previous = -1 + for segment_end in segments: + dimensions.append(segment_end - previous) + previous = segment_end + return dimensions + + +def rectangle_bounds(constraint, dimension): + """Return explicit box bounds when a set admits a rectangle representation.""" + if isinstance(constraint, NoConstraints): + return [float("-inf")] * dimension, [float("inf")] * dimension + + if isinstance(constraint, Zero): + return [0.0] * dimension, [0.0] * dimension + + if isinstance(constraint, Rectangle): + if constraint.dimension() != dimension: + raise ValueError("constraint dimension does not match its segment length") + xmin = constraint.xmin if constraint.xmin is not None else [float("-inf")] * dimension + xmax = constraint.xmax if constraint.xmax is not None else [float("inf")] * dimension + return list(xmin), list(xmax) + + return None + + +def make_constraint_product(segments, constraints): + """Build the most specific set representing a block product of constraints.""" + if not constraints: + return NoConstraints() + + if len(constraints) == 1: + return constraints[0] + + if all(isinstance(constraint, NoConstraints) for constraint in constraints): + return NoConstraints() + + if all(isinstance(constraint, Zero) for constraint in constraints): + return Zero() + + dimensions = segment_dimensions(segments) + rectangle_data = [ + rectangle_bounds(constraint, dimension) + for constraint, dimension in zip(constraints, dimensions) + ] + + if all(bounds is not None for bounds in rectangle_data): + xmin = [] + xmax = [] + for current_xmin, current_xmax in rectangle_data: + xmin.extend(current_xmin) + xmax.extend(current_xmax) + return Rectangle(xmin, xmax) + + return CartesianProduct(segments, constraints) diff --git a/open-codegen/opengen/ocp/dynamics.py b/open-codegen/opengen/ocp/dynamics.py new file mode 100644 index 00000000..9f9ca3c7 --- /dev/null +++ b/open-codegen/opengen/ocp/dynamics.py @@ -0,0 +1,167 @@ +"""Continuous-time dynamics discretization helpers for OCP problems.""" + +import casadi.casadi as cs + + +class DynamicsDiscretizer: + r"""Discretizer for continuous-time dynamics. + + This helper wraps a continuous-time model of the form + :math:`\dot{x} = f(x, u, p)` and produces a discrete-time callback + compatible with :meth:`opengen.ocp.problem.OptimalControlProblem.with_dynamics`. + """ + + def __init__(self, continuous_dynamics, sampling_time): + r"""Construct a dynamics discretizer. + + :param continuous_dynamics: callable implementing + :math:`\dot{x} = f(x, u, p)` + :param sampling_time: sampling time :math:`T_s` + :raises ValueError: if the sampling time is not positive + """ + if sampling_time <= 0: + raise ValueError("sampling_time must be positive") + + self.__continuous_dynamics = continuous_dynamics + self.__sampling_time = float(sampling_time) + + @property + def sampling_time(self): + """Sampling time :math:`T_s`.""" + return self.__sampling_time + + @property + def continuous_dynamics(self): + """Continuous-time dynamics callback.""" + return self.__continuous_dynamics + + def euler(self): + r"""Return an explicit Euler discretization callback. + + The returned callback implements: + + :math:`x^+ = x + T_s f(x, u, p)`. + """ + + def discrete_dynamics(x, u, param): + return x + self.__sampling_time * self.__continuous_dynamics(x, u, param) + + return discrete_dynamics + + def midpoint(self): + r"""Return an explicit midpoint discretization callback. + + The returned callback implements the second-order Runge-Kutta rule: + + :math:`x^+ = x + T_s f(x + \tfrac{T_s}{2} k_1, u, p)`, + + where :math:`k_1 = f(x, u, p)`. + """ + ts = self.__sampling_time + f = self.__continuous_dynamics + + def discrete_dynamics(x, u, param): + k1 = f(x, u, param) + midpoint_state = x + 0.5 * ts * k1 + return x + ts * f(midpoint_state, u, param) + + return discrete_dynamics + + def heun(self): + r"""Return a Heun discretization callback. + + The returned callback implements the explicit trapezoidal method: + + :math:`x^+ = x + \tfrac{T_s}{2}(k_1 + k_2)`, + + where :math:`k_1 = f(x, u, p)` and + :math:`k_2 = f(x + T_s k_1, u, p)`. + """ + ts = self.__sampling_time + f = self.__continuous_dynamics + + def discrete_dynamics(x, u, param): + k1 = f(x, u, param) + k2 = f(x + ts * k1, u, param) + return x + 0.5 * ts * (k1 + k2) + + return discrete_dynamics + + def rk4(self): + r"""Return a classical fourth-order Runge-Kutta discretization callback. + + The returned callback implements: + + :math:`x^+ = x + \frac{T_s}{6}(k_1 + 2k_2 + 2k_3 + k_4)`, + + where the intermediate slopes are computed from the continuous-time + dynamics. + """ + ts = self.__sampling_time + f = self.__continuous_dynamics + + def discrete_dynamics(x, u, param): + k1 = f(x, u, param) + k2 = f(x + 0.5 * ts * k1, u, param) + k3 = f(x + 0.5 * ts * k2, u, param) + k4 = f(x + ts * k3, u, param) + return x + (ts / 6.0) * (k1 + 2.0 * k2 + 2.0 * k3 + k4) + + return discrete_dynamics + + def multistep(self, method="rk4", num_steps=1): + """Return a discrete-time callback with internal substeps. + + This helper subdivides the sampling interval :math:`T_s` into + ``num_steps`` smaller steps and applies the chosen explicit method on + each subinterval. + + Supported methods are ``"euler"``, ``"midpoint"``, ``"heun"``, and + ``"rk4"``. + + :param method: base discretization method + :param num_steps: number of internal substeps + :return: discrete-time dynamics callback + :raises ValueError: if ``num_steps`` is not a positive integer or the + method is unknown + """ + if not isinstance(num_steps, int) or num_steps <= 0: + raise ValueError("num_steps must be a positive integer") + + if num_steps == 1: + return self.discretize(method) + + substep = DynamicsDiscretizer( + self.__continuous_dynamics, + self.__sampling_time / float(num_steps), + ) + base_step = substep.discretize(method) + + def discrete_dynamics(x, u, param): + x_next = x + for _ in range(num_steps): + x_next = base_step(x_next, u, param) + return x_next + + return discrete_dynamics + + def discretize(self, method="euler"): + """Return a discrete-time dynamics callback for a chosen method. + + Supported methods are ``"euler"``, ``"midpoint"``, ``"heun"``, and + ``"rk4"``. + + :param method: discretization method + :return: discrete-time dynamics callback + :raises ValueError: if the method is unknown + """ + method = method.lower() + if method == "euler": + return self.euler() + if method == "midpoint": + return self.midpoint() + if method == "heun": + return self.heun() + if method == "rk4": + return self.rk4() + raise ValueError(f"unknown discretization method '{method}'") diff --git a/open-codegen/opengen/ocp/parameter.py b/open-codegen/opengen/ocp/parameter.py new file mode 100644 index 00000000..1f0a91f2 --- /dev/null +++ b/open-codegen/opengen/ocp/parameter.py @@ -0,0 +1,209 @@ +"""Utilities for named OCP parameters and flat OpEn parameter vectors.""" + +import casadi.casadi as cs + + +class ParameterDefinition: + """Definition of a named parameter block. + + A parameter block has a name, a dimension and, optionally, a default + numeric value. Multiple parameter blocks are later packed into the flat + parameter vector expected by the low-level OpEn builder. + """ + + def __init__(self, name, size, default=None): + """Construct a parameter definition. + + :param name: parameter name + :param size: block dimension + :param default: optional default numeric value + :raises ValueError: if the name, size or default are invalid + """ + if not isinstance(name, str) or not name: + raise ValueError("parameter name must be a non-empty string") + if not isinstance(size, int) or size <= 0: + raise ValueError("parameter size must be a positive integer") + + self.__name = name + self.__size = size + self.__default = ParameterDefinition._normalize_default(default, size) + + @staticmethod + def _normalize_default(default, size): + """Validate and normalize a default value to a list of floats.""" + if default is None: + return None + + if size == 1 and not isinstance(default, (list, tuple)): + return [float(default)] + + if len(default) != size: + raise ValueError("default value has incompatible dimension") + + return [float(value) for value in default] + + @property + def name(self): + """Name of the parameter block.""" + return self.__name + + @property + def size(self): + """Dimension of the parameter block.""" + return self.__size + + @property + def default(self): + """Default value as a list of floats, or ``None`` if absent.""" + return None if self.__default is None else list(self.__default) + + def has_default(self): + """Whether this parameter block has a default value.""" + return self.__default is not None + + +class ParameterView: + """Dictionary-like accessor for symbolic parameter slices. + + Instances of this class are passed to user-defined callbacks so they can + access named symbolic parameters using expressions such as + ``param["xref"]``. + """ + + def __init__(self, packed_symbol, slices): + """Construct a parameter view. + + :param packed_symbol: flat symbolic parameter vector + :param slices: dictionary mapping parameter names to index pairs + """ + self.__packed_symbol = packed_symbol + self.__slices = dict(slices) + + def __getitem__(self, name): + """Return the symbolic slice associated with a named parameter.""" + if name not in self.__slices: + raise KeyError(f"unknown parameter '{name}'") + start, end = self.__slices[name] + return self.__packed_symbol[start:end] + + def get(self, name, default=None): + """Return the symbolic slice associated with ``name`` if it exists.""" + if name not in self.__slices: + return default + return self[name] + + def keys(self): + """Return all known parameter names.""" + return self.__slices.keys() + + @property + def packed(self): + """The underlying flat symbolic parameter vector.""" + return self.__packed_symbol + + +class ParameterPack: + """Registry and packer for named parameters. + + This class stores the declared parameter blocks of an OCP and provides + helpers to: + + - construct a flat symbolic parameter vector, + - create named symbolic views into that vector, and + - pack concrete numeric values for calls to the generated solver. + """ + + def __init__(self, symbol_type=cs.SX.sym): + """Construct an empty parameter registry. + + :param symbol_type: CasADi symbol constructor, typically ``cs.SX.sym`` + or ``cs.MX.sym`` + """ + self.__definitions = [] + self.__definitions_by_name = {} + self.__symbol_type = symbol_type + + def add(self, name, size, default=None): + """Add a named parameter block. + + :param name: parameter name + :param size: block dimension + :param default: optional default numeric value + :return: current instance + :raises ValueError: if a parameter with the same name already exists + """ + if name in self.__definitions_by_name: + raise ValueError(f"parameter '{name}' already exists") + + definition = ParameterDefinition(name, size, default=default) + self.__definitions.append(definition) + self.__definitions_by_name[name] = definition + return self + + def total_size(self): + """Return the total dimension of the packed parameter vector.""" + return sum(definition.size for definition in self.__definitions) + + def symbol(self, name="p"): + """Construct the packed symbolic parameter vector. + + :param name: symbolic variable name + :return: CasADi symbolic vector containing all parameter blocks + """ + return self.__symbol_type(name, self.total_size()) + + def slices(self): + """Return the slice map of all parameter blocks. + + :return: dictionary ``name -> (start, stop)`` + """ + offset = 0 + slices = {} + for definition in self.__definitions: + next_offset = offset + definition.size + slices[definition.name] = (offset, next_offset) + offset = next_offset + return slices + + def view(self, packed_symbol): + """Create a named symbolic view of a packed parameter vector.""" + return ParameterView(packed_symbol, self.slices()) + + def pack(self, values=None): + """Pack numeric values into the flat parameter vector. + + :param values: dictionary mapping parameter names to values + :return: flat list of floats + :raises ValueError: if a required parameter is missing or dimensions are + inconsistent + """ + values = {} if values is None else dict(values) + packed = [] + missing = [] + + for definition in self.__definitions: + value = values.get(definition.name, definition.default) + if value is None: + missing.append(definition.name) + continue + + if definition.size == 1 and not isinstance(value, (list, tuple)): + value = [value] + + if len(value) != definition.size: + raise ValueError( + f"parameter '{definition.name}' has incompatible dimension" + ) + + packed.extend(float(item) for item in value) + + if missing: + raise ValueError( + "missing values for parameters: " + ", ".join(sorted(missing)) + ) + + return packed + + def definitions(self): + """Return the declared parameter definitions.""" + return list(self.__definitions) diff --git a/open-codegen/opengen/ocp/problem.py b/open-codegen/opengen/ocp/problem.py new file mode 100644 index 00000000..fd1a5db2 --- /dev/null +++ b/open-codegen/opengen/ocp/problem.py @@ -0,0 +1,532 @@ +"""High-level optimal control problem definitions for ``opengen``.""" + +from enum import Enum + +import casadi.casadi as cs + +from opengen.constraints.zero import Zero + +from .constraint_utils import make_constraint_product +from .parameter import ParameterPack + + +class ShootingMethod(Enum): + """Available OCP formulations.""" + + SINGLE = "single" + MULTIPLE = "multiple" + + +class OptimalControlProblem: + """High-level specification of a finite-horizon optimal control problem. + + The OCP layer collects dynamics, costs, parameters and constraints in an + OCP-oriented format and later lowers them to the low-level + :class:`opengen.builder.problem.Problem` abstraction. + """ + + def __init__(self, nx, nu, horizon, shooting=ShootingMethod.SINGLE): + """Construct a new optimal control problem. + + :param nx: state dimension + :param nu: input dimension + :param horizon: prediction horizon + :param shooting: shooting formulation, either single or multiple + :raises ValueError: if dimensions are invalid + """ + if not isinstance(nx, int) or nx <= 0: + raise ValueError("nx must be a positive integer") + if not isinstance(nu, int) or nu <= 0: + raise ValueError("nu must be a positive integer") + if not isinstance(horizon, int) or horizon <= 0: + raise ValueError("horizon must be a positive integer") + + if isinstance(shooting, str): + shooting = ShootingMethod(shooting) + + self.__nx = nx + self.__nu = nu + self.__horizon = horizon + self.__shooting = shooting + self.__symbol_type = cs.SX.sym + self.__parameters = ParameterPack(symbol_type=self.__symbol_type) + + self.__dynamics = None + self.__dynamics_constraint_kind = "penalty" + self.__stage_cost = None + self.__terminal_cost = None + self.__input_constraints = None + self.__hard_stage_state_input_constraints = None + self.__hard_terminal_state_constraints = None + self.__path_constraints = [] + self.__terminal_constraints = [] + + @property + def nx(self): + """State dimension.""" + return self.__nx + + @property + def nu(self): + """Input dimension.""" + return self.__nu + + @property + def horizon(self): + """Prediction horizon.""" + return self.__horizon + + @property + def shooting(self): + """Selected shooting formulation.""" + return self.__shooting + + @property + def symbol_type(self): + """CasADi symbol constructor used internally.""" + return self.__symbol_type + + @property + def parameters(self): + """Declared named parameters.""" + return self.__parameters + + @property + def dynamics(self): + """User-defined dynamics callback.""" + return self.__dynamics + + @property + def input_constraints(self): + """Hard stage-wise input constraints.""" + return self.__input_constraints + + @property + def hard_stage_state_input_constraints(self): + """Hard multiple-shooting constraints on stage-wise ``(x_t, u_t)``.""" + return self.__hard_stage_state_input_constraints + + @property + def hard_terminal_state_constraints(self): + """Hard multiple-shooting constraints on the terminal state ``x_N``.""" + return self.__hard_terminal_state_constraints + + @property + def path_constraints(self): + """Stage-wise symbolic PM/ALM constraints.""" + return list(self.__path_constraints) + + @property + def terminal_constraints(self): + """Terminal symbolic PM/ALM constraints.""" + return list(self.__terminal_constraints) + + def add_parameter(self, name, size, default=None): + """Add a named parameter block to the OCP. + + :param name: parameter name + :param size: parameter dimension + :param default: optional default numeric value + :return: current instance + + :raises ValueError: if the parameter definition is invalid or a + parameter with the same name has already been declared + """ + self.__parameters.add(name, size, default=default) + return self + + def with_dynamics(self, dynamics): + """Specify the system dynamics callback. + + The callback must accept ``(x, u, param)`` and return the next state + :math:`x^+ = F(x, u, p)`. + + Note that the callback must be a function involving + symbolic computations using CasADi functions. + + :param dynamics: dynamics callback + :return: current instance + """ + self.__dynamics = dynamics + return self + + def with_dynamics_constraints(self, kind="penalty"): + """Choose how multiple-shooting defect constraints are imposed. + + Supported values are ``"penalty"`` and ``"alm"``. This option is only + relevant in multiple shooting. The corresponding defect constraints are + of the form :math:`x_{t+1} - F(x_t, u_t, p)`. + + :param kind: constraint treatment for dynamics defects + :return: current instance + :raises ValueError: if ``kind`` is invalid + """ + if kind not in ("penalty", "alm"): + raise ValueError("dynamics constraint kind must be either 'penalty' or 'alm'") + self.__dynamics_constraint_kind = kind + return self + + def with_stage_cost(self, stage_cost): + """Specify the stage cost callback. + + The callback must accept ``(x, u, param, stage_idx)``. + + :param stage_cost: stage cost callback + :return: current instance + """ + self.__stage_cost = stage_cost + return self + + def with_terminal_cost(self, terminal_cost): + """Specify the terminal cost callback. + + The callback must accept ``(x, param)``. + + :param terminal_cost: terminal cost callback + :return: current instance + """ + self.__terminal_cost = terminal_cost + return self + + def with_input_constraints(self, constraints): + """Specify hard constraints on the stage-wise control inputs. + + In single shooting these are the only hard constraints that act + directly on decision variables. In multiple shooting they are applied + to every input block :math:`u_t`. + + :param constraints: instance of ``opengen.constraints.Constraint`` + :return: current instance + """ + self.__input_constraints = constraints + return self + + def with_hard_stage_state_input_constraints(self, constraints): + r"""Specify hard stage-wise constraints on :math:`(x_t, u_t)`. + + This method is only meaningful in the multiple-shooting formulation. + + :param constraints: set acting on the stacked vector + :math:`\operatorname{vertcat}(x_t, u_t)` + :return: current instance + """ + self.__hard_stage_state_input_constraints = constraints + return self + + def with_hard_terminal_state_constraints(self, constraints): + """Specify hard constraints on the terminal state :math:`x_N`. + + This method is only meaningful in the multiple-shooting formulation. + + :param constraints: set acting on :math:`x_N` + :return: current instance + """ + self.__hard_terminal_state_constraints = constraints + return self + + @staticmethod + def __constraint_dimension(mapping): + """Return the scalar dimension of a CasADi mapping.""" + return mapping.size1() * mapping.size2() + + @staticmethod + def __append_constraint_definition(container, kind, constraint, set_c=None, set_y=None): + """Validate and append a symbolic PM/ALM constraint definition.""" + if kind not in ("penalty", "alm"): + raise ValueError("constraint kind must be either 'penalty' or 'alm'") + if kind == "alm" and set_c is None: + raise ValueError("set_c must be provided for ALM constraints") + container.append({ + "kind": kind, + "constraint": constraint, + "set_c": set_c, + "set_y": set_y, + }) + + @staticmethod + def __assemble_constraint_set(blocks, key): + """Assemble repeated constraint blocks into a set or Cartesian product.""" + if not blocks: + return None + if len(blocks) == 1: + return blocks[0][key] + + segments = [] + constraints = [] + offset = -1 + for block in blocks: + offset += block["dimension"] + segments.append(offset) + constraints.append(block[key]) + return make_constraint_product(segments, constraints) + + def with_path_constraint(self, constraint, kind="penalty", set_c=None, set_y=None): + r"""Add a stage-wise symbolic constraint. + + The callback must accept ``(x, u, param, stage_idx)``. When + ``kind="penalty"``, its output is appended to the penalty mapping + :math:`F_2`. When ``kind="alm"``, its output is appended to the ALM + mapping :math:`F_1` and the user must also provide ``set_c`` so that + the constraints take the form :math:`F_1(x_t, u_t, p) \in C`. + + :param constraint: stage constraint callback + :param kind: either ``"penalty"`` or ``"alm"`` + :param set_c: set :math:`C` for ALM constraints + :param set_y: optional dual set :math:`Y` for ALM constraints + :return: current instance + """ + self.__append_constraint_definition( + self.__path_constraints, + kind, + constraint, + set_c=set_c, + set_y=set_y, + ) + return self + + def with_terminal_constraint(self, constraint, kind="penalty", set_c=None, set_y=None): + """Add a terminal symbolic constraint. + + The callback must accept ``(x, param)``. PM and ALM semantics are the + same as in :meth:`with_path_constraint`. + + :param constraint: terminal constraint callback + :param kind: either ``"penalty"`` or ``"alm"`` + :param set_c: set :math:`C` for ALM constraints + :param set_y: optional dual set :math:`Y` for ALM constraints + :return: current instance + """ + self.__append_constraint_definition( + self.__terminal_constraints, + kind, + constraint, + set_c=set_c, + set_y=set_y, + ) + return self + + def validate(self): + """Validate the OCP definition before lowering it. + + :return: current instance + :raises ValueError: if mandatory components are missing or a hard + state-based constraint is used in single shooting + """ + if self.__dynamics is None: + raise ValueError("dynamics must be specified") + if self.__stage_cost is None: + raise ValueError("stage cost must be specified") + if self.__shooting == ShootingMethod.SINGLE: + if self.__hard_stage_state_input_constraints is not None: + raise ValueError( + "with_hard_stage_state_input_constraints is only available in multiple shooting" + ) + if self.__hard_terminal_state_constraints is not None: + raise ValueError( + "with_hard_terminal_state_constraints is only available in multiple shooting" + ) + return self + + def __build_single_shooting_model(self): + """Build the symbolic low-level model for single shooting.""" + u = self.__symbol_type("u", self.__nu * self.__horizon) + p = self.__parameters.symbol() + param = self.__parameters.view(p) + + x0 = param.get("x0") + if x0 is None: + raise ValueError("parameter 'x0' must be declared for OCP problems") + if x0.size1() * x0.size2() != self.__nx: + raise ValueError("parameter 'x0' has incompatible dimension") + + cost = 0 + x = x0 + states = [x0] + penalty_terms = [] + alm_blocks = [] + + for stage_idx in range(self.__horizon): + start = stage_idx * self.__nu + stop = start + self.__nu + u_t = u[start:stop] + cost += self.__stage_cost(x, u_t, param, stage_idx) + + for definition in self.__path_constraints: + mapping = definition["constraint"](x, u_t, param, stage_idx) + if definition["kind"] == "penalty": + penalty_terms.append(mapping) + else: + alm_blocks.append({ + "mapping": mapping, + "dimension": self.__constraint_dimension(mapping), + "set_c": definition["set_c"], + "set_y": definition["set_y"], + }) + + x = self.__dynamics(x, u_t, param) + states.append(x) + + if self.__terminal_cost is not None: + cost += self.__terminal_cost(x, param) + + for definition in self.__terminal_constraints: + mapping = definition["constraint"](x, param) + if definition["kind"] == "penalty": + penalty_terms.append(mapping) + else: + alm_blocks.append({ + "mapping": mapping, + "dimension": self.__constraint_dimension(mapping), + "set_c": definition["set_c"], + "set_y": definition["set_y"], + }) + + penalty_mapping = None + if penalty_terms: + penalty_mapping = cs.vertcat(*penalty_terms) + + alm_mapping = None + alm_set_c = None + alm_set_y = None + if alm_blocks: + alm_mapping = cs.vertcat(*[block["mapping"] for block in alm_blocks]) + alm_set_c = self.__assemble_constraint_set(alm_blocks, "set_c") + if all(block["set_y"] is not None for block in alm_blocks): + alm_set_y = self.__assemble_constraint_set(alm_blocks, "set_y") + + return { + "shooting": self.__shooting.value, + "u": u, + "p": p, + "param": param, + "cost": cost, + "alm_mapping": alm_mapping, + "alm_set_c": alm_set_c, + "alm_set_y": alm_set_y, + "penalty_mapping": penalty_mapping, + "state_trajectory": states, + } + + def __build_multiple_shooting_model(self): + """Build the symbolic low-level model for multiple shooting.""" + p = self.__parameters.symbol() + param = self.__parameters.view(p) + + x0 = param.get("x0") + if x0 is None: + raise ValueError("parameter 'x0' must be declared for OCP problems") + if x0.size1() * x0.size2() != self.__nx: + raise ValueError("parameter 'x0' has incompatible dimension") + + decision_blocks = [] + input_slices = [] + state_slices = [] + states = [x0] + cost = 0 + penalty_terms = [] + alm_blocks = [] + dynamics_alm_terms = [] + + x_current = x0 + offset = 0 + + for stage_idx in range(self.__horizon): + u_t = self.__symbol_type(f"u_{stage_idx}", self.__nu) + x_next = self.__symbol_type(f"x_{stage_idx + 1}", self.__nx) + + decision_blocks.extend([u_t, x_next]) + input_slices.append((offset, offset + self.__nu)) + offset += self.__nu + state_slices.append((offset, offset + self.__nx)) + offset += self.__nx + + cost += self.__stage_cost(x_current, u_t, param, stage_idx) + + for definition in self.__path_constraints: + mapping = definition["constraint"](x_current, u_t, param, stage_idx) + if definition["kind"] == "penalty": + penalty_terms.append(mapping) + else: + alm_blocks.append({ + "mapping": mapping, + "dimension": self.__constraint_dimension(mapping), + "set_c": definition["set_c"], + "set_y": definition["set_y"], + }) + + dynamics_defect = x_next - self.__dynamics(x_current, u_t, param) + if self.__dynamics_constraint_kind == "penalty": + penalty_terms.append(dynamics_defect) + else: + dynamics_alm_terms.append(dynamics_defect) + + x_current = x_next + states.append(x_current) + + if self.__terminal_cost is not None: + cost += self.__terminal_cost(x_current, param) + + if dynamics_alm_terms: + dynamics_mapping = cs.vertcat(*dynamics_alm_terms) + alm_blocks.append({ + "mapping": dynamics_mapping, + "dimension": self.__constraint_dimension(dynamics_mapping), + "set_c": Zero(), + "set_y": None, + }) + + for definition in self.__terminal_constraints: + mapping = definition["constraint"](x_current, param) + if definition["kind"] == "penalty": + penalty_terms.append(mapping) + else: + alm_blocks.append({ + "mapping": mapping, + "dimension": self.__constraint_dimension(mapping), + "set_c": definition["set_c"], + "set_y": definition["set_y"], + }) + + u = cs.vertcat(*decision_blocks) + penalty_mapping = cs.vertcat(*penalty_terms) if penalty_terms else None + alm_mapping = None + alm_set_c = None + alm_set_y = None + if alm_blocks: + alm_mapping = cs.vertcat(*[block["mapping"] for block in alm_blocks]) + alm_set_c = self.__assemble_constraint_set(alm_blocks, "set_c") + if all(block["set_y"] is not None for block in alm_blocks): + alm_set_y = self.__assemble_constraint_set(alm_blocks, "set_y") + + return { + "shooting": self.__shooting.value, + "u": u, + "p": p, + "param": param, + "cost": cost, + "alm_mapping": alm_mapping, + "alm_set_c": alm_set_c, + "alm_set_y": alm_set_y, + "penalty_mapping": penalty_mapping, + "state_trajectory": states, + "input_slices": input_slices, + "state_slices": state_slices, + } + + def build_symbolic_model(self): + r"""Lower the OCP to a symbolic model understood by the OCP builder. + + The returned dictionary contains the decision variables, packed + parameter vector, symbolic cost, optional ALM/penalty mappings and the + symbolic state trajectory. In particular, the returned mappings are the + low-level objects later used to define constraints of the form + :math:`F_1(u, p) \in C` and :math:`F_2(u, p) = 0`. + + :return: symbolic model dictionary + """ + self.validate() + if self.__shooting == ShootingMethod.SINGLE: + return self.__build_single_shooting_model() + if self.__shooting == ShootingMethod.MULTIPLE: + return self.__build_multiple_shooting_model() + raise NotImplementedError("unsupported shooting method") diff --git a/open-codegen/opengen/ocp/solution.py b/open-codegen/opengen/ocp/solution.py new file mode 100644 index 00000000..8555d1b1 --- /dev/null +++ b/open-codegen/opengen/ocp/solution.py @@ -0,0 +1,68 @@ +"""High-level solution objects returned by OCP-generated optimizers.""" + +from numbers import Real + +class OcpSolution: + """High-level solution returned by :meth:`GeneratedOptimizer.solve`. + + The object wraps the raw low-level solver status and adds OCP-oriented + views such as stage-wise inputs and the reconstructed state trajectory. + """ + + def __init__(self, raw, inputs, states): + """Construct an OCP solution object. + + :param raw: raw low-level solver status + :param inputs: stage-wise input sequence + :param states: state trajectory + """ + self.raw = raw + self.inputs = inputs + self.states = states + self.solution = getattr(raw, "solution", []) + self.cost = getattr(raw, "cost", None) + self.exit_status = getattr(raw, "exit_status", None) + self.solve_time_ms = getattr(raw, "solve_time_ms", None) + self.penalty = getattr(raw, "penalty", None) + self.num_outer_iterations = getattr(raw, "num_outer_iterations", None) + self.num_inner_iterations = getattr(raw, "num_inner_iterations", None) + self.last_problem_norm_fpr = getattr(raw, "last_problem_norm_fpr", None) + self.f1_infeasibility = getattr(raw, "f1_infeasibility", None) + self.f2_norm = getattr(raw, "f2_norm", None) + self.lagrange_multipliers = getattr(raw, "lagrange_multipliers", None) + + def __repr__(self): + """Return a readable multi-line summary of the solution.""" + def fmt(value): + if isinstance(value, bool): + return str(value) + if isinstance(value, Real): + numeric = float(value) + if numeric == 0.0: + return "0.0" + text = f"{numeric:.4g}" + return "0.0" if text == "-0" else text + if isinstance(value, list): + return "[" + ", ".join(fmt(item) for item in value) + "]" + if isinstance(value, tuple): + return "(" + ", ".join(fmt(item) for item in value) + ")" + return str(value) + + return "\n".join([ + "OCP Solution:", + f" Exit status.......... {self.exit_status}", + f" Cost................. {fmt(self.cost)}", + f" Solve time [ms]...... {fmt(self.solve_time_ms)}", + f" Penalty.............. {fmt(self.penalty)}", + f" Outer iterations..... {fmt(self.num_outer_iterations)}", + f" Inner iterations..... {fmt(self.num_inner_iterations)}", + f" FPR.................. {fmt(self.last_problem_norm_fpr)}", + f" ALM infeasibility.... {fmt(self.f1_infeasibility)}", + f" PM infeasibility..... {fmt(self.f2_norm)}", + f" Decision variables... {fmt(self.solution)}", + f" Inputs............... {fmt(self.inputs)}", + f" States............... {fmt(self.states)}", + f" Lagrange multipliers. {fmt(self.lagrange_multipliers)}", + ]) + + __str__ = __repr__ diff --git a/open-codegen/opengen/tcp/optimizer_tcp_manager.py b/open-codegen/opengen/tcp/optimizer_tcp_manager.py index 68df889f..77c2297c 100644 --- a/open-codegen/opengen/tcp/optimizer_tcp_manager.py +++ b/open-codegen/opengen/tcp/optimizer_tcp_manager.py @@ -11,6 +11,9 @@ from .solver_response import SolverResponse from importlib.metadata import version +# A bit of warning suppressing for the retry module +logging.getLogger("retry").setLevel(logging.ERROR) + class OptimizerTcpManager: """Client for TCP interface of parametric optimizers diff --git a/open-codegen/test/test.py b/open-codegen/test/test.py index 21a7d13f..d56f3075 100644 --- a/open-codegen/test/test.py +++ b/open-codegen/test/test.py @@ -195,7 +195,8 @@ def setUpOnlyParametricF2(cls): solver_config = og.config.SolverConfiguration() \ .with_tolerance(1e-6) \ .with_initial_tolerance(1e-4) \ - .with_delta_tolerance(1e-5) + .with_delta_tolerance(1e-5) \ + .with_penalty_weight_update_factor(5) og.builder.OpEnOptimizerBuilder( problem, meta, build_config, solver_config).build() @@ -548,6 +549,22 @@ def test_squared_norm(self): y = fun([3, 4]) self.assertAlmostEqual(25., y, places=12) + def test_optimizer_meta_valid_version(self): + meta = og.config.OptimizerMeta().with_version("1.2.3-alpha.1+build.5") + self.assertEqual("1.2.3-alpha.1+build.5", meta.version) + + def test_optimizer_meta_invalid_version1(self): + with self.assertRaises(ValueError) as context: + og.config.OptimizerMeta().with_version("^1.2") + + self.assertIn("Cargo package version", str(context.exception)) + + def test_optimizer_meta_invalid_version2(self): + with self.assertRaises(ValueError) as context: + og.config.OptimizerMeta().with_version("0.1") + + self.assertIn("Cargo package version", str(context.exception)) + if __name__ == '__main__': logging.getLogger('retry').setLevel(logging.ERROR) diff --git a/open-codegen/test/test_ocp.py b/open-codegen/test/test_ocp.py new file mode 100644 index 00000000..5407fbd8 --- /dev/null +++ b/open-codegen/test/test_ocp.py @@ -0,0 +1,737 @@ +import json +import os +import unittest + +import casadi.casadi as cs +import opengen as og + + +class DummySolverStatus: + def __init__(self, solution): + self.solution = solution + self.cost = 1.23 + self.exit_status = "Converged" + self.solve_time_ms = 0.7 + self.penalty = 12.5 + self.num_outer_iterations = 3 + self.num_inner_iterations = 27 + self.last_problem_norm_fpr = 1e-6 + self.f1_infeasibility = 2e-5 + self.f2_norm = 3e-5 + self.lagrange_multipliers = [] + + +class DummyDirectSolver: + def __init__(self, solution): + self.solution = solution + self.last_call = None + + def run(self, p, initial_guess=None, initial_lagrange_multipliers=None, initial_penalty=None): + self.last_call = { + "p": p, + "initial_guess": initial_guess, + "initial_lagrange_multipliers": initial_lagrange_multipliers, + "initial_penalty": initial_penalty, + } + return DummySolverStatus(self.solution) + + +class OcpTestCase(unittest.TestCase): + TEST_DIR = ".python_test_build_ocp" + + @staticmethod + def get_open_local_absolute_path(): + return os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + + @classmethod + def setUpOCP1(cls): + optimizer_name = "ocp_manifest_bindings" + ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=3) + ocp.add_parameter("x0", 2) + ocp.add_parameter("xref", 2, default=[0.0, 0.0]) + ocp.add_parameter("q", 1, default=1) + ocp.add_parameter("r", 1, default=0.1) + ocp.with_dynamics(lambda x, u, param: cs.vertcat(x[0] + u[0], x[1] - u[0])) + ocp.with_stage_cost( + lambda x, u, param, _t: + param["q"] * cs.dot(x - param["xref"], x - param["xref"]) + param["r"] * cs.dot(u, u) + ) + ocp.with_terminal_cost( + lambda x, param: cs.dot(x - param["xref"], x - param["xref"]) + ) + ocp.with_input_constraints(og.constraints.Rectangle([-1.0], [1.0])) + + cls.ocp1_optimizer = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name(optimizer_name), + build_configuration=og.config.BuildConfiguration() + .with_open_version(local_path=OcpTestCase.get_open_local_absolute_path()) + .with_build_directory(OcpTestCase.TEST_DIR) + .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) + .with_build_python_bindings(), + solver_configuration=og.config.SolverConfiguration() + .with_tolerance(1e-5) + .with_delta_tolerance(1e-5) + .with_initial_penalty(10.0) + .with_penalty_weight_update_factor(1.2) + .with_max_inner_iterations(500) + .with_max_outer_iterations(10), + ).build() + cls.ocp1_manifest_path = os.path.join(cls.ocp1_optimizer.target_dir, "optimizer_manifest.json") + cls.ocp1_rollout_path = os.path.join(cls.ocp1_optimizer.target_dir, "rollout.casadi") + + @classmethod + def setUpOCP2(cls): + def make_problem(shooting): + ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=5, shooting=shooting) + ocp.add_parameter("x0", 2) + ocp.add_parameter("xref", 2, default=[0.0, 0.0]) + ocp.with_dynamics(lambda x, u, param: + cs.vertcat(x[0] + u[0], x[1] - u[0])) + ocp.with_stage_cost( + lambda x, u, param, _t: + cs.dot(x - param["xref"], x - param["xref"]) + 0.01 * cs.dot(u, u) + ) + ocp.with_terminal_cost( + lambda x, param: + 2.0 * cs.dot(x - param["xref"], x - param["xref"]) + ) + ocp.with_input_constraints(og.constraints.Rectangle([-0.4], [0.4])) + ocp.with_path_constraint( + lambda x, u, param, _t: cs.fmax(0.0, x[0] - 1.5) + ) + if shooting == og.ocp.ShootingMethod.MULTIPLE: + ocp.with_dynamics_constraints("alm") + return ocp + + solver_config = og.config.SolverConfiguration() \ + .with_tolerance(1e-5) \ + .with_delta_tolerance(1e-5) \ + .with_initial_penalty(10.0) \ + .with_penalty_weight_update_factor(1.2) \ + .with_max_inner_iterations(5000) \ + .with_max_outer_iterations(20) + + cls.ocp2_single_optimizer = og.ocp.OCPBuilder( + make_problem(og.ocp.ShootingMethod.SINGLE), + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_single_tcp"), + build_configuration=og.config.BuildConfiguration() + .with_open_version(local_path=OcpTestCase.get_open_local_absolute_path()) + .with_build_directory(OcpTestCase.TEST_DIR) + .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) + .with_tcp_interface_config( + tcp_interface_config=og.config.TcpServerConfiguration(bind_port=3391) + ), + solver_configuration=solver_config, + ).build() + cls.ocp2_multiple_optimizer = og.ocp.OCPBuilder( + make_problem(og.ocp.ShootingMethod.MULTIPLE), + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_multiple_tcp"), + build_configuration=og.config.BuildConfiguration() + .with_open_version(local_path=OcpTestCase.get_open_local_absolute_path()) + .with_build_directory(OcpTestCase.TEST_DIR) + .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) + .with_tcp_interface_config( + tcp_interface_config=og.config.TcpServerConfiguration(bind_port=3392) + ), + solver_configuration=solver_config, + ).build() + + cls.ocp2_single_manifest_path = os.path.join( + cls.ocp2_single_optimizer.target_dir, "optimizer_manifest.json") + cls.ocp2_multiple_manifest_path = os.path.join( + cls.ocp2_multiple_optimizer.target_dir, "optimizer_manifest.json") + + @classmethod + def setUpOCP3(cls): + nx, nu, ts = 2, 1, 0.1 + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: cs.vertcat(x[1], -x[0] + u[0]), + sampling_time=ts, + ) + + ocp = og.ocp.OptimalControlProblem(nx=nx, nu=nu, horizon=5) + ocp.add_parameter("x0", nx) + ocp.add_parameter("xref", nx, default=[0.0, 0.0]) + ocp.with_dynamics(discretizer.multistep(method="rk4", num_steps=3)) + ocp.with_stage_cost( + lambda xk, uk, param, _t: cs.dot(xk - param["xref"], xk - param["xref"]) + cs.dot(uk, uk) + ) + ocp.with_terminal_cost( + lambda xk, param: cs.dot(xk - param["xref"], xk - param["xref"]) + ) + ocp.with_input_constraints(og.constraints.Rectangle([-1.0], [1.0])) + + cls.ocp3_optimizer = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_multistep_rk4_bindings"), + build_configuration=og.config.BuildConfiguration() + .with_open_version(local_path=OcpTestCase.get_open_local_absolute_path()) + .with_build_directory(OcpTestCase.TEST_DIR) + .with_build_mode(og.config.BuildConfiguration.DEBUG_MODE) + .with_build_python_bindings(), + solver_configuration=og.config.SolverConfiguration() + .with_tolerance(1e-5) + .with_delta_tolerance(1e-5) + .with_initial_penalty(10.0) + .with_penalty_weight_update_factor(1.2) + .with_max_inner_iterations(500) + .with_max_outer_iterations(10), + ).build() + + @classmethod + def setUpClass(cls): + cls.setUpOCP1() + cls.setUpOCP2() + cls.setUpOCP3() + + def make_ocp(self, shooting=og.ocp.ShootingMethod.SINGLE): + ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=3, shooting=shooting) + ocp.add_parameter("x0", 2) + ocp.add_parameter("xref", 2, default=[1.0, -1.0]) + + def dynamics(x, u, param): + return cs.vertcat(x[0] + u[0], x[1] - u[0]) + + def stage_cost(x, u, param, _t): + err = x - param["xref"] + return cs.dot(err, err) + 0.5 * cs.dot(u, u) + + def terminal_cost(x, param): + err = x - param["xref"] + return 2.0 * cs.dot(err, err) + + ocp.with_dynamics(dynamics) + ocp.with_stage_cost(stage_cost) + ocp.with_terminal_cost(terminal_cost) + ocp.with_input_constraints(og.constraints.Rectangle([-2.0], [2.0])) + ocp.with_path_constraint(lambda x, u, param, _t: x[0] + u[0] - param["xref"][0]) + return ocp + + def test_parameter_defaults(self): + ocp = self.make_ocp() + packed = ocp.parameters.pack({"x0": [0.5, -0.25]}) + self.assertEqual(packed, [0.5, -0.25, 1.0, -1.0]) + + def test_duplicate_parameters(self): + ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=3) + ocp.add_parameter("x0", 2) + with self.assertRaises(ValueError): + ocp.add_parameter("x0", 2) + + def test_missing_parameter(self): + ocp = self.make_ocp() + with self.assertRaises(ValueError): + ocp.parameters.pack({"xref": [0.0, 0.0]}) + + def test_parameter_dimension_mismatch(self): + ocp = self.make_ocp() + with self.assertRaises(ValueError): + ocp.parameters.pack({"x0": [1.0], "xref": [0.0, 0.0]}) + + with self.assertRaises(ValueError): + ocp.parameters.pack({"x0": [1.0, 2.0], "xref": [0.0]}) + + def test_parameter_default_shapes(self): + pack = og.ocp.ParameterPack() + pack.add("alpha", 1, default=2.5) + pack.add("beta", 2, default=[1.0, -1.0]) + + self.assertEqual(pack.pack(), [2.5, 1.0, -1.0]) + + def test_invalid_default_dimension(self): + with self.assertRaises(ValueError): + og.ocp.ParameterDefinition("xref", 2, default=[0.0]) + + def test_matrix_weighted_terminal_cost(self): + ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=2) + ocp.add_parameter("x0", 2) + ocp.add_parameter("xref", 2, default=[0.0, 0.0]) + ocp.with_dynamics(lambda x, u, param: cs.vertcat(x[0] + u[0], x[1] - u[0])) + ocp.with_stage_cost(lambda x, u, param, _t: cs.dot(u, u)) + + p_matrix = cs.DM([ + [10.0, 0.0], + [0.0, 2.0], + ]) + ocp.with_terminal_cost( + lambda x, param: cs.mtimes([ + (x - param["xref"]).T, + p_matrix, + (x - param["xref"]), + ]) + ) + + model = ocp.build_symbolic_model() + cost_fun = cs.Function("terminal_cost_test", [model["u"], model["p"]], [model["cost"]]) + + value = float(cost_fun([0.0, 0.0], [1.0, -2.0, 0.0, 0.0]).full().reshape(-1)[0]) + self.assertAlmostEqual(value, 18.0) + + def test_discretizer_euler(self): + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: -x + u, + sampling_time=0.1, + ) + x = cs.SX.sym("x", 1) + u = cs.SX.sym("u", 1) + p = cs.SX.sym("p", 0) + discrete = discretizer.euler() + fun = cs.Function("euler_discrete", [x, u, p], [discrete(x, u, p)]) + + y = fun([2.0], [1.0], []).full().reshape(-1).tolist() + self.assertAlmostEqual(y[0], 1.9) + + def test_discretizer_rk4(self): + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: -x, + sampling_time=0.1, + ) + x = cs.SX.sym("x", 1) + u = cs.SX.sym("u", 1) + p = cs.SX.sym("p", 0) + discrete = discretizer.rk4() + fun = cs.Function("rk4_discrete", [x, u, p], [discrete(x, u, p)]) + + y = fun([1.0], [0.0], []).full().reshape(-1).tolist() + self.assertAlmostEqual(y[0], 0.9048375, places=6) + + def test_discretizer_midpoint(self): + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: -x, + sampling_time=0.1, + ) + x = cs.SX.sym("x", 1) + u = cs.SX.sym("u", 1) + p = cs.SX.sym("p", 0) + discrete = discretizer.midpoint() + fun = cs.Function("midpoint_discrete", [x, u, p], [discrete(x, u, p)]) + + y = fun([1.0], [0.0], []).full().reshape(-1).tolist() + self.assertAlmostEqual(y[0], 0.905) + + def test_discretizer_heun(self): + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: -x, + sampling_time=0.1, + ) + x = cs.SX.sym("x", 1) + u = cs.SX.sym("u", 1) + p = cs.SX.sym("p", 0) + discrete = discretizer.heun() + fun = cs.Function("heun_discrete", [x, u, p], [discrete(x, u, p)]) + + y = fun([1.0], [0.0], []).full().reshape(-1).tolist() + self.assertAlmostEqual(y[0], 0.905) + + def test_discretizer_multistep(self): + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: -x, + sampling_time=0.1, + ) + x = cs.SX.sym("x", 1) + u = cs.SX.sym("u", 1) + p = cs.SX.sym("p", 0) + discrete = discretizer.multistep(method="euler", num_steps=2) + fun = cs.Function("multistep_discrete", [x, u, p], [discrete(x, u, p)]) + + y = fun([1.0], [0.0], []).full().reshape(-1).tolist() + self.assertAlmostEqual(y[0], 0.9025) + + def test_discretizer_invalid_multistep(self): + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: x, + sampling_time=0.1, + ) + with self.assertRaises(ValueError): + discretizer.multistep(method="euler", num_steps=0) + + def test_multistep_rk4_codegen(self): + result = self.ocp3_optimizer.solve( + x0=[1.0, 0.0], + xref=[0.0, 0.0], + initial_guess=[0.0] * 5, + ) + + # Check the result has all necessary attributes + self.assertTrue(hasattr(result, "raw")) + self.assertTrue(hasattr(result, "solution")) + self.assertTrue(hasattr(result, "inputs")) + self.assertTrue(hasattr(result, "states")) + self.assertTrue(hasattr(result, "cost")) + self.assertTrue(hasattr(result, "exit_status")) + self.assertTrue(hasattr(result, "solve_time_ms")) + self.assertTrue(hasattr(result, "penalty")) + self.assertTrue(hasattr(result, "num_outer_iterations")) + self.assertTrue(hasattr(result, "num_inner_iterations")) + self.assertTrue(hasattr(result, "last_problem_norm_fpr")) + self.assertTrue(hasattr(result, "f1_infeasibility")) + self.assertTrue(hasattr(result, "f2_norm")) + self.assertTrue(hasattr(result, "lagrange_multipliers")) + + # Check all attributes of `result` + self.assertEqual("Converged", result.exit_status) + self.assertIsInstance(result.solution, list) + self.assertIsInstance(result.inputs, list) + self.assertIsInstance(result.states, list) + self.assertEqual(5, len(result.solution)) + self.assertEqual(5, len(result.inputs)) + self.assertEqual(6, len(result.states)) + self.assertIsInstance(result.cost, float) + self.assertGreaterEqual(result.cost, 0.0) + self.assertIsInstance(result.solve_time_ms, float) + self.assertGreater(result.solve_time_ms, 0.0) + self.assertIsInstance(result.penalty, float) + self.assertIsInstance(result.num_outer_iterations, int) + self.assertGreaterEqual(result.num_outer_iterations, 1) + self.assertIsInstance(result.num_inner_iterations, int) + self.assertGreaterEqual(result.num_inner_iterations, 1) + self.assertIsInstance(result.last_problem_norm_fpr, float) + self.assertGreaterEqual(result.last_problem_norm_fpr, 0.0) + self.assertIsInstance(result.f1_infeasibility, float) + self.assertGreaterEqual(result.f1_infeasibility, 0.0) + self.assertIsInstance(result.f2_norm, float) + self.assertGreaterEqual(result.f2_norm, 0.0) + self.assertIsInstance(result.lagrange_multipliers, list) + self.assertAlmostEqual(result.states[0][0], 1.0) + self.assertAlmostEqual(result.states[0][1], 0.0) + + def test_discretizer_invalid_method(self): + discretizer = og.ocp.DynamicsDiscretizer( + lambda x, u, param: x, + sampling_time=0.1, + ) + with self.assertRaises(ValueError): + discretizer.discretize("unknown") + + def test_single_shooting_hard_constraints(self): + ocp_stage = self.make_ocp() + ocp_stage.with_hard_stage_state_input_constraints( + og.constraints.Rectangle([-1.0, -1.0, -1.0], [1.0, 1.0, 1.0]) + ) + with self.assertRaises(ValueError): + ocp_stage.build_symbolic_model() + + ocp_terminal = self.make_ocp() + ocp_terminal.with_hard_terminal_state_constraints( + og.constraints.Rectangle([-1.0, -1.0], [1.0, 1.0]) + ) + with self.assertRaises(ValueError): + ocp_terminal.build_symbolic_model() + + def test_lowering_penalty_constraints(self): + ocp = self.make_ocp() + builder = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_test"), + ) + low_level = builder.build_problem() + + self.assertEqual(low_level.dim_decision_variables(), 3) + self.assertEqual(low_level.dim_parameters(), 4) + self.assertEqual(low_level.dim_constraints_penalty(), 3) + self.assertIsInstance(low_level.constraints, og.constraints.Rectangle) + self.assertEqual(low_level.constraints.xmin, [-2.0, -2.0, -2.0]) + self.assertEqual(low_level.constraints.xmax, [2.0, 2.0, 2.0]) + + def test_lowering_alm_constraints(self): + ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=3) + ocp.add_parameter("x0", 2) + ocp.add_parameter("xref", 2, default=[0.0, 0.0]) + ocp.with_dynamics(lambda x, u, param: cs.vertcat(x[0] + u[0], x[1] - u[0])) + ocp.with_stage_cost(lambda x, u, param, _t: cs.dot(x - param["xref"], x - param["xref"]) + cs.dot(u, u)) + ocp.with_terminal_cost(lambda x, param: cs.dot(x - param["xref"], x - param["xref"])) + ocp.with_path_constraint( + lambda x, u, param, _t: x[0] + u[0], + kind="alm", + set_c=og.constraints.Ball2(None, 5.), + ) + ocp.with_terminal_constraint( + lambda x, param: x[1] - 0.5, + kind="alm", + set_c=og.constraints.Ball2(None, 0.1), + ) + ocp.with_terminal_constraint( + lambda x, param: x[0] + x[1] - 2.0, + kind="penalty", + ) + + builder = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_alm_test"), + ) + low_level = builder.build_problem() + + self.assertEqual(low_level.dim_constraints_aug_lagrangian(), 4) + self.assertEqual(low_level.dim_constraints_penalty(), 1) + self.assertIsInstance(low_level.alm_set_c, og.constraints.CartesianProduct) + self.assertEqual(low_level.alm_set_c.segments, [0, 1, 2, 3]) + + def test_lowering_alm_rectangle_constraints(self): + ocp = og.ocp.OptimalControlProblem(nx=2, nu=1, horizon=3) + ocp.add_parameter("x0", 2) + ocp.add_parameter("xmin", 1, default=[-0.5]) + ocp.with_dynamics(lambda x, u, param: cs.vertcat(x[0] + u[0], x[1] - u[0])) + ocp.with_stage_cost(lambda x, u, param, _t: cs.dot(x, x) + cs.dot(u, u)) + ocp.with_terminal_cost(lambda x, param: cs.dot(x, x)) + ocp.with_path_constraint( + lambda x, u, param, _t: x[1] - param["xmin"], + kind="alm", + set_c=og.constraints.Rectangle([0.0], [float("inf")]), + ) + + builder = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_alm_rect_test"), + ) + low_level = builder.build_problem() + + self.assertEqual(low_level.dim_constraints_aug_lagrangian(), 3) + self.assertIsInstance(low_level.alm_set_c, og.constraints.Rectangle) + self.assertEqual(low_level.alm_set_c.xmin, [0.0, 0.0, 0.0]) + self.assertEqual(low_level.alm_set_c.xmax, [float("inf"), float("inf"), float("inf")]) + + def test_generated_optimizer_defaults(self): + ocp = self.make_ocp() + backend = DummyDirectSolver(solution=[0.1, 0.2, 0.3]) + optimizer = og.ocp.GeneratedOptimizer( + ocp=ocp, + optimizer_name="dummy", + target_dir=".", + backend=backend, + backend_kind="direct", + ) + + result = optimizer.solve(x0=[0.0, 0.0]) + # print(result) + + self.assertEqual(backend.last_call["p"], [0.0, 0.0, 1.0, -1.0]) + self.assertEqual(result.inputs, [[0.1], [0.2], [0.3]]) + self.assertEqual(result.states[0], [0.0, 0.0]) + self.assertAlmostEqual(result.states[-1][0], 0.6) + self.assertAlmostEqual(result.states[-1][1], -0.6) + self.assertEqual(result.penalty, 12.5) + self.assertEqual(result.num_outer_iterations, 3) + self.assertEqual(result.num_inner_iterations, 27) + self.assertEqual(result.last_problem_norm_fpr, 1e-6) + self.assertEqual(result.f1_infeasibility, 2e-5) + self.assertEqual(result.f2_norm, 3e-5) + + def test_optimizer_manifest_roundtrip(self): + manifest_path = self.ocp1_manifest_path + rollout_path = self.ocp1_rollout_path + + self.assertTrue(os.path.exists(manifest_path)) + self.assertTrue(os.path.exists(rollout_path)) + + with open(manifest_path, "r") as fh: + manifest = json.load(fh) + + self.assertEqual(1, manifest["manifest_version"]) + self.assertEqual("ocp_manifest_bindings", manifest["optimizer_name"]) + self.assertEqual("direct", manifest["backend_kind"]) + self.assertEqual("single", manifest["shooting"]) + self.assertEqual(2, manifest["nx"]) + self.assertEqual(1, manifest["nu"]) + self.assertEqual(3, manifest["horizon"]) + self.assertEqual(3, manifest["decision_dimension"]) + self.assertEqual(6, manifest["parameter_dimension"]) # p = (x0, xref, q, r) + self.assertEqual("rollout.casadi", manifest["rollout_file"]) + self.assertIsNotNone(manifest["rollout_sha256"]) + self.assertIn("created_at", manifest) + + loaded_optimizer = og.ocp.GeneratedOptimizer.load(manifest_path) + + result = loaded_optimizer.solve( + x0=[1.0, 0.0] + ) + + self.assertEqual("ocp_manifest_bindings", loaded_optimizer.optimizer_name) + self.assertEqual("direct", loaded_optimizer.backend_kind) + self.assertEqual("Converged", result.exit_status) + self.assertEqual(3, len(result.inputs)) + self.assertEqual(4, len(result.states)) + + def test_multiple_shooting_penalty(self): + ocp = self.make_ocp(shooting=og.ocp.ShootingMethod.MULTIPLE) + builder = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_ms_test"), + ) + low_level = builder.build_problem() + + self.assertEqual(low_level.dim_decision_variables(), 9) + self.assertEqual(low_level.dim_parameters(), 4) + self.assertEqual(low_level.dim_constraints_penalty(), 9) + self.assertIsInstance(low_level.constraints, og.constraints.Rectangle) + self.assertEqual(low_level.constraints.xmin, [-2.0, float("-inf"), float("-inf"), + -2.0, float("-inf"), float("-inf"), + -2.0, float("-inf"), float("-inf")]) + self.assertEqual(low_level.constraints.xmax, [2.0, float("inf"), float("inf"), + 2.0, float("inf"), float("inf"), + 2.0, float("inf"), float("inf")]) + + def test_multiple_shooting_alm(self): + ocp = self.make_ocp(shooting=og.ocp.ShootingMethod.MULTIPLE) + ocp.with_dynamics_constraints("alm") + builder = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_ms_alm_test"), + ) + low_level = builder.build_problem() + + self.assertEqual(low_level.dim_decision_variables(), 9) + self.assertEqual(low_level.dim_constraints_penalty(), 3) + self.assertEqual(low_level.dim_constraints_aug_lagrangian(), 6) + self.assertIsInstance(low_level.alm_set_c, og.constraints.Zero) + self.assertIsInstance(low_level.alm_set_y, og.constraints.BallInf) + + def test_multiple_shooting_terminal_constraints(self): + ocp = self.make_ocp(shooting=og.ocp.ShootingMethod.MULTIPLE) + ocp.with_hard_terminal_state_constraints( + og.constraints.Rectangle([-10.0, -10.0], [10.0, 10.0]) + ) + builder = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_ms_terminal_hard"), + ) + low_level = builder.build_problem() + + self.assertIsInstance(low_level.constraints, og.constraints.Rectangle) + self.assertEqual(low_level.constraints.xmin, [-2.0, float("-inf"), float("-inf"), + -2.0, float("-inf"), float("-inf"), + -2.0, -10.0, -10.0]) + self.assertEqual(low_level.constraints.xmax, [2.0, float("inf"), float("inf"), + 2.0, float("inf"), float("inf"), + 2.0, 10.0, 10.0]) + + def test_multiple_shooting_zero_constraints(self): + ocp = og.ocp.OptimalControlProblem( + nx=2, + nu=1, + horizon=3, + shooting=og.ocp.ShootingMethod.MULTIPLE, + ) + ocp.add_parameter("x0", 2) + ocp.add_parameter("xref", 2, default=[0.0, 0.0]) + ocp.with_dynamics(lambda x, u, param: cs.vertcat(x[0] + u[0], x[1] - u[0])) + ocp.with_stage_cost(lambda x, u, param, _t: cs.dot(x - param["xref"], x - param["xref"])) + ocp.with_terminal_cost(lambda x, param: cs.dot(x - param["xref"], x - param["xref"])) + ocp.with_hard_stage_state_input_constraints(og.constraints.Zero()) + + builder = og.ocp.OCPBuilder( + ocp, + metadata=og.config.OptimizerMeta().with_optimizer_name("ocp_ms_zero"), + ) + low_level = builder.build_problem() + + self.assertIsInstance(low_level.constraints, og.constraints.Zero) + + def test_multiple_shooting_state_extraction(self): + ocp = self.make_ocp(shooting=og.ocp.ShootingMethod.MULTIPLE) + backend = DummyDirectSolver(solution=[ + 0.1, 0.1, -0.1, + 0.2, 0.3, -0.3, + 0.4, 0.7, -0.7, + ]) + optimizer = og.ocp.GeneratedOptimizer( + ocp=ocp, + optimizer_name="dummy_ms", + target_dir=".", + backend=backend, + backend_kind="direct", + ) + + result = optimizer.solve(x0=[0.0, 0.0]) + + self.assertEqual(result.inputs, [[0.1], [0.2], [0.4]]) + self.assertEqual(result.states, [[0.0, 0.0], [0.1, -0.1], [0.3, -0.3], [0.7, -0.7]]) + + def test_tcp_optimizer_result(self): + optimizer = og.ocp.GeneratedOptimizer.load(self.ocp2_single_manifest_path) + + try: + result = optimizer.solve( + x0=[1.0, -1.0], + xref=[0.0, 0.0], + ) + + self.assertTrue(hasattr(result, "raw")) + self.assertTrue(hasattr(result, "solution")) + self.assertTrue(hasattr(result, "inputs")) + self.assertTrue(hasattr(result, "states")) + self.assertTrue(hasattr(result, "cost")) + self.assertTrue(hasattr(result, "exit_status")) + self.assertTrue(hasattr(result, "solve_time_ms")) + self.assertTrue(hasattr(result, "penalty")) + self.assertTrue(hasattr(result, "num_outer_iterations")) + self.assertTrue(hasattr(result, "num_inner_iterations")) + self.assertTrue(hasattr(result, "last_problem_norm_fpr")) + self.assertTrue(hasattr(result, "f1_infeasibility")) + self.assertTrue(hasattr(result, "f2_norm")) + self.assertTrue(hasattr(result, "lagrange_multipliers")) + + self.assertEqual("Converged", result.exit_status) + self.assertIsInstance(result.solution, list) + self.assertIsInstance(result.inputs, list) + self.assertIsInstance(result.states, list) + self.assertEqual(5, len(result.inputs)) + self.assertEqual(6, len(result.states)) + self.assertIsInstance(result.cost, float) + self.assertGreaterEqual(result.cost, 0.0) + self.assertIsInstance(result.solve_time_ms, float) + self.assertGreater(result.solve_time_ms, 0.0) + self.assertIsInstance(result.penalty, float) + self.assertGreater(result.penalty, 0.0) + self.assertIsInstance(result.num_outer_iterations, int) + self.assertGreaterEqual(result.num_outer_iterations, 1) + self.assertIsInstance(result.num_inner_iterations, int) + self.assertGreaterEqual(result.num_inner_iterations, 1) + self.assertIsInstance(result.last_problem_norm_fpr, float) + self.assertGreaterEqual(result.last_problem_norm_fpr, 0.0) + self.assertIsInstance(result.f1_infeasibility, float) + self.assertGreaterEqual(result.f1_infeasibility, 0.0) + self.assertIsInstance(result.f2_norm, float) + self.assertGreaterEqual(result.f2_norm, 0.0) + self.assertIsInstance(result.lagrange_multipliers, list) + self.assertAlmostEqual(result.states[0][0], 1.0) + self.assertAlmostEqual(result.states[0][1], -1.0) + finally: + optimizer.kill() + + def test_single_vs_multiple(self): + single_optimizer = og.ocp.GeneratedOptimizer.load(self.ocp2_single_manifest_path) + multiple_optimizer = og.ocp.GeneratedOptimizer.load(self.ocp2_multiple_manifest_path) + + try: + x0 = [1.0, -1.0] + xref = [0.0, 0.0] + single_result = single_optimizer.solve(x0=x0, xref=xref) + multiple_result = multiple_optimizer.solve(x0=x0, xref=xref) + # print("SINGLE\n------------") + # print(single_result) + # print("MULTIPLE\n------------") + # print(multiple_result) + + self.assertEqual("Converged", single_result.exit_status) + self.assertEqual("Converged", multiple_result.exit_status) + self.assertEqual(5, len(single_result.inputs)) + self.assertEqual(5, len(multiple_result.inputs)) + self.assertEqual(6, len(single_result.states)) + self.assertEqual(6, len(multiple_result.states)) + + for u_single, u_multiple in zip(single_result.inputs, multiple_result.inputs): + self.assertAlmostEqual(u_single[0], u_multiple[0], delta=1e-3) + + for x_single, x_multiple in zip(single_result.states, multiple_result.states): + self.assertAlmostEqual(x_single[0], x_multiple[0], delta=1e-3) + self.assertAlmostEqual(x_single[1], x_multiple[1], delta=1e-3) + + self.assertAlmostEqual(single_result.cost, multiple_result.cost, delta=1e-3) + finally: + single_optimizer.kill() + multiple_optimizer.kill() + + +if __name__ == "__main__": + unittest.main() diff --git a/test/icasadi_test/.gitignore b/test/icasadi_test/.gitignore deleted file mode 100644 index 046d2998..00000000 --- a/test/icasadi_test/.gitignore +++ /dev/null @@ -1,7 +0,0 @@ -/target -**/*.rs.bk -Cargo.lock -*.o -*.a -icasadirunner -*~ diff --git a/test/icasadi_test/extern/Makefile b/test/icasadi_test/extern/Makefile deleted file mode 100644 index 1a308308..00000000 --- a/test/icasadi_test/extern/Makefile +++ /dev/null @@ -1,18 +0,0 @@ -all: - gcc -Wall -fPIC -c -o auto_casadi_cost.o auto_casadi_cost.c; - gcc -Wall -fPIC -c -o auto_casadi_grad.o auto_casadi_grad.c; - gcc -Wall -fPIC -c -o icasadi.o icasadi.c; - gcc -Wall -fPIC -c -o auto_casadi_constraints_type_penalty.o auto_casadi_constraints_type_penalty.c; - gcc -Wall -o icasadirunner main.c auto_casadi_grad.o auto_casadi_cost.o auto_casadi_constraints_type_penalty.o icasadi.o -lm; - - -run: all - ./icasadirunner - - -clean: - rm -rf *.o - rm -f ./icasadirunner - - -.PHONY: run, clean, all, icasadirunner diff --git a/test/icasadi_test/extern/icasadi.c b/test/icasadi_test/extern/icasadi.c deleted file mode 100644 index 892693d6..00000000 --- a/test/icasadi_test/extern/icasadi.c +++ /dev/null @@ -1,34 +0,0 @@ -#include "icasadi.h" - -int icasadi_cost_( - const double *u, - const double *casadi_static_params, - double *cost_value) -{ - const double *casadi_arguments[2]; - casadi_arguments[0] = u; - casadi_arguments[1] = casadi_static_params; - return CASADI_COST_NAME(casadi_arguments, &cost_value, 0, 0, 0); -} - -int icasadi_grad_( - const double *u, - const double *casadi_static_params, - double *gradient) -{ - const double *casadi_arguments[2]; - casadi_arguments[0] = u; - casadi_arguments[1] = casadi_static_params; - return CASADI_GRAD_NAME(casadi_arguments, &gradient, 0, 0, 0); -} - -int icasadi_constraints_as_penalty_( - const double *u, - const double *casadi_static_params, - double *constraints) -{ - const double *casadi_arguments[2]; - casadi_arguments[0] = u; - casadi_arguments[1] = casadi_static_params; - return CASADI_CONSTRAINTS_AS_PENALTY_NAME(casadi_arguments, &constraints, 0, 0, 0); -} diff --git a/test/icasadi_test/extern/icasadi.h b/test/icasadi_test/extern/icasadi.h deleted file mode 100644 index e32e1a5a..00000000 --- a/test/icasadi_test/extern/icasadi.h +++ /dev/null @@ -1,42 +0,0 @@ -#ifndef ICASADI_HEADER_SENTINEL -#define ICASADI_HEADER_SENTINEL - -#include "icasadi_config.h" - -extern int CASADI_COST_NAME( - const double **arg, - double **casadi_results, - long long int *iw, - double *w, - void *mem); - -extern int CASADI_GRAD_NAME( - const double **arg, - double **casadi_results, - long long int *iw, - double *w, - void *mem); - -extern int CASADI_CONSTRAINTS_AS_PENALTY_NAME( - const double **arg, - double **res, - long long int *iw, - double *w, - void *mem); - -int icasadi_cost_( - const double *u, - const double *casadi_static_params, - double *cost_value); - -int icasadi_grad_( - const double *u, - const double *casadi_static_params, - double *gradient); - -int icasadi_constraints_as_penalty_( - const double *u, - const double *casadi_static_params, - double *constraints); - -#endif diff --git a/test/icasadi_test/extern/main.c b/test/icasadi_test/extern/main.c deleted file mode 100644 index a3c3f8e3..00000000 --- a/test/icasadi_test/extern/main.c +++ /dev/null @@ -1,35 +0,0 @@ -#include "icasadi.h" -#include - -int main() { - // long long int n = phi_n_in(); - double u[CASADI_NU] = {1.0, 2.0, 3.0, -5.0, 1.0}; - double p[CASADI_NP] = {1.0,-1.0,3.0}; - - double phival = 0; - double cost_jacobian[CASADI_NU] = {0}; - double c[CASADI_NUM_CONSTAINTS_TYPE_PENALTY] = {0}; - - icasadi_cost_(u, p, &phival); - icasadi_grad_(u, p, cost_jacobian); - icasadi_constraints_as_penalty_(u, p, c); - - printf("cost value = %g\n", phival); - printf("jacobian of cost =\n"); - printf("[\n"); - int i; - for (i = 0; i < CASADI_NU; ++i){ - printf(" %g\n", cost_jacobian[i]); - } - printf("]\n\n"); - - printf("c(x) =\n"); - printf("[\n"); - for (i = 0; i < CASADI_NUM_CONSTAINTS_TYPE_PENALTY; ++i){ - printf(" %g\n", c[i]); - } - printf("]\n"); - - - return 0; -} \ No newline at end of file diff --git a/test/icasadi_test/src/lib.rs b/test/icasadi_test/src/lib.rs deleted file mode 100644 index e07fe37b..00000000 --- a/test/icasadi_test/src/lib.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! # CasADi Rust interface -//! -//! This is a Rust interface to C functions generated by Rust -//! -//! This is a `no-std` library (however, mind that the CasADi-generated code -//! requires `libm` to call functions such as `sqrt`, `sin`, etc...) - -#![no_std] - -// Get the CasADi problem size and parameter size and re-export them, this is necessary to have -// them available as compile time constants -include!(concat!(env!("OUT_DIR"), "/bindings.rs")); - -/// Number of static parameters -pub use CASADI_NP as NUM_STATIC_PARAMETERS; - -/// Number of decision variables -pub use CASADI_NU as NUM_DECISION_VARIABLES; - -use libc::{c_double, c_int}; - -extern "C" { - fn icasadi_cost_( - u: *const c_double, - casadi_static_params: *const c_double, - cost_value: *mut c_double, - ) -> c_int; - - fn icasadi_grad_( - u: *const c_double, - casadi_static_params: *const c_double, - cost_jacobian: *mut c_double, - ) -> c_int; - - fn icasadi_constraints_as_penalty_( - u: *const c_double, - casadi_static_params: *const c_double, - constraints_as_penalty: *mut c_double, - ) -> c_int; - -} - -/// -/// Consume the cost function written in C -/// -/// # Example -/// ``` -/// fn tst_call_casadi_cost() { -/// let u = [1.0, 2.0, 3.0, -5.0, 1.0, 10.0, 14.0, 17.0, 3.0, 5.0]; -/// let p = [1.0, -1.0]; -/// let mut cost_value = 0.0; -/// icasadi::icasadi_cost(&u, &p, &mut cost_value); -/// } -/// ``` -/// -/// # Panics -/// This method does not panic (on purpose). However, users need to be -/// careful when providing the arguments `u` and `casadi_static_params` -/// as they must be arrays of appropriate size. -/// -/// As a safety measure, you may check whether -/// -/// - `u.len() >= NUM_DECISION_VARIABLES` -/// - `casadi_static_params.len() >= NUM_STATIC_PARAMETERS` -/// -pub fn icasadi_cost(u: &[f64], casadi_static_params: &[f64], cost_value: &mut f64) -> c_int { - unsafe { icasadi_cost_(u.as_ptr(), casadi_static_params.as_ptr(), cost_value) } -} - -/// -/// Consume the Jacobian function written in C -/// -/// # Example -/// ``` -/// fn tst_call_casadi_cost() { -/// let u = [1.0, 2.0, 3.0, -5.0, 1.0, 10.0, 14.0, 17.0, 3.0, 5.0]; -/// let p = [1.0, -1.0]; -/// let mut jac = [0.0; 10]; -/// icasadi::icasadi_grad(&u, &p, &mut jac); -/// } -/// ``` -/// -/// # Panics -/// This method does not panic (on purpose). However, users need to be -/// careful when providing the arguments `u` and `casadi_static_params` -/// as they must be arrays of appropriate size. -/// -/// As a safety measure, you may check whether -/// -/// - `u.len() >= icasadi::num_decision_variables()` -/// - `casadi_static_params.len() >= icasadi::num_static_parameters()` -/// - `cost_jacobian.len() >= icasadi::num_decision_variables()` -/// -pub fn icasadi_grad(u: &[f64], casadi_static_params: &[f64], cost_jacobian: &mut [f64]) -> c_int { - unsafe { - icasadi_grad_( - u.as_ptr(), - casadi_static_params.as_ptr(), - cost_jacobian.as_mut_ptr(), - ) - } -} - -pub fn icasadi_constraints_as_penalty(u: &[f64], casadi_static_params: &[f64], constraints_as_penalty: &mut [f64]) -> c_int { - unsafe { - icasadi_constraints_as_penalty_( - u.as_ptr(), - casadi_static_params.as_ptr(), - constraints_as_penalty.as_mut_ptr(), - ) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn tst_num_static() { - let _np = NUM_STATIC_PARAMETERS; - } - - #[test] - fn tst_num_decision_var() { - let _nu = NUM_DECISION_VARIABLES; - } - -} diff --git a/website/sidebars.json b/website/sidebars.json index 68f637c4..dd3e3973 100644 --- a/website/sidebars.json +++ b/website/sidebars.json @@ -6,22 +6,28 @@ ], "Python": [ "python-interface", - "python-advanced", + "python-advanced", "python-c", "python-bindings", "python-tcp-ip", "python-ros", "python-examples" ], - "MATLAB": [ - "matlab-interface", - "matlab-examples" - ], + "Optimal Control": [ + "python-ocp-1", + "python-ocp-2", + "python-ocp-3", + "python-ocp-4" + ], "Rust": [ "openrust-basic", "openrust-alm", "openrust-features" ], + "MATLAB": [ + "matlab-interface", + "matlab-examples" + ], "Docker": [ "docker" ], diff --git a/website/static/img/ocp-inputs.png b/website/static/img/ocp-inputs.png new file mode 100644 index 00000000..b914f3fd Binary files /dev/null and b/website/static/img/ocp-inputs.png differ diff --git a/website/static/img/ocp-states.png b/website/static/img/ocp-states.png new file mode 100644 index 00000000..719beccf Binary files /dev/null and b/website/static/img/ocp-states.png differ