# Source code for sharpy.solvers.steplinearuvlm

"""
Time domain solver to integrate the linear UVLM aerodynamic system developed by S. Maraniello
N Goizueta
Nov 18
"""
from sharpy.utils.solver_interface import BaseSolver, solver
import numpy as np
import sharpy.utils.settings as settings_utils
import sharpy.utils.generator_interface as gen_interface
import sharpy.utils.algebra as algebra
import sharpy.linear.src.linuvlm as linuvlm

[docs]@solver
class StepLinearUVLM(BaseSolver):
r"""
Time domain aerodynamic solver that uses a linear UVLM formulation to be used with the
:class:solvers.DynamicCoupled solver.

To use this solver, the solver_id = StepLinearUVLM must be given as the name for the aero_solver
is the case of an aeroelastic solver, where the setting below would be parsed through aero_solver_settings.

Notes:

The integr_order variable refers to the finite differencing scheme used to calculate the bound circulation
derivative with respect to time :math:\dot{\mathbf{\Gamma}}. A first order scheme is used when
integr_order == 1

.. math:: \dot{\mathbf{\Gamma}}^{n+1} = \frac{\mathbf{\Gamma}^{n+1}-\mathbf{\Gamma}^n}{\Delta t}

If integr_order == 2 a higher order scheme is used (but it isn't exactly second order accurate [1]).

.. math:: \dot{\mathbf{\Gamma}}^{n+1} = \frac{3\mathbf{\Gamma}^{n+1}-4\mathbf{\Gamma}^n + \mathbf{\Gamma}^{n-1}}
{2\Delta t}

If track_body is True, the UVLM is projected onto a frame U that is:

* Coincident with G at the linearisation timestep.

* Thence, rotates by the same quantity as the FoR A.

It is similar to a stability axes and is recommended any time rigid body dynamics are included.

:class:sharpy.sharpy.linear.assembler.linearuvlm.LinearUVLM

References:

[1] Maraniello, S., & Palacios, R.. State-Space Realizations and Internal Balancing in Potential-Flow
Aerodynamics with Arbitrary Kinematics. AIAA Journal, 57(6), 1–14. 2019. https://doi.org/10.2514/1.J058153

"""
solver_id = 'StepLinearUVLM'
solver_classification = 'aero'

settings_types = dict()
settings_default = dict()
settings_description = dict()

settings_types['dt'] = 'float'
settings_default['dt'] = 0.1
settings_description['dt'] = 'Time step'

settings_types['integr_order'] = 'int'
settings_default['integr_order'] = 2
settings_description['integr_order'] = 'Integration order of the circulation derivative. Either 1 or 2.'

settings_types['ScalingDict'] = 'dict'
settings_default['ScalingDict'] = dict()
settings_description['ScalingDict'] = 'Dictionary of scaling factors to achieve normalised UVLM realisation.'

settings_types['remove_predictor'] = 'bool'
settings_default['remove_predictor'] = True
settings_description['remove_predictor'] = 'Remove the predictor term from the UVLM equations'

settings_types['use_sparse'] = 'bool'
settings_default['use_sparse'] = True
settings_description['use_sparse'] = 'Assemble UVLM plant matrix in sparse format'

settings_types['density'] = 'float'
settings_default['density'] = 1.225
settings_description['density'] = 'Air density'

settings_types['track_body'] = 'bool'
settings_default['track_body'] = True
settings_description['track_body'] = 'UVLM inputs and outputs projected to coincide with lattice at linearisation'

settings_types['track_body_number'] = 'int'
settings_default['track_body_number'] = -1
settings_description['track_body_number'] = 'Frame of reference number to follow. If -1 track A frame.'

settings_types['velocity_field_generator'] = 'str'
settings_description['velocity_field_generator'] = 'Name of the velocity field generator to be used in the ' \
'simulation'

settings_types['velocity_field_input'] = 'dict'
settings_default['velocity_field_input'] = {}
settings_description['velocity_field_input'] = 'Dictionary of settings for the velocity field generator'

settings_description['vortex_radius'] = 'Distance between points below which induction is not computed'

settings_description['vortex_radius_wake_ind'] = 'Distance between points below which induction is not computed in the wake convection'

settings_types['cfl1'] = 'bool'
settings_default['cfl1'] = True
settings_description['cfl1'] = 'If it is True, it assumes that the discretisation complies with CFL=1'

settings_table = settings_utils.SettingsTable()
__doc__ += settings_table.generate(settings_types, settings_default, settings_description)

scaling_settings_types = dict()
scaling_settings_default = dict()
scaling_settings_description = dict()

scaling_settings_types['length'] = 'float'
scaling_settings_default['length'] = 1.0
scaling_settings_description['length'] = 'Reference length to be used for UVLM scaling'

scaling_settings_types['speed'] = 'float'
scaling_settings_default['speed'] = 1.0
scaling_settings_description['speed'] = 'Reference speed to be used for UVLM scaling'

scaling_settings_types['density'] = 'float'
scaling_settings_default['density'] = 1.0
scaling_settings_description['density'] = 'Reference density to be used for UVLM scaling'

__doc__ += settings_table.generate(scaling_settings_types,
scaling_settings_default,
scaling_settings_description, header_line='The settings that ScalingDict '
'accepts are the following:')

def __init__(self):
self.data = None
self.settings = None
self.lin_uvlm_system = None
self.velocity_generator = None

[docs]    def initialise(self, data, custom_settings=None, restart=False):
r"""
Initialises the Linear UVLM aerodynamic solver and the chosen velocity generator.

Settings are parsed into the standard SHARPy settings format for solvers. It then checks whether there is
any previous information about the linearised system (in order for a solution to be restarted without
overwriting the linearisation).

If a linearised system does not exist, a linear UVLM system is created linearising about the current time step.

The reference values for the input and output are transformed into column vectors :math:\mathbf{u}
and :math:\mathbf{y}, respectively.

The information pertaining to the linear system is stored in a dictionary self.data.aero.linear within
the main data variable.

Args:
data (PreSharpy): class containing the problem information
custom_settings (dict): custom settings dictionary

"""

self.data = data

if custom_settings is None:
self.settings = data.settings[self.solver_id]
else:
self.settings = custom_settings
settings_utils.to_custom_types(self.settings, self.settings_types, self.settings_default, no_ctype=True)
settings_utils.to_custom_types(self.settings['ScalingDict'], self.scaling_settings_types,
self.scaling_settings_default, no_ctype=True)

# Initialise velocity generator
velocity_generator_type = gen_interface.generator_from_string(self.settings['velocity_field_generator'])
self.velocity_generator = velocity_generator_type()
self.velocity_generator.initialise(self.settings['velocity_field_input'], restart=restart)

# Check whether linear UVLM has been initialised
try:
self.data.aero.linear
except AttributeError:
self.data.aero.linear = dict()
aero_tstep = self.data.aero.timestep_info[-1]

### Record body orientation/velocities at time 0
# This option allows to rotate the linearised UVLM with the A frame
# or a specific body (multi-body solution)
if self.settings['track_body']:

self.num_body_track = self.settings['track_body_number']

# track A frame
if self.num_body_track == -1:
self.quat0 = self.data.structure.timestep_info[-1].quat.copy()
self.for_vel0 = self.data.structure.timestep_info[-1].for_vel.copy()
else: # track a specific body
self.quat0 = \
self.data.structure.timestep_info[-1].mb_quat[self.num_body_track,:].copy()
self.for_vel0 = \
self.data.structure.timestep_info[-1].mb_FoR_vel[self.num_body_track ,:].copy()

# convert to G frame
self.Cga0 = algebra.quat2rotation(self.quat0)
self.Cga = self.Cga0.copy()
self.for_vel0[:3] = self.Cga0.dot(self.for_vel0[:3])
self.for_vel0[3:] = self.Cga0.dot(self.for_vel0[3:])

else: # check/record initial rotation speed
self.num_body_track = None
self.quat0 = None
self.Cag0 = None
self.Cga = None
self.for_vel0 = np.zeros((6,))

# TODO: verify of a better way to implement rho
aero_tstep.rho = self.settings['density']

# Generate instance of linuvlm.Dynamic()
lin_uvlm_system = linuvlm.DynamicBlock(aero_tstep,
dynamic_settings=self.settings,
# dt=self.settings['dt'],
# integr_order=self.settings['integr_order'],
# ScalingDict=self.settings['ScalingDict'],
# RemovePredictor=self.settings['remove_predictor'],
# UseSparse=self.settings['use_sparse'],
for_vel=self.for_vel0)

for ii in range(lin_uvlm_system.MS.n_surf):
lin_uvlm_system.MS.Surfs[ii].omega = self.for_vel0[3:]

# Save reference values
# System Inputs
u_0 = self.pack_input_vector()

# Linearised state
dt = self.settings['dt']
x_0 = self.pack_state_vector(aero_tstep, None, dt, self.settings['integr_order'])

# Reference forces
f_0 = np.concatenate([aero_tstep.forces[ss][0:3].reshape(-1, order='C')
for ss in range(aero_tstep.n_surf)])

# Assemble the state space system
wake_prop_settings = {'dt': self.settings['dt'],
'ts': self.data.ts,
't': self.data.ts*self.settings['dt'],
'for_pos':self.data.structure.timestep_info[-1].for_pos,
'cfl1': self.settings['cfl1'],
'vel_gen': self.velocity_generator}
lin_uvlm_system.assemble_ss(wake_prop_settings=wake_prop_settings)
self.data.aero.linear['System'] = lin_uvlm_system
self.data.aero.linear['SS'] = lin_uvlm_system.SS
self.data.aero.linear['x_0'] = x_0
self.data.aero.linear['u_0'] = u_0
self.data.aero.linear['y_0'] = f_0
# self.data.aero.linear['gamma_0'] = gamma
# self.data.aero.linear['gamma_star_0'] = gamma_star
# self.data.aero.linear['gamma_dot_0'] = gamma_dot

# TODO: Implement in AeroTimeStepInfo a way to store the state vectors
# aero_tstep.linear.x = x_0
# aero_tstep.linear.u = u_0
# aero_tstep.linear.y = f_0

[docs]    def run(self, **kwargs):
r"""
Solve the linear aerodynamic UVLM model at the current time step n. The step increment is solved as:

.. math::
\mathbf{x}^n &= \mathbf{A\,x}^{n-1} + \mathbf{B\,u}^n \\
\mathbf{y}^n &= \mathbf{C\,x}^n + \mathbf{D\,u}^n

A change of state is possible in order to solve the system without the predictor term. In which case the system
is solved by:

.. math::
\mathbf{h}^n &= \mathbf{A\,h}^{n-1} + \mathbf{B\,u}^{n-1} \\
\mathbf{y}^n &= \mathbf{C\,h}^n + \mathbf{D\,u}^n

Variations are taken with respect to initial reference state. The state and input vectors for the linear
UVLM system are of the form:

If integr_order==1:
.. math:: \mathbf{x}_n = [\delta\mathbf{\Gamma}^T_n,\,
\delta\mathbf{\Gamma_w}_n^T,\,
\Delta t \,\delta\mathbf{\dot{\Gamma}}_n^T]^T

Else, if integr_order==2:
.. math:: \mathbf{x}_n = [\delta\mathbf{\Gamma}_n^T,\,
\delta\mathbf{\Gamma_w}_n^T,\,
\Delta t \,\delta\mathbf{\dot{\Gamma}}_n^T,\,
\delta\mathbf{\Gamma}_{n-1}^T]^T

And the input vector:
.. math:: \mathbf{u}_n = [\delta\mathbf{\zeta}_n^T,\,
\delta\dot{\mathbf{\zeta}}_n^T,\,\delta\mathbf{u_{ext}}^T_n]^T

where the subscript n refers to the time step.

The linear UVLM system is then solved as detailed in :func:sharpy.linear.src.linuvlm.Dynamic.solve_step.
The output is a column vector containing the aerodynamic forces at the panel vertices.

To Do: option for impulsive start?

Args:
aero_tstep (AeroTimeStepInfo): object containing the aerodynamic data at the current time step
structure_tstep (StructTimeStepInfo): object containing the structural data at the current time step
convect_wake (bool): for backward compatibility only. The linear UVLM assumes a frozen wake geometry
dt (float): time increment
t (float): current time

Returns:
PreSharpy: updated self.data class with the new forces and circulation terms of the system

"""

aero_tstep = settings_utils.set_value_or_default(kwargs, 'aero_step', self.data.aero.timestep_info[-1])
structure_tstep = settings_utils.set_value_or_default(kwargs, 'structural_step', self.data.structure.timestep_info[-1])
convect_wake = settings_utils.set_value_or_default(kwargs, 'convect_wake', False)
dt= settings_utils.set_value_or_default(kwargs, 'dt', self.settings['dt'])
t = settings_utils.set_value_or_default(kwargs, 't', self.data.ts*dt)

integr_order = self.settings['integr_order']

### Define Input

# Generate external velocity field u_ext
self.velocity_generator.generate({'zeta': aero_tstep.zeta,
'override': True,
't': t,
'ts': self.data.ts,
'dt': dt,
'for_pos': structure_tstep.for_pos},
aero_tstep.u_ext)

### Proj from FoR G to linearisation frame
# - proj happens in self.pack_input_vector and unpack_ss_vectors
if self.settings['track_body']:
# track A frame
if self.num_body_track  == -1:
self.Cga = algebra.quat2rotation( structure_tstep.quat )
else: # track a specific body
self.Cga = algebra.quat2rotation(
structure_tstep.mb_quat[self.num_body_track,:] )

# Column vector that will be the input to the linearised UVLM system
# Input is at time step n, since it is updated in the aeroelastic solver prior to aerodynamic solver
u_n = self.pack_input_vector()

du_n = u_n - self.data.aero.linear['u_0']

if self.settings['remove_predictor']:
u_m1 = self.pack_input_vector()
du_m1 = u_m1 - self.data.aero.linear['u_0']
else:
du_m1 = None

# Retrieve State vector at time n-1
if len(self.data.aero.timestep_info) < 2:
x_m1 = self.pack_state_vector(aero_tstep, None, dt, integr_order)
else:
x_m1 = self.pack_state_vector(aero_tstep, self.data.aero.timestep_info[-2], dt, integr_order)

# dx is at timestep n-1
dx_m1 = x_m1 - self.data.aero.linear['x_0']

### Solve system - output is the variation in force
dx_n, dy_n = self.data.aero.linear['System'].solve_step(dx_m1, du_m1, du_n, transform_state=True)

x_n = self.data.aero.linear['x_0'] + dx_n
y_n = self.data.aero.linear['y_0'] + dy_n

# if self.settings['physical_model']:
forces, gamma, gamma_dot, gamma_star = self.unpack_ss_vectors(y_n, x_n, u_n, aero_tstep)
aero_tstep.forces = forces
aero_tstep.gamma = gamma
aero_tstep.gamma_dot = gamma_dot
aero_tstep.gamma_star = gamma_star

return self.data

def update_grid(self, beam):
self.data.aero.generate_zeta(beam, self.data.aero.aero_settings, -1, beam_ts=-1)

def update_custom_grid(self, structure_tstep, aero_tstep):
self.data.aero.generate_zeta_timestep_info(structure_tstep, aero_tstep, self.data.structure, self.data.aero.aero_settings)

[docs]    def unpack_ss_vectors(self, y_n, x_n, u_n, aero_tstep):
r"""
Transform column vectors used in the state space formulation into SHARPy format

The column vectors are transformed into lists with one entry per aerodynamic surface. Each entry contains a
matrix with the quantities at each grid vertex.

.. math::
\mathbf{y}_n \longrightarrow \mathbf{f}_{aero}

.. math:: \mathbf{x}_n \longrightarrow \mathbf{\Gamma}_n,\,
\mathbf{\Gamma_w}_n,\,
\mathbf{\dot{\Gamma}}_n

If the track_body option is on, the output forces are projected from
the linearization frame, to the G frame. Note that the linearisation
frame is:

a. equal to the FoR G at time 0 (linearisation point)
b. rotates as the body frame specified in the track_body_number

Args:
y_n (np.ndarray): Column output vector of linear UVLM system
x_n (np.ndarray): Column state vector of linear UVLM system
u_n (np.ndarray): Column input vector of linear UVLM system
aero_tstep (AeroTimeStepInfo): aerodynamic timestep information class instance

Returns:
tuple: Tuple containing:

forces (list):
Aerodynamic forces in a list with n_surf entries.
Each entry is a (6, M+1, N+1) matrix, where the first 3
indices correspond to the components in x, y and z. The latter 3 are zero.

gamma (list):
Bound circulation list with n_surf entries. Circulation is stored in an (M+1, N+1)
matrix, corresponding to the panel vertices.

gamma_dot (list):
Bound circulation derivative list with n_surf entries.
Circulation derivative is stored in an (M+1, N+1) matrix, corresponding to the panel
vertices.

gamma_star (list):
Wake (free) circulation list with n_surf entries. Wake circulation is stored in an
(M_star+1, N+1) matrix, corresponding to the panel vertices of the wake.

"""

### project forces from uvlm FoR to FoR G
if self.settings['track_body']:
Cg_uvlm = np.dot( self.Cga, self.Cga0.T )

f_aero = y_n

gamma_vec, gamma_star_vec, gamma_dot_vec = self.data.aero.linear['System'].unpack_state(x_n)

# Reshape output into forces[i_surface] where forces[i_surface] is a (6,M+1,N+1) matrix and circulation terms
# where gamma is a [i_surf](M+1, N+1) matrix
forces = []
gamma = []
gamma_star = []
gamma_dot = []

worked_points = 0
worked_panels = 0
worked_wake_panels = 0

for i_surf in range(aero_tstep.n_surf):
# Tuple with dimensions of the aerogrid zeta, which is the same shape for forces
dimensions = aero_tstep.zeta[i_surf].shape
dimensions_gamma = self.data.aero.aero_dimensions[i_surf]
dimensions_wake = self.data.aero.aero_dimensions_star[i_surf]

# Number of entries in zeta
points_in_surface = aero_tstep.zeta[i_surf].size
panels_in_surface = aero_tstep.gamma[i_surf].size
panels_in_wake = aero_tstep.gamma_star[i_surf].size

# Append reshaped forces to each entry in list (one for each surface)
forces.append(f_aero[worked_points:worked_points+points_in_surface].reshape(dimensions, order='C'))

### project forces.
# - forces are in UVLM linearisation frame. Hence, these  are projected
# into FoR (using rotation matrix Cag0 time 0) A and back to FoR G
if self.settings['track_body']:
for mm in range(dimensions[1]):
for nn in range(dimensions[2]):
forces[i_surf][:,mm,nn] = np.dot(Cg_uvlm, forces[i_surf][:,mm,nn])

# Add the null bottom 3 rows to to the forces entry
forces[i_surf] = np.concatenate((forces[i_surf], np.zeros(dimensions)))

# Reshape bound circulation terms
gamma.append(gamma_vec[worked_panels:worked_panels+panels_in_surface].reshape(
dimensions_gamma, order='C'))
gamma_dot.append(gamma_dot_vec[worked_panels:worked_panels+panels_in_surface].reshape(
dimensions_gamma, order='C'))

# Reshape wake circulation terms
gamma_star.append(gamma_star_vec[worked_wake_panels:worked_wake_panels+panels_in_wake].reshape(
dimensions_wake, order='C'))

worked_points += points_in_surface
worked_panels += panels_in_surface
worked_wake_panels += panels_in_wake

return forces, gamma, gamma_dot, gamma_star

[docs]    def pack_input_vector(self):
r"""
Transform a SHARPy AeroTimestep instance into a column vector containing the input to the linear UVLM system.

.. math:: [\zeta,\, \dot{\zeta}, u_{ext}] \longrightarrow \mathbf{u}

If the track_body option is on, the function projects all the input
into a frame that:

a. is equal to the FoR G at time 0 (linearisation point)
b. rotates as the body frame specified in the track_body_number

Returns:
np.ndarray: Input vector
"""

aero_tstep = self.data.aero.timestep_info[-1]

### re-compute projection in G frame as if A was not rotating
# - u_n is in FoR G. Hence, this is project in FoR A and back to FoR G
# using rotation matrix aat time 0 (as if FoR A was not rotating).
if self.settings['track_body']:

Cuvlm_g = np.dot( self.Cga0, self.Cga.T )
zeta_uvlm, zeta_dot_uvlm, u_ext_uvlm = [], [], []

for i_surf in range(aero_tstep.n_surf):

Mp1, Np1 = aero_tstep.dimensions[i_surf] + 1

zeta_uvlm.append( np.empty((3,Mp1,Np1)) )
zeta_dot_uvlm.append( np.empty((3,Mp1,Np1)) )
u_ext_uvlm.append( np.empty((3,Mp1,Np1)) )

for mm in range(Mp1):
for nn in range(Np1):
zeta_uvlm[i_surf][:,mm,nn] = \
np.dot(Cuvlm_g, aero_tstep.zeta[i_surf][:,mm,nn])
zeta_dot_uvlm[i_surf][:,mm,nn] = \
np.dot(Cuvlm_g, aero_tstep.zeta_dot[i_surf][:,mm,nn])
u_ext_uvlm[i_surf][:,mm,nn] = \
np.dot(Cuvlm_g, aero_tstep.u_ext[i_surf][:,mm,nn])

zeta = np.concatenate([zeta_uvlm[i_surf].reshape(-1, order='C')
for i_surf in range(aero_tstep.n_surf)])
zeta_dot = np.concatenate([zeta_dot_uvlm[i_surf].reshape(-1, order='C')
for i_surf in range(aero_tstep.n_surf)])
u_ext = np.concatenate([u_ext_uvlm[i_surf].reshape(-1, order='C')
for i_surf in range(aero_tstep.n_surf)])

else:

zeta = np.concatenate([aero_tstep.zeta[i_surf].reshape(-1, order='C')
for i_surf in range(aero_tstep.n_surf)])
zeta_dot = np.concatenate([aero_tstep.zeta_dot[i_surf].reshape(-1, order='C')
for i_surf in range(aero_tstep.n_surf)])
u_ext = np.concatenate([aero_tstep.u_ext[i_surf].reshape(-1, order='C')
for i_surf in range(aero_tstep.n_surf)])

u = np.concatenate((zeta, zeta_dot, u_ext))

return u

[docs]    @staticmethod
def pack_state_vector(aero_tstep, aero_tstep_m1, dt, integr_order):
r"""
Transform SHARPy Aerotimestep format into column vector containing the state information.

The state vector is of a different form depending on the order of integration chosen. If a second order
scheme is chosen, the state includes the bound circulation at the previous timestep,
hence the timestep information for the previous timestep shall be parsed.

The transformation is of the form:

- If integr_order==1:

.. math:: \mathbf{x}_n = [\mathbf{\Gamma}^T_n,\,
\mathbf{\Gamma_w}_n^T,\,
\Delta t \,\mathbf{\dot{\Gamma}}_n^T]^T

- Else, if integr_order==2:

.. math:: \mathbf{x}_n = [\mathbf{\Gamma}_n^T,\,
\mathbf{\Gamma_w}_n^T,\,
\Delta t \,\mathbf{\dot{\Gamma}}_n^T,\,
\mathbf{\Gamma}_{n-1}^T]^T

For the second order integration scheme, if the previous timestep information is not parsed, a first order
stencil is employed to estimate the bound circulation at the previous timestep:

.. math:: \mathbf{\Gamma}^{n-1} = \mathbf{\Gamma}^n - \Delta t \mathbf{\dot{\Gamma}}^n

Args:
aero_tstep (AeroTimeStepInfo): Aerodynamic timestep information at the current timestep n.
aero_tstep_m1 (AeroTimeStepInfo) Aerodynamic timestep information at the previous timestep n-1.

Returns:
np.ndarray: State vector

"""

# Extract current state...
gamma = np.concatenate([aero_tstep.gamma[ss].reshape(-1, order='C')
for ss in range(aero_tstep.n_surf)])
gamma_star = np.concatenate([aero_tstep.gamma_star[ss].reshape(-1, order='C')
for ss in range(aero_tstep.n_surf)])
gamma_dot = np.concatenate([aero_tstep.gamma_dot[ss].reshape(-1, order='C')
for ss in range(aero_tstep.n_surf)])

if integr_order == 1:
gamma_m1 = []

else:
if aero_tstep_m1:
gamma_m1 = np.concatenate([aero_tstep_m1.gamma[ss].reshape(-1, order='C')
for ss in range(aero_tstep.n_surf)])
else:
gamma_m1 = gamma - dt * gamma_dot

x = np.concatenate((gamma, gamma_star, dt * gamma_dot, gamma_m1))

return x