2121from .timeresp import TimeResponseData
2222
2323# Define module default parameter values
24+ _optimal_trajectory_methods = {'shooting' , 'collocation' }
2425_optimal_defaults = {
26+ 'optimal.trajectory_method' : 'shooting' ,
2527 'optimal.minimize_method' : None ,
2628 'optimal.minimize_options' : {},
2729 'optimal.minimize_kwargs' : {},
@@ -65,9 +67,11 @@ class OptimalControlProblem():
6567 inputs should either be a 2D vector of shape (ninputs, horizon)
6668 or a 1D input of shape (ninputs,) that will be broadcast by
6769 extension of the time axis.
68- method : string, optional
69- Method to use for carrying out the optimization. Currently only
70- 'shooting' is supported.
70+ trajectory_method : string, optional
71+ Method to use for carrying out the optimization. Currently supported
72+ methods are 'shooting' and 'collocation' (continuous time only). The
73+ default value is 'shooting' for discrete time systems and
74+ 'collocation' for continuous time systems
7175 log : bool, optional
7276 If `True`, turn on logging messages (using Python logging module).
7377 Use ``logging.basicConfig`` to enable logging output (e.g., to a file).
@@ -133,7 +137,7 @@ class OptimalControlProblem():
133137 def __init__ (
134138 self , sys , timepts , integral_cost , trajectory_constraints = [],
135139 terminal_cost = None , terminal_constraints = [], initial_guess = None ,
136- method = 'shooting' , basis = None , log = False , ** kwargs ):
140+ trajectory_method = None , basis = None , log = False , ** kwargs ):
137141 """Set up an optimal control problem."""
138142 # Save the basic information for use later
139143 self .system = sys
@@ -144,11 +148,15 @@ def __init__(
144148 self .basis = basis
145149
146150 # Keep track of what type of method we are using
147- if method not in {'shooting' , 'collocation' }:
151+ if trajectory_method is None :
152+ # TODO: change default
153+ # trajectory_method = 'collocation' if sys.isctime() else 'shooting'
154+ trajectory_method = 'shooting' if sys .isctime () else 'shooting'
155+ elif trajectory_method not in _optimal_trajectory_methods :
148156 raise NotImplementedError (f"Unkown method { method } " )
149- else :
150- self .shooting = method in {'shooting' }
151- self .collocation = method in {'collocation' }
157+
158+ self .shooting = trajectory_method in {'shooting' }
159+ self .collocation = trajectory_method in {'collocation' }
152160
153161 # Process keyword arguments
154162 self .solve_ivp_kwargs = {}
@@ -249,20 +257,21 @@ def __init__(
249257 self .eqconst_value ))
250258 if self .collocation :
251259 # Add the collocation constraints
252- colloc_zeros = np .zeros ((self .timepts .size - 1 ) * sys .nstates )
260+ colloc_zeros = np .zeros (sys .nstates * self .timepts .size )
261+ self .colloc_vals = np .zeros ((sys .nstates , self .timepts .size ))
253262 self .constraints .append (sp .optimize .NonlinearConstraint (
254263 self ._collocation_constraint , colloc_zeros , colloc_zeros ))
255264
265+ # Initialize run-time statistics
266+ self ._reset_statistics (log )
267+
256268 # Process the initial guess
257269 self .initial_guess = self ._process_initial_guess (initial_guess )
258270
259271 # Store states, input, used later to minimize re-computation
260272 self .last_x = np .full (self .system .nstates , np .nan )
261273 self .last_coeffs = np .full (self .initial_guess .shape , np .nan )
262274
263- # Reset run-time statistics
264- self ._reset_statistics (log )
265-
266275 # Log information
267276 if log :
268277 logging .info ("New optimal control problem initailized" )
@@ -468,10 +477,6 @@ def _eqconst_function(self, coeffs):
468477 # Checked above => we should never get here
469478 raise TypeError ("unknown constraint type {ctype}" )
470479
471- # Add the collocation constraints
472- if self .collocation :
473- raise NotImplementedError ("collocation not yet implemented" )
474-
475480 # Update statistics
476481 self .eqconst_evaluations += 1
477482 if self .log :
@@ -489,11 +494,32 @@ def _eqconst_function(self, coeffs):
489494 # Return the value of the constraint function
490495 return np .hstack (value )
491496
492- def _collocation_constraints (self , coeffs ):
497+ def _collocation_constraint (self , coeffs ):
493498 # Compute the states and inputs
494499 states , inputs = self ._compute_states_inputs (coeffs )
495500
496- raise NotImplementedError ("collocation not yet implemented" )
501+ if self .system .isctime ():
502+ # Compute the collocation constraints
503+ for i , t in enumerate (self .timepts ):
504+ if i == 0 :
505+ # Initial condition constraint (self.x = initial point)
506+ self .colloc_vals [:, 0 ] = states [:, 0 ] - self .x
507+ fk = self .system ._rhs (
508+ self .timepts [0 ], states [:, 0 ], inputs [:, 0 ])
509+ continue
510+
511+ # M. Kelly, SIAM Review (2017), equation (3.2), i = k+1
512+ # x[k+1] - x[k] = 0.5 hk (f(x[k+1], u[k+1] + f(x[k], u[k]))
513+ fkp1 = self .system ._rhs (t , states [:, i ], inputs [:, i ])
514+ self .colloc_vals [:, i ] = states [:, i ] - states [:, i - 1 ] - \
515+ 0.5 * (self .timepts [i ] - self .timepts [i - 1 ]) * (fkp1 + fk )
516+ fk = fkp1
517+ else :
518+ raise NotImplementedError (
519+ "collocation not yet implemented for discrete time systems" )
520+
521+ # Return the value of the constraint function
522+ return self .colloc_vals .reshape (- 1 )
497523
498524 #
499525 # Initial guess
@@ -509,40 +535,61 @@ def _collocation_constraints(self, coeffs):
509535 # TODO: figure out how to modify this for collocation
510536 #
511537 def _process_initial_guess (self , initial_guess ):
512- if self .shooting and initial_guess is not None :
513- # Convert to a 1D array (or higher)
514- initial_guess = np .atleast_1d (initial_guess )
515-
516- # See whether we got entire guess or just first time point
517- if initial_guess .ndim == 1 :
518- # Broadcast inputs to entire time vector
519- try :
520- initial_guess = np .broadcast_to (
521- initial_guess .reshape (- 1 , 1 ),
522- (self .system .ninputs , self .timepts .size ))
523- except ValueError :
524- raise ValueError ("initial guess is the wrong shape" )
525-
526- elif initial_guess .shape != \
527- (self .system .ninputs , self .timepts .size ):
528- raise ValueError ("initial guess is the wrong shape" )
538+ # Sort out the input guess and the state guess
539+ if self .collocation and initial_guess is not None and \
540+ isinstance (initial_guess , tuple ):
541+ state_guess , input_guess = initial_guess
542+ else :
543+ state_guess , input_guess = None , initial_guess
544+
545+ # Process the input guess
546+ if input_guess is not None :
547+ input_guess = self ._broadcast_initial_guess (
548+ input_guess , (self .system .ninputs , self .timepts .size ))
529549
530550 # If we were given a basis, project onto the basis elements
531551 if self .basis is not None :
532- initial_guess = self ._inputs_to_coeffs (initial_guess )
552+ input_guess = self ._inputs_to_coeffs (input_guess )
553+ else :
554+ input_guess = np .zeros (
555+ self .system .ninputs *
556+ (self .timepts .size if self .basis is None else self .basis .N ))
557+
558+ # Process the state guess
559+ if self .collocation :
560+ if state_guess is None :
561+ # Run a simulation to get the initial guess
562+ inputs = input_guess .reshape (self .system .ninputs , - 1 )
563+ state_guess = self ._simulate_states (
564+ np .zeros (self .system .nstates ), inputs )
565+ else :
566+ state_guess = self ._broadcast_initial_guess (
567+ state_guess , (self .system .nstates , self .timepts .size ))
533568
534569 # Reshape for use by scipy.optimize.minimize()
535- return initial_guess .reshape (- 1 )
570+ return np .hstack ([
571+ input_guess .reshape (- 1 ), state_guess .reshape (- 1 )])
572+ else :
573+ # Reshape for use by scipy.optimize.minimize()
574+ return input_guess .reshape (- 1 )
575+
576+ def _broadcast_initial_guess (self , initial_guess , shape ):
577+ # Convert to a 1D array (or higher)
578+ initial_guess = np .atleast_1d (initial_guess )
579+
580+ # See whether we got entire guess or just first time point
581+ if initial_guess .ndim == 1 :
582+ # Broadcast inputs to entire time vector
583+ try :
584+ initial_guess = np .broadcast_to (
585+ initial_guess .reshape (- 1 , 1 ), shape )
586+ except ValueError :
587+ raise ValueError ("initial guess is the wrong shape" )
536588
537- elif self . collocation and initial_guess is not None :
538- raise NotImplementedError ( "collocation not yet implemented " )
589+ elif initial_guess . shape != shape :
590+ raise ValueError ( "initial guess is the wrong shape " )
539591
540- # Default is no initial guess
541- input_size = self .system .ninputs * \
542- (self .timepts .size if self .basis is None else self .basis .N )
543- state_size = 0 if not self .collocation else \
544- (self .timepts .size - 1 ) * self .sys .nstates
545- return np .zeros (input_size + state_size )
592+ return initial_guess
546593
547594 #
548595 # Utility function to convert input vector to coefficient vector
@@ -642,9 +689,10 @@ def _compute_states_inputs(self, coeffs):
642689 #
643690 if self .collocation :
644691 # States are appended to end of (input) coefficients
645- states = coeffs [- self .sys .nstates * self .timepts .size :0 ]
646- coeffs = coeffs [0 :- self .sys .nstates * self .timepts .size ]
647-
692+ states = coeffs [- self .system .nstates * self .timepts .size :].reshape (
693+ self .system .nstates , - 1 )
694+ coeffs = coeffs [:- self .system .nstates * self .timepts .size ]
695+
648696 # Compute input at time points
649697 if self .basis :
650698 inputs = self ._coeffs_to_inputs (coeffs )
@@ -855,11 +903,8 @@ def __init__(
855903 # Remember the optimal control problem that we solved
856904 self .problem = ocp
857905
858- # Compute input at time points
859- if ocp .basis :
860- inputs = ocp ._coeffs_to_inputs (res .x )
861- else :
862- inputs = res .x .reshape ((ocp .system .ninputs , - 1 ))
906+ # Parse the optimization variables into states and inputs
907+ states , inputs = ocp ._compute_states_inputs (res .x )
863908
864909 # See if we got an answer
865910 if not res .success :
@@ -877,6 +922,7 @@ def __init__(
877922
878923 if return_states and inputs .shape [1 ] == ocp .timepts .shape [0 ]:
879924 # Simulate the system if we need the state back
925+ # TODO: this is probably not needed due to compute_states_inputs()
880926 _ , _ , states = ct .input_output_response (
881927 ocp .system , ocp .timepts , inputs , ocp .x , return_x = True ,
882928 solve_ivp_kwargs = ocp .solve_ivp_kwargs , t_eval = ocp .timepts )
@@ -1005,7 +1051,7 @@ def solve_ocp(
10051051 'optimal' , 'return_x' , kwargs , return_states , pop = True )
10061052
10071053 # Process (legacy) method keyword
1008- if kwargs .get ('method' ):
1054+ if kwargs .get ('method' ) and method not in optimal_methods :
10091055 if kwargs .get ('minimize_method' ):
10101056 raise ValueError ("'minimize_method' specified more than once" )
10111057 kwargs ['minimize_method' ] = kwargs .pop ('method' )
0 commit comments