def axis_range(x_data_axis: np.ndarray) -> Tuple[float, float]: """ Given the entries of one axis of the dependent variable, determine a min/max range over which to plot the fit. Args: x_data_axis: The entries of one axis of the dependent variable, i.e. x_data["x1"]. Returns: A tuple representing the (min, max) value over which to plot that axis. """ minval = np.min(x_data_axis) maxval = np.max(x_data_axis) return (minval, maxval)
def draw(self, draw_mcl=True, backend="matplotlib", show=True): """ Draw the airfoil object. :param draw_mcl: Should we draw the mean camber line (MCL)? [boolean] :param backend: Which backend should we use? "plotly" or "matplotlib" :return: None """ x = np.array(self.x()).reshape(-1) y = np.array(self.y()).reshape(-1) if draw_mcl: x_mcl = np.linspace(np.min(x), np.max(x), len(x)) y_mcl = self.local_camber(x_mcl) if backend == "matplotlib": color = '#280887' plt.plot(x, y, ".-", zorder=11, color=color) plt.fill(x, y, zorder=10, color=color, alpha=0.2) if draw_mcl: plt.plot(x_mcl, y_mcl, "-", zorder=4, color=color, alpha=0.4) plt.axis("equal") plt.xlabel(r"$x/c$") plt.ylabel(r"$y/c$") plt.title(f"{self.name} Airfoil") plt.tight_layout() if show: plt.show() elif backend == "plotly": from aerosandbox.visualization.plotly import go fig = go.Figure() fig.add_trace( go.Scatter(x=x, y=y, mode="lines+markers", name="Airfoil", fill="toself", line=dict(color="blue")), ) if draw_mcl: fig.add_trace( go.Scatter(x=x_mcl, y=y_mcl, mode="lines+markers", name="Mean Camber Line (MCL)", line=dict(color="navy"))) fig.update_layout(xaxis_title="x/c", yaxis_title="y/c", yaxis=dict(scaleanchor="x", scaleratio=1), title=f"{self.name} Airfoil") if show: fig.show() else: return fig
def draw(self, draw_mcl=True, backend="plotly", show=True): """ Draw the airfoil object. :param draw_mcl: Should we draw the mean camber line (MCL)? [boolean] :param backend: Which backend should we use? "plotly" or "matplotlib" :return: None """ x = np.array(self.x()).reshape(-1) y = np.array(self.y()).reshape(-1) if draw_mcl: x_mcl = np.linspace(np.min(x), np.max(x), len(x)) y_mcl = self.local_camber(x_mcl) if backend == "plotly": fig = go.Figure() fig.add_trace( go.Scatter(x=x, y=y, mode="lines+markers", name="Airfoil", fill="toself", line=dict(color="blue")), ) if draw_mcl: fig.add_trace( go.Scatter(x=x_mcl, y=y_mcl, mode="lines+markers", name="Mean Camber Line (MCL)", line=dict(color="navy"))) fig.update_layout(xaxis_title="x/c", yaxis_title="y/c", yaxis=dict(scaleanchor="x", scaleratio=1), title="%s Airfoil" % self.name) if show: fig.show() else: return fig elif backend == "matplotlib": fig, ax = plt.subplots(1, 1, figsize=(6.4, 4.8), dpi=200) plt.plot(x, y, ".-", zorder=11, color='#280887') if draw_mcl: plt.plot(x_mcl, y_mcl, "-", zorder=4, color='#28088744') plt.axis("equal") plt.xlabel(r"$x/c$") plt.ylabel(r"$y/c$") plt.title("%s Airfoil" % self.name) plt.tight_layout() if show: plt.show() else: return fig, ax
def alpha( self, alpha: Union[float, np.ndarray], start_at: Union[float, None] = 0, ) -> Dict[str, np.ndarray]: """ Execute XFoil at a given angle of attack, or at a sequence of angles of attack. Args: alpha: The angle of attack [degrees]. Can be either a float or an iterable of floats, such as an array. start_at: Chooses whether to split a large sweep into two runs that diverge away from some central value, to improve convergence. As an example, if you wanted to sweep from alpha=-20 to alpha=20, you might want to instead do two sweeps and stitch them together: 0 to 20, and 0 to -20. `start_at` can be either: * None, in which case the alpha inputs are run as a single sequence in the order given. * A float that corresponds to an angle of attack (in degrees), in which case the alpha inputs are split into two sequences that diverge from the `start_at` value. Successful runs are then sorted by `alpha` before returning. Returns: A dictionary with the XFoil results. Dictionary values are arrays; they may not be the same shape as your input array if some points did not converge. """ alphas = np.array(alpha).reshape(-1) if np.length(alphas) > 1: if start_at is not None: if np.min(alphas) < start_at < np.max(alphas): alphas = np.sort(alphas) alphas_upper = alphas[alphas > start_at] alphas_lower = alphas[alpha <= start_at][::-1] output = self._run_xfoil( "\n".join([f"a {a}" for a in alphas_upper] + ["init"] + [f"a {a}" for a in alphas_lower])) sort_order = np.argsort(output['alpha']) output = {k: v[sort_order] for k, v in output.items()} return output return self._run_xfoil("\n".join([f"a {a}" for a in alphas]))
def __init__(self, x_data: Union[np.ndarray, Dict[str, np.ndarray]], y_data: np.ndarray, x_data_resample: Union[int, Dict[str, Union[int, np.ndarray]]] = 10, resampling_interpolator: object = interpolate.RBFInterpolator, resampling_interpolator_kwargs: Dict[str, Any] = None, fill_value=np.NaN, # Default behavior: return NaN for all inputs outside data range. interpolated_model_kwargs: Dict[str, Any] = None, ): """ Creates the interpolator. Note that data must be unstructured (i.e., point cloud) for general N-dimensional interpolation. Note that if data is either 1D or structured, Args: x_data: Values of the dependent variable(s) in the dataset to be fitted. This is a dictionary; syntax is { var_name:var_data}. * If the model is one-dimensional (e.g. f(x1) instead of f(x1, x2, x3...)), you can instead supply x_data as a 1D ndarray. (If you do this, just treat `x` as an array in your model, not a dict.) y_data: Values of the independent variable in the dataset to be fitted. [1D ndarray of length n] x_data_resample: A parameter that guides how the x_data should be resampled onto a structured grid. * If this is an int, we look at each axis of the `x_data` (here, we'll call this `xi`), and we resample onto a linearly-spaced grid between `min(xi)` and `max(xi)` with `x_data_resample` points. * If this is a dict, it must be a dict where the keys are strings matching the keys of (the dictionary) `x_data`. The values can either be ints or 1D np.ndarrays. * If the values are ints, then that axis is linearly spaced between `min(xi)` and `max(xi)` with `x_data_resample` points. * If the values are 1D np.ndarrays, then those 1D np.ndarrays are used as the resampled spacing for the given axis. resampling_interpolator: Indicates the interpolator to use in order to resample the unstructured data onto a structured grid. Should be analogous to scipy.interpolate.RBFInterpolator in __init__ and __call__ syntax. See reference here: * https://docs.scipy.org/doc/scipy/reference/generated/scipy.interpolate.RBFInterpolator.html resampling_interpolator_kwargs: Indicates keyword arguments (keyword-value pairs, as a dictionary) to pass into the resampling interpolator. fill_value: Gives the value that the interpolator should return for points outside of the interpolation domain. The interpolation domain is defined as the hypercube bounded by the coordinates specified in `x_data_resample`. By default, these coordinates are the tightest axis-aligned hypercube that bounds the point cloud data. If fill_value is None, then the interpolator will attempt to extrapolate if the interpolation method allows. interpolated_model_kwargs: Indicates keyword arguments to pass into the (structured) InterpolatedModel. Also a dictionary. See aerosandbox.InterpolatedModel for documentation on possible inputs here. """ if resampling_interpolator_kwargs is None: resampling_interpolator_kwargs = {} if interpolated_model_kwargs is None: interpolated_model_kwargs = {} try: # Try to use the InterpolatedModel initializer. If it doesn't work, then move on. super().__init__( x_data_coordinates=x_data, y_data_structured=y_data, ) return except ValueError: pass # If it didn't work, this implies that x_data is multidimensional, and hence a dict-like object. Validate this. try: # Determine type of `x_data` x_data.keys() x_data.values() x_data.items() except AttributeError: raise TypeError("`x_data` must be a dict-like object!") # Make the interpolator, based on x_data and y_data. if resampling_interpolator == interpolate.RBFInterpolator: resampling_interpolator_kwargs = { "kernel": "thin_plate_spline", "degree": 1, **resampling_interpolator_kwargs } interpolator = resampling_interpolator( y=np.stack(tuple(x_data.values()), axis=1), d=y_data, **resampling_interpolator_kwargs ) # If x_data_resample is an int, make it into a dict that matches x_data. if isinstance(x_data_resample, int): x_data_resample = { k: x_data_resample for k in x_data.keys() } # Now, x_data_resample should be dict-like. Validate this. try: x_data_resample.keys() x_data_resample.values() x_data_resample.items() except AttributeError: raise TypeError("`x_data_resample` must be a dict-like object!") # Go through x_data_resample, and replace any values that are ints with linspaced arrays. for k, v in x_data_resample.items(): if isinstance(v, int): x_data_resample[k] = np.linspace( np.min(x_data[k]), np.max(x_data[k]), v ) x_data_coordinates: Dict = x_data_resample x_data_structured_values = [ xi.flatten() for xi in np.meshgrid(*x_data_coordinates.values(), indexing="ij") ] x_data_structured = { k: xi for k, xi in zip(x_data.keys(), x_data_structured_values) } y_data_structured = interpolator( np.stack(tuple(x_data_structured_values), axis=1) ) y_data_structured = y_data_structured.reshape([ np.length(xi) for xi in x_data_coordinates.values() ]) interpolated_model_kwargs = { "fill_value": fill_value, **interpolated_model_kwargs } super().__init__( x_data_coordinates=x_data_coordinates, y_data_structured=y_data_structured, **interpolated_model_kwargs, ) self.x_data_raw_unstructured = x_data self.y_data_raw = y_data
def test_min(): a = cas.SX([1, 2, 3]) b = [1, 2, 3] assert int(np.min(a)) == int(np.min(b))
def generate_polars( self, alphas=np.linspace(-15, 15, 21), Res=np.geomspace(1e4, 1e7, 10), cache_filename: str = None, xfoil_kwargs: Dict[str, Any] = None, unstructured_interpolated_model_kwargs: Dict[str, Any] = None, ) -> None: """ Generates airfoil polars (CL, CD, CM functions) and assigns them in-place to this Airfoil's polar functions. In other words, when this function is run, the following functions will be added (or overwritten) to the instance: * Airfoil.CL_function(alpha, Re, mach, deflection) * Airfoil.CD_function(alpha, Re, mach, deflection) * Airfoil.CM_function(alpha, Re, mach, deflection) Where alpha is in degrees. Right now, deflection is not used. Args: alphas: The range of alphas to sample from XFoil at. Res: The range of Reynolds numbers to sample from XFoil at. cache_filename: A path-like filename (ideally a "*.json" file) that can be used to cache the XFoil results, making it much faster to regenerate the results. xfoil_kwargs: Keyword arguments to pass into the AeroSandbox XFoil module. See the aerosandbox.XFoil constructor for options. unstructured_interpolated_model_kwargs: Keyword arguments to pass into the UnstructuredInterpolatedModels that contain the polars themselves. See the aerosandbox.UnstructuredInterpolatedModel constructor for options. Warning: In-place operation! Modifies this Airfoil object by setting Airfoil.CL_function, etc. to the new polars. Returns: None (in-place) """ if self.coordinates is None: raise ValueError( "Cannot generate polars for an airfoil that you don't have the coordinates of!" ) ### Set defaults if xfoil_kwargs is None: xfoil_kwargs = {} if unstructured_interpolated_model_kwargs is None: unstructured_interpolated_model_kwargs = {} xfoil_kwargs = { # See asb.XFoil for documentation on these. "verbose": False, "max_iter": 20, "xfoil_repanel": True, **xfoil_kwargs } unstructured_interpolated_model_kwargs = { # These were tuned heuristically as defaults! "resampling_interpolator_kwargs": { "degree": 0, # "kernel": "linear", "kernel": "multiquadric", "epsilon": 3, "smoothing": 0.01, # "kernel": "cubic" }, **unstructured_interpolated_model_kwargs } ### Retrieve XFoil Polar Data from cache, if it exists. data = None if cache_filename is not None: try: with open(cache_filename, "r") as f: data = {k: np.array(v) for k, v in json.load(f).items()} except FileNotFoundError: pass ### Analyze airfoil with XFoil, if needed if data is None: from aerosandbox.aerodynamics.aero_2D import XFoil def get_run_data( Re ): # Get the data for an XFoil alpha sweep at one specific Re. run_data = XFoil(airfoil=self, Re=Re, **xfoil_kwargs).alpha(alphas) run_data["Re"] = Re * np.ones_like(run_data["alpha"]) return run_data # Data is a dict where keys are figures of merit [str] and values are 1D ndarrays. from tqdm import tqdm run_datas = [ # Get a list of dicts, where each dict is the result of an XFoil run at a particular Re. get_run_data(Re) for Re in tqdm( Res, desc= f"Running XFoil to generate polars for Airfoil '{self.name}':", ) ] data = { # Merge the dicts into one big database of all runs. k: np.concatenate(tuple([run_data[k] for run_data in run_datas])) for k in run_datas[0].keys() } if cache_filename is not None: # Cache the accumulated data for later use, if it doesn't already exist. with open(cache_filename, "w+") as f: json.dump({k: v.tolist() for k, v in data.items()}, f, indent=4) ### Save the raw data as an instance attribute for later use self.xfoil_data = data ### Make the interpolators for attached aerodynamics from aerosandbox.modeling import UnstructuredInterpolatedModel alpha_resample = np.concatenate( [ np.array([-180, -150, -120, -90, -60, -30]), alphas[::2], np.array([30, 60, 90, 120, 150, 180]) ] ) # This is the list of points that we're going to resample from the XFoil runs for our InterpolatedModel, using an RBF. Re_resample = np.concatenate( [ np.array([1e0, 1e1, 1e2, 1e3]), Res, np.array([1e8, 1e9, 1e10, 1e11, 1e12]) ] ) # This is the list of points that we're going to resample from the XFoil runs for our InterpolatedModel, using an RBF. x_data = { "alpha": data["alpha"], "ln_Re": np.log(data["Re"]), } x_data_resample = { "alpha": alpha_resample, "ln_Re": np.log(Re_resample) } CL_attached_interpolator = UnstructuredInterpolatedModel( x_data=x_data, y_data=data["CL"], x_data_resample=x_data_resample, **unstructured_interpolated_model_kwargs) log10_CD_attached_interpolator = UnstructuredInterpolatedModel( x_data=x_data, y_data=np.log10(data["CD"]), x_data_resample=x_data_resample, **unstructured_interpolated_model_kwargs) CM_attached_interpolator = UnstructuredInterpolatedModel( x_data=x_data, y_data=data["CM"], x_data_resample=x_data_resample, **unstructured_interpolated_model_kwargs) ### Determine if separated alpha_stall_positive = np.max(data["alpha"]) # Across all Re alpha_stall_negative = np.min(data["alpha"]) # Across all Re def separation_parameter(alpha, Re=0): """ Positive if separated, negative if attached. This will be an input to a tanh() sigmoid blend via asb.numpy.blend(), so a value of 1 means the flow is ~90% separated, and a value of -1 means the flow is ~90% attached. """ return 0.5 * np.softmax(alpha - alpha_stall_positive, alpha_stall_negative - alpha) ### Make the interpolators for separated aerodynamics from aerosandbox.aerodynamics.aero_2D.airfoil_polar_functions import airfoil_coefficients_post_stall CL_if_separated, CD_if_separated, CM_if_separated = airfoil_coefficients_post_stall( airfoil=self, alpha=alpha_resample) CD_if_separated = CD_if_separated + np.median(data["CD"]) # The line above effectively ensures that separated CD will never be less than attached CD. Not exactly, but generally close. A good heuristic. CL_separated_interpolator = UnstructuredInterpolatedModel( x_data=alpha_resample, y_data=CL_if_separated) log10_CD_separated_interpolator = UnstructuredInterpolatedModel( x_data=alpha_resample, y_data=np.log10(CD_if_separated)) CM_separated_interpolator = UnstructuredInterpolatedModel( x_data=alpha_resample, y_data=CM_if_separated) def CL_function(alpha, Re, mach=0, deflection=0): alpha = np.mod(alpha + 180, 360) - 180 # Keep alpha in the valid range. CL_attached = CL_attached_interpolator({ "alpha": alpha, "ln_Re": np.log(Re), }) CL_separated = CL_separated_interpolator(alpha) return np.blend(separation_parameter(alpha, Re), CL_separated, CL_attached) def CD_function(alpha, Re, mach=0, deflection=0): alpha = np.mod(alpha + 180, 360) - 180 # Keep alpha in the valid range. log10_CD_attached = log10_CD_attached_interpolator({ "alpha": alpha, "ln_Re": np.log(Re), }) log10_CD_separated = log10_CD_separated_interpolator(alpha) return 10**np.blend( separation_parameter(alpha, Re), log10_CD_separated, log10_CD_attached, ) def CM_function(alpha, Re, mach=0, deflection=0): alpha = np.mod(alpha + 180, 360) - 180 # Keep alpha in the valid range. CM_attached = CM_attached_interpolator({ "alpha": alpha, "ln_Re": np.log(Re), }) CM_separated = CM_separated_interpolator(alpha) return np.blend(separation_parameter(alpha, Re), CM_separated, CM_attached) self.CL_function = CL_function self.CD_function = CD_function self.CM_function = CM_function
def contour( X: np.ndarray, Y: np.ndarray, Z: np.ndarray, levels: Union[int, List, np.ndarray] = 31, colorbar: bool = True, linelabels: bool = True, cmap=mpl.cm.get_cmap('viridis'), alpha: float = 0.7, extend: str = "both", linecolor="k", linewidths: float = 0.5, extendrect: bool = True, linelabels_format: Union[str, Callable[[float], str]] = eng_string, linelabels_fontsize: float = 8, colorbar_label: str = None, z_log_scale: bool = False, contour_kwargs: Dict = None, contourf_kwargs: Dict = None, colorbar_kwargs: Dict = None, linelabels_kwargs: Dict = None, **kwargs, ): """ An analogue for plt.contour and plt.tricontour and friends that produces a much prettier default graph. Can take inputs with either contour or tricontour syntax. See syntax here: https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.contour.html https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.contourf.html https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.tricontour.html https://matplotlib.org/stable/api/_as_gen/matplotlib.pyplot.tricontourf.html Args: X: See contour docs. Y: See contour docs. Z: See contour docs. levels: See contour docs. colorbar: Should we draw a colorbar? linelabels: Should we add line labels? cmap: What colormap should we use? alpha: What transparency should all plot elements be? extend: See contour docs. linecolor: What color should the line labels be? linewidths: See contour docs. extendrect: See colorbar docs. linelabels_format: See ax.clabel docs. linelabels_fontsize: See ax.clabel docs. contour_kwargs: Additional keyword arguments for contour. contourf_kwargs: Additional keyword arguments for contourf. colorbar_kwargs: Additional keyword arguments for colorbar. linelabels_kwargs: Additional keyword arguments for the line labels (ax.clabel). **kwargs: Additional keywords, which are passed to both contour and contourf. Returns: A tuple of (contour, contourf, colorbar) objects. """ if contour_kwargs is None: contour_kwargs = {} if contourf_kwargs is None: contourf_kwargs = {} if colorbar_kwargs is None: colorbar_kwargs = {} if linelabels_kwargs is None: linelabels_kwargs = {} args = [ X, Y, Z, ] shared_kwargs = kwargs if levels is not None: shared_kwargs["levels"] = levels if alpha is not None: shared_kwargs["alpha"] = alpha if extend is not None: shared_kwargs["extend"] = extend if z_log_scale: shared_kwargs = { "norm" : mpl.colors.LogNorm(), "locator": mpl.ticker.LogLocator( subs=np.geomspace(1, 10, 4 + 1)[:-1] ), **shared_kwargs } if np.min(Z) <= 0: import warnings warnings.warn( "Warning: All values of the `Z` input to `contour()` should be nonnegative if `z_log_scale` is True!", stacklevel=2 ) Z = np.maximum(Z, 1e-300) # Make all values nonnegative if colorbar_label is not None: colorbar_kwargs["label"] = colorbar_label contour_kwargs = { "colors" : linecolor, "linewidths": linewidths, **shared_kwargs, **contour_kwargs } contourf_kwargs = { "cmap": cmap, **shared_kwargs, **contourf_kwargs } colorbar_kwargs = { "extendrect": extendrect, **colorbar_kwargs } linelabels_kwargs = { "inline" : 1, "fontsize": linelabels_fontsize, "fmt" : linelabels_format, **linelabels_kwargs } try: cont = plt.contour(*args, **contour_kwargs) contf = plt.contourf(*args, **contourf_kwargs) except TypeError as e: try: cont = plt.tricontour(*args, **contour_kwargs) contf = plt.tricontourf(*args, **contourf_kwargs) except TypeError: raise e if colorbar: cbar = plt.colorbar(**colorbar_kwargs) if z_log_scale: cbar.ax.yaxis.set_major_locator(mpl.ticker.LogLocator()) cbar.ax.yaxis.set_major_formatter(mpl.ticker.LogFormatter()) else: cbar = None if linelabels: plt.gca().clabel(cont, **linelabels_kwargs) return cont, contf, cbar