Code
from typing import Dict, Union
from matplotlib.collections import LineCollection
import matplotlib.pyplot as plt
import numpy as np
from scipy.integrate import solve_ivp
from scipy.optimize import fsolve
Santiago Valencia
January 16, 2024
This is my first post in this blog. Welcome! Here, we’ll create a model of a helicopter and run a simulation of its trajectory. This model will be the base for future posts where we’ll explore how to control the helicopter’s motion.
As in any computational representation, the helicopter model we’ll use for our simulation has certain simplifications and assumptions. In this case, we will study a model with three degrees of freedom (two translation axes and one rotation axis) instead of the full six degrees of freedom available to a real-world helicopter.
The model we’re going to use is a 3 degree-of-freedom representation of the helicopter. The degrees of freedom of the model are the helicopter’s longitudinal position
In the frame of reference we’re going to look at, the helicopter is constrained to motion in the
In this simplified case, we can control the helicopter with two angular inputs: the collective pitch
In contrast, the collective control adjusts the pitch of all the main rotor blades simultaneously. When the pilot raises the collective control, it increases the pitch of all rotor blades, generating more lift, which results in the helicopter ascending. Conversely, lowering the collective control decreases the pitch of the rotor blades, reducing lift and causing the helicopter to descend. In our three-degree-of-freedom case, this means the collective control is mainly responsible for setting the helicopter’s vertical position
There are some helicopter parameters that we need for the equations of motion. These are explained in Table 1 below.
Parameter | Symbol | UH-60A Value |
---|---|---|
Helicopter mass [kg] | 4945 | |
Moment of inertia ( |
54233 | |
Drag coeffient times frontal area [m |
1.26 | |
Rotor rotational velocity [rad/s] | 27 | |
Rotor radius [m] | 8.178 | |
Rotor solidity [-] | 0.0821 | |
Lift-curve slope, 2-dimensional [-] | 5.73 | |
Lock number [-] | 8.1936 | |
Height of the disk plane above the center of gravity [m] | 1.6 |
Before going into the equations of motion, we can consider the main forces acting on the helicopter. These are the aircraft’s weight, the main rotor thrust, and the aerodynamic drag. The helicopter’s weight is straightforward; it’s simply
On the other hand, the drag force
With the weight, thrust, and drag forces defined, we can go ahead and formulate the equations of motion for the three-degree-of-freedom helicopter. For the derivation of these equations, see [1].
First, we define the horizontal and vertical velocities
Similarly, we define the pitch rate
Finally, Equation 6, Equation 7, and Equation 8 define the accelerations in all three of the model helicopter’s degrees of freedom:
In these last three equations, a new term shows up that we haven’t discussed: the rotor tilt angle
With our equations of motion, we can move on to actually solving them to carry out the simulation.
Let’s import some packages we’ll use down the line.
Following the steps outlined in [1], we can determine the equations of motion at a given time
The angle of attack of the control plane
The advance ratio
The non-dimensional inflow velocity of the control plane
Because we’re dealing with a system of differential equations with temporal derivatives, we can use the Runge-Kutta method to discretize the equations of motion and solve them iteratively.
Before solving the equations of motion, we need to take a look at how we can find the thrust coefficient
Here, both definitions are connected by the non-dimensional induced velocity
def thrust_coefficient_residual(lambda_i: float, params: Dict[str, float]):
"""Calculate thrust coefficients and related parameters based on Blade Element
Theory (BEM) theory and Glauert's momentum theory.
Args:
lambda_i (float):
Inflow ratio, representing the ratio of axial velocity induced by the
rotor to the free-stream velocity.
params (Dict[str, float]):
Dictionary containing aerodynamic parameters necessary for the calculations.
The dictionary should include the following keys:
- "gamma" (float): Lock number.
- "q" (float): Pitch rate [rad/s].
- "Omega" (float): Rotor angular velocity [rad/s].
- "mu" (float): Advance ratio [-].
- "theta_0" (float): Collective pitch angle [rad].
- "lambda_c" (float): Non-dimensional inflow velocity of the control plane [-].
- "cl_alpha" (float): Lift curve slope [rad^-1].
- "sigma" (float): Solidity [-].
- "alpha_c" (float): Angle of attack of the control plane [rad].
- "V" (float): Helicopter speed [m/s].
- "OmegaR" (float): Product of rotor angular velocity and rotor radius [m*rad/s].
Returns:
tuple:
A tuple containing the following values:
- The difference (residual) of the thrust coefficients calculated with both approaches.
- Thrust coefficient based on BEM theory.
- Thrust coefficient based on Glauert's correction.
- Intermediate parameter 'a_1' used in the calculations.
"""
# Calculate the rotor tilt angle
a_1 = (
(-16 / params["gamma"]) * (params["q"] / params["Omega"])
+ (8 / 3) * (params["mu"] * params["theta_0"])
- 2 * params["mu"] * (params["lambda_c"] + lambda_i)
) / (1 - (1 / 2) * params["mu"] ** 2)
# Blade element thrust coefficient
thrust_coeff_BEM = (
params["cl_alpha"]
* (params["sigma"] / 4)
* (
(2 / 3) * params["theta_0"] * (1 + (3 / 2) * params["mu"] ** 2)
- (params["lambda_c"] + lambda_i)
)
)
# Glauert momentum thrust coefficient
thrust_coeff_Glauert = (
2
* lambda_i
* np.sqrt(
(params["V"] * np.cos(params["alpha_c"] - a_1) / params["OmegaR"]) ** 2
+ (
params["V"] * np.sin(params["alpha_c"] - a_1) / params["OmegaR"]
+ lambda_i
)
** 2
)
)
return (
thrust_coeff_BEM - thrust_coeff_Glauert,
thrust_coeff_Glauert,
thrust_coeff_BEM,
a_1,
)
Now, we can define a lambda form to get only the first output of the thrust_coefficient_residual
function above:
We’ll aim to find the root of this target
function, thus getting the thrust coefficient we’re looking for and the angle fsolve
, which finds the roots of a function given an initial guess. Let’s look at an example of how fsolve
can help us find the thrust coefficient for a particular set of parameters. The dictionary sample_params
will contain the values of the parameters needed for the calculation, and we’ll give an initial guess of fsolve
will give us the value of
sample_params = {
"gamma": 8.1936,
"q": 0.1,
"Omega": 27,
"mu": 0.25,
"theta_0": 0.1,
"lambda_c": 0.05,
"cl_alpha": 0.1,
"sigma": 0.08,
"alpha_c": 0.08,
"V": 5,
"OmegaR": 27 * 8.178,
}
lambda_i = fsolve(target, 0.1, args=sample_params, xtol=1e-4)[0]
print(f'λ_i = {lambda_i:.3e}')
residual, c_t_glauert, c_t_bem, a_1 = thrust_coefficient_residual(lambda_i, sample_params)
print(f'C_T, Glauert = {c_t_glauert:.3e}')
print(f'C_T, BEM = {c_t_bem:.3e}')
print(f'a_1 = {a_1:.3f} rad = {np.rad2deg(a_1):.3f}°')
λ_i = 9.666e-04
C_T, Glauert = 4.390e-05
C_T, BEM = 4.390e-05
a_1 = 0.035 rad = 2.008°
With this example, we can see how fsolve
helps us get the values for
Now, we’ll solve the system of six equations of motion (Equation 3 through Equation 8) to determine the path taken by the helicopter. To do this, we’ll take the system as an initial value problem and use SciPy’s solve_ivp
.
The first step we need to take is to make a function in a format that works with solve_ivp
’s signature. solve_ivp
expects a function that gives the time derivatives of the form fun(t, y)
, where t
(a scalar) is the time and y
is an ndarray with all the state variables. In our helicopter case, the state variables are
def derivatives(
t: float,
y: Union[list, np.ndarray],
theta_0: float,
theta_c: float,
aircraft_parameters: dict,
) -> list:
"""
Function that determines the 3-DoF helicopter's equations of motion
Args:
t (float): Current time
y (Union[list, np.ndarray]): State vector [x, u, z, w, theta_f, q]^T
theta_0 (float): Control action on collective pitch
theta_c (float): Control action on cyclic pitch
aircraft_parameters (dict): Dictionary with helicopter's parameters
Returns:
list: dy/dt vector
"""
# Unpack the state vector y
x, u, z, w, theta_f, q = y
# Calculate velocity magnitude
V = np.sqrt(u**2 + w**2)
# Calculate the parameter alpha_c
if u == 0:
alpha_c = np.pi / 2
else:
alpha_c = -np.arctan(w / u) + theta_c
if u < 0:
alpha_c += np.pi
# Calculate the parameter mu
mu = (V / (aircraft_parameters["Omega"] * aircraft_parameters["R"])) * np.cos(
alpha_c
)
# Calculate the parameter lambda_c
lambda_c = (V / (aircraft_parameters["Omega"] * aircraft_parameters["R"])) * np.sin(
alpha_c
)
# Parameter dictionary for thrust coefficient calculation
params = {
**aircraft_parameters,
"OmegaR": aircraft_parameters["Omega"] * aircraft_parameters["R"],
"q": q,
"mu": mu,
"theta_0": theta_0,
"lambda_c": lambda_c,
"alpha_c": alpha_c,
"V": V,
}
# Determine the correct induced velocity lambda_i by iteratively making
# C_T_Glauert and C_T_elem equal
lambda_i = fsolve(target, 0.1, args=params, xtol=1e-4)[0]
# Get the correct thrust coefficient and a_1 parameter with the lambda_i calculated above
_, C_T, _, a_1 = thrust_coefficient_residual(lambda_i, params)
# Calculate dimensional thrust
T = (
C_T
* aircraft_parameters["rho"]
* (aircraft_parameters["Omega"] * aircraft_parameters["R"]) ** 2
* np.pi
* aircraft_parameters["R"] ** 2
)
# Calculate dimensional drag
D = aircraft_parameters["CD_S"] * 0.5 * aircraft_parameters["rho"] * V**2
# Longitudinal acceleration
u_dot = (
-aircraft_parameters["g"] * np.sin(theta_f)
- (D * u) / (aircraft_parameters["M"] * V)
+ (T / aircraft_parameters["M"]) * np.sin(theta_c - a_1)
- q * w
)
# Vertical acceleration
w_dot = (
aircraft_parameters["g"] * np.cos(theta_f)
- (D * w) / (aircraft_parameters["M"] * V)
- (T / aircraft_parameters["M"]) * np.cos(theta_c - a_1)
+ q * u
)
# Angular (pitch) acceleration
q_dot = (
-(T / aircraft_parameters["I_y"])
* aircraft_parameters["h"]
* np.sin(theta_c - a_1)
)
# Vector of derivatives:
# x_dot, u_dot, z_dot, w_dot, theta_f_dot, q_dot
y_dot = [u, u_dot, w, w_dot, q, q_dot]
return y_dot
Now, we can solve the equations of motion given initial values for our state variables at
-10 | 5 | 10 | 0 | -1 | 0 |
For our first simulation, we’ll just take constant values of the control variables
aircraft_parameters = {
"Omega": 27, # Angular velocity of the rotor (in rad/s)
"R": 8.178, # Rotor radius (in meters)
"gamma": 8.1936, # Ratio of specific heats
"cl_alpha": 5.73, # Lift curve slope (per rad)
"sigma": 0.08210, # Solidity of the rotor
"CD_S": 1.26, # Drag coefficient times reference area
"rho": 1.225, # Air density (in kg/m^3)
"M": 4945, # Mass of the helicopter (in kg)
"I_y": 54233, # Moment of inertia about the y-axis (in kg*m^2)
"h": 1.6, # Distance from the rotor to the center of mass (in meters)
"g": 9.81, # Acceleration due to gravity (in m/s^2)
}
# Initial state variables: [x0, u0, z0, w0, θ_f0, q0]
y_0 = np.array([-10, 5, 10, 0, -1, 0])
solution = solve_ivp(
fun=derivatives,
t_span=[0, 30],
y0=y_0,
method="BDF",
args=(np.deg2rad(6), np.deg2rad(1), aircraft_parameters),
max_step=0.1,
)
Let’s visualize the helciopter’s trajectory. To do this, we’ll extract some information from the solution
object returned by solve_ivp
.
points = np.vstack((solution.y[0, :], solution.y[2, :])).T.reshape(-1, 1, 2)
segments = np.concatenate([points[:-1], points[1:]], axis=1)
fig, ax = plt.subplots()
norm = plt.Normalize(min(solution.t), max(solution.t))
lc = LineCollection(segments, cmap="viridis", norm=norm)
lc.set_array(solution.t)
line = ax.add_collection(lc)
fig.colorbar(line, ax=ax, label="Time [s]")
ax.set_xlim(-50, max(solution.y[0, :]) * 1.05)
ax.set_ylim(min(solution.y[2, :]) * 1.05, max(solution.y[2, :]) * 1.05)
ax.set_xlabel("$x$ [m]")
ax.set_ylabel("$z$ [m]")
ax.set_title("Helicopter trajectory")
plt.show()
Figure 2 shows the result. It looks like a pretty rough ride! We see lots of vertical oscillation and a very sudden downward plunge towards the end of the simulation. We would be better off selecting our control variables more carefully. We’ll do just that in a future post. For now, we’ve successfully shown how to simulate a three-degree-of-freedom helicopter model by solving its equations of motion.