def test_basic_math(types): for x in types["all"]: for y in types["all"]: ### Arithmetic x + y x - y x * y x / y np.sum(x) # Sum of all entries of array-like object x ### Exponentials & Powers x**y np.power(x, y) np.exp(x) np.log(x) np.log10(x) np.sqrt(x) # Note: do x ** 0.5 rather than np.sqrt(x). ### Trig np.sin(x) np.cos(x) np.tan(x) np.arcsin(x) np.arccos(x) np.arctan(x) np.arctan2(y, x) np.sinh(x) np.cosh(x) np.tanh(x) np.arcsinh(x) np.arccosh(x) np.arctanh(x - 0.5) # `- 0.5` to give valid argument
def draw( self, scalar_to_plot: str = "potential", # "potential", "streamfunction", "xvel", "yvel", "velmag", "Cp" x_points: np.ndarray = np.linspace(-10, 10, 400), y_points: np.ndarray = np.linspace(-10, 10, 300), percentiles_to_include=99.7, show=True, ): X, Y = np.meshgrid(x_points, y_points) X_r = np.reshape(X, -1) Y_r = np.reshape(Y, -1) points = np.vstack((X_r, Y_r)).T if scalar_to_plot == "potential": scalar_to_plot_value = sum( [object.get_potential_at(points) for object in self.objects]) elif scalar_to_plot == "streamfunction": scalar_to_plot_value = sum([ object.get_streamfunction_at(points) for object in self.objects ]) elif scalar_to_plot == "xvel": scalar_to_plot_value = sum( [object.get_x_velocity_at(points) for object in self.objects]) elif scalar_to_plot == "yvel": scalar_to_plot_value = sum( [object.get_y_velocity_at(points) for object in self.objects]) elif scalar_to_plot == "velmag": x_vels = sum( [object.get_x_velocity_at(points) for object in self.objects]) y_vels = sum( [object.get_y_velocity_at(points) for object in self.objects]) scalar_to_plot_value = np.sqrt(x_vels**2 + y_vels**2) elif scalar_to_plot == "Cp": x_vels = sum( [object.get_x_velocity_at(points) for object in self.objects]) y_vels = sum( [object.get_y_velocity_at(points) for object in self.objects]) V = np.sqrt(x_vels**2 + y_vels**2) scalar_to_plot_value = 1 - V**2 else: raise ValueError("Bad value of `scalar_to_plot`!") min = np.nanpercentile(scalar_to_plot_value, 50 - percentiles_to_include / 2) max = np.nanpercentile(scalar_to_plot_value, 50 + percentiles_to_include / 2) contour(x_points, y_points, scalar_to_plot_value.reshape(X.shape), levels=np.linspace(min, max, 80), linelabels=False, cmap=plt.get_cmap("rainbow"), contour_kwargs={ "linestyles": 'solid', "alpha": 0.4 }) plt.gca().set_aspect("equal", adjustable='box') show_plot(f"Potential Flow: {scalar_to_plot}", "$x$", "$y$", show=show)
def indicated_airspeed(self): """ Returns the indicated airspeed associated with the current flight condition, in meters per second. """ return np.sqrt(2 * (self.total_pressure() - self.atmosphere.pressure()) / self.atmosphere.density())
def test_opti_poorly_scaled_constraints( constraint_jacobian_condition_number=1e10): # Constants a = 1 b = 100 # Set up an optimization environment opti = asb.Opti() # Define optimization variables x = opti.variable(init_guess=10) y = opti.variable(init_guess=10) c = np.sqrt(constraint_jacobian_condition_number) # Define constraints opti.subject_to([x * c <= 0.9 * c, y / c <= 0.9 / c]) # Define objective f = (a - x)**2 + b * (y - x**2)**2 opti.minimize(f) # Optimize sol = opti.solve() # Check assert sol.value(x) == pytest.approx(0.9, abs=1e-4) assert sol.value(y) == pytest.approx(0.81, abs=1e-4)
def Cd_wave_e216(Cl, mach, sweep=0.): r""" A curve fit I did to Eppler 216 airfoil data. Within -0.4 < CL < 0.75 and 0 < mach < ~0.9, has R^2 = 0.9982. See: C:\Projects\GitHub\firefly_aerodynamics\MSES Interface\analysis\e216 :param Cl: Lift coefficient :param mach: Mach number :param sweep: Sweep angle, in deg :return: Wave drag coefficient. """ mach = np.fmax(mach, 0) mach_perpendicular = mach * np.cosd(sweep) # Relation from FVA Eq. 8.176 Cl_perpendicular = Cl / np.cosd(sweep)**2 # Relation from FVA Eq. 8.177 # Coeffs c0 = 7.2685945744797997e-01 c1 = -1.5483144040727698e-01 c3 = 2.1305118052118968e-01 c4 = 7.8812272501525316e-01 c5 = 3.3888938102072169e-03 l0 = 1.5298928303149546e+00 l1 = 5.2389999717540392e-01 m = mach_perpendicular l = Cl_perpendicular Cd_wave = (np.fmax(m - (c0 + c1 * np.sqrt(c3 + (l - c4)**2) + c5 * l), 0) * (l0 + l1 * l))**2 return Cd_wave
def Cd_wave_rae2822(Cl, mach, sweep=0.): r""" A curve fit I did to RAE2822 airfoil data. Within -0.4 < CL < 0.75 and 0 < mach < ~0.9, has R^2 = 0.9982. See: C:\Projects\GitHub\firefly_aerodynamics\MSES Interface\analysis\rae2822 :param Cl: Lift coefficient :param mach: Mach number :param sweep: Sweep angle, in deg :return: Wave drag coefficient. """ mach = np.fmax(mach, 0) mach_perpendicular = mach * np.cosd(sweep) # Relation from FVA Eq. 8.176 Cl_perpendicular = Cl / np.cosd(sweep)**2 # Relation from FVA Eq. 8.177 # Coeffs c2 = 4.5776476424519119e+00 mc0 = 9.5623337929607111e-01 mc1 = 2.0552787101770234e-01 mc2 = 1.1259268018737063e+00 mc3 = 1.9538856688443659e-01 m = mach_perpendicular l = Cl_perpendicular Cd_wave = np.fmax(m - (mc0 - mc1 * np.sqrt(mc2 + (l - mc3)**2)), 0)**2 * c2 return Cd_wave
def expansion_ratio_from_pressure(chamber_pressure, exit_pressure, gamma, oxamide_fraction): """Find the nozzle expansion ratio from the chamber and exit pressures. See :ref:`expansion-ratio-tutorial-label` for a physical description of the expansion ratio. Reference: Rocket Propulsion Elements, 8th Edition, Equation 3-25 Arguments: chamber_pressure (scalar): Nozzle stagnation chamber pressure [units: pascal]. exit_pressure (scalar): Nozzle exit static pressure [units: pascal]. gamma (scalar): Exhaust gas ratio of specific heats [units: dimensionless]. Returns: scalar: Expansion ratio :math:`\\epsilon = A_e / A_t` [units: dimensionless] """ chamber_pressure = np.fmax( chamber_pressure, dubious_min_combustion_pressure(oxamide_fraction)) chamber_pressure = np.fmax(chamber_pressure, exit_pressure * 1.5) AtAe = ((gamma + 1) / 2) ** (1 / (gamma - 1)) \ * (exit_pressure / chamber_pressure) ** (1 / gamma) \ * np.sqrt((gamma + 1) / (gamma - 1) * (1 - (exit_pressure / chamber_pressure) ** ((gamma - 1) / gamma))) er = 1 / AtAe return er
def propeller_shaft_power_from_thrust( thrust_force, area_propulsive, airspeed, rho, propeller_coefficient_of_performance=0.8, ): """ Using dynamic disc actuator theory, gives the shaft power required to generate a certain amount of thrust. Source: https://web.mit.edu/16.unified/www/FALL/thermodynamics/notes/node86.html :param thrust_force: Thrust force [N] :param area_propulsive: Total disc area of all propulsive surfaces [m^2] :param airspeed: Airspeed [m/s] :param rho: Air density [kg/m^3] :param propeller_coefficient_of_performance: propeller coeff. of performance (due to viscous losses) [unitless] :return: Shaft power [W] """ return 0.5 * thrust_force * airspeed * ( np.sqrt( thrust_force / (area_propulsive * airspeed ** 2 * rho / 2) + 1 ) + 1 ) / propeller_coefficient_of_performance
def test_opti_hanging_chain_with_callback(plot=False): N = 40 m = 40 / N D = 70 * N g = 9.81 L = 1 opti = asb.Opti() x = opti.variable(init_guess=np.linspace(-2, 2, N)) y = opti.variable( init_guess=1, n_vars=N, ) distance = np.sqrt( # Distance from one node to the next np.diff(x)**2 + np.diff(y)**2) potential_energy_spring = 0.5 * D * np.sum((distance - L / N)**2) potential_energy_gravity = g * m * np.sum(y) potential_energy = potential_energy_spring + potential_energy_gravity opti.minimize(potential_energy) # Add end point constraints opti.subject_to([x[0] == -2, y[0] == 1, x[-1] == 2, y[-1] == 1]) # Add a ground constraint opti.subject_to(y >= np.cos(0.1 * x) - 0.5) # Add a callback if plot: def my_callback(iter: int): plt.plot(opti.debug.value(x), opti.debug.value(y), ".-", label=f"Iter {iter}", zorder=3 + iter) fig, ax = plt.subplots(1, 1, figsize=(6.4, 4.8), dpi=200) x_ground = np.linspace(-2, 2, N) y_ground = np.cos(0.1 * x_ground) - 0.5 plt.plot(x_ground, y_ground, "--k", zorder=2) else: def my_callback(iter: int): print(f"Iter {iter}") print(f"\tx = {opti.debug.value(x)}") print(f"\ty = {opti.debug.value(y)}") sol = opti.solve(callback=my_callback) assert sol.value(potential_energy) == pytest.approx(626.462, abs=1e-3) if plot: plt.show()
def CL_over_Cl(aspect_ratio: float, mach: float = 0., sweep: float = 0.) -> float: """ Returns the ratio of 3D lift coefficient (with compressibility) to 2D lift coefficient (incompressible). :param aspect_ratio: Aspect ratio :param mach: Mach number :param sweep: Sweep angle [deg] :return: """ beta_squared = np.sqrt((1 - mach)**2 + 0.02) # return aspect_ratio / (aspect_ratio + 2) # Equivalent to equation in Drela's FVA in incompressible, 2*pi*alpha limit. # return aspect_ratio / (2 + np.sqrt(4 + aspect_ratio ** 2)) # more theoretically sound at low aspect_ratio eta = 0.95 return aspect_ratio / ( 2 + np.sqrt(4 + (aspect_ratio**2 * beta_squared / eta**2) * (1 + np.tand(sweep)**2 / beta_squared)) ) # From Raymer, Sect. 12.4.1; citing DATCOM
def mean_free_path(self): """ Returns the mean free path of an air molecule, in meters. To find the collision radius, assumes "a hard-sphere gas that has the same viscosity as the actual gas being considered". From Vincenti, W. G. and Kruger, C. H. (1965). Introduction to physical gas dynamics. Krieger Publishing Company. p. 414. """ return self.dynamic_viscosity() / self.pressure() * np.sqrt( np.pi * gas_constant_universal * self.temperature() / (2 * molecular_mass_air))
def _compute_frame_of_WingXSec( self, index: int) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: """ Computes the local reference frame associated with a particular cross section (XSec) of this wing. Args: index: Which cross section (as indexed in Wing.xsecs) should we get the frame of? Returns: A tuple of (xg_local, yg_local, zg_local), where each entry refers to the respective (normalized) axis of the local reference frame of the WingXSec. Given in geometry axes. """ ### Compute the untwisted reference frame xg_local = np.array([1, 0, 0]) if index == 0: span_vector = self.xsecs[1].xyz_le - self.xsecs[0].xyz_le span_vector[0] = 0 yg_local = span_vector / np.linalg.norm(span_vector) z_scale = 1 elif index == len(self.xsecs) - 1 or index == -1: span_vector = self.xsecs[-1].xyz_le - self.xsecs[-2].xyz_le span_vector[0] = 0 yg_local = span_vector / np.linalg.norm(span_vector) z_scale = 1 else: vector_before = self.xsecs[index].xyz_le - self.xsecs[index - 1].xyz_le vector_after = self.xsecs[index + 1].xyz_le - self.xsecs[index].xyz_le vector_before[0] = 0 # Project onto YZ plane. vector_after[0] = 0 # Project onto YZ plane. vector_before = vector_before / np.linalg.norm(vector_before) vector_after = vector_after / np.linalg.norm(vector_after) span_vector = (vector_before + vector_after) / 2 yg_local = span_vector / np.linalg.norm(span_vector) cos_vectors = np.linalg.inner(vector_before, vector_after) z_scale = np.sqrt(2 / (cos_vectors + 1)) zg_local = np.cross(xg_local, yg_local) * z_scale ### Twist the reference frame by the WingXSec twist angle rot = np.rotation_matrix_3D(self.xsecs[index].twist * pi / 180, yg_local) xg_local = rot @ xg_local zg_local = rot @ zg_local return xg_local, yg_local, zg_local
def test_opti_poorly_scaled_objective(objective_hessian_condition_number=1e10): opti = asb.Opti() x = opti.variable(init_guess=10) y = opti.variable(init_guess=10) c = np.sqrt(objective_hessian_condition_number) # Define objective f = x**4 * c + y**4 / c opti.minimize(f) # Optimize sol = opti.solve() # Check assert sol.value(x) == pytest.approx(0, abs=1e-2) assert sol.value(y) == pytest.approx(0, abs=1e-2) assert sol.value(f) == pytest.approx(0, abs=1e-4)
def solve(): opti = asb.Opti() ### Env. constants g = 9.81 # gravitational acceleration, m/s^2 mu = 1.775e-5 # viscosity of air, kg/m/s rho = 1.23 # density of air, kg/m^3 rho_f = 817 # density of fuel, kg/m^3 ### Non-dimensional constants C_Lmax = 1.6 # stall CL e = 0.92 # Oswald's efficiency factor k = 1.17 # form factor N_ult = 3.3 # ultimate load factor S_wetratio = 2.075 # wetted area ratio tau = 0.12 # airfoil thickness to chord ratio W_W_coeff1 = 2e-5 # wing weight coefficient 1 W_W_coeff2 = 60 # wing weight coefficient 2 ### Dimensional constants Range = 1000e3 # aircraft range, m TSFC = 0.6 / 3600 # thrust specific fuel consumption, 1/sec V_min = 25 # takeoff speed, m/s W_0 = 6250 # aircraft weight excluding wing, N ### Free variables (same as SimPleAC, with extraneous variables removed) AR = opti.variable(init_guess=10, log_transform=True) # aspect ratio S = opti.variable(init_guess=10, log_transform=True) # total wing area, m^2 V = opti.variable(init_guess=100, log_transform=True) # cruise speed, m/s W = opti.variable(init_guess=10000, log_transform=True) # total aircraft weight, N C_L = opti.variable(init_guess=1, log_transform=True) # lift coefficient W_f = opti.variable(init_guess=3000, log_transform=True) # fuel weight, N V_f_fuse = opti.variable(init_guess=1, log_transform=True) # fuel volume in the fuselage, m^3 ### Wing weight W_w_surf = W_W_coeff2 * S W_w_strc = W_W_coeff1 / tau * N_ult * AR ** 1.5 * np.sqrt( (W_0 + V_f_fuse * g * rho_f) * W * S ) W_w = W_w_surf + W_w_strc ### Entire weight opti.subject_to( W >= W_0 + W_w + W_f ) ### Lift equals weight constraint opti.subject_to([ W_0 + W_w + 0.5 * W_f <= 0.5 * rho * S * C_L * V ** 2, W <= 0.5 * rho * S * C_Lmax * V_min ** 2, ]) ### Flight duration T_flight = Range / V ### Drag Re = (rho / mu) * V * (S / AR) ** 0.5 C_f = 0.074 / Re ** 0.2 CDA0 = V_f_fuse / 10 C_D_fuse = CDA0 / S C_D_wpar = k * C_f * S_wetratio C_D_ind = C_L ** 2 / (np.pi * AR * e) C_D = C_D_fuse + C_D_wpar + C_D_ind D = 0.5 * rho * S * C_D * V ** 2 opti.subject_to([ W_f >= TSFC * T_flight * D, ]) V_f = W_f / g / rho_f V_f_wing = 0.03 * S ** 1.5 / AR ** 0.5 * tau # linear with b and tau, quadratic with chord V_f_avail = V_f_wing + V_f_fuse opti.subject_to( V_f_avail >= V_f ) opti.minimize(W_f) sol = opti.solve(verbose=False)
def beta(mach): return np.sqrt(1 - mach**2)
def equivalent_airspeed(self): """ Returns the equivalent airspeed associated with the current flight condition, in meters per second. """ return self.velocity * np.sqrt(self.atmosphere.density() / Atmosphere( altitude=0, method="isa").density())
def fuselage_aerodynamics( self, fuselage: Fuselage, ): """ Estimates the aerodynamic forces, moments, and derivatives on a fuselage in isolation. Assumes: * The fuselage is a body of revolution aligned with the x_b axis. * The angle between the nose and the freestream is less than 90 degrees. Moments are given with the reference at Fuselage [0, 0, 0]. Uses methods from Jorgensen, Leland Howard. "Prediction of Static Aerodynamic Characteristics for Slender Bodies Alone and with Lifting Surfaces to Very High Angles of Attack". NASA TR R-474. 1977. Args: fuselage: A Fuselage object that you wish to analyze. Returns: """ ##### Alias a few things for convenience op_point = self.op_point Re = op_point.reynolds(reference_length=fuselage.length()) fuse_options = self.get_options(fuselage) ####### Reference quantities (Set these 1 here, just so we can follow Jorgensen syntax.) # Outputs of this function should be invariant of these quantities, if normalization has been done correctly. S_ref = 1 # m^2 c_ref = 1 # m ####### Fuselage zero-lift drag estimation ### Forebody drag C_f_forebody = aerolib.Cf_flat_plate(Re_L=Re) ### Base Drag C_D_base = 0.029 / np.sqrt(C_f_forebody) * fuselage.area_base() / S_ref ### Skin friction drag C_D_skin = C_f_forebody * fuselage.area_wetted() / S_ref ### Wave drag if self.include_wave_drag: sears_haack_drag = transonic.sears_haack_drag_from_volume( volume=fuselage.volume(), length=fuselage.length()) C_D_wave = transonic.approximate_CD_wave( mach=op_point.mach(), mach_crit=critical_mach( fineness_ratio_nose=fuse_options["nose_fineness_ratio"]), CD_wave_at_fully_supersonic=fuse_options["E_wave_drag"] * sears_haack_drag, ) else: C_D_wave = 0 ### Total zero-lift drag C_D_zero_lift = C_D_skin + C_D_base + C_D_wave ####### Jorgensen model ### First, merge the alpha and beta into a single "generalized alpha", which represents the degrees between the fuselage axis and the freestream. x_w, y_w, z_w = op_point.convert_axes(1, 0, 0, from_axes="body", to_axes="wind") generalized_alpha = np.arccosd(x_w / (1 + 1e-14)) sin_generalized_alpha = np.sind(generalized_alpha) cos_generalized_alpha = x_w # ### Limit generalized alpha to -90 < alpha < 90, for now. # generalized_alpha = np.clip(generalized_alpha, -90, 90) # # TODO make the drag/moment functions not give negative results for alpha > 90. alpha_fractional_component = -z_w / np.sqrt( y_w**2 + z_w**2 + 1e-16 ) # The fraction of any "generalized lift" to be in the direction of alpha beta_fractional_component = y_w / np.sqrt( y_w**2 + z_w**2 + 1e-16 ) # The fraction of any "generalized lift" to be in the direction of beta ### Compute normal quantities ### Note the (N)ormal, (A)ligned coordinate system. (See Jorgensen for definitions.) # M_n = sin_generalized_alpha * op_point.mach() Re_n = sin_generalized_alpha * Re # V_n = sin_generalized_alpha * op_point.velocity q = op_point.dynamic_pressure() x_nose = fuselage.xsecs[0].xyz_c[0] x_m = 0 - x_nose x_c = fuselage.x_centroid_projected() - x_nose ##### Potential flow crossflow model C_N_p = ( # Normal force coefficient due to potential flow. (Jorgensen Eq. 2.12, part 1) fuselage.area_base() / S_ref * np.sind(2 * generalized_alpha) * np.cosd(generalized_alpha / 2)) C_m_p = ((fuselage.volume() - fuselage.area_base() * (fuselage.length() - x_m)) / (S_ref * c_ref) * np.sind(2 * generalized_alpha) * np.cosd(generalized_alpha / 2)) ##### Viscous crossflow model C_d_n = np.where( Re_n != 0, aerolib.Cd_cylinder( Re_D=Re_n ), # Replace with 1.20 from Jorgensen Table 1 if not working well 0) eta = jorgensen_eta(fuselage.fineness_ratio()) C_N_v = ( # Normal force coefficient due to viscous crossflow. (Jorgensen Eq. 2.12, part 2) eta * C_d_n * fuselage.area_projected() / S_ref * sin_generalized_alpha**2) C_m_v = (eta * C_d_n * fuselage.area_projected() / S_ref * (x_m - x_c) / c_ref * sin_generalized_alpha**2) ##### Total C_N model C_N = C_N_p + C_N_v C_m_generalized = C_m_p + C_m_v ##### Total C_A model C_A = C_D_zero_lift * cos_generalized_alpha * np.abs( cos_generalized_alpha) ##### Convert to lift, drag C_L_generalized = C_N * cos_generalized_alpha - C_A * sin_generalized_alpha C_D = C_N * sin_generalized_alpha + C_A * cos_generalized_alpha ### Set proper directions C_L = C_L_generalized * alpha_fractional_component C_Y = -C_L_generalized * beta_fractional_component C_l = 0 C_m = C_m_generalized * alpha_fractional_component C_n = -C_m_generalized * beta_fractional_component ### Un-normalize L = C_L * q * S_ref Y = C_Y * q * S_ref D = C_D * q * S_ref l_w = C_l * q * S_ref * c_ref m_w = C_m * q * S_ref * c_ref n_w = C_n * q * S_ref * c_ref ### Convert to axes coordinates for reporting F_w = (-D, Y, -L) F_b = op_point.convert_axes(*F_w, from_axes="wind", to_axes="body") F_g = op_point.convert_axes(*F_b, from_axes="body", to_axes="geometry") M_w = ( l_w, m_w, n_w, ) M_b = op_point.convert_axes(*M_w, from_axes="wind", to_axes="body") M_g = op_point.convert_axes(*M_b, from_axes="body", to_axes="geometry") return { "F_g": F_g, "F_b": F_b, "F_w": F_w, "M_g": M_g, "M_b": M_b, "M_w": M_w, "L": -F_w[2], "Y": F_w[1], "D": -F_w[0], "l_b": M_b[0], "m_b": M_b[1], "n_b": M_b[2] }
def wing_aerodynamics( self, wing: Wing, ): """ Estimates the aerodynamic forces, moments, and derivatives on a wing in isolation. Moments are given with the reference at Wing [0, 0, 0]. Args: wing: A Wing object that you wish to analyze. op_point: The OperatingPoint that you wish to analyze the fuselage at. TODO account for wing airfoil pitching moment Returns: """ ##### Alias a few things for convenience op_point = self.op_point wing_options = self.get_options(wing) ##### Compute general wing properties and things to be used in sectional analysis. sweep = wing.mean_sweep_angle() AR = wing.aspect_ratio() mach = op_point.mach() q = op_point.dynamic_pressure() CL_over_Cl = aerolib.CL_over_Cl(aspect_ratio=AR, mach=mach, sweep=sweep) oswalds_efficiency = aerolib.oswalds_efficiency( taper_ratio=wing.taper_ratio(), aspect_ratio=AR, sweep=sweep, fuselage_diameter_to_span_ratio=0 # an assumption ) areas = wing.area(_sectional=True) aerodynamic_centers = wing.aerodynamic_center(_sectional=True) F_g = [0, 0, 0] M_g = [0, 0, 0] ##### Iterate through the wing sections. for sect_id in range(len(wing.xsecs) - 1): ##### Identify the wing cross sections adjacent to this wing section. xsec_a = wing.xsecs[sect_id] xsec_b = wing.xsecs[sect_id + 1] ##### When linearly interpolating, weight things by the relative chord. a_weight = xsec_a.chord / (xsec_a.chord + xsec_b.chord) b_weight = xsec_b.chord / (xsec_a.chord + xsec_b.chord) ##### Compute the local frame of this section, and put the z (normal) component into wind axes. xg_local, yg_local, zg_local = wing._compute_frame_of_section( sect_id) sect_aerodynamic_center = aerodynamic_centers[sect_id] sect_z_w = op_point.convert_axes( x_from=zg_local[0], y_from=zg_local[1], z_from=zg_local[2], from_axes="geometry", to_axes="wind", ) ##### Compute the generalized angle of attack, so the geometric alpha that the wing section "sees". velocity_vector_b_from_freestream = op_point.convert_axes( x_from=-op_point.velocity, y_from=0, z_from=0, from_axes="wind", to_axes="body") velocity_vector_b_from_rotation = np.cross(op_point.convert_axes( sect_aerodynamic_center[0], sect_aerodynamic_center[1], sect_aerodynamic_center[2], from_axes="geometry", to_axes="body"), [op_point.p, op_point.q, op_point.r], manual=True) velocity_vector_b = [ velocity_vector_b_from_freestream[i] + velocity_vector_b_from_rotation[i] for i in range(3) ] velocity_mag_b = np.sqrt( sum([comp**2 for comp in velocity_vector_b])) velocity_dir_b = [ velocity_vector_b[i] / velocity_mag_b for i in range(3) ] sect_z_b = op_point.convert_axes( x_from=zg_local[0], y_from=zg_local[1], z_from=zg_local[2], from_axes="geometry", to_axes="body", ) vel_dot_normal = np.dot(velocity_dir_b, sect_z_b, manual=True) sect_alpha_generalized = 90 - np.arccosd(vel_dot_normal) def get_deflection(xsec): n_surfs = len(xsec.control_surfaces) if n_surfs == 0: return 0 elif n_surfs == 1: surf = xsec.control_surfaces[0] return surf.deflection else: raise NotImplementedError( "AeroBuildup currently cannot handle multiple control surfaces attached to a given WingXSec." ) ##### Compute sectional lift at cross sections using lookup functions. Merge them linearly to get section CL. xsec_a_Cl_incompressible = xsec_a.airfoil.CL_function( alpha=sect_alpha_generalized, Re=op_point.reynolds(xsec_a.chord), mach= 0, # Note: this is correct, mach correction happens in 2D -> 3D step deflection=get_deflection(xsec_a)) xsec_b_Cl_incompressible = xsec_b.airfoil.CL_function( alpha=sect_alpha_generalized, Re=op_point.reynolds(xsec_b.chord), mach= 0, # Note: this is correct, mach correction happens in 2D -> 3D step deflection=get_deflection(xsec_b)) sect_CL = (xsec_a_Cl_incompressible * a_weight + xsec_b_Cl_incompressible * b_weight) * CL_over_Cl ##### Compute sectional drag at cross sections using lookup functions. Merge them linearly to get section CD. xsec_a_Cd_profile = xsec_a.airfoil.CD_function( alpha=sect_alpha_generalized, Re=op_point.reynolds(xsec_a.chord), mach=mach, deflection=get_deflection(xsec_a)) xsec_b_Cd_profile = xsec_b.airfoil.CD_function( alpha=sect_alpha_generalized, Re=op_point.reynolds(xsec_b.chord), mach=mach, deflection=get_deflection(xsec_b)) sect_CDp = (xsec_a_Cd_profile * a_weight + xsec_b_Cd_profile * b_weight) ##### Compute induced drag from local CL and full-wing properties (AR, e) sect_CDi = (sect_CL**2 / (np.pi * AR * oswalds_efficiency)) ##### Total the drag. sect_CD = sect_CDp + sect_CDi ##### Go to dimensional quantities using the area. area = areas[sect_id] sect_L = q * area * sect_CL sect_D = q * area * sect_CD ##### Compute the direction of the lift by projecting the section's normal vector into the plane orthogonal to the freestream. sect_L_direction_w = (np.zeros_like(sect_z_w[0]), sect_z_w[1] / np.sqrt(sect_z_w[1]**2 + sect_z_w[2]**2), sect_z_w[2] / np.sqrt(sect_z_w[1]**2 + sect_z_w[2]**2)) sect_L_direction_g = op_point.convert_axes(*sect_L_direction_w, from_axes="wind", to_axes="geometry") ##### Compute the direction of the drag by aligning the drag vector with the freestream vector. sect_D_direction_w = (-1, 0, 0) sect_D_direction_g = op_point.convert_axes(*sect_D_direction_w, from_axes="wind", to_axes="geometry") ##### Compute the force vector in geometry axes. sect_F_g = [ sect_L * sect_L_direction_g[i] + sect_D * sect_D_direction_g[i] for i in range(3) ] ##### Compute the moment vector in geometry axes. sect_M_g = np.cross(sect_aerodynamic_center, sect_F_g, manual=True) ##### Add section forces and moments to overall forces and moments F_g = [F_g[i] + sect_F_g[i] for i in range(3)] M_g = [M_g[i] + sect_M_g[i] for i in range(3)] ##### Treat symmetry if wing.symmetric: ##### Compute the local frame of this section, and put the z (normal) component into wind axes. sym_sect_aerodynamic_center = aerodynamic_centers[sect_id] sym_sect_aerodynamic_center[1] *= -1 sym_sect_z_w = op_point.convert_axes( x_from=zg_local[0], y_from=-zg_local[1], z_from=zg_local[2], from_axes="geometry", to_axes="wind", ) ##### Compute the generalized angle of attack, so the geometric alpha that the wing section "sees". sym_velocity_vector_b_from_freestream = op_point.convert_axes( x_from=-op_point.velocity, y_from=0, z_from=0, from_axes="wind", to_axes="body") sym_velocity_vector_b_from_rotation = np.cross( op_point.convert_axes(sym_sect_aerodynamic_center[0], sym_sect_aerodynamic_center[1], sym_sect_aerodynamic_center[2], from_axes="geometry", to_axes="body"), [op_point.p, op_point.q, op_point.r], manual=True) sym_velocity_vector_b = [ sym_velocity_vector_b_from_freestream[i] + sym_velocity_vector_b_from_rotation[i] for i in range(3) ] sym_velocity_mag_b = np.sqrt( sum([comp**2 for comp in sym_velocity_vector_b])) sym_velocity_dir_b = [ sym_velocity_vector_b[i] / sym_velocity_mag_b for i in range(3) ] sym_sect_z_b = op_point.convert_axes( x_from=zg_local[0], y_from=-zg_local[1], z_from=zg_local[2], from_axes="geometry", to_axes="body", ) sym_vel_dot_normal = np.dot(sym_velocity_dir_b, sym_sect_z_b, manual=True) sym_sect_alpha_generalized = 90 - np.arccosd( sym_vel_dot_normal) def get_deflection(xsec): n_surfs = len(xsec.control_surfaces) if n_surfs == 0: return 0 elif n_surfs == 1: surf = xsec.control_surfaces[0] return surf.deflection if surf.symmetric else -surf.deflection else: raise NotImplementedError( "AeroBuildup currently cannot handle multiple control surfaces attached to a given WingXSec." ) ##### Compute sectional lift at cross sections using lookup functions. Merge them linearly to get section CL. sym_xsec_a_Cl_incompressible = xsec_a.airfoil.CL_function( alpha=sym_sect_alpha_generalized, Re=op_point.reynolds(xsec_a.chord), mach= 0, # Note: this is correct, mach correction happens in 2D -> 3D step deflection=get_deflection(xsec_a)) sym_xsec_b_Cl_incompressible = xsec_b.airfoil.CL_function( alpha=sym_sect_alpha_generalized, Re=op_point.reynolds(xsec_b.chord), mach= 0, # Note: this is correct, mach correction happens in 2D -> 3D step deflection=get_deflection(xsec_b)) sym_sect_CL = ( sym_xsec_a_Cl_incompressible * a_weight + sym_xsec_b_Cl_incompressible * b_weight) * CL_over_Cl ##### Compute sectional drag at cross sections using lookup functions. Merge them linearly to get section CD. sym_xsec_a_Cd_profile = xsec_a.airfoil.CD_function( alpha=sym_sect_alpha_generalized, Re=op_point.reynolds(xsec_a.chord), mach=mach, deflection=get_deflection(xsec_a)) sym_xsec_b_Cd_profile = xsec_b.airfoil.CD_function( alpha=sym_sect_alpha_generalized, Re=op_point.reynolds(xsec_b.chord), mach=mach, deflection=get_deflection(xsec_b)) sym_sect_CDp = (sym_xsec_a_Cd_profile * a_weight + sym_xsec_b_Cd_profile * b_weight) ##### Compute induced drag from local CL and full-wing properties (AR, e) sym_sect_CDi = (sym_sect_CL**2 / (np.pi * AR * oswalds_efficiency)) ##### Total the drag. sym_sect_CD = sym_sect_CDp + sym_sect_CDi ##### Go to dimensional quantities using the area. area = areas[sect_id] sym_sect_L = q * area * sym_sect_CL sym_sect_D = q * area * sym_sect_CD ##### Compute the direction of the lift by projecting the section's normal vector into the plane orthogonal to the freestream. sym_sect_L_direction_w = ( np.zeros_like(sym_sect_z_w[0]), sym_sect_z_w[1] / np.sqrt(sym_sect_z_w[1]**2 + sym_sect_z_w[2]**2), sym_sect_z_w[2] / np.sqrt(sym_sect_z_w[1]**2 + sym_sect_z_w[2]**2)) sym_sect_L_direction_g = op_point.convert_axes( *sym_sect_L_direction_w, from_axes="wind", to_axes="geometry") ##### Compute the direction of the drag by aligning the drag vector with the freestream vector. sym_sect_D_direction_w = (-1, 0, 0) sym_sect_D_direction_g = op_point.convert_axes( *sym_sect_D_direction_w, from_axes="wind", to_axes="geometry") ##### Compute the force vector in geometry axes. sym_sect_F_g = [ sym_sect_L * sym_sect_L_direction_g[i] + sym_sect_D * sym_sect_D_direction_g[i] for i in range(3) ] ##### Compute the moment vector in geometry axes. sym_sect_M_g = np.cross(sym_sect_aerodynamic_center, sym_sect_F_g, manual=True) ##### Add section forces and moments to overall forces and moments F_g = [F_g[i] + sym_sect_F_g[i] for i in range(3)] M_g = [M_g[i] + sym_sect_M_g[i] for i in range(3)] ##### Convert F_g and M_g to body and wind axes for reporting. F_b = op_point.convert_axes(*F_g, from_axes="geometry", to_axes="body") F_w = op_point.convert_axes(*F_b, from_axes="body", to_axes="wind") M_b = op_point.convert_axes(*M_g, from_axes="geometry", to_axes="body") M_w = op_point.convert_axes(*M_b, from_axes="body", to_axes="wind") return { "F_g": F_g, "F_b": F_b, "F_w": F_w, "M_g": M_g, "M_b": M_b, "M_w": M_w, "L": -F_w[2], "Y": F_w[1], "D": -F_w[0], "l_b": M_b[0], "m_b": M_b[1], "n_b": M_b[2] }
def fuselage_aerodynamics(self, fuselage: Fuselage, ) -> Dict[str, Any]: """ Estimates the aerodynamic forces, moments, and derivatives on a fuselage in isolation. Assumes: * The fuselage is a body of revolution aligned with the x_b axis. * The angle between the nose and the freestream is less than 90 degrees. Moments are given with the reference at Fuselage [0, 0, 0]. Uses methods from Jorgensen, Leland Howard. "Prediction of Static Aerodynamic Characteristics for Slender Bodies Alone and with Lifting Surfaces to Very High Angles of Attack". NASA TR R-474. 1977. Args: fuselage: A Fuselage object that you wish to analyze. Returns: """ ##### Alias a few things for convenience op_point = self.op_point Re = op_point.reynolds(reference_length=fuselage.length()) fuse_options = self.get_options(fuselage) ####### Jorgensen model ### First, merge the alpha and beta into a single "generalized alpha", which represents the degrees between the fuselage axis and the freestream. x_w, y_w, z_w = op_point.convert_axes( 1, 0, 0, from_axes="body", to_axes="wind" ) generalized_alpha = np.arccosd(x_w / (1 + 1e-14)) sin_generalized_alpha = (y_w ** 2 + z_w ** 2 + 1e-14) ** 0.5 # sin_generalized_alpha = np.sind(generalized_alpha) cos_generalized_alpha = x_w sin_squared_generalized_alpha = y_w ** 2 + z_w ** 2 # ### Limit generalized alpha to -90 < alpha < 90, for now. # generalized_alpha = np.clip(generalized_alpha, -90, 90) # # TODO make the drag/moment functions not give negative results for alpha > 90. alpha_fractional_component = -z_w / np.sqrt( # This is positive when alpha is positive y_w ** 2 + z_w ** 2 + 1e-16) # The fraction of any "generalized lift" to be in the direction of alpha beta_fractional_component = -y_w / np.sqrt( # This is positive when beta is positive y_w ** 2 + z_w ** 2 + 1e-16) # The fraction of any "generalized lift" to be in the direction of beta ### Compute normal quantities ### Note the (N)ormal, (A)ligned coordinate system. (See Jorgensen for definitions.) q = op_point.dynamic_pressure() eta = jorgensen_eta(fuselage.fineness_ratio()) def forces_on_fuselage_section( xsec_a: FuselageXSec, xsec_b: FuselageXSec, ): ### Some metrics, like effective force location, are area-weighted. Here, we compute those weights. r_a = xsec_a.radius r_b = xsec_b.radius x_a = xsec_a.xyz_c[0] x_b = xsec_b.xyz_c[0] area_a = xsec_a.xsec_area() area_b = xsec_b.xsec_area() total_area = area_a + area_b a_weight = area_a / total_area b_weight = area_b / total_area delta_x = x_b - x_a mean_geometric_radius = (r_a + r_b) / 2 mean_aerodynamic_radius = r_a * a_weight + r_b * b_weight force_x_location = x_a * a_weight + x_b * b_weight ##### Inviscid Forces force_potential_flow = q * ( # From Munk, via Jorgensen np.sind(2 * generalized_alpha) * (area_b - area_a) ) # Matches Drela, Flight Vehicle Aerodynamics Eqn. 6.75 in the small-alpha limit. # Note that no delta_x should be here; dA/dx * dx = dA. # Direction of force is midway between the normal to the axis of revolution of the body and the # normal to the free-stream velocity, according to: # Ward, via Jorgensen force_normal_potential_flow = force_potential_flow * np.cosd(generalized_alpha / 2) force_axial_potential_flow = -force_potential_flow * np.sind(generalized_alpha / 2) # Reminder: axial force is defined positive-aft ##### Viscous Forces Re_n = sin_generalized_alpha * op_point.reynolds(reference_length=2 * mean_aerodynamic_radius) M_n = sin_generalized_alpha * op_point.mach() C_d_n = np.where( Re_n != 0, aerolib.Cd_cylinder( Re_D=Re_n, mach=M_n ), # Replace with 1.20 from Jorgensen Table 1 if this isn't working well 0, ) force_viscous_flow = delta_x * q * ( 2 * eta * C_d_n * sin_squared_generalized_alpha * mean_geometric_radius ) # Viscous crossflow acts exactly normal to vehicle axis, definitionally. (Axial forces accounted for on a total-body basis) force_normal_viscous_flow = force_viscous_flow force_axial_viscous_flow = 0 normal_force = force_normal_potential_flow + force_normal_viscous_flow axial_force = force_axial_potential_flow + force_axial_viscous_flow return normal_force, axial_force, force_x_location normal_force_contributions = [] axial_force_contributions = [] force_x_locations = [] for xsec_a, xsec_b in zip( fuselage.xsecs[:-1], fuselage.xsecs[1:] ): normal_force_contribution, axial_force_contribution, force_x_location = \ forces_on_fuselage_section( xsec_a, xsec_b ) normal_force_contributions.append(normal_force_contribution) axial_force_contributions.append(axial_force_contribution) force_x_locations.append(force_x_location) ##### Add up all forces normal_force = sum(normal_force_contributions) axial_force = sum(axial_force_contributions) generalized_pitching_moment = sum( [ -force * x for force, x in zip(normal_force_contributions, force_x_locations) ] ) ##### Add in profile drag: viscous drag forces and wave drag forces ### Base Drag base_drag_coefficient = fuselage_base_drag_coefficient(mach=op_point.mach()) drag_base = base_drag_coefficient * fuselage.area_base() * q * cos_generalized_alpha ** 2 # One cosine from q dependency, one cosine from direction of drag force ### Skin friction drag C_f_forebody = aerolib.Cf_flat_plate(Re_L=Re) drag_skin = C_f_forebody * fuselage.area_wetted() * q ### Wave drag S_ref = 1 # Does not matter here, just for accounting. if self.include_wave_drag: sears_haack_drag_area = transonic.sears_haack_drag_from_volume( volume=fuselage.volume(), length=fuselage.length() ) # Units of area sears_haack_C_D_wave = sears_haack_drag_area / S_ref C_D_wave = transonic.approximate_CD_wave( mach=op_point.mach(), mach_crit=critical_mach( fineness_ratio_nose=fuse_options["nose_fineness_ratio"] ), CD_wave_at_fully_supersonic=fuse_options["E_wave_drag"] * sears_haack_C_D_wave, ) else: C_D_wave = 0 drag_wave = C_D_wave * q * S_ref ### Sum up the profile drag drag_profile = drag_base + drag_skin + drag_wave ##### Convert Normal/Axial to Lift/Drag, but still in generalized (2D-esque) coordinates L_generalized = normal_force * cos_generalized_alpha - axial_force * sin_generalized_alpha D = normal_force * sin_generalized_alpha + axial_force * cos_generalized_alpha + drag_profile ##### Convert from generalized (2D-esque) coordinates to full 3D L = L_generalized * alpha_fractional_component Y = -L_generalized * beta_fractional_component l_w = 0 # No roll moment m_w = generalized_pitching_moment * alpha_fractional_component n_w = -generalized_pitching_moment * beta_fractional_component ##### Convert to various axes coordinates for reporting F_w = ( -D, Y, -L ) F_b = op_point.convert_axes(*F_w, from_axes="wind", to_axes="body") F_g = op_point.convert_axes(*F_b, from_axes="body", to_axes="geometry") M_w = ( l_w, m_w, n_w, ) M_b = op_point.convert_axes(*M_w, from_axes="wind", to_axes="body") M_g = op_point.convert_axes(*M_b, from_axes="body", to_axes="geometry") ##### Return return { "F_g": F_g, "F_b": F_b, "F_w": F_w, "M_g": M_g, "M_b": M_b, "M_w": M_w, "L" : L, "Y" : Y, "D" : D, "l_b": M_b[0], "m_b": M_b[1], "n_b": M_b[2], }