Ejemplo n.º 1
0
    def max_thickness(
        self, x_over_c_sample: np.ndarray = np.linspace(0, 1, 101)) -> float:
        """
        Returns the maximum thickness of the airfoil.

        Args:
            x_over_c_sample: Where should the airfoil be sampled to determine the max thickness?

        Returns: The maximum thickness, as a fraction of chord.

        """
        return np.max(self.local_thickness(x_over_c=x_over_c_sample))
Ejemplo n.º 2
0
        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)
Ejemplo n.º 3
0
def test_block_move_fixed_time():
    opti = asb.Opti()

    n_timesteps = 300

    time = np.linspace(0, 1, n_timesteps)

    dyn = asb.DynamicsPointMass1DHorizontal(
        mass_props=asb.MassProperties(mass=1),
        x_e=opti.variable(init_guess=np.linspace(0, 1, n_timesteps)),
        u_e=opti.variable(init_guess=1, n_vars=n_timesteps),
    )

    u = opti.variable(init_guess=np.linspace(1, -1, n_timesteps))

    dyn.add_force(
        Fx=u
    )

    dyn.constrain_derivatives(
        opti=opti,
        time=time
    )

    opti.subject_to([
        dyn.x_e[0] == 0,
        dyn.x_e[-1] == 1,
        dyn.u_e[0] == 0,
        dyn.u_e[-1] == 0,
    ])

    # effort = np.sum(
    #     np.trapz(dyn.X ** 2) * np.diff(time)
    # )

    effort = np.sum(  # More sophisticated integral-of-squares integration (closed form correct)
        np.diff(time) / 3 *
        (u[:-1] ** 2 + u[:-1] * u[1:] + u[1:] ** 2)
    )

    opti.minimize(effort)

    sol = opti.solve()

    dyn.substitute_solution(sol)

    assert dyn.x_e[0] == pytest.approx(0)
    assert dyn.x_e[-1] == pytest.approx(1)
    assert dyn.u_e[0] == pytest.approx(0)
    assert dyn.u_e[-1] == pytest.approx(0)
    assert np.max(dyn.u_e) == pytest.approx(1.5, abs=0.01)
    assert sol.value(u)[0] == pytest.approx(6, abs=0.05)
    assert sol.value(u)[-1] == pytest.approx(-6, abs=0.05)
Ejemplo n.º 4
0
    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
Ejemplo n.º 5
0
def test_block_move_minimum_time():
    opti = asb.Opti()

    n_timesteps = 300

    time = np.linspace(
        0,
        opti.variable(init_guess=1, lower_bound=0),
        n_timesteps,
    )

    dyn = asb.DynamicsPointMass1DHorizontal(
        mass_props=asb.MassProperties(mass=1),
        x_e=opti.variable(init_guess=np.linspace(0, 1, n_timesteps)),
        u_e=opti.variable(init_guess=1, n_vars=n_timesteps),
    )

    u = opti.variable(init_guess=np.linspace(1, -1, n_timesteps), lower_bound=-1, upper_bound=1)

    dyn.add_force(
        Fx=u
    )

    dyn.constrain_derivatives(
        opti=opti,
        time=time
    )

    opti.subject_to([
        dyn.x_e[0] == 0,
        dyn.x_e[-1] == 1,
        dyn.u_e[0] == 0,
        dyn.u_e[-1] == 0,
    ])

    opti.minimize(
        time[-1]
    )

    sol = opti.solve()

    dyn.substitute_solution(sol)

    assert dyn.x_e[0] == pytest.approx(0)
    assert dyn.x_e[-1] == pytest.approx(1)
    assert dyn.u_e[0] == pytest.approx(0)
    assert dyn.u_e[-1] == pytest.approx(0)
    assert np.max(dyn.u_e) == pytest.approx(1, abs=0.01)
    assert sol.value(u)[0] == pytest.approx(1, abs=0.05)
    assert sol.value(u)[-1] == pytest.approx(-1, abs=0.05)
    assert np.mean(np.abs(sol.value(u))) == pytest.approx(1, abs=0.01)
Ejemplo n.º 6
0
 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
Ejemplo n.º 7
0
    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]))
Ejemplo n.º 8
0
    def goodness_of_fit(self, type="R^2"):
        """
        Returns a metric of the goodness of the fit.

        Args:

            type: Type of metric to use for goodness of fit. One of:

                * "R^2": The coefficient of determination. Strictly speaking only mathematically rigorous to use this
                for linear fits.

                    https://en.wikipedia.org/wiki/Coefficient_of_determination

                * "deviation" or "Linf": The maximum deviation of the fit from any of the data points.

        Returns: The metric of the goodness of the fit.

        """
        if type == "R^2":

            y_mean = np.mean(self.y_data)

            SS_tot = np.sum((self.y_data - y_mean)**2)

            y_model = self(self.x_data)

            SS_res = np.sum((self.y_data - y_model)**2)

            R_squared = 1 - SS_res / SS_tot

            return R_squared

        elif type == "deviation" or type == "Linf":
            return np.max(np.abs(self.y_data - self(self.x_data)))

        else:
            raise ValueError("Bad value of `type`!")
Ejemplo n.º 9
0
    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
Ejemplo n.º 10
0
def test_max():
    a = cas.SX([1, 2, 3])
    b = [1, 2, 3]

    assert int(np.max(a)) == int(np.max(b))
Ejemplo n.º 11
0
    def draw(
        self,
        vehicle_model: Airplane = None,
        backend: str = "pyvista",
        draw_axes: bool = True,
        scale_vehicle_model: Union[float, None] = None,
        n_vehicles_to_draw: int = 10,
        cg_axes: str = "geometry",
        show: bool = True,
    ):
        if backend == "pyvista":
            import pyvista as pv
            import aerosandbox.tools.pretty_plots as p

            if vehicle_model is None:
                default_vehicle_stl = _asb_root / "dynamics/visualization/default_assets/yf23.stl"
                vehicle_model = pv.read(str(default_vehicle_stl))
            elif isinstance(vehicle_model, pv.PolyData):
                pass
            elif isinstance(vehicle_model, Airplane):
                vehicle_model = vehicle_model.draw(backend="pyvista",
                                                   show=False)
                vehicle_model.rotate_y(
                    180)  # Rotate from geometry axes to body axes.
            elif isinstance(
                    vehicle_model, str
            ):  # Interpret the string as a filepath to a .stl or similar
                try:
                    pv.read(filename=vehicle_model)
                except:
                    raise ValueError("Could not parse `vehicle_model`!")
            else:
                raise TypeError(
                    "`vehicle_model` should be an Airplane or PolyData object."
                )

            x_e = np.array(self.x_e)
            y_e = np.array(self.y_e)
            z_e = np.array(self.z_e)
            if np.length(x_e) == 1:
                x_e = x_e * np.ones(len(self))
            if np.length(y_e) == 1:
                y_e = y_e * np.ones(len(self))
            if np.length(z_e) == 1:
                z_e = z_e * np.ones(len(self))

            if scale_vehicle_model is None:
                trajectory_bounds = np.array([
                    [x_e.min(), x_e.max()],
                    [y_e.min(), y_e.max()],
                    [z_e.min(), z_e.max()],
                ])
                trajectory_size = np.max(np.diff(trajectory_bounds, axis=1))

                vehicle_bounds = np.array(vehicle_model.bounds).reshape((3, 2))
                vehicle_size = np.max(np.diff(vehicle_bounds, axis=1))

                scale_vehicle_model = 0.1 * trajectory_size / vehicle_size

            ### Initialize the plotter
            plotter = pv.Plotter()

            # Set the window title
            title = "ASB Dynamics"
            addenda = []
            if scale_vehicle_model != 1:
                addenda.append(
                    f"Vehicle drawn at {scale_vehicle_model:.2g}x scale")
            addenda.append(f"{self.__class__.__name__} Engine")
            if len(addenda) != 0:
                title = title + f" ({'; '.join(addenda)})"
            plotter.title = title

            # Draw axes and grid
            plotter.add_axes()
            plotter.show_grid(color='gray')

            ### Draw the vehicle
            for i in np.unique(
                    np.round(np.linspace(0,
                                         len(self) - 1,
                                         n_vehicles_to_draw))).astype(int):
                dyn = self[i]
                try:
                    phi = dyn.phi
                except AttributeError:
                    phi = dyn.bank
                try:
                    theta = dyn.theta
                except AttributeError:
                    theta = dyn.gamma
                try:
                    psi = dyn.psi
                except AttributeError:
                    psi = dyn.track

                x_cg_b, y_cg_b, z_cg_b = dyn.convert_axes(dyn.mass_props.x_cg,
                                                          dyn.mass_props.y_cg,
                                                          dyn.mass_props.z_cg,
                                                          from_axes=cg_axes,
                                                          to_axes="body")

                this_vehicle = copy.deepcopy(vehicle_model)
                this_vehicle.translate([
                    -x_cg_b,
                    -y_cg_b,
                    -z_cg_b,
                ],
                                       inplace=True)
                this_vehicle.points *= scale_vehicle_model
                this_vehicle.rotate_x(np.degrees(phi), inplace=True)
                this_vehicle.rotate_y(np.degrees(theta), inplace=True)
                this_vehicle.rotate_z(np.degrees(psi), inplace=True)
                this_vehicle.translate([
                    dyn.x_e,
                    dyn.y_e,
                    dyn.z_e,
                ],
                                       inplace=True)
                plotter.add_mesh(this_vehicle, )
                if draw_axes:
                    rot = np.rotation_matrix_from_euler_angles(phi, theta, psi)
                    axes_scale = 0.5 * np.max(
                        np.diff(np.array(this_vehicle.bounds).reshape((3, -1)),
                                axis=1))
                    origin = np.array([
                        dyn.x_e,
                        dyn.y_e,
                        dyn.z_e,
                    ])
                    for i, c in enumerate(["r", "g", "b"]):
                        plotter.add_mesh(
                            pv.Spline(
                                np.array(
                                    [origin,
                                     origin + rot[:, i] * axes_scale])),
                            color=c,
                            line_width=2.5,
                        )

            for i in range(len(self)):
                ### Draw the trajectory line

                polyline = pv.Spline(np.array([x_e, y_e, z_e]).T)
                plotter.add_mesh(
                    polyline,
                    color=p.adjust_lightness(p.palettes["categorical"][0],
                                             1.2),
                    line_width=3,
                )

            ### Finalize the plotter
            plotter.camera.up = (0, 0, -1)
            plotter.camera.Azimuth(90)
            plotter.camera.Elevation(60)
            if show:
                plotter.show()
            return plotter
Ejemplo n.º 12
0
    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