Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improvements over acados MPC formulation #183

Open
wants to merge 9 commits into
base: main
Choose a base branch
from

Conversation

aghezz1
Copy link

@aghezz1 aghezz1 commented Dec 5, 2024

I had some problems in rebasing #181 and updating #182.
So I created this new PR to have a clean git history, sorry for the mess.

A quick comparison by running the tracking problem for the drone of a 3D lemniscate
Order: casadi/ipopt, acados/sqp, acados/sqp_rti
Considerable speed up of computation time (cf. t_wall metrics)
Screenshot 2024-12-05 at 15 20 06 Screenshot 2024-12-05 at 15 19 44 Screenshot 2024-12-05 at 15 22 08

@aghezz1 aghezz1 marked this pull request as ready for review December 5, 2024 14:23
@Federico-PizarroBejarano Federico-PizarroBejarano added the enhancement New feature or request label Dec 6, 2024
Copy link
Collaborator

@Federico-PizarroBejarano Federico-PizarroBejarano left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! Here are just some basic comments. I don't know much about acados so I will leave that review more for @MingxuanChe and @adamhall

examples/mpc/mpc_experiment.py Show resolved Hide resolved
@@ -409,10 +409,16 @@ def compute_metrics(self, data, verbose=False):
'average_constraint_violation': np.asarray(self.get_episode_constraint_violation_steps()).mean(),
'constraint_violation_std': np.asarray(self.get_episode_constraint_violation_steps()).std(),
'constraint_violation': np.asarray(self.get_episode_constraint_violation_steps()) if len(self.get_episode_constraint_violation_steps()) > 1 else self.get_episode_constraint_violation_steps()[0],
'mean_t_wall_ms': np.asarray(self.get_t_wall()).mean()*1e3,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will this raise errors for the other controllers which do not have 't_wall' in their data?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, thanks for pointing out. I propose a fix with a try/except.
Ultimately, i believe it would also be interesting to add the t_wall for the inference of neural networks.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In another branch what we did is we measure the inference time in the base_experiment.py class itself, so we don't need to change every controller. I think this is a cleaner solution. For now I propose we remove this and instead update the base experiment later to measure the time

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a supplementary comment on the plan for the inference time metric. The inference time in the other branch is temporary. This metric should consider only the online optimization time and exclude all the variable logging etc. So in the end we will need to modify every controller to get this metric.

I also agree on removing this first from base_experiment.py but keeping the timing in the mpc_acados as some initial progess for unifying this metric.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For linear_mpc, if we add the option 'record_time': True when self.solver == 'qrqp', then we can get t_wall_total for that controller as well. I think its a really useful metric so maybe we can keep it in mpc, linear_mpc, and mpc_acados, but not in base_experiment.py. I think in the future we should look at how to get a good timing metric for all controllers though (that doesn't include logging time).

else:
raise Exception('Initial guess method not implemented.')

self.x_prev = x_guess
self.u_prev = u_guess

# set the solver back
self.setup_optimizer(solver=self.solver)
self.setup_optimizer(solver=self.solver) # TODO why? this method is also called from mpc_acados! at least move to line 209!
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't see setup_optimizer being called in mpc_acados. It calls setup_acados_optimizer, but this function is for casadi. I am a bit confused about this whole section lol.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With my comment I wanted to highlight that when you call compute_initial_guess() from mpc_acados.py you end up calling compute_initial_guess()from mpc.py and also you run line 229 which creates an instance of a casadi nlpsolver that is not needed in mpcacados.py.
Line 229 is important only in case you are using mpc.py, in order to overwrite the solver at line 188.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm I see your point, we're setting up casadi for no reason. We should fix this, will talk to the group

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was added by me. This idea of this is allowing solver swiching for mpc. For example, do an initial guess (self.init_solver) with ipopt then switch to a faster sqp solver (self.solver). I have tested with cartpole previously and this can improve the overall runtime and accuracy. But indeed acados will not use this. I think we can keep this if there is no clear interference with other code.

One problem I notice is that this will add several milliseconds to the runtime. So the init step runtime needs a special treatment (maybe simply remove the first step).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move it to line 209, you are interested to have a robust solver for the warmstart only.
So you switch to ipopt solver when ipopt is the chosen warmstart type.
Otherwise you go on using the NLP solver you started with.
For instance, if you choose sqpmethod with casadi, and lqr as warmstart type you don't have to create/change solver for the initialization.

safe_control_gym/controllers/mpc/mpc.py Outdated Show resolved Hide resolved
@Federico-PizarroBejarano
Copy link
Collaborator

BTW @aghezz1, what version of acados are you using? How often should we update our own acados?

@aghezz1
Copy link
Author

aghezz1 commented Dec 11, 2024

BTW @aghezz1, what version of acados are you using? How often should we update our own acados?

I am on master branch at the moment, as I am using an helper function for detecting constraints that was bugged in v0.4.2. Now the function is fixed but not yet included in an official version.
For what we are doing here any recent version of acados would work (>= v0.4.0) but I would update when v0.4.4 is released.

@MingxuanChe
Copy link
Collaborator

Just a heads-up: I tried release 0.4.3 and it complains the following bug. Building from the master branch works well.

File "/home/mingxuan/Repositories/scg_andrea/safe_control_gym/controllers/mpc/mpc_acados.py", line 194, in setup_acados_optimizer
    ocp = self.processing_acados_constraints_expression(ocp, h0_expr, h_expr, he_expr)
  File "/home/mingxuan/Repositories/scg_andrea/safe_control_gym/controllers/mpc/mpc_acados.py", line 337, in processing_acados_constraints_expression
    detect_constraint_structure(ocp.model, ocp.constraints, stage_type=s_type)
  File "/home/mingxuan/Repositories/acados/interfaces/acados_template/acados_template/mpc_utils.py", line 297, in detect_constraint_structure
    constraints.idxbx = J_to_idx(Jbx)
  File "/home/mingxuan/Repositories/acados/interfaces/acados_template/acados_template/mpc_utils.py", line 313, in J_to_idx
    raise ValueError(
ValueError: J_to_idx: Invalid J matrix. Exiting. Found more than one nonzero in row 1.

Copy link
Collaborator

@MingxuanChe MingxuanChe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the PR and I am happy to learn more from you. In general, this looks good and I find that your method for parsing the constraints is faster than my old implementation. I have some comments about some particular code.

@@ -344,31 +426,14 @@ def select_action(self,
self.acados_ocp_solver.set(self.T, 'yref', y_ref_e)

# solve the optimization problem
if self.use_RTI:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I want to learn more about why this is removed. I previously learned this phase switching from the examples, e.g. here and here, and I thought this was the standard way of using RTI. Also, when is this phase switching necessary?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting RTI in two phases is useful in two cases:

  1. you are really working on an embedded system where is important to minimize feedback delay. As you can do the preparation phase while the system is within a sampling time and as soon as you receive the new initial state you solve for the feedback phase. (You don't need to wait for the new initial state for doing linear algebra routines like condensing)
  2. a more accurate profiling: for instance if you compare against a neural network controller you can compare the time spent in the feedback phase only. As the preparation phase can be during the previous sampling time. But again if you have very short sampling time the difference is smaller.

For more on RTI and it's implementation you can have a look to RTI paper

@@ -260,38 +260,120 @@ def processing_acados_constraints_expression(self,
ocp.constraints.ubu = self.env.constraints.input_constraints[0].upper_bounds
ocp.constraints.idxbu = idxbu # active constraints dimension
'''

ub = {'h': set_acados_constraint_bound(h_expr, 'ub', self.constraint_tol),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I briefly compared these two implementations, and I find yours is faster.
Updated version:
image
Before:
image

I'd like to learn more about what case 1 2 3 means. Also I didn't fully understand what detect_constraint_structure does. It seems that it doesn't return anything (or could you ask your colleague to add some docstring for this function.)

Apart from that, I wonder whether we can condense these codes more. For example I think gym has a fixed way to parse the constraint and we might always have lower bounds before upper bounds (here). So we might could safely remove the flag start_with_ub. Please correct me if I am wrong @adamhall .

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the code could avoided if the constraints keep their structures as simple bounds, linear, ... when you pass them to the environment. I know that rewriting everything as h(x, u) \leq 0 make many things simpler but also slower 😄
So to keep your code as it is, I think is good to preprocess the constraints if the solver you use exploits the constraint structure.

My implementation is a bit complicated but the rationale behind is simple:

  1. acados prefers double-sided constraints as lh <= h(x, u) <= uh where the bounds are finite (not 1e10). Large numbers creates numerical difficulties.
  2. Connected to 1. is therefore better to specify lh <= h(x, u) <= uhthan -inf <= -h(x, u) + lh <= 0 AND -inf <= h(x, u) - uh <=0
  3. Some constraints structures like simple bounds or linear constraint are directly supported within acados. So if they are correctly declared, acados will not compile and build external functions.

Therefore in my implementation I do two steps:

  1. I look into your constraints and check if the constraints is double-sided. Case 1., 2., 3., they simply look to different possible structure of the constraint Jacobian. Example: you would enter case 1 if you have constraints only on the state, case 2 if only on the control, case 3 if constraints are both on state and control.
  2. I extract lower and upper bound from your generic expression h
  3. I now have all the fields h, lh, uh for initial, path and terminal constraints. I populate the acados OCP object with these fields. Then, I call the acados helper function for detecting if this constraints are simple bounds or linear.
  4. If such constraint structures are detected, the acados detecting constraints directly writes in your ocp object

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very interesting! I didn't realize that the solvers care that much about how constraints are specified. I wonder if instead of having this block of code we modify or add something to constraints.py to handle different formulations. For example, the DefaultConstraint class is a child of BoudnedConstraint where we could add an option or function that creates them in the form of lb < h(x,u) < ub. On the contrary, this would only be useful in mpc_acados, so maybe it makes sense to do the parsing in mpc_acados, unless ipopt also benefits from having this constraint structure?

If we do keep the code likee this, I think some additional comments, like the ones in your comment above, might be helpful when we have to edit or debug in the future.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think for IPOPT the benefit would the very minimal for the computation speed on small problems. But for large problems if the constraints are double-sided and you implement them as h(x, u) < 0 you basically double the number of constraints. So it would be nice indeed to preserve the constraints structure.

@aghezz1
Copy link
Author

aghezz1 commented Dec 12, 2024

@MingxuanChe regarding acados version, at the moment to use detect_constraints_structure you need to stay on master.
It will be included in the upcoming release.

BTW @aghezz1, what version of acados are you using? How often should we update our own acados?

I am on master branch at the moment, as I am using an helper function for detecting constraints that was bugged in v0.4.2. Now the function is fixed but not yet included in an official version. For what we are doing here any recent version of acados would work (>= v0.4.0) but I would update when v0.4.4 is released.

return self.data['controller_data'][0]['t_wall'][0]
except:
return np.nan

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In another branch what we did is we measure the inference time in the base_experiment.py class itself, so we don't need to change every controller. I think this is a cleaner solution. For now I propose we remove this and instead update the base experiment later to measure the time

I think this a valid option, what you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the problem with this is that there is a lot of logging that goes on inside some of the select_action functions. If we can separate out that logging, then I think the timings from base_experiment would make sense, but it might be necessary that all the algo's have their own timing routine as they all log a little differently?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I understood your comment..
I think would be cool to have the time spent in the solver, the python overhead in select_action should not be included.

else:
raise Exception('Initial guess method not implemented.')

self.x_prev = x_guess
self.u_prev = u_guess

# set the solver back
self.setup_optimizer(solver=self.solver)
self.setup_optimizer(solver=self.solver) # TODO why? this method is also called from mpc_acados! at least move to line 209!
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would move it to line 209, you are interested to have a robust solver for the warmstart only.
So you switch to ipopt solver when ipopt is the chosen warmstart type.
Otherwise you go on using the NLP solver you started with.
For instance, if you choose sqpmethod with casadi, and lqr as warmstart type you don't have to create/change solver for the initialization.

@@ -344,31 +426,14 @@ def select_action(self,
self.acados_ocp_solver.set(self.T, 'yref', y_ref_e)

# solve the optimization problem
if self.use_RTI:
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Splitting RTI in two phases is useful in two cases:

  1. you are really working on an embedded system where is important to minimize feedback delay. As you can do the preparation phase while the system is within a sampling time and as soon as you receive the new initial state you solve for the feedback phase. (You don't need to wait for the new initial state for doing linear algebra routines like condensing)
  2. a more accurate profiling: for instance if you compare against a neural network controller you can compare the time spent in the feedback phase only. As the preparation phase can be during the previous sampling time. But again if you have very short sampling time the difference is smaller.

For more on RTI and it's implementation you can have a look to RTI paper

@@ -260,38 +260,120 @@ def processing_acados_constraints_expression(self,
ocp.constraints.ubu = self.env.constraints.input_constraints[0].upper_bounds
ocp.constraints.idxbu = idxbu # active constraints dimension
'''

ub = {'h': set_acados_constraint_bound(h_expr, 'ub', self.constraint_tol),
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the code could avoided if the constraints keep their structures as simple bounds, linear, ... when you pass them to the environment. I know that rewriting everything as h(x, u) \leq 0 make many things simpler but also slower 😄
So to keep your code as it is, I think is good to preprocess the constraints if the solver you use exploits the constraint structure.

My implementation is a bit complicated but the rationale behind is simple:

  1. acados prefers double-sided constraints as lh <= h(x, u) <= uh where the bounds are finite (not 1e10). Large numbers creates numerical difficulties.
  2. Connected to 1. is therefore better to specify lh <= h(x, u) <= uhthan -inf <= -h(x, u) + lh <= 0 AND -inf <= h(x, u) - uh <=0
  3. Some constraints structures like simple bounds or linear constraint are directly supported within acados. So if they are correctly declared, acados will not compile and build external functions.

Therefore in my implementation I do two steps:

  1. I look into your constraints and check if the constraints is double-sided. Case 1., 2., 3., they simply look to different possible structure of the constraint Jacobian. Example: you would enter case 1 if you have constraints only on the state, case 2 if only on the control, case 3 if constraints are both on state and control.
  2. I extract lower and upper bound from your generic expression h
  3. I now have all the fields h, lh, uh for initial, path and terminal constraints. I populate the acados OCP object with these fields. Then, I call the acados helper function for detecting if this constraints are simple bounds or linear.
  4. If such constraint structures are detected, the acados detecting constraints directly writes in your ocp object

Copy link
Contributor

@adamhall adamhall left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks pretty great! I think my comments are similar to @Federico-PizarroBejarano and @MingxuanChe. I managed to run your fork (with some small changes to the t_wall stuff) and got similar results to what you posted.

I think the biggest thing is about the constraint parsing. We can either keep it in mpc_acados or modify constraints.py to keep have a way to give the constraints in the form that is best. Does anyone have a preference?

@@ -260,38 +260,120 @@ def processing_acados_constraints_expression(self,
ocp.constraints.ubu = self.env.constraints.input_constraints[0].upper_bounds
ocp.constraints.idxbu = idxbu # active constraints dimension
'''

ub = {'h': set_acados_constraint_bound(h_expr, 'ub', self.constraint_tol),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is very interesting! I didn't realize that the solvers care that much about how constraints are specified. I wonder if instead of having this block of code we modify or add something to constraints.py to handle different formulations. For example, the DefaultConstraint class is a child of BoudnedConstraint where we could add an option or function that creates them in the form of lb < h(x,u) < ub. On the contrary, this would only be useful in mpc_acados, so maybe it makes sense to do the parsing in mpc_acados, unless ipopt also benefits from having this constraint structure?

If we do keep the code likee this, I think some additional comments, like the ones in your comment above, might be helpful when we have to edit or debug in the future.

lb.update({'he': set_acados_constraint_bound(he_expr, 'lb')})

if ub != {}:
# make sure all the ub and lb are 1D numpy arrays
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

again, I wonder if we can ensure the bounds are 1D numpy arrays in the constraint class such that this code isn't needed here.

return self.data['controller_data'][0]['t_wall'][0]
except:
return np.nan

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the problem with this is that there is a lot of logging that goes on inside some of the select_action functions. If we can separate out that logging, then I think the timings from base_experiment would make sense, but it might be necessary that all the algo's have their own timing routine as they all log a little differently?

@aghezz1
Copy link
Author

aghezz1 commented Dec 23, 2024

I think the biggest thing is about the constraint parsing. We can either keep it in mpc_acados or modify constraints.py to keep have a way to give the constraints in the form that is best. Does anyone have a preference?

Would be cool to modify constraints.py (cf. my comment above), I could have a look into it but might be more efficient if some of you join, and we do a session together.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants