External Nonlinear Cost Function Setup

Hi :wave:

I am new to acados and I am facing some issues setting up an external cost function for a NMPC problem through python interface. The optimal control problem at hand is the classic cartpole inverted pendulum problem, which I was able to successfully set up on do-mpc and using casadi. However, both of these packages do not solve the NLP problem fast enough (with the compute I have) for real-time implementation (>=40 Hz needed). Hence, I turned to acados and I am impressed by the computational efficiency!

To make the controller solve the pendulum problem regardless of the angle of the pole (and to set it up for double pendulum in the future), I would like to set up the cost function to minimize the kinetic energy, maximize the potential energy, with setpoint tracking on the x position, and move suppression:
Screenshot 2025-07-06 184910

System Model

Code
def pendulum_ode_model(m_cart, mp1, l1, g=9.81, b_c=0.0, b_p=0.05) -> AcadosModel:
    model_name = 'pendulum'

    # constants
    m_cart = m_cart  # mass of the cart [kg]
    m = mp1  # mass of the ball [kg]
    g = g  # gravity constant [m/s^2]
    l = l1  # length of the rod [m]
    b_c = b_c  # friction coefficient for the cart
    b_p = b_p  # friction coefficient for the pendulum

    # set up states & controls
    x1      = SX.sym('x1')
    theta   = SX.sym('theta')
    v1      = SX.sym('v1')
    dtheta  = SX.sym('dtheta')
    u_prev  = SX.sym('u_prev')

    x = vertcat(x1, theta, v1, dtheta, u_prev)

    F = SX.sym('F')
    u = vertcat(F)

    # xdot
    x1_dot      = SX.sym('x1_dot')
    theta_dot   = SX.sym('theta_dot')
    v1_dot      = SX.sym('v1_dot')
    dtheta_dot  = SX.sym('dtheta_dot')
    u_prev_dot  = SX.sym('u_prev_dot')

    xdot = vertcat(x1_dot, theta_dot, v1_dot, dtheta_dot, u_prev_dot)

    # dynamics
    cos_theta = cos(theta)
    sin_theta = sin(theta)
    denominator = m_cart + m - m*cos_theta*cos_theta
    f_expl = vertcat(v1,
                     dtheta,
                     (-m*l*sin_theta*dtheta*dtheta + m*g*cos_theta*sin_theta + F - b_c*v1)/denominator,
                     (-m*l*cos_theta*sin_theta*dtheta*dtheta + F*cos_theta + (m_cart+m)*g*sin_theta - b_p*dtheta)/(l*denominator),
                     F
                     )

    f_impl = xdot - f_expl

    model = AcadosModel()

    model.f_impl_expr = f_impl
    model.f_expl_expr = f_expl
    model.x = x
    model.xdot = xdot
    model.u = u
    # model.z = u_prev
    model.name = model_name

    # add potential and kinetic energy expressions
    E_kin_cart = 1 / 2 * m_cart * v1**2
    E_kin_pendulum = 1 / 2 * m * ((v1 + l * dtheta * cos(theta))**2 + (l * dtheta * sin(theta))**2) + 1 / 2 * (m * l**2 / 3) * dtheta**2
    E_kin = E_kin_cart + E_kin_pendulum
    
    E_pot = m * g * l * cos(theta)
    
    model.E_kin = E_kin
    model.E_pot = E_pot

    # store meta information
    model.x_labels = ['$x$ [m]', r'$\theta$ [rad]', '$v$ [m]', r'$\dot{\theta}$ [rad/s]', '$u_{prev}$ [N]']
    model.u_labels = ['$F$']
    model.t_label = '$t$ [s]'

    return model

Following the example in the acados repo, I was able to formulate the NMPC with a NONLINEAR_LS cost function, as can be seen in this code:

Code
def formulate_pendulum_sim(model: AcadosModel, 
                           dt: float = 0.05) -> AcadosSimSolver:
    """
    Construct the simulator/integrator for the plant model.
    """
    model.name += '_plant_sim'
    sim = AcadosSim()
    sim.model = model
    sim.solver_options.T = dt
    sim.solver_options.integrator_type = 'IRK'
    sim.solver_options.num_stages = 4
    sim.solver_options.num_steps = 3
    sim.solver_options.newton_iter = 3
    sim.solver_options.collocation_type = 'GAUSS_LEGENDRE'
    sim_solver = AcadosSimSolver(sim)
    return sim_solver

def formulate_pendulum_mpc(model: AcadosModel, N: int = 20, Tf: float = 1.0, RTI: bool = False,
                                Q: np.ndarray = np.diag([1e3, 1e3, 1e-2, 1e-2]), 
                                Q_e: np.ndarray = np.diag([1e3, 1e3, 1e-2, 1e-2]),
                                cost_type: str = 'NONLINEAR_LS',
                                use_energy: bool = True,
                                x0: np.ndarray = np.array([0.0, np.pi, 0.0, 0.0]),
                                u_min: np.ndarray = np.array([-10.0]),
                                u_max: np.ndarray = np.array([10.0]),
                                x_min: np.ndarray = np.array([-0.7, -np.inf, -np.inf, -np.inf]),
                                x_max: np.ndarray = np.array([0.7, np.inf, np.inf, np.inf])
                                ) -> AcadosOcp:
    """
    Formulate the MPC problem for the pendulum model.
    """
    ocp = AcadosOcp()
    ocp.model = model
    ocp.solver_options.N_horizon = N
    ocp.solver_options.tf = Tf

    nx = model.x.rows()
    nu = model.u.rows()
    ny = nx + nu
    ny_e = nx
    x = ocp.model.x
    u = ocp.model.u
    E_kin = ocp.model.E_kin
    E_pot = ocp.model.E_pot
    u_prev = x[-1]
    
    if cost_type == 'NONLINEAR_LS':
        # Nonlinear least squares cost using kinetic and potential energy, cart position, and move suppression
        ocp.cost.cost_type = cost_type
        ocp.cost.cost_type_e = cost_type
        ocp.solver_options.hessian_approx = 'GAUSS_NEWTON'
        if use_energy:
            ocp.model.cost_y_expr = ca.vertcat(E_kin, -E_pot, x[0], (u - u_prev))
            ocp.model.cost_y_expr_e = ca.vertcat(E_kin, -E_pot, x[0], 0.0)
            n_ref = ocp.model.cost_y_expr.rows()
            ocp.cost.cost_type = cost_type
            ocp.cost.W = Q
            ocp.cost.W_e = Q_e
            ocp.cost.yref = np.zeros((n_ref,))
            ocp.cost.yref_e = np.zeros((n_ref,))
        else:
            ocp.model.cost_y_expr = ca.vertcat(x[:-1], u - u_prev)
            ocp.model.cost_y_expr_e = x[:-1]
            ocp.cost.W = Q
            ocp.cost.W_e = Q_e
            n_ref = ocp.model.cost_y_expr.rows()
            n_ref_e = ocp.model.cost_y_expr_e.rows()
            ocp.cost.yref = np.zeros((n_ref,))
            ocp.cost.yref_e = np.zeros((n_ref_e,))
    elif cost_type == 'EXTERNAL':
        # External cost function
        ocp.cost.cost_type = cost_type
        ocp.cost.cost_type_e = cost_type
        # Set up the external cost function
        ocp.model.cost_expr_ext_cost = Q[0,0] * E_kin + Q[1,1] * (-E_pot) + Q[2,2] * (ocp.model.x[0]**2) + Q[3,3] * (u - u_prev)**2
        ocp.model.cost_expr_ext_cost_e = Q_e[0,0] * E_kin + Q_e[1,1] * (-E_pot) + Q_e[2,2] * ocp.model.x[0]**2
        ext_cost_use_num_hess = False  # use numerical Hessian for external cost
        ocp.solver_options.ext_cost_num_hess = ext_cost_use_num_hess
        ocp.solver_options.hessian_approx = 'EXACT'
        ocp.solver_options.globalization_alpha_min = 1e-25



    # set constraints
    ocp.constraints.lbu = u_min
    ocp.constraints.ubu = u_max
    ocp.constraints.idxbu = np.arange(nu)

    ocp.constraints.lbx = x_min
    ocp.constraints.ubx = x_max
    ocp.constraints.idxbx = np.arange(nx)

    ocp.constraints.x0 = x0

    ocp.solver_options.qp_solver = 'FULL_CONDENSING_HPIPM' 
    ocp.solver_options.qp_solver = 'PARTIAL_CONDENSING_HPIPM' 
    # ocp.solver_options.qp_solver = 'PARTIAL_CONDENSING_OSQP'
    # ocp.solver_options.qp_solver = 'FULL_CONDENSING_QPOASES'
    ocp.solver_options.integrator_type = 'IRK'
    ocp.solver_options.nlp_solver_type = 'SQP' # SQP_RTI
    # ocp.solver_options.sim_method_num_steps = 10
    ocp.solver_options.sim_method_newton_iter = 5
    ocp.solver_options.tol = 1e-12
    # ocp.solver_options.tol = 1e-5
    # ocp.solver_options.sim_method_newton_tol = 1e-5
    ocp.solver_options.qp_solver_cond_N = N

    if RTI:
        ocp.solver_options.nlp_solver_type = 'SQP_RTI'
    else:
        ocp.solver_options.nlp_solver_type = 'SQP'
        ocp.solver_options.qp_solver_iter_max = 5000
        ocp.solver_options.nlp_solver_max_iter = 5000
        ocp.solver_options.globalization = 'MERIT_BACKTRACKING'

    solver_json = 'acados_ocp_' + model.name + '.json'
    acados_ocp_solver = AcadosOcpSolver(ocp, json_file = solver_json)
    return acados_ocp_solver

This formulation does not really follow the objective function presented at the top. More importantly, it seems that there are some robustness issues when utilizing this NONLINEAR_LS cost function. I noticed this as running the optimizer with SQP_RTI works well for all initial conditions, but this is not the case using just SQP. I also run into robustness issues when there is a slight plant-model mismatch or measurement noise.

The code above also includes my attempt at setting up an EXTERNAL cost function, which has not been successful thus far. Whenever I run it with the EXTERNAL cost, I end up with the following message:

SQP_RTI: QP solver returned error status 3 QP iteration 15.

The controller does not make any significant moves in the manipulated variable with the EXTERNAL cost function set up. I tried reducing globalization_alpha_min but that does not seem to fix the issue. I also tried using different weights with no luck.

NMPC Setup in main.py

Code
def main():
    # Define parameters
    m_cart = 0.035  # mass of the cart [kg]
    mp1 = 0.034  # mass of the pendulum [kg]
    l1 = 0.25  # length of the rod [m]
    g = 9.81  # gravity constant [m/s^2]
    b_c = 0.0  # friction coefficient for the cart
    b_p = 0.0  # friction coefficient for the pendulum

    Tsim = 8.0  # final time for simulation [s]
    dt = 0.025  # time step for simulation [s] and sampling time for the ocp
    Nsim = int(Tsim / dt)  # number of steps
    Tf = 1.0 # length of the prediction horizon [s]
    N_horizon = int(Tf/dt)  # MPC number of stages in the prediction horizon

    # get the pendulum model
    model = pendulum_ode_model(m_cart, mp1, l1, g, b_c, b_p)
    nx = model.x.rows()  # number of states
    nu = model.u.rows()  # number of controls

    # Initiate simulation integrator
    sim_solver = formulate_pendulum_sim(model, dt=dt)

    # Formulate the MPC controller as an OCP solver 
    cost_type = 'EXTERNAL'  # cost type
    # cost_type = 'EXTERNAL'  # cost type for external cost function
    RTI = True  # Real-Time Iteration
    use_energy = True  # use energy in the cost function
    
    if cost_type == 'NONLINEAR_LS':
        if use_energy:
            Q = 200*np.diag([1.0, 30.0, 1.0, 0.6])  # output reference tracking cost matrix
            Q_e = 200*np.diag([1.0, 15.0, 0.75, 0])  # terminal output reference tracking cost matrix
        else:
            Q = 2*np.diag([1e2, 1e2, 0, 0, 4e-1])  # state reference tracking cost matrix
            Q_e = 2*np.diag([1e2, 1e2, 0, 0]) # terminal state reference tracking cost matrix
    elif cost_type == 'EXTERNAL':
        Q = 200*np.diag([1.0, 10.0, 15.0, 0.7])  # weights for the external cost function
        Q_e = 200*np.diag([1.0, 1.0, 0.75, 0])  # terminal weights for the external cost function

    x0 = np.array([0.0, 1*np.pi, 0.0, 0.0, 0.0])  # initial state [x1, theta, v1, dtheta, u_prev]
    u_min = np.array([-2.0])  # minimum control input
    u_max = np.array([2.0])  # maximum control input
    x_min = np.array([-0.5, -ACADOS_INFTY, -ACADOS_INFTY, -ACADOS_INFTY, -ACADOS_INFTY])  # minimum state
    x_max = np.array([0.5, ACADOS_INFTY, ACADOS_INFTY, ACADOS_INFTY, ACADOS_INFTY])     # maximum state
    controller_config = {
        'N': N_horizon,  # number of stages in the prediction horizon
        'Tf': Tf,  # length of the prediction horizon [s]
        'Q': Q,  # state cost matrix
        'Q_e': Q_e,  # terminal state cost matrix
        'x0': x0,  # initial state
        'u_min': u_min,  # minimum control input
        'u_max': u_max,   # maximum control input
        'x_min': x_min,  # minimum state
        'x_max': x_max,  # maximum state
        'cost_type': cost_type,  # cost type
        'RTI': RTI,  # Real-Time Iteration
        'use_energy': use_energy,  # use energy in the cost function
    }
    mpc = formulate_pendulum_mpc(model, **controller_config)

Questions
Is there anything I am missing or overlooking in my code, especially for the EXTERNAL cost set up?
Are there any additional examples for EXTERNAL cost set up that can help guide me with this project?
Could the numerical issues cause the lack of robustness in the NONLINEAR_LS cost formulation (the masses of my 3D printed setup in the lab are quite low)? and if so, would adjusting the scaling result in more robust NMPC?

Thank you for your advice! :smile:

Hi :waving_hand:

please first check your model: I assume that you would like to penalize the change in the control u. This requires you to introduce u as an additional state with dynamics \dot u = a and then penalize a (which is your new control input).

Best, Katrin

Thank you for the recommendation :folded_hands: Kaethe! This is a more efficient way to formulate the model. I made the adjustment as you suggested. However, I am still facing the same robustness issue with NONLINEAR_LS cost : SQP_RTI works well for all initial conditions, but this is not the case using just SQP.

With the new model, I run into infeasibility issues with the EXTERNAL cost.

RuntimeError: acados acados_ocp_solver returned status 4

I will keep on working on it. Please let me know if there is anything else you recommend.
I appreciate your help and advice

Could you provide the output of print_statistics for the first occurence of a nonzero solver status?

Absolutely. The error occurs in the initialization loop. Status 4 occurs on the 3rd iteration of the initiation loop, and the output of the print_statistics is as follows:

iter    qp_stat qp_iter
0       0       0
1       3       2

Dear @elmistiri,

I believe that SQP_RTI and (SQP with maximum 1 iteration and globalization = 'FIXED_STEP') should behave identically. Maybe you could try this as a sanity check.

It might be that the globalization brings you to some iterates where the QP is infeasible. There are two ways how you could proceed. Either you change your problem formulation and use slack variables to guarantee that the QP is always feasible. Or, you could use our new solver ocp.solver_options.nlp_solver_type = SQP_WITH_FEASIBLE_QP that tries to overcome the issue of infeasible QPs. There might be issues still because of bad scaling. In that case, you can add the option opts.qpscaling_scale_objective = 'OBJECTIVE_GERSHGORIN'. Now acados should be able to solve the problem.

If you would like to guarantee in RTI that you always have a well-defined problem, you can use the solver nlp_solver_type = SQP_WITH_FEASIBLE_QP with max iterations 1 and with globalization 'FIXED_STEP' AND additional options:

opts.search_direction_mode = 'BYRD_OMOJOKUN'
opts.allow_direction_mode_switch_to_nominal = False

Note that this kind of RTI is not tested in practice yet, but it will guarantee always well-defined subproblems.

I hope this helps.

Best,
David

1 Like

Hi David :waving_hand:

Thank you for your response. My bad for the delay, I got busy with travel and taking care of some other responsibilities. I got back to the swing of things now, and I have some updates to share.

I performed the sanity check as you suggested. When running the NONLINEAR_LS using SQP nlp solver type with maximum iterations as 1 and globalization = 'FIXED_STEP', the solver reaches the maximum number of iterations then produces the following error:
RuntimeError: acados acados_ocp_solver returned status 2
The same robustness issue persists for the NONLINEAR_LS set up.

For the EXTERNAL cost function set up:
To investigate the infeasibility issues, I added slack to the problem formulation. This took care of the infeasibility at the initial condition of theta[0] = np.pi. However, the obtained solution in this case is trivial; moves in the manipulated variable are close to 0 throughout the simulation, as shown in the figure below.

This set up seems to work only for some initial conditions, but not others. When the initial condition is between -0.25*np.pi <= theta[0] <= 0.25*np.pi, the intended result is reached, and the system stabilizes at the upright position. This achieved with some constraint violations made possible with slack.

Any other initial condition for theta (other than the ones specified above), results in infeasibility:
RuntimeError: acados acados_ocp_solver returned status 4

This is the case even when I use SQP_WITH_FEASIBLE_QP and/or remove constraints fully to simplify the problem further. Here are the settings I used for SQP_WITH_FEASIBLE_QP:

    elif nlp_solver_type == 'SQP_WITH_FEASIBLE_QP':
        ocp.solver_options.nlp_solver_type = 'SQP_WITH_FEASIBLE_QP'
        ocp.solver_options.qpscaling_scale_objective  = 'OBJECTIVE_GERSHGORIN'
        ocp.solver_options.search_direction_mode = 'BYRD_OMOJOKUN'
        ocp.solver_options.allow_direction_mode_switch_to_nominal = False
        ocp.solver_options.globalization = 'FIXED_STEP'

To examine if this is a scalability issue, I tried using higher model parameters (m_cart = 1, mp1 = 0.1, l = 0.8). In this case, the controller seems to work for all initial conditions, except for theta[0] = np.pi. With this starting condition, similar to the case above, the controller does not make any moves. I suspect that this happens because the controller gets stuck on a local minimum. Other combinations of model parameters (e.g. m_cart = 1, mp1 = 0.3, l = 0.8), reproduce the same issues shown in the figures above (the controller with EXTERNAL cost type works only for a tight range of initial conditions -0.25*np.pi <= theta[0] <= 0.25*np.pi). Based on that, I think the issue has to do with robustness to model parameters.

Please let me know if you have any suggestions on how to proceed to address both the robustness issue, and the lack of manipulated variable moves when theta[0] = np.pi.

Thank you for your help! :smile:

Hi,

regarding the sanity check: it is expected that acados retuns status 2 since we only allow 1 iteration, but you should not throw a runtimeerror, but use the iterate that acados gives you after this iteration and apply this to your controller. Then, the results should be exactly like SQP_RTI.

Regarding the slack variables: I would not slack the initial condition. The initial condition + dynamics + bounds on your control will never be a reason for infeasible QPs. All other constraints should be slacked. And you have to set a high weight in the objective function for the slack variables.

Could you share your problem formulation here, then I could have a look and see if I can fix it.

Cheers,
David

Thank you for the clarification. I edited the code to not run into a runtimeerror in the sanity check. The results for the NONLINEAR_LS with SQP_RTI are different from the ones I get from SQP with 1 iteration and globalization = 'FIXED_STEP. With the SQP solver, the controller makes no moves in the manipulated variable at all. I am not sure what I am missing. I shared my code at the end of this reply.

For the addition of slack, I included a feature to turn slack on all variables (control and state variables) just for the sake of testing. Here is my formulation:

Pendulum model & simulator:

def plot_pendulum(t, u_lim, U, x_lim, X_true, latexify=False, plt_show=True, time_label='$t$', x_labels=None, u_labels=None, fig_name='acados_pendulum_mpc_results'):
    """
    Params:
        t: time values of the discretization
        u_max: maximum absolute value of u
        U: arrray with shape (N_sim-1, nu) or (N_sim, nu)
        X_true: arrray with shape (N_sim, nx)
        latexify: latex style plots
    """

    if latexify:
        latexify_plot()

    nx = X_true.shape[1]
    fig, axes = plt.subplots(nx+1, 1, sharex=True)

    for i in range(nx):
        axes[i].step(t, X_true[:, i])
        axes[i].grid()
        if x_labels is not None:
            axes[i].set_ylabel(x_labels[i])
        else:
            axes[i].set_ylabel(f'$x_{i}$')

        if x_lim[i] < ACADOS_INFTY:
            axes[i].hlines(x_lim[i], t[0], t[-1], linestyles='dashed', alpha=0.7, color='red')
            axes[i].hlines(-x_lim[i], t[0], t[-1], linestyles='dashed', alpha=0.7, color='red')

    axes[-1].step(t, np.append([U[0]], U), color='black', label='u')

    if u_labels is not None:
        axes[-1].set_ylabel(u_labels[0])
    else:
        axes[-1].set_ylabel('$u$')

    if u_lim < ACADOS_INFTY:
        axes[-1].hlines(u_lim, t[0], t[-1], linestyles='dashed', alpha=0.7, color='red')
        axes[-1].hlines(-u_lim, t[0], t[-1], linestyles='dashed', alpha=0.7, color='red')
    # axes[-1].set_ylim([-1.2*u_lim, 1.2*u_lim])
    axes[-1].set_xlim(t[0], t[-1])
    axes[-1].set_xlabel(time_label)
    axes[-1].grid()

    plt.subplots_adjust(left=None, bottom=None, right=None, top=None, hspace=0.4)

    fig.align_ylabels()
    plt.savefig(f'./Results/{fig_name}.png', dpi=300, bbox_inches='tight')

    if plt_show:
        plt.show()

def pendulum_ode_model_aug_udot(m_cart, mp1, l1, g=9.81, b_c=0.0, b_p=0.05) -> AcadosModel:
    model_name = 'pendulum'

    # constants
    m_cart = m_cart  # mass of the cart [kg]
    m = mp1  # mass of the ball [kg]
    g = g  # gravity constant [m/s^2]
    l = l1  # length of the rod [m]
    b_c = b_c  # friction coefficient for the cart
    b_p = b_p  # friction coefficient for the pendulum

    # set up states & controls
    x1      = SX.sym('x1')
    theta   = SX.sym('theta')
    v1      = SX.sym('v1')
    dtheta  = SX.sym('dtheta')
    F       = SX.sym('F')

    x = vertcat(x1, theta, v1, dtheta, F)

    a = SX.sym('delta_u')  # change in control input
    u = a

    # xdot
    x1_dot      = SX.sym('x1_dot')
    theta_dot   = SX.sym('theta_dot')
    v1_dot      = SX.sym('v1_dot')
    dtheta_dot  = SX.sym('dtheta_dot')
    u_dot       = SX.sym('u_dot')

    xdot = vertcat(x1_dot, theta_dot, v1_dot, dtheta_dot, u_dot)

    # dynamics
    cos_theta = cos(theta)
    sin_theta = sin(theta)
    denominator = m_cart + m - m*cos_theta*cos_theta
    f_expl = vertcat(v1,
                     dtheta,
                     (-m*l*sin_theta*dtheta*dtheta + m*g*cos_theta*sin_theta + F - b_c*v1)/denominator,
                     (-m*l*cos_theta*sin_theta*dtheta*dtheta + F*cos_theta + (m_cart+m)*g*sin_theta - b_p*dtheta)/(l*denominator),
                     a
                     )

    f_impl = xdot - f_expl

    model = AcadosModel()

    model.f_impl_expr = f_impl
    model.f_expl_expr = f_expl
    model.x = x
    model.xdot = xdot
    model.u = u
    model.name = model_name

    # add potential and kinetic energy expressions
    E_kin_cart = 1 / 2 * m_cart * v1**2
    E_kin_pendulum = 1 / 2 * m * ((v1 + l * dtheta * cos_theta)**2 + (l * dtheta * sin_theta)**2) + 1 / 2 * (m * l**2 / 3) * dtheta**2
    E_kin = E_kin_cart + E_kin_pendulum
    
    E_pot = m * g * l * cos(theta)
    
    model.E_kin = E_kin
    model.E_pot = E_pot

    # store meta information
    model.x_labels = ['$x$ [m]', r'$\theta$ [rad]', '$v$ [m]', r'$\dot{\theta}$ [rad/s]', '$u$ [N]']
    model.u_labels = ['$\Delta u$ [N]']
    model.t_label = '$t$ [s]'

    return model


def formulate_pendulum_sim(model: AcadosModel, 
                           dt: float = 0.05) -> AcadosSimSolver:
    """
    Construct the simulator/integrator for the plant model.
    """
    model.name += '_plant_sim'
    sim = AcadosSim()
    sim.model = model
    sim.solver_options.T = dt
    sim.solver_options.integrator_type = 'IRK'
    sim.solver_options.num_stages = 4
    sim.solver_options.num_steps = 3
    sim.solver_options.newton_iter = 3
    sim.solver_options.collocation_type = 'GAUSS_LEGENDRE'
    sim_solver = AcadosSimSolver(sim)
    return sim_solver

Controller Formulation:

class AcadosMPC:
    """
    Class for acados-based MPC for the pendulum (delta_u formulation).
    """
    def __init__(self, model: AcadosModel, N: int = 20, Tf: float = 1.0,
                 nlp_solver_type: str = "SQP_RTI",
                 Q: np.ndarray = np.diag([1e3, 1e3, 1e-2, 1e-2, 1e-2]), 
                 Q_e: np.ndarray = np.diag([1e3, 1e3, 1e-2, 1e-2, 1]),
                 cost_type: str = 'NONLINEAR_LS',
                 use_energy: bool = True,
                 x0: np.ndarray = np.array([0.0, np.pi, 0.0, 0.0, 0.0]),
                 u_min: np.ndarray = np.array([-10.0]),
                 u_max: np.ndarray = np.array([10.0]),
                 x_min: np.ndarray = np.array([-0.7, -np.inf, -np.inf, -np.inf, -5]),
                 x_max: np.ndarray = np.array([0.7, np.inf, np.inf, np.inf, 5]),
                 soften_controls: bool = False,
                 soften_states: bool = False,
                 use_qp_scaling: bool = True):
        """
        Initialize the AcadosMPC object and set up the OCP solver.
        """
        self.model = model
        self.N = N
        self.Tf = Tf
        self.Q = Q
        self.Q_e = Q_e
        self.cost_type = cost_type
        self.use_energy = use_energy
        self.x0 = x0
        self.u_min = u_min
        self.u_max = u_max
        self.x_min = x_min
        self.x_max = x_max
        self.soften_controls = soften_controls
        self.soften_states = soften_states
        self.use_qp_scaling = use_qp_scaling
        self.nlp_solver_type = nlp_solver_type

        # Set up the OCP solver
        self.ocp_solver, self.ocp = self._setup_ocp_solver()

    def _setup_ocp_solver(self):
        """
        Set up the OCP solver using the delta_u formulation.
        This is equivalent to the logic in formulate_pendulum_mpc_delta_u.
        """
        ocp = AcadosOcp()
        ocp.model = self.model
        ocp.solver_options.N_horizon = self.N
        ocp.solver_options.tf = self.Tf

        nx = self.model.x.rows()
        nu = self.model.u.rows()
        ny = nx + nu
        ny_e = nx
        x = ocp.model.x
        u = ocp.model.u
        E_kin = ocp.model.E_kin
        E_pot = ocp.model.E_pot

        # Cost function setup
        if self.cost_type == 'LINEAR_LS':
            # Linear least squares cost
            ocp.cost.cost_type = 'LINEAR_LS'
            ocp.cost.cost_type_e = 'LINEAR_LS'
            ocp.solver_options.hessian_approx = 'GAUSS_NEWTON'
            ocp.cost.yref = np.zeros((ny, ))
            ocp.cost.yref_e = np.zeros((ny_e, ))
            ocp.cost.Vx = np.zeros((ny, nx))
            ocp.cost.Vx[:nx,:nx] = np.eye(nx)
            ocp.cost.W_e = self.Q_e
            ocp.cost.W = self.Q
            Vu = np.zeros((ny, nu))
            Vu[-1,0] = 1.0
            ocp.cost.Vu = Vu
            ocp.cost.Vx_e = np.eye(nx)
        elif self.cost_type == 'NONLINEAR_LS':
            # Nonlinear least squares cost
            ocp.cost.cost_type = 'NONLINEAR_LS'
            ocp.cost.cost_type_e = 'NONLINEAR_LS'
            ocp.solver_options.hessian_approx = 'GAUSS_NEWTON'
            if self.use_energy:
                # Use energy-based cost
                ocp.model.cost_y_expr = ca.vertcat(E_kin, -E_pot, x[0], u)
                ocp.model.cost_y_expr_e = ca.vertcat(E_kin, -E_pot, x[0], 0.0)
                n_ref = ocp.model.cost_y_expr.rows()
                ocp.cost.W = self.Q
                ocp.cost.W_e = self.Q_e
                ocp.cost.yref = np.zeros((n_ref,))
                ocp.cost.yref_e = np.zeros((n_ref,))
            else:
                # Use state and input cost
                ocp.model.cost_y_expr = ca.vertcat(x, u)
                ocp.model.cost_y_expr_e = x
                ocp.cost.W = self.Q
                ocp.cost.W_e = self.Q_e
                n_ref = ocp.model.cost_y_expr.rows()
                n_ref_e = ocp.model.cost_y_expr_e.rows()
                ocp.cost.yref = np.zeros((n_ref,))
                ocp.cost.yref_e = np.zeros((n_ref_e,))
        elif self.cost_type == 'EXTERNAL':
            # External cost function
            ocp.cost.cost_type = 'EXTERNAL'
            ocp.cost.cost_type_e = 'EXTERNAL'
            ocp.model.cost_expr_ext_cost = self.Q[0,0] * E_kin + self.Q[1,1] * (-E_pot) + self.Q[2,2] * (x[0]-0)**2 + self.Q[3,3] * (u)**2
            ocp.model.cost_expr_ext_cost_e = self.Q_e[0,0] * E_kin + self.Q_e[1,1] * (-E_pot) + self.Q_e[2,2] * (x[0]-0)**2
            ext_cost_use_num_hess = False
            ocp.solver_options.ext_cost_num_hess = ext_cost_use_num_hess
            ocp.solver_options.hessian_approx = 'EXACT'

        # Input constraints
        ocp.constraints.lbu = self.u_min
        ocp.constraints.ubu = self.u_max
        ocp.constraints.idxbu = np.arange(nu)
        if self.soften_controls:
            # Add slack variables for input constraints
            slack = 1e1
            ocp.constraints.idxsbu = np.array(range(nu))
            ocp.cost.zl = slack * np.ones(nu)
            ocp.cost.zu = slack * np.ones(nu)
            ocp.cost.Zl = slack * np.ones(nu)
            ocp.cost.Zu = slack * np.ones(nu)

        # State constraints
        ocp.constraints.lbx = self.x_min
        ocp.constraints.ubx = self.x_max
        ocp.constraints.idxbx = np.arange(nx)
        ocp.constraints.x0 = self.x0
        if self.soften_states:
            # Add slack variables for state constraints
            ocp.constraints.idxsbx = np.arange(nx)
            Zh = 1e0 * np.ones(nx)
            zh = 1e0 * np.ones(nx)
            # No slack for initial state values
            ocp.cost.zl_0 = ocp.cost.zl
            ocp.cost.zu_0 = ocp.cost.zu
            ocp.cost.Zl_0 = ocp.cost.Zl
            ocp.cost.Zu_0 = ocp.cost.Zu
            # slack state constraints along trajectory
            ocp.cost.zl = np.concatenate((ocp.cost.zl, zh))
            ocp.cost.zu = np.concatenate((ocp.cost.zu, zh))
            ocp.cost.Zl = np.concatenate((ocp.cost.Zl, Zh))
            ocp.cost.Zu = np.concatenate((ocp.cost.Zu, Zh))

        # Solver options
        ocp.solver_options.qp_solver = 'FULL_CONDENSING_HPIPM'
        ocp.solver_options.integrator_type = 'ERK'
        ocp.solver_options.sim_method_num_steps = 10
        ocp.solver_options.sim_method_newton_iter = 5
        ocp.solver_options.qp_solver_cond_N = self.N
        ocp.solver_options.qp_tol = 1e-9
        ocp.solver_options.qp_solver_ric_alg = 1
        ocp.solver_options.qp_solver_warm_start = 1
        ocp.solver_options.qp_solver_iter_max = 400
        ocp.solver_options.globalization_full_step_dual = True
        ocp.solver_options.print_level = 0
        ocp.solver_options.nlp_solver_max_iter = 30
        ocp.solver_options.use_constraint_hessian_in_feas_qp = False
        ocp.solver_options.nlp_solver_ext_qp_res = 0

        # QP scaling options
        if self.use_qp_scaling:
            ocp.solver_options.qpscaling_scale_constraints = "INF_NORM"
            ocp.solver_options.qpscaling_scale_objective = "OBJECTIVE_GERSHGORIN"

        # NLP solver type
        if self.nlp_solver_type == 'SQP_RTI':
            ocp.solver_options.nlp_solver_type = self.nlp_solver_type
        elif self.nlp_solver_type == 'SQP':
            ocp.solver_options.nlp_solver_type = 'SQP'
            ocp.solver_options.qp_solver_iter_max = 1
            ocp.solver_options.nlp_solver_max_iter = 1
            ocp.solver_options.globalization = 'FIXED_STEP'
        elif self.nlp_solver_type == 'SQP_WITH_FEASIBLE_QP':
            ocp.solver_options.nlp_solver_type = 'SQP_WITH_FEASIBLE_QP'
            ocp.solver_options.qpscaling_scale_objective  = 'OBJECTIVE_GERSHGORIN'
            ocp.solver_options.search_direction_mode = 'BYRD_OMOJOKUN'
            ocp.solver_options.allow_direction_mode_switch_to_nominal = False
            ocp.solver_options.globalization = 'FIXED_STEP'

        # Create the solver
        solver_json = 'acados_ocp_' + self.model.name + '.json'
        acados_ocp_solver = AcadosOcpSolver(ocp, json_file=solver_json)
        return acados_ocp_solver, ocp

    def solve(self, x_init=None, error_on_failure: bool = False):
        """
        Solve the MPC problem for the current initial state.
        Optionally set a new initial state before solving.
        """
        if self.nlp_solver_type == "SQP_RTI":
            # preparation phase
            self.ocp_solver.options_set('rti_phase', 1)
            status = self.ocp_solver.solve()
            t_preparation = self.ocp_solver.get_stats('time_tot')

            # set initial state
            self.ocp_solver.set(0, "lbx", x_init)
            self.ocp_solver.set(0, "ubx", x_init)

            # feedback phase
            self.ocp_solver.options_set('rti_phase', 2)
            status = self.ocp_solver.solve()
            t_feedback = self.ocp_solver.get_stats('time_tot')
            self.ocp_solver.print_statistics()
            u = self.ocp_solver.get(0, "u")
            return u, t_preparation, t_feedback, status

        else:
            # solve ocp and get next control input
            # set initial state
            self.ocp_solver.set(0, "lbx", x_init)
            self.ocp_solver.set(0, "ubx", x_init)
            status = self.ocp_solver.solve()
            if status != 0:
                print(f"Warning: OCP solver status: {status}")
                self.ocp_solver.print_statistics()
                if error_on_failure:
                    raise RuntimeError(f"OCP solver failed with status {status}")
            u = self.ocp_solver.get(0, "u")
            t_ = self.ocp_solver.get_stats('time_tot')
            return u, t_, status

main.py:

import numpy as np
import scipy.linalg
import matplotlib.pyplot as plt
from acados_template import latexify_plot
from utils.acados_functions import *
from acados_template import ACADOS_INFTY
import casadi as ca
import time

def main():
    # Define parameters
    m_cart = 1  # mass of the cart [kg]
    mp1 = 0.3  # mass of the pendulum [kg]
    l1 = 0.8  # length of the rod [m]
    g = 9.81  # gravity constant [m/s^2]
    b_c = 0.0  # friction coefficient for the cart
    b_p = 0.0  # friction coefficient for the pendulum
    m_cart = 0.034  # mass of the cart [kg]
    mp1 = 0.036  # mass of the pendulum [kg]
    l1 = 0.2  # length of the rod [m]
    g = 9.81  # gravity constant [m/s^2]
    b_c = 0.0  # friction coefficient for the cart
    b_p = 0.0  # friction coefficient for the pendulum
    
    # Define plant parameters
    m_cart_p = m_cart  # mass of the cart [kg]
    mp1_p = mp1  # mass of the pendulum [kg]
    l1_p = l1  # length of the rod [m]

    Tsim = 8.0  # final time for simulation [s]
    dt = 0.05  # time step for simulation [s]
    Nsim = int(Tsim / dt)  # number of steps
    Tf = 1.0 # length of the prediction horizon [s]
    N_horizon = int(Tf/dt)  # MPC number of stages in the prediction horizon

    # get the pendulum model
    model = pendulum_ode_model_aug_udot(m_cart, mp1, l1, g, b_c, b_p)
    model_p = pendulum_ode_model_aug_udot(m_cart_p, mp1_p, l1_p, g, b_c, b_p)
    nx = model.x.rows()  # number of states
    nu = model.u.rows()  # number of controls

    # Initiate simulation integrator
    sim_solver = formulate_pendulum_sim(model_p, dt=dt)

    # Formulate the MPC controller as an OCP solver 
    # cost_type = 'LINEAR_LS'  # cost type
    # cost_type = 'NONLINEAR_LS'  # cost type
    cost_type = 'EXTERNAL'  # cost type for external cost function
    
    nlp_solver_type = 'SQP_RTI'  # Real-Time Iteration
    # nlp_solver_type = 'SQP_WITH_FEASIBLE_QP'  # Real-Time Iteration with feasible QP
    # nlp_solver_type = 'SQP'  # Sequential Quadratic Programming
    use_energy = True  # use energy in the cost function
    if cost_type == 'LINEAR_LS':
        Q = 2*np.diag([3, 3, 0, 0, 0, 1])  # output reference tracking cost matrix
        Q_e = 2*np.diag([1, 1, 1e-3, 1e-3, 1e-1])  # terminal output reference tracking cost matrix
    elif cost_type == 'NONLINEAR_LS':
        if use_energy:
            Q = 2*np.diag([0.50, 10.0, 1.0, 2e-3])  # output reference tracking cost matrix
            Q_e = 2*np.diag([0.50, 10.0, 5, 0])  # terminal output reference tracking cost matrix
        else:
            Q = 2*np.diag([1, 1, 0, 0, 0, 6e-1])  # state reference tracking cost matrix
            Q_e = 2*np.diag([1, 1, 0, 0, 2e-1]) # terminal state reference tracking cost matrix
    elif cost_type == 'EXTERNAL':
        Q = 2*np.diag([1.0, 100.0, 1.0, 6e-3])  # weights for the external cost function
        Q_e = 2*np.diag([1.0, 100.0, 0.75, 0])  # terminal weights for the external cost function
    u_lim = 5.0  # Force constraint
    # u_lim = ACADOS_INFTY # Force constraint
    delta_u_lim = 20  # Change in force constraint
    # delta_u_lim = ACADOS_INFTY  # Change in force constraint
    pos_lim = 0.7  # Position constraint
    # pos_lim = ACADOS_INFTY  # Position constraint
    x0 = np.array([0.0, 0.2*np.pi, 0.0, 0.0, 0.0])  # initial state [x1, theta, v1, dtheta, u_prev]
    u_min = np.array([-delta_u_lim])  # minimum control input
    u_max = np.array([delta_u_lim])  # maximum control input
    x_min = np.array([-pos_lim, -ACADOS_INFTY, -ACADOS_INFTY, -ACADOS_INFTY, -u_lim])  # minimum state
    x_max = np.array([pos_lim, ACADOS_INFTY, ACADOS_INFTY, ACADOS_INFTY, u_lim])     # maximum state
    soften_controls = False  # soften control input constraints
    soften_states = True  # soften state constraints
    use_qp_scaling = True  # use qp scaling
    controller_config = {
        'N': N_horizon,  # number of stages in the prediction horizon
        'Tf': Tf,  # length of the prediction horizon [s]
        'Q': Q,  # state cost matrix
        'Q_e': Q_e,  # terminal state cost matrix
        'x0': x0,  # initial state
        'u_min': u_min,  # minimum control input
        'u_max': u_max,   # maximum control input
        'x_min': x_min,  # minimum state
        'x_max': x_max,  # maximum state
        'cost_type': cost_type,  # cost type
        'nlp_solver_type': nlp_solver_type,  # Real-Time Iteration
        'use_energy': use_energy,  # use energy in the cost function
        'soften_controls': soften_controls,  # soften control input constraints
        'soften_states': soften_states,  # soften state constraints
        'use_qp_scaling': use_qp_scaling,  # use qp scaling
    }
    mpc = AcadosMPC(model, **controller_config)

    Xsim = np.zeros((Nsim+1, nx))  # state trajectory for simulation
    Usim = np.zeros((Nsim, nu))  # control input trajectory for simulation
    E_kin = np.zeros((Nsim+1, 1))  # kinetic energy for each step
    E_pot = np.zeros((Nsim+1, 1))  # potential energy for each step
    cost_val = np.zeros((Nsim, 1))  # cost values for each step
    force = np.zeros((Nsim+1, 1))  # force values for each step
    
    Xsim[0, :] = x0  # set initial state in the trajectory

    if nlp_solver_type == "SQP_RTI":
        t_preparation = np.zeros((Nsim))
        t_feedback = np.zeros((Nsim))

    else:
        t_ = np.zeros((Nsim))

    # do some initial iterations to start with a good initial guess
    num_iter_initial = 20
    try:
        for _ in range(num_iter_initial):
            print(f"Initial iteration {_+1} of {num_iter_initial}")
            status = mpc.solve(x0)
            print(mpc.ocp_solver.get(0,"x"))
            print(mpc.ocp_solver.get(0,"u"))
    except Exception as e:
        # mpc.print_statistics()
        print(status)
        raise e
    finally:
        mpc.ocp_solver.print_statistics()
        time.sleep(1)  # wait for a second to avoid too fast iterations
        
    E_kin_f = ca.Function('E_kin', [model.x[1], model.x[2], model.x[3]], [model.E_kin],["xp_dot", "theta", "theta_dot"],["E_Kin"])
    E_pot_f = ca.Function('E_pot', [model.x[1]], [model.E_pot],["theta"],["E_Pot"])
    for i in range(Nsim):
        print(f"Step {i+1} of {Nsim}")
        if nlp_solver_type == "SQP_RTI":
            Usim[i, :], t_preparation[i], t_feedback[i], status = mpc.solve(Xsim[i, :])
        else:
            # solve ocp and get next control input
            Usim[i, :], t_[i], status = mpc.solve(Xsim[i, :])
        if i == 0:
            force[i+1] = Usim[i, 0]*dt
        else:
            force[i+1] = force[i] + Usim[i, 0]*dt
        # simulate system
        noise = np.random.normal(0, 0.0, (nx,))  # noise for simulation
        noise[-1] = 0.0  # no noise for control input
        Xsim[i+1, :] = sim_solver.simulate(Xsim[i, :], Usim[i, :]) + noise
        # Xsim[i+1, -1] = force[i+1]  # update control input in the state trajectory
        E_kin[i] = E_kin_f(Xsim[i, 1], Xsim[i, 2], Xsim[i, 3])  # compute kinetic energy
        E_pot[i] = E_pot_f(Xsim[i, 1])  # compute potential energy
        cost_val[i] = mpc.ocp_solver.get_cost()
        print(f"Step {i}: {Xsim[i, :]}")
    E_kin[-1] = E_kin_f(Xsim[-1, 1], Xsim[-1, 2], Xsim[-1, 3])  # compute kinetic energy for the last step
    E_pot[-1] = E_pot_f(Xsim[-1, 1])  # compute potential energy for the last step

    # evaluate timings
    if nlp_solver_type == "SQP_RTI":
        # scale to milliseconds
        t_preparation *= 1000
        t_feedback *= 1000
        print(f'Computation time in preparation phase in ms: \
                min {np.min(t_preparation):.3f} median {np.median(t_preparation):.3f} max {np.max(t_preparation):.3f}')
        print(f'Computation time in feedback phase in ms:    \
                min {np.min(t_feedback):.3f} median {np.median(t_feedback):.3f} max {np.max(t_feedback):.3f}')
    else:
        # scale to milliseconds
        t_ *= 1000
        print(f'Computation time in ms: min {np.min(t_):.3f} median {np.median(t_):.3f} max {np.max(t_):.3f}')

    x_labels = model.x_labels
    u_labels = model.u_labels

    t = np.linspace(0, Tsim, Nsim+1)
    fig_name = f'{cost_type}_pendulum_mpc_results_3'
    plot_pendulum(t, u_max, Usim, x_max, Xsim, latexify=False, plt_show=True,
                 time_label=model.t_label, x_labels=x_labels, u_labels=u_labels, fig_name=fig_name)

    plt.figure(figsize=(8, 4))
    plt.subplot(3, 1, 1)
    plt.step(t, E_kin, label='kinetic energy')
    plt.ylabel('Kinetic Energy [J]')
    plt.grid()
    plt.subplot(3, 1, 2)
    plt.step(t, E_pot, label='potential energy')
    plt.ylabel('Potential Energy [J]')
    plt.grid()
    plt.subplot(3, 1, 3)
    plt.step(t[:-1], cost_val, label='cost')
    plt.legend()
    plt.xlabel('Time [s]')
    plt.ylabel('Cost')
    plt.grid()
    plt.tight_layout()
    plt.savefig(f'./Results/{cost_type}_pendulum_cost_3.png', dpi=300)
    plt.show()
    
    plt.step(t[:-1], force[:-1], label='force')
    plt.step(t[:-1], Usim[:, 0], label='control input')
    plt.step(t[:-1], Xsim[:-1, -1], label='force integrated')
    plt.xlabel('Time [s]')
    plt.ylabel('Force [N]')
    plt.legend()
    plt.grid()
    plt.tight_layout()
    plt.savefig(f'./Results/{cost_type}_pendulum_force_3.png', dpi=300)
    plt.show()

if __name__ == "__main__":
    main()

I truly appreciate your help.

Hi,

I found a bug here. You should not set the qp_solver_max_iter to 1. You should solve the QP to convergence. Then SQP and SQP_RTI give the same result.

I also checked with the SQP. If you use the EXTERNAL cost function, you should regularize the Hessian matrix. Otherwise HPIPM cannot solve it:

ocp.solver_options.regularize_method = 'MIRROR'
ocp.solver_options.reg_epsilon = 1e-6

I hope everything works fine now

Thank you so much David! Everything seems to be working without any errors now!

As it stands right now, I am not having any errors with any of the objective function formulations (LINEAR_LS, NONLINEAR_LS, and EXTERNAL) at any initial condition. The EXTERNAL cost results in a trivial solution with the current controller tuning when theta[0] = np.pi. I will test different tuning weights/tinkering with the cost function formulation to make it work at this initial condition.

I appreciate your and Kaetheโ€™s help and support. You guys rock! :smile: