def _pressure_isa(self): """ Computes the pressure at the Atmosphere's altitude based on the International Standard Atmosphere. Uses the Barometric formula, as implemented here: https://en.wikipedia.org/wiki/Barometric_formula Returns: Pressure [Pa] """ alt = self.altitude pressure = 0 * alt # Initialize the pressure to all zeros. for i in range(len(isa_table)): pressure = np.where( alt > isa_base_altitude[i], barometric_formula(P_b=isa_pressure[i], T_b=isa_base_temperature[i], L_b=isa_lapse_rate[i], h=alt, h_b=isa_base_altitude[i]), pressure) ### Add lower bound case pressure = np.where( alt <= isa_base_altitude[0], barometric_formula(P_b=isa_pressure[0], T_b=isa_base_temperature[0], L_b=isa_lapse_rate[0], h=alt, h_b=isa_base_altitude[0]), pressure) return pressure
def test_logic(types): for option_set in [ types["scalar"], types["vector"], types["matrix"], ]: for x in option_set: for y in option_set: ### Comparisons """ Note: if warnings appear here, they're from `np.array(1) == cas.MX(1)` - sensitive to order, as `cas.MX(1) == np.array(1)` is fine. However, checking the outputs, these seem to be yielding correct results despite the warning sooo... """ x == y # Warnings coming from here x != y # Warnings coming from here x > y x >= y x < y x <= y ### Conditionals np.where(x > 1, x**2, 0) ### Elementwise min/max np.fmax(x, y) np.fmin(x, y) for x in types["all"]: np.fabs(x) np.floor(x) np.ceil(x) np.clip(x, 0, 1)
def pressure_isa(altitude): """ Computes the pressure at a given altitude based on the International Standard Atmosphere. Uses the Barometric formula, as implemented here: https://en.wikipedia.org/wiki/Barometric_formula Args: altitude: Geopotential altitude [m] Returns: Pressure [Pa] """ pressure = 0 * altitude # Initialize the pressure to all zeros. for i in range(len(isa_table)): pressure = np.where( altitude > isa_base_altitude[i], barometric_formula(P_b=isa_pressure[i], T_b=isa_base_temperature[i], L_b=isa_lapse_rate[i], h=altitude, h_b=isa_base_altitude[i]), pressure) ### Add lower bound case pressure = np.where( altitude <= isa_base_altitude[0], barometric_formula(P_b=isa_pressure[0], T_b=isa_base_temperature[0], L_b=isa_lapse_rate[0], h=altitude, h_b=isa_base_altitude[0]), pressure) return pressure
def lower_bound(n): ''' finds a lower bound for instance n ''' opti = asb.Opti() coeffs = opti.variable(init_guess=np.zeros(degree + 1)) n_new = np.where(n == None, 1, n) n_new = np.array(n_new) y_model = model(coeffs * n_new) error = loss(y_model, y_data) opti.minimize(error + eta * np.sum(np.where(n == None, 0, n))) sol = opti.solve(verbose=False) return sol.value(error) + eta * np.sum(np.where(n == None, 0, n))
def test_where_casadi(): a = cas.GenDM_ones(4) b = 2 * cas.GenDM_ones(4) c = np.where(cas.DM([1, 0, 1, 0]), a, b) assert np.all(c == cas.DM([1, 2, 1, 2]))
def test_where_numpy(): a = np.ones(4) b = 2 * np.ones(4) c = np.where(np.array([True, False, True, False]), a, b) assert np.all(c == np.array([1, 2, 1, 2]))
def solar_azimuth_angle(latitude, day_of_year, time): """ Azimuth angle of the sun [degrees] for a local observer. :param latitude: Latitude [degrees] :param day_of_year: Julian day (1 == Jan. 1, 365 == Dec. 31) :param time: Time after local solar noon [seconds] :return: Solar azimuth angle [degrees] (the compass direction from which the sunlight is coming). """ # Solar azimuth angle (including seasonality, latitude, and time of day) # Source: https://www.pveducation.org/pvcdrom/properties-of-sunlight/azimuth-angle declination = declination_angle(day_of_year) sdec = np.sind(declination) cdec = np.cosd(declination) slat = np.sind(latitude) clat = np.cosd(latitude) ctime = np.cosd(time / 86400 * 360) elevation = solar_elevation_angle(latitude, day_of_year, time) cele = np.cosd(elevation) cos_azimuth = (sdec * clat - cdec * slat * ctime) / cele cos_azimuth = np.clip(cos_azimuth, -1, 1) azimuth_raw = np.arccosd(cos_azimuth) is_solar_morning = np.mod(time, 86400) > 43200 solar_azimuth_angle = np.where( is_solar_morning, azimuth_raw, 360 - azimuth_raw ) return solar_azimuth_angle
def kussners_function(reduced_time: Union[np.ndarray, float]): """ A commonly used approximation to Kussner's function (Sears and Sparks 1941) Args: reduced_time (float,np.ndarray) : This is equal to the number of semichords travelled. See function calculate_reduced_time """ kussner = (1 - 0.5 * np.exp(-0.13 * reduced_time) - 0.5 * np.exp(-reduced_time)) * np.where(reduced_time >= 0, 1, 0) return kussner
def wagners_function(reduced_time: Union[np.ndarray, float]): """ A commonly used approximation to Wagner's function (Jones, R.T. The Unsteady Lift of a Finite Wing; Technical Report NACA TN-682; NACA: Washington, DC, USA, 1939) Args: reduced_time (float,np.ndarray) : Equal to the number of semichords travelled. See function calculate_reduced_time """ wagner = (1 - 0.165 * np.exp(-0.0455 * reduced_time) - 0.335 * np.exp(-0.3 * reduced_time)) * np.where(reduced_time >= 0, 1, 0) return wagner
def _temperature_isa(self): """ Computes the temperature at the Atmosphere's altitude based on the International Standard Atmosphere. Returns: Temperature [K] """ alt = self.altitude temp = 0 * alt # Initialize the temperature to all zeros. for i in range(len(isa_table)): temp = np.where(alt > isa_base_altitude[i], (alt - isa_base_altitude[i]) * isa_lapse_rate[i] + isa_base_temperature[i], temp) ### Add lower bound case temp = np.where(alt <= isa_base_altitude[0], (alt - isa_base_altitude[0]) * isa_lapse_rate[0] + isa_base_temperature[0], temp) return temp
def temperature_isa(altitude): """ Computes the temperature at a given altitude based on the International Standard Atmosphere. Args: altitude: Geopotential altitude [m] Returns: Temperature [K] """ temp = 0 * altitude # Initialize the temperature to all zeros. for i in range(len(isa_table)): temp = np.where(altitude > isa_base_altitude[i], (altitude - isa_base_altitude[i]) * isa_lapse_rate[i] + isa_base_temperature[i], temp) ### Add lower bound case temp = np.where(altitude <= isa_base_altitude[0], (altitude - isa_base_altitude[0]) * isa_lapse_rate[0] + isa_base_temperature[0], temp) return temp
def Cd_wave_Korn(Cl, t_over_c, mach, sweep=0, kappa_A=0.95): """ Wave drag_force coefficient prediction using the (very) low-fidelity Korn Equation method; derived in "Configuration Aerodynamics" by W.H. Mason, Sect. 7.5.2, pg. 7-18 :param Cl: Sectional lift coefficient :param t_over_c: thickness-to-chord ratio :param sweep: sweep angle, in degrees :param kappa_A: Airfoil technology factor (0.95 for supercritical section, 0.87 for NACA 6-series) :return: Wave drag coefficient """ mach = np.fmax(mach, 0) Mdd = kappa_A / np.cosd(sweep) - t_over_c / np.cosd(sweep)**2 - Cl / ( 10 * np.cosd(sweep)**3) Mcrit = Mdd - (0.1 / 80)**(1 / 3) Cd_wave = np.where(mach > Mcrit, 20 * (mach - Mcrit)**4, 0) return Cd_wave
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 = np.where(1 - mach**2 >= 0, np.fmax(1 - mach**2, 0)**0.5, 0) # return aspect_ratio / (aspect_ratio + 2) # Equivalent to equation in Drela's FVA in incompressible, 2*pi*alpha limit. # return aspect_ratio / (2 + cas.sqrt(4 + aspect_ratio ** 2)) # more theoretically sound at low aspect_ratio eta = 0.95 return aspect_ratio / (2 + np.sqrt(4 + (aspect_ratio * beta / eta)**2 * (1 + (np.tand(sweep) / beta)**2)) ) # From Raymer, Sect. 12.4.1; citing DATCOM
def dynamics(t, y): dyn = asb.FreeBodyDynamics( *y, g=1, ) aero = asb.AeroBuildup( airplane=airplane, op_point=dyn.op_point ).run() dyn.X, dyn.Y, dyn.Z = aero["F_b"] dyn.L, dyn.M, dyn.N = aero["M_b"] derivatives = dyn.state_derivatives() derivatives["u"] = np.where( dyn.u < 0, 0, derivatives["u"] ) return np.array(list(dyn.state_derivatives().values()))
def incidence_angle_function( latitude: float, day_of_year: float, time: float, panel_azimuth_angle: float = 0, panel_tilt_angle: float = 0, scattering: bool = True, ): """ This website will be useful for accounting for direction of the vertical surface https://www.pveducation.org/pvcdrom/properties-of-sunlight/arbitrary-orientation-and-tilt :param latitude: Latitude [degrees] :param day_of_year: Julian day (1 == Jan. 1, 365 == Dec. 31) :param time: Time since (local) solar noon [seconds] :param panel_azimuth_angle: The azimuth angle of the panel normal, in degrees. (0 degrees if pointing North and 90 if East) :param panel_tilt_angle: The angle between the panel normal and vertical, in degrees. (0 if horizontal and 90 if vertical) :param scattering: Boolean: include scattering effects at very low angles? :returns illumination_factor: Fraction of solar insolation received, relative to what it would get if it were perfectly oriented to the sun. """ solar_elevation = solar_elevation_angle(latitude, day_of_year, time) solar_azimuth = solar_azimuth_angle(latitude, day_of_year, time) cosine_factor = ( np.cosd(solar_elevation) * np.sind(panel_tilt_angle) * np.cosd(panel_azimuth_angle - solar_azimuth) + np.sind(solar_elevation) * np.cosd(panel_tilt_angle) ) if scattering: illumination_factor = cosine_factor * scattering_factor(solar_elevation) else: illumination_factor = cosine_factor illumination_factor = np.fmax(illumination_factor, 0) illumination_factor = np.where( solar_elevation < 0, 0, illumination_factor ) return illumination_factor
x_data=x, y_data=y ) def randspace(start, stop, n=50): vals = (stop - start) * np.random.rand(n) + start vals = np.concatenate((vals[:-2], np.array([start, stop]))) # vals = np.sort(vals) return vals np.random.seed(4) X = randspace(-5, 5, 200) Y = randspace(-5, 5, 200) f = np.where(X > 0, 1, 0) + np.where(Y > 0, 1, 0) # f = X ** 2 + Y ** 2 interp = UnstructuredInterpolatedModel( x_data={ "x": X.flatten(), "y": Y.flatten(), }, y_data=f.flatten() ) from aerosandbox.tools.pretty_plots import plt, show_plot fig = plt.figure() ax = fig.add_subplot(projection='3d') # ax.plot_surface(X, Y, f, color="blue", alpha=0.2) ax.scatter(X.flatten(), Y.flatten(), f.flatten())
def approximate_CD_wave( mach, mach_crit, CD_wave_at_fully_supersonic, ): """ An approximate relation for computing transonic wave drag, based on an object's Mach number. Considered reasonably valid from Mach 0 up to around Mach 2 or 3-ish. Methodology is a combination of: * The methodology described in Raymer, "Aircraft Design: A Conceptual Approach", Section 12.5.10 Transonic Parasite Drag (pg. 449 in Ed. 2) and * The methodology described in W.H. Mason's Configuration Aerodynamics, Chapter 7. Transonic Aerodynamics of Airfoils and Wings. Args: mach: Mach number at the operating point to be evaluated mach_crit: Critical mach number, a function of the body geometry CD_wave_at_fully_supersonic: The wave drag coefficient of the body at the speed that it first goes ( effectively) fully supersonic. Here, that is taken to mean at the Mach 1.2 case. This value should probably be derived using something similar to a Sears-Haack relation for the body in question, with a markup depending on geometry smoothness. The CD_wave predicted by this function will match this value exactly at M=1.2 and M=1.05. The peak CD_wave that is predicted is ~1.23 * this value, which occurs at M=1.10. In the high-Mach limit, this function asymptotes at 0.80 * this value, as empirically stated by Raymer. However, this model is only approximate and is likely not valid for high-supersonic flows. Returns: The approximate wave drag coefficient at the specified Mach number. The reference area is whatever the reference area used in the `CD_wave_at_fully_supersonic` parameter is. """ mach_crit_max = 1 - (0.1 / 80)**(1 / 3) mach_crit = -np.softmax(-mach_crit, -mach_crit_max, hardness=50) ### The following approximate relation is derived in W.H. Mason, "Configuration Aerodynamics", Chapter 7. Transonic Aerodynamics of Airfoils and Wings. ### Equation 7-8 on Page 7-19. ### This is in turn based on Lock's proposed empirically-derived shape of the drag rise, from Hilton, W.F., High Speed Aerodynamics, Longmans, Green & Co., London, 1952, pp. 47-49 mach_dd = mach_crit + (0.1 / 80)**(1 / 3) ### Model drag sections and cutoffs: return CD_wave_at_fully_supersonic * np.where( mach < mach_crit, 0, np.where( mach < mach_dd, 20 * (mach - mach_crit)**4, np.where( mach < 1.05, cubic_hermite_patch(mach, x_a=mach_dd, x_b=1.05, f_a=20 * (0.1 / 80)**(4 / 3), f_b=1, dfdx_a=0.1, dfdx_b=10), np.where(mach < 1.2, cubic_hermite_patch(mach, x_a=1.05, x_b=1.2, f_a=1, f_b=1, dfdx_a=10, dfdx_b=-4), np.blend( switch=4 * 2 * (mach - 1.2) / (1.2 - 0.8), value_switch_high=0.8, value_switch_low=1.2, ) # 0.8 + 0.2 * np.exp(20 * (1.2 - mach)) ))))
def _calculate_induced_velocity_line_singularity_panel_coordinates( xp_field: Union[float, np.ndarray], yp_field: Union[float, np.ndarray], gamma_start: float = 0., gamma_end: float = 0., sigma_start: float = 0., sigma_end: float = 0., xp_panel_end: float = 1., ) -> [Union[float, np.ndarray], Union[float, np.ndarray]]: """ Calculates the induced velocity at a point (xp_field, yp_field) in a 2D potential-flow flowfield. The `p` suffix in `xp...` and `yp...` denotes the use of the panel coordinate system, where: * xp_hat is along the length of the panel * yp_hat is orthogonal (90 deg. counterclockwise) to it. In this flowfield, there is only one singularity element: A line vortex going from (0, 0) to (xp_panel_end, 0). The strength of this vortex varies linearly from: * gamma_start at (0, 0), to: * gamma_end at (xp_panel_end, 0). # TODO update paragraph By convention here, positive gamma induces clockwise swirl in the flow field. Function returns the 2D velocity u, v in the local coordinate system of the panel. Inputs x and y can be 1D ndarrays representing various field points, in which case the resulting velocities u and v have corresponding dimensionality. Equations from the seminal textbook "Low Speed Aerodynamics" by Katz and Plotkin. Vortex equations are Eq. 11.99 and Eq. 11.100. * Note: there is an error in equation 11.100 in Katz and Plotkin, at least in the 2nd ed: The last term of equation 11.100, which is given as: (x_{j+1} - x_j) / z + (theta_{j+1} - theta_j) has a sign error and should instead be written as: (x_{j+1} - x_j) / z - (theta_{j+1} - theta_j) Source equations are Eq. 11.89 and Eq. 11.90. """ ### Modify any incoming floats if isinstance(xp_field, (float, int)): xp_field = np.array([xp_field]) if isinstance(yp_field, (float, int)): yp_field = np.array([yp_field]) ### Determine if you can skip either the vortex or source parts skip_vortex_math = not (isinstance(gamma_start, cas.MX) or isinstance( gamma_end, cas.MX)) and gamma_start == 0 and gamma_end == 0 skip_source_math = not (isinstance(sigma_start, cas.MX) or isinstance( sigma_end, cas.MX)) and sigma_start == 0 and sigma_end == 0 ### Determine which points are effectively on the panel, necessitating different math: is_on_panel = np.fabs(yp_field) <= 1e-8 ### Do some geometry calculation r_1 = (xp_field**2 + yp_field**2)**0.5 r_2 = ((xp_field - xp_panel_end)**2 + yp_field**2)**0.5 ### Regularize is_on_endpoint = ((r_1 == 0) | (r_2 == 0)) r_1 = np.where( r_1 == 0, 1, r_1, ) r_2 = np.where(r_2 == 0, 1, r_2) ### Continue geometry calculation theta_1 = np.arctan2(yp_field, xp_field) theta_2 = np.arctan2(yp_field, xp_field - xp_panel_end) ln_r_2_r_1 = np.log(r_2 / r_1) d_theta = theta_2 - theta_1 tau = 2 * np.pi ### Regularize if the point is on the panel. yp_field_regularized = np.where(is_on_panel, 1, yp_field) ### VORTEX MATH if skip_vortex_math: u_vortex = 0 v_vortex = 0 else: d_gamma = gamma_end - gamma_start u_vortex_term_1_quantity = (yp_field / tau * d_gamma / xp_panel_end) u_vortex_term_2_quantity = (gamma_start * xp_panel_end + d_gamma * xp_field) / (tau * xp_panel_end) # Calculate u_vortex u_vortex_term_1 = u_vortex_term_1_quantity * ln_r_2_r_1 u_vortex_term_2 = u_vortex_term_2_quantity * d_theta u_vortex = u_vortex_term_1 + u_vortex_term_2 # Correct the u-velocity if field point is on the panel u_vortex = np.where(is_on_panel, 0, u_vortex) # Calculate v_vortex v_vortex_term_1 = u_vortex_term_2_quantity * ln_r_2_r_1 v_vortex_term_2 = np.where( is_on_panel, d_gamma / tau, u_vortex_term_1_quantity * (xp_panel_end / yp_field_regularized - d_theta), ) v_vortex = v_vortex_term_1 + v_vortex_term_2 ### SOURCE MATH if skip_source_math: u_source = 0 v_source = 0 else: d_sigma = sigma_end - sigma_start v_source_term_1_quantity = (yp_field / tau * d_sigma / xp_panel_end) v_source_term_2_quantity = (sigma_start * xp_panel_end + d_sigma * xp_field) / (tau * xp_panel_end) # Calculate v_source v_source_term_1 = -v_source_term_1_quantity * ln_r_2_r_1 v_source_term_2 = v_source_term_2_quantity * d_theta v_source = v_source_term_1 + v_source_term_2 # Correct the v-velocity if field point is on the panel v_source = np.where(is_on_panel, 0, v_source) # Calculate u_source u_source_term_1 = -v_source_term_2_quantity * ln_r_2_r_1 u_source_term_2 = np.where( is_on_panel, -d_sigma / tau, -v_source_term_1_quantity * (xp_panel_end / yp_field_regularized - d_theta), ) u_source = u_source_term_1 + u_source_term_2 ### Return u = u_vortex + u_source v = v_vortex + v_source ### If the field point is on the endpoint of the panel, replace the NaN with a zero. u = np.where(is_on_endpoint, 0, u) v = np.where(is_on_endpoint, 0, v) return u, v
def get_NACA_coordinates( name: str = 'naca2412', n_points_per_side: int = _default_n_points_per_side) -> np.ndarray: """ Returns the coordinates of a specified 4-digit NACA airfoil. Args: name: Name of the NACA airfoil. n_points_per_side: Number of points per side of the airfoil (top/bottom). Returns: The coordinates of the airfoil as a Nx2 ndarray [x, y] """ name = name.lower().strip() if not "naca" in name: raise ValueError("Not a NACA airfoil!") nacanumber = name.split("naca")[1] if not nacanumber.isdigit(): raise ValueError("Couldn't parse the number of the NACA airfoil!") if not len(nacanumber) == 4: raise NotImplementedError( "Only 4-digit NACA airfoils are currently supported!") # Parse max_camber = int(nacanumber[0]) * 0.01 camber_loc = int(nacanumber[1]) * 0.1 thickness = int(nacanumber[2:]) * 0.01 # Referencing https://en.wikipedia.org/wiki/NACA_airfoil#Equation_for_a_cambered_4-digit_NACA_airfoil # from here on out # Make uncambered coordinates x_t = np.cosspace(0, 1, n_points_per_side) # Generate some cosine-spaced points y_t = 5 * thickness * ( +0.2969 * x_t**0.5 - 0.1260 * x_t - 0.3516 * x_t**2 + 0.2843 * x_t**3 - 0.1015 * x_t**4 # 0.1015 is original, #0.1036 for sharp TE ) if camber_loc == 0: camber_loc = 0.5 # prevents divide by zero errors for things like naca0012's. # Get camber y_c = np.where( x_t <= camber_loc, max_camber / camber_loc**2 * (2 * camber_loc * x_t - x_t**2), max_camber / (1 - camber_loc)**2 * ((1 - 2 * camber_loc) + 2 * camber_loc * x_t - x_t**2)) # Get camber slope dycdx = np.where(x_t <= camber_loc, 2 * max_camber / camber_loc**2 * (camber_loc - x_t), 2 * max_camber / (1 - camber_loc)**2 * (camber_loc - x_t)) theta = np.arctan(dycdx) # Combine everything x_U = x_t - y_t * np.sin(theta) x_L = x_t + y_t * np.sin(theta) y_U = y_c + y_t * np.cos(theta) y_L = y_c - y_t * np.cos(theta) # Flip upper surface so it's back to front x_U, y_U = x_U[::-1], y_U[::-1] # Trim 1 point from lower surface so there's no overlap x_L, y_L = x_L[1:], y_L[1:] x = np.hstack((x_U, x_L)) y = np.hstack((y_U, y_L)) return stack_coordinates(x, y)
opti = asb.Opti() theta = opti.variable( init_guess=theta_0, n_vars=N, ) H = opti.variable( H_0, n_vars=N, ) Re_theta = ue * theta / nu H_star = np.where( H < 4, 1.515 + 0.076 * (H - 4) ** 2 / H, 1.515 + 0.040 * (H - 4) ** 2 / H ) # From AVF Eq. 4.53 c_f = 2 / Re_theta * np.where( H < 6.2, -0.066 + 0.066 * np.abs(6.2 - H) ** 1.5 / (H - 1), -0.066 + 0.066 * (H - 6.2) ** 2 / (H - 4) ** 2 ) # From AVF Eq. 4.54 c_D = H_star / 2 / Re_theta * np.where( H < 4, 0.207 + 0.00205 * np.abs(4 - H) ** 5.5, 0.207 - 0.100 * (H - 4) ** 2 / H ** 2 ) # From AVF Eq. 4.55 Re_theta_o = 10 ** ( 2.492 / (H - 1) ** 0.43 + 0.7 * (
def variable( self, init_guess: Union[float, np.ndarray], n_vars: int = None, scale: float = None, freeze: bool = False, log_transform: bool = False, category: str = "Uncategorized", lower_bound: float = None, upper_bound: float = None, ) -> cas.MX: """ Initializes a new decision variable (or vector of decision variables). You must pass an initial guess ( `init_guess`) upon defining a new variable. Dimensionality is inferred from this initial guess, but it can be overridden; see below for syntax. It is highly, highly recommended that you provide a scale (`scale`) for each variable, especially for nonconvex problems, although this is not strictly required. Args: init_guess: Initial guess for the optimal value of the variable being initialized. This is where in the design space the optimizer will start looking. This can be either a float or a NumPy ndarray; the dimension of the variable (i.e. scalar, vector) that is created will be automatically inferred from the shape of the initial guess you provide here. (Although it can also be overridden using the `n_vars` parameter; see below.) For scalar variables, your initial guess should be a float: >>> opti = asb.Opti() >>> scalar_var = opti.variable(init_guess=5) # Initializes a scalar variable at a value of 5 For vector variables, your initial guess should be either: * a float, in which case you must pass the length of the vector as `n_vars`, otherwise a scalar variable will be created: >>> opti = asb.Opti() >>> vector_var = opti.variable(init_guess=5, n_vars=10) # Initializes a vector variable of length >>> # 10, with all 10 elements set to an initial guess of 5. * a NumPy ndarray, in which case each element will be initialized to the corresponding value in the given array: >>> opti = asb.Opti() >>> vector_var = opti.variable(init_guess=np.linspace(0, 5, 10)) # Initializes a vector variable of >>> # length 10, with all 10 elements initialized to linearly vary between 0 and 5. In the case where the variable is to be log-transformed (see `log_transform`), the initial guess should not be log-transformed as well - just supply the initial guess as usual. (Log-transform of the initial guess happens under the hood.) The initial guess must, of course, be a positive number in this case. n_vars: [Optional] Used to manually override the dimensionality of the variable to create; if not provided, the dimensionality of the variable is inferred from the initial guess `init_guess`. The only real case where you need to use this argument would be if you are initializing a vector variable to a scalar value, but you don't feel like using `init_guess=value * np.ones(n_vars)`. For example: >>> opti = asb.Opti() >>> vector_var = opti.variable(init_guess=5, n_vars=10) # Initializes a vector variable of length >>> # 10, with all 10 elements set to an initial guess of 5. scale: [Optional] Approximate scale of the variable. For example, if you're optimizing the design of a automobile and setting the tire diameter as an optimization variable, you might choose `scale=0.5`, corresponding to 0.5 meters. Properly scaling your variables can have a huge impact on solution speed (or even if the optimizer converges at all). Although most modern second-order optimizers (such as IPOPT, used here) are theoretically scale-invariant, numerical precision issues due to floating-point arithmetic can make solving poorly-scaled problems really difficult or impossible. See here for more info: https://web.casadi.org/blog/nlp-scaling/ If not specified, the code will try to pick a sensible value by defaulting to the `init_guess`. freeze: [Optional] This boolean tells the optimizer to "freeze" the variable at a specific value. In order to select the determine to freeze the variable at, the optimizer will use the following logic: * If you initialize a new variable with the parameter `freeze=True`: the optimizer will freeze the variable at the value of initial guess. >>> opti = Opti() >>> my_var = opti.variable(init_guess=5, freeze=True) # This will freeze my_var at a value of 5. * If the Opti instance is associated with a cache file, and you told it to freeze a specific category(s) of variables that your variable is a member of, and you didn't manually specify to freeze the variable: the variable will be frozen based on the value in the cache file (and ignore the `init_guess`). Example: >>> opti = Opti(cache_filename="my_file.json", variable_categories_to_freeze=["Wheel Sizing"]) >>> # Assume, for example, that `my_file.json` was from a previous run where my_var=10. >>> my_var = opti.variable(init_guess=5, category="Wheel Sizing") >>> # This will freeze my_var at a value of 10 (from the cache file, not the init_guess) * If the Opti instance is associated with a cache file, and you told it to freeze a specific category(s) of variables that your variable is a member of, but you then manually specified that the variable should be frozen: the variable will once again be frozen at the value of `init_guess`: >>> opti = Opti(cache_filename="my_file.json", variable_categories_to_freeze=["Wheel Sizing"]) >>> # Assume, for example, that `my_file.json` was from a previous run where my_var=10. >>> my_var = opti.variable(init_guess=5, category="Wheel Sizing", freeze=True) >>> # This will freeze my_var at a value of 5 (`freeze` overrides category loading.) Motivation for freezing variables: The ability to freeze variables is exceptionally useful when designing engineering systems. Let's say we're designing an airplane. In the beginning of the design process, we're doing "clean-sheet" design - any variable is up for grabs for us to optimize on, because the airplane doesn't exist yet! However, the farther we get into the design process, the more things get "locked in" - we may have ordered jigs, settled on a wingspan, chosen an engine, et cetera. So, if something changes later ( let's say that we discover that one of our assumptions was too optimistic halfway through the design process), we have to make up for that lost margin using only the variables that are still free. To do this, we would freeze the variables that are already decided on. By categorizing variables, you can also freeze entire categories of variables. For example, you can freeze all of the wing design variables for an airplane but leave all of the fuselage variables free. This idea of freezing variables can also be used to look at off-design performance - freeze a design, but change the operating conditions. log_transform: [Optional] Advanced use only. A flag of whether to internally-log-transform this variable before passing it to the optimizer. Good for known positive engineering quantities that become nonsensical if negative (e.g. mass). Log-transforming these variables can also help maintain convexity. category: [Optional] What category of variables does this belong to? Usage notes: When using vector variables, individual components of this vector of variables can be accessed via normal indexing. Example: >>> opti = asb.Opti() >>> my_var = opti.variable(n_vars = 5) >>> opti.subject_to(my_var[3] >= my_var[2]) # This is a valid way of indexing >>> my_sum = asb.sum(my_var) # This will sum up all elements of `my_var` Returns: The variable itself as a symbolic CasADi variable (MX type). """ ### Set defaults if n_vars is None: # Infer dimensionality from init_guess if it is not provided n_vars = np.length(init_guess) if scale is None: # Infer a scale from init_guess if it is not provided if log_transform: scale = 1 else: scale = np.mean( np.fabs(init_guess) ) # Initialize the scale to a heuristic based on the init_guess if scale == 0: # If that heuristic leads to a scale of 0, use a scale of 1 instead. scale = 1 scale = np.fabs(np.where(init_guess != 0, init_guess, 1)) # Validate the inputs if log_transform: if np.any(init_guess <= 0): raise ValueError( "If you are initializing a log-transformed variable, the initial guess(es) must all be positive." ) if np.any(scale <= 0): raise ValueError("The 'scale' argument must be a positive number.") # If the variable is in a category to be frozen, fix the variable at the initial guess. is_manually_frozen = freeze if category in self.variable_categories_to_freeze: freeze = True # If the variable is to be frozen, return the initial guess. Otherwise, define the variable using CasADi symbolics. if freeze: var = self.parameter(n_params=n_vars, value=init_guess) else: if not log_transform: var = scale * super().variable(n_vars) self.set_initial(var, init_guess) else: log_scale = scale / init_guess log_var = log_scale * super().variable(n_vars) var = np.exp(log_var) self.set_initial(log_var, np.log(init_guess)) # Track the variable if category not in self.variables_categorized: # Add a category if it does not exist self.variables_categorized[category] = [] self.variables_categorized[category].append(var) var.is_manually_frozen = is_manually_frozen # Apply bounds if lower_bound is not None: self.subject_to(var >= lower_bound) if upper_bound is not None: self.subject_to(var <= upper_bound) return var
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
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] }