# steering-optimal.py - optimal control for vehicle steering
# RMM, 18 Feb 2021
#
# This file works through an optimal control example for the vehicle
# steering system.  It is intended to demonstrate the functionality for
# optimal control module (control.optimal) in the python-control package.

import numpy as np
import math
import control as ct
import control.optimal as obc
import matplotlib.pyplot as plt
import logging
import time
import os

#
# Vehicle steering dynamics
#
# The vehicle dynamics are given by a simple bicycle model.  We take the state
# of the system as (x, y, theta) where (x, y) is the position of the vehicle
# in the plane and theta is the angle of the vehicle with respect to
# horizontal.  The vehicle input is given by (v, phi) where v is the forward
# velocity of the vehicle and phi is the angle of the steering wheel.  The
# model includes saturation of the vehicle steering angle.
#
# System state: x, y, theta
# System input: v, phi
# System output: x, y
# System parameters: wheelbase, maxsteer
#
def vehicle_update(t, x, u, params):
    # Get the parameters for the model
    l = params.get('wheelbase', 3.)         # vehicle wheelbase
    phimax = params.get('maxsteer', 0.5)    # max steering angle (rad)

    # Saturate the steering input (use min/max instead of clip for speed)
    phi = max(-phimax, min(u[1], phimax))

    # Return the derivative of the state
    return np.array([
        math.cos(x[2]) * u[0],            # xdot = cos(theta) v
        math.sin(x[2]) * u[0],            # ydot = sin(theta) v
        (u[0] / l) * math.tan(phi)        # thdot = v/l tan(phi)
    ])


def vehicle_output(t, x, u, params):
    return x                            # return x, y, theta (full state)

# Define the vehicle steering dynamics as an input/output system
vehicle = ct.NonlinearIOSystem(
    vehicle_update, vehicle_output, states=3, name='vehicle',
    inputs=('v', 'phi'),
    outputs=('x', 'y', 'theta'))

#
# Utility function to plot the results
#
def plot_lanechange(t, y, u, yf=None, figure=None):
    plt.figure(figure)

    # Plot the xy trajectory
    plt.subplot(3, 1, 1)
    plt.plot(y[0], y[1])
    plt.xlabel("x [m]")
    plt.ylabel("y [m]")
    if yf is not None:
        plt.plot(yf[0], yf[1], 'ro')

    # Plot the inputs as a function of time
    plt.subplot(3, 1, 2)
    plt.plot(t, u[0])
    plt.xlabel("t [sec]")
    plt.ylabel("velocity [m/s]")

    plt.subplot(3, 1, 3)
    plt.plot(t, u[1])
    plt.xlabel("t [sec]")
    plt.ylabel("steering [rad/s]")

    plt.suptitle("Lane change maneuver")
    plt.tight_layout()
    plt.show(block=False)

#
# Optimal control problem
#
# Perform a "lane change" maneuver over the course of 10 seconds.
#

# Initial and final conditions
x0 = np.array([0., -2., 0.]); u0 = np.array([10., 0.])
xf = np.array([100., 2., 0.]); uf = np.array([10., 0.])
Tf = 10

#
# Approach 1: standard quadratic cost
#
# We can set up the optimal control problem as trying to minimize the
# distance form the desired final point while at the same time as not
# exerting too much control effort to achieve our goal.
#
print("Approach 1: standard quadratic cost")

# Set up the cost functions
Q = np.diag([.1, 10, .1])       # keep lateral error low
R = np.diag([.1, 1])            # minimize applied inputs
quad_cost = obc.quadratic_cost(vehicle, Q, R, x0=xf, u0=uf)

# Define the time horizon (and spacing) for the optimization
timepts = np.linspace(0, Tf, 20, endpoint=True)

# Provide an initial guess
straight_line = (
    np.array([x0 + (xf - x0) * time/Tf for time in timepts]).transpose(),
    np.outer(u0, np.ones_like(timepts))
)

# Turn on debug level logging so that we can see what the optimizer is doing
logging.basicConfig(
    level=logging.DEBUG, filename="steering-integral_cost.log",
    filemode='w', force=True)

# Compute the optimal control, setting step size for gradient calculation (eps)
start_time = time.process_time()
result1 = obc.solve_ocp(
    vehicle, timepts, x0, quad_cost, initial_guess=straight_line, log=True,
    # minimize_method='trust-constr',
    # minimize_options={'finite_diff_rel_step': 0.01},
)
print("* Total time = %5g seconds\n" % (time.process_time() - start_time))

# If we are running CI tests, make sure we succeeded
if 'PYCONTROL_TEST_EXAMPLES' in os.environ:
    assert result1.success

# Plot the results from the optimization
plot_lanechange(timepts, result1.states, result1.inputs, xf, figure=1)
print("Final computed state: ", result1.states[:,-1])

# Simulate the system and see what happens
t1, u1 = result1.time, result1.inputs
t1, y1 = ct.input_output_response(vehicle, timepts, u1, x0)
plot_lanechange(t1, y1, u1, yf=xf[0:2], figure=1)
print("Final simulated state:", y1[:,-1])

#
# Approach 2: input cost, input constraints, terminal cost
#
# The previous solution integrates the position error for the entire
# horizon, and so the car changes lanes very quickly (at the cost of larger
# inputs).  Instead, we can penalize the final state and impose a higher
# cost on the inputs, resuling in a more graduate lane change.
#
# We also set the solver explicitly.
#
print("\nApproach 2: input cost and constraints plus terminal cost")

# Add input constraint, input cost, terminal cost
constraints = [ obc.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ]
traj_cost = obc.quadratic_cost(vehicle, None, np.diag([0.1, 1]), u0=uf)
term_cost = obc.quadratic_cost(vehicle, np.diag([1, 10, 10]), None, x0=xf)

# Change logging to keep less information
logging.basicConfig(
    level=logging.INFO, filename="./steering-terminal_cost.log",
    filemode='w', force=True)

# Use a straight line between initial and final position as initial guesss
input_guess = np.outer(u0, np.ones((1, timepts.size)))
state_guess = np.array([
    x0 + (xf - x0) * time/Tf for time in timepts]).transpose()
straight_line = (state_guess, input_guess)

# Compute the optimal control
start_time = time.process_time()
result2 = obc.solve_ocp(
    vehicle, timepts, x0, traj_cost, constraints, terminal_cost=term_cost,
    initial_guess=straight_line, log=True,
    # minimize_method='SLSQP', minimize_options={'eps': 0.01}
)
print("* Total time = %5g seconds\n" % (time.process_time() - start_time))

# If we are running CI tests, make sure we succeeded
if 'PYCONTROL_TEST_EXAMPLES' in os.environ:
    assert result2.success

# Plot the results from the optimization
plot_lanechange(timepts, result2.states, result2.inputs, xf, figure=2)
print("Final computed state: ", result2.states[:,-1])

# Simulate the system and see what happens
t2, u2 = result2.time, result2.inputs
t2, y2 = ct.input_output_response(vehicle, timepts, u2, x0)
plot_lanechange(t2, y2, u2, yf=xf[0:2], figure=2)
print("Final simulated state:", y2[:,-1])

#
# Approach 3: terminal constraints
#
# We can also remove the cost function on the state and replace it
# with a terminal *constraint* on the state.  If a solution is found,
# it guarantees we get to exactly the final state.
#
print("\nApproach 3: terminal constraints")

# Input cost and terminal constraints
R = np.diag([1, 1])                 # minimize applied inputs
cost3 = obc.quadratic_cost(vehicle, np.zeros((3,3)), R, u0=uf)
constraints = [
    obc.input_range_constraint(vehicle, [8, -0.1], [12, 0.1]) ]
terminal = [ obc.state_range_constraint(vehicle, xf, xf) ]

# Reset logging to its default values
logging.basicConfig(
    level=logging.DEBUG, filename="./steering-terminal_constraint.log",
    filemode='w', force=True)

# Compute the optimal control
start_time = time.process_time()
result3 = obc.solve_ocp(
    vehicle, timepts, x0, cost3, constraints,
    terminal_constraints=terminal, initial_guess=straight_line, log=False,
    # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2},
    # minimize_method='trust-constr',
)
print("* Total time = %5g seconds\n" % (time.process_time() - start_time))

# If we are running CI tests, make sure we succeeded
if 'PYCONTROL_TEST_EXAMPLES' in os.environ:
    assert result3.success

# Plot the results from the optimization
plot_lanechange(timepts, result3.states, result3.inputs, xf, figure=3)
print("Final computed state: ", result3.states[:,-1])

# Simulate the system and see what happens
t3, u3 = result3.time, result3.inputs
t3, y3 = ct.input_output_response(vehicle, timepts, u3, x0)
plot_lanechange(t3, y3, u3, yf=xf[0:2], figure=3)
print("Final simulated state:", y3[:,-1])

#
# Approach 4: terminal constraints w/ basis functions
#
# As a final example, we can use a basis function to reduce the size
# of the problem and get faster answers with more temporal resolution.
# Here we parameterize the input by a set of 4 Bezier curves but solve
# for a much more time resolved set of inputs.

print("\nApproach 4: Bezier basis")
import control.flatsys as flat

# Compute the optimal control
start_time = time.process_time()
result4 = obc.solve_ocp(
    vehicle, timepts, x0, quad_cost,
    constraints,
    terminal_constraints=terminal,
    initial_guess=straight_line,
    basis=flat.BezierFamily(6, T=Tf),
    # solve_ivp_kwargs={'method': 'RK45', 'atol': 1e-2, 'rtol': 1e-2},
    # solve_ivp_kwargs={'atol': 1e-3, 'rtol': 1e-2},
    # minimize_method='trust-constr', minimize_options={'disp': True},
    log=False
)
print("* Total time = %5g seconds\n" % (time.process_time() - start_time))

# If we are running CI tests, make sure we succeeded
if 'PYCONTROL_TEST_EXAMPLES' in os.environ:
    assert result4.success

# Plot the results from the optimization
plot_lanechange(timepts, result4.states, result4.inputs, xf, figure=4)
print("Final computed state: ", result3.states[:,-1])

# Simulate the system and see what happens
t4, u4 = result4.time, result4.inputs
t4, y4 = ct.input_output_response(vehicle, timepts, u4, x0)
plot_lanechange(t4, y4, u4, yf=xf[0:2], figure=4)
print("Final simulated state: ", y4[:,-1])

# If we are not running CI tests, display the results
if 'PYCONTROL_TEST_EXAMPLES' not in os.environ:
    plt.show()
