Esempio n. 1
0
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
Esempio n. 2
0
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 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)
Esempio n. 4
0
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
Esempio n. 5
0
def Cl_rae2822(alpha, Re_c):
    # A curve fit I did to a RAE2822 airfoil, 2D XFoil data. Incompressible flow.
    # Within -2 < alpha < 12 and 10^4 < Re_c < 10^6, has R^2 = 0.9857
    # Likely valid from -6 < alpha < 12 and 10^4 < Re_c < 10^6.
    # See: C:\Projects\GitHub\firefly_aerodynamics\Gists and Ideas\XFoil Drag Fitting\rae2822

    Re_c = np.fmax(Re_c, 1)
    log10_Re = np.log10(Re_c)

    # Coeffs
    a1l = 5.5686866813855172e-02
    a1t = 9.7472055628494134e-02
    a4l = -7.2145733312046152e-09
    a4t = -3.6886704372829236e-06
    atr = 8.3723547264375520e-01
    atr2 = -8.3128119739031697e-02
    c0l = -4.9103908291438701e-02
    c0t = 2.3903424824298553e-01
    ctr = 1.3082854754897108e+01
    rtr = 2.6963082864300731e+00

    a = alpha
    r = log10_Re

    Cl = (c0t + a1t * a + a4t * a**4) * 1 / (
        1 + np.exp(ctr - rtr * r - atr * a - atr2 * a**2)) + (
            c0l + a1l * a +
            a4l * a**4) * (1 - 1 /
                           (1 + np.exp(ctr - rtr * r - atr * a - atr2 * a**2)))

    return Cl
Esempio n. 6
0
def Cd_profile_e216(alpha, Re_c):
    # A curve fit I did to a Eppler 216 (e216) airfoil, 2D XFoil data. Incompressible flow.
    # Within -2 < alpha < 12 and 10^4 < Re_c < 10^6, has R^2 = 0.9995
    # Likely valid from -6 < alpha < 12 and 10^4 < Re_c < 10^6.
    # see: C:\Projects\GitHub\firefly_aerodynamics\Gists and Ideas\XFoil Drag Fitting\e216

    Re_c = np.fmax(Re_c, 1)
    log10_Re = np.log10(Re_c)

    # Coeffs
    a1l = 4.7167470806940448e-02
    a1t = 7.5663005080888857e-02
    a2l = 8.7552076545610764e-04
    a4t = 1.1220763679805319e-05
    atr = 4.2456038382581129e-01
    c0l = -1.4099657419753771e+00
    c0t = -2.3855286371940609e+00
    ctr = 9.1474872611212135e+01
    rtr = 3.0218483612170434e+01
    rtr2 = -2.4515094313899279e+00

    a = alpha
    r = log10_Re

    log10_Cd = (c0t + a1t * a + a4t * a**4) * 1 / (
        1 + np.exp(ctr - rtr * r - atr * a - rtr2 * r**2)) + (
            c0l + a1l * a +
            a2l * a**2) * (1 - 1 /
                           (1 + np.exp(ctr - rtr * r - atr * a - rtr2 * r**2)))

    Cd = 10**log10_Cd

    return Cd
Esempio n. 7
0
def Cl_e216(alpha, Re_c):
    # A curve fit I did to a Eppler 216 (e216) airfoil, 2D XFoil data. Incompressible flow.
    # Within -2 < alpha < 12 and 10^4 < Re_c < 10^6, has R^2 = 0.9994
    # Likely valid from -6 < alpha < 12 and 10^4 < Re_c < Inf.
    # See: C:\Projects\GitHub\firefly_aerodynamics\Gists and Ideas\XFoil Drag Fitting\e216

    Re_c = np.fmax(Re_c, 1)
    log10_Re = np.log10(Re_c)

    # Coeffs
    a1l = 3.0904412662858878e-02
    a1t = 9.6452654383488254e-02
    a4t = -2.5633334023068302e-05
    asl = 6.4175433185427011e-01
    atr = 3.6775107602844948e-01
    c0l = -2.5909363461176749e-01
    c0t = 8.3824440586718862e-01
    ctr = 1.1431810545735890e+02
    ksl = 5.3416670116733611e-01
    rtr = 3.9713338634462829e+01
    rtr2 = -3.3634858542657771e+00
    xsl = -1.2220899840236835e-01

    a = alpha
    r = log10_Re

    Cl = (c0t + a1t * a + a4t * a**4) * 1 / (
        1 + np.exp(ctr - rtr * r - atr * a - rtr2 * r**2)) + (
            c0l + a1l * a + asl / (1 + np.exp(-ksl * (a - xsl)))) * (
                1 - 1 / (1 + np.exp(ctr - rtr * r - atr * a - rtr2 * r**2)))

    return Cl
Esempio n. 8
0
def Cd_profile_rae2822(alpha, Re_c):
    # A curve fit I did to a RAE2822 airfoil, 2D XFoil data. Incompressible flow.
    # Within -2 < alpha < 12 and 10^4 < Re_c < 10^6, has R^2 = 0.9995
    # Likely valid from -6 < alpha < 12 and 10^4 < Re_c < Inf.
    # see: C:\Projects\GitHub\firefly_aerodynamics\Gists and Ideas\XFoil Drag Fitting\e216

    Re_c = np.fmax(Re_c, 1)
    log10_Re = np.log10(Re_c)

    # Coeffs
    at = 8.1034027621509015e+00
    c0l = -8.4296746456429639e-01
    c0t = -1.3700609138855402e+00
    kart = -4.1609994062600880e-01
    kat = 5.9510959342452441e-01
    krt = -7.1938030052506197e-01
    r1l = 1.1548628822014631e-01
    r1t = -4.9133662875044504e-01
    rt = 5.0070459892411696e+00

    a = alpha
    r = log10_Re

    log10_Cd = (c0t + r1t * (r - 4)) * (
            1 / (1 + np.exp(kat * (a - at) + krt * (r - rt) + kart * (a - at) * (r - rt)))) + (
                       c0l + r1l * (r - 4)) * (
                       1 - 1 / (1 + np.exp(kat * (a - at) + krt * (r - rt) + kart * (a - at) * (r - rt))))

    Cd = 10 ** log10_Cd

    return Cd
Esempio n. 9
0
def barometric_formula(
    P_b,
    T_b,
    L_b,
    h,
    h_b,
):
    """
    The barometric pressure equation, from here: https://en.wikipedia.org/wiki/Barometric_formula
    Args:
        P_b: Pressure at the base of the layer, in Pa
        T_b: Temperature at the base of the layer, in K
        L_b: Temperature lapse rate, in K/m
        h: Altitude, in m
        h_b:

    Returns:

    """
    with np.errstate(divide="ignore", over="ignore"):
        T = T_b + L_b * (h - h_b)
        T = np.fmax(T,
                    0)  # Keep temperature nonnegative, no matter the inputs.
        if L_b != 0:
            return P_b * (T / T_b)**(-g / (gas_constant_air * L_b))
        else:
            return P_b * np.exp(-g * (h - h_b) / (gas_constant_air * T_b))
Esempio n. 10
0
def airfoil_CL(alpha, Re, Ma):
    alpha_rad = alpha * pi / 180
    beta = (1 - Ma ** 2) ** 0.5
    cl_0 = 0.5
    cl_alpha = 5.8
    cl_min = -0.3
    cl_max = 1.2
    cl = (alpha_rad * cl_alpha + cl_0) / beta
    Cl = np.fmin(np.fmax(cl, cl_min), cl_max)
    return Cl
Esempio n. 11
0
def burn_rate_coefficient(oxamide_fraction):
    """Burn rate vs oxamide content model.
    Valid from 0% to 15% oxamide. # TODO IMPLEMENT THIS

    Returns:
        a: propellant burn rate coefficient
            [units: pascal**(-n) meter second**-1].
    """
    oxamide_fraction = np.fmax(oxamide_fraction, 0)

    return a_0 * (1 - oxamide_fraction) / (1 + lamb * oxamide_fraction)
Esempio n. 12
0
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
Esempio n. 13
0
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
Esempio n. 14
0
def smoothmax(value1, value2, hardness):
    """
    A smooth maximum between two functions. Also referred to as the logsumexp() function.
    Useful because it's differentiable and preserves convexity!
    Great writeup by John D Cook here:
        https://www.johndcook.com/soft_maximum.pdf
    :param value1: Value of function 1.
    :param value2: Value of function 2.
    :param hardness: Hardness parameter. Higher values make this closer to max(x1, x2).
    :return: Soft maximum of the two supplied values.
    """
    value1 = value1 * hardness
    value2 = value2 * hardness
    max = np.fmax(value1, value2)
    min = np.fmin(value1, value2)
    out = max + np.log(1 + np.exp(min - max))
    out /= hardness
    return out
Esempio n. 15
0
def solar_elevation_angle(latitude, day_of_year, time):
    """
    Elevation 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 elevation angle [degrees] (angle between horizon and sun). Returns 0 if the sun is below the horizon.
    """

    # Solar elevation angle (including seasonality, latitude, and time of day)
    # Source: https://www.pveducation.org/pvcdrom/properties-of-sunlight/elevation-angle
    declination = declination_angle(day_of_year)

    solar_elevation_angle = np.arcsind(
        np.sind(declination) * np.sind(latitude) +
        np.cosd(declination) * np.cosd(latitude) * np.cosd(time / 86400 * 360)
    )  # in degrees
    solar_elevation_angle = np.fmax(solar_elevation_angle, 0)
    return solar_elevation_angle
Esempio n. 16
0
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
Esempio n. 17
0
def Cd_profile_2412(alpha, Re_c):
    # A curve fit I did to a NACA 2412 airfoil in incompressible flow.
    # Within -2 < alpha < 12 and 10^5 < Re_c < 10^7, has R^2 = 0.9713

    Re_c = np.fmax(Re_c, 1)
    log_Re = np.log(Re_c)

    CD0 = -5.249
    Re0 = 15.61
    Re1 = 15.31
    alpha0 = 1.049
    alpha1 = -4.715
    cx = 0.009528
    cxy = -0.00588
    cy = 0.04838

    log_CD = CD0 + cx * (alpha - alpha0) ** 2 + cy * (log_Re - Re0) ** 2 + cxy * (alpha - alpha1) * (
            log_Re - Re1)  # basically, a rotated paraboloid in logspace
    CD = np.exp(log_CD)

    return CD
Esempio n. 18
0
def softmax(*args, hardness=1):
    """
    An element-wise softmax between two or more arrays. Also referred to as the logsumexp() function.

    Useful for optimization because it's differentiable and preserves convexity!

    Great writeup by John D Cook here:
        https://www.johndcook.com/soft_maximum.pdf

    Args:
        Provide any number of arguments as values to take the softmax of.

        hardness: Hardness parameter. Higher values make this closer to max(x1, x2).

    Returns:
        Soft maximum of the supplied values.
    """
    if hardness <= 0:
        raise ValueError("The value of `hardness` must be positive.")

    if len(args) <= 1:
        raise ValueError("You must call softmax with the value of two or more arrays that you'd like to take the "
                         "element-wise softmax of.")

    ### Scale the args by hardness
    args = [arg * hardness for arg in args]

    ### Find the element-wise max and min of the arrays:
    min = args[0]
    max = args[0]
    for arg in args[1:]:
        min = _np.fmin(min, arg)
        max = _np.fmax(max, arg)

    out = max + _np.log(sum(
            [_np.exp(array - max) for array in args]
        )
    )
    out = out / hardness
    return out
def _calculate_induced_velocity_line_singularity(
    x_field: Union[float, np.ndarray],
    y_field: Union[float, np.ndarray],
    x_panel_start: float,
    y_panel_start: float,
    x_panel_end: float,
    y_panel_end: float,
    gamma_start: float = 0.,
    gamma_end: float = 0.,
    sigma_start: float = 0.,
    sigma_end: float = 0.,
) -> [Union[float, np.ndarray], Union[float, np.ndarray]]:
    """
    Calculates the induced velocity at a point (x_field, y_field) in a 2D potential-flow flowfield.

    In this flowfield, there is only one singularity element: # TODO update paragraph
    A line vortex going from (x_panel_start, y_panel_start) to (x_panel_end, y_panel_end).
    The strength of this vortex varies linearly from:
        * gamma_start at (x_panel_start, y_panel_start), to:
        * gamma_end at (x_panel_end, y_panel_end).

    By convention here, positive gamma induces clockwise swirl in the flow field.

    Function returns the 2D velocity u, v in the global coordinate system (x, y).

    Inputs x and y can be 1D ndarrays representing various field points,
    in which case the resulting velocities u and v have the corresponding dimensionality.

    """
    ### Calculate the panel coordinate transform (x -> xp, y -> yp), where
    panel_dx = x_panel_end - x_panel_start
    panel_dy = y_panel_end - y_panel_start
    panel_length = (panel_dx**2 + panel_dy**2)**0.5

    panel_length = np.fmax(panel_length, 1e-16)

    xp_hat_x = panel_dx / panel_length  # x-coordinate of the xp_hat vector
    xp_hat_y = panel_dy / panel_length  # y-coordinate of the yp_hat vector

    yp_hat_x = -xp_hat_y
    yp_hat_y = xp_hat_x

    ### Transform the field points in to panel coordinates
    x_field_relative = x_field - x_panel_start
    y_field_relative = y_field - y_panel_start

    xp_field = x_field_relative * xp_hat_x + y_field_relative * xp_hat_y  # dot product with the xp unit vector
    yp_field = x_field_relative * yp_hat_x + y_field_relative * yp_hat_y  # dot product with the xp unit vector

    ### Do the vortex math
    up, vp = _calculate_induced_velocity_line_singularity_panel_coordinates(
        xp_field=xp_field,
        yp_field=yp_field,
        gamma_start=gamma_start,
        gamma_end=gamma_end,
        sigma_start=sigma_start,
        sigma_end=sigma_end,
        xp_panel_end=panel_length,
    )

    ### Transform the velocities in panel coordinates back to global coordinates
    u = up * xp_hat_x + vp * yp_hat_x
    v = up * xp_hat_y + vp * yp_hat_y

    ### Return
    return u, v
Esempio n. 20
0
    def __init__(
        self,
        model: Callable[
            [Union[np.ndarray,
                   Dict[str, np.ndarray]], Dict[str, float]], np.ndarray],
        x_data: Union[np.ndarray, Dict[str, np.ndarray]],
        y_data: np.ndarray,
        parameter_guesses: Dict[str, float],
        parameter_bounds: Dict[str, tuple] = None,
        residual_norm_type: str = "L2",
        fit_type: str = "best",
        weights: np.ndarray = None,
        put_residuals_in_logspace: bool = False,
        verbose=True,
    ):
        """
        Fits an analytical model to n-dimensional unstructured data using an automatic-differentiable optimization approach.

        Args:

            model: The model that you want to fit your dataset to. This is a callable with syntax f(x, p) where:

                * x is a dict of dependent variables. Same format as x_data [dict of 1D ndarrays of length n].

                    * If the model is one-dimensional (e.g. f(x1) instead of f(x1, x2, x3...)), you can instead interpret x
                    as a 1D ndarray. (If you do this, just give `x_data` as an array.)

                * p is a dict of parameters. Same format as param_guesses [dict with syntax param_name:param_value].

                Model should return a 1D ndarray of length n.

                Basically, if you've done it right:
                >>> model(x_data, parameter_guesses)
                should evaluate to a 1D ndarray where each x_data is mapped to something analogous to y_data. (The fit
                will likely be bad at this point, because we haven't yet optimized on param_guesses - but the types
                should be happy.)

                Model should use aerosandbox.numpy operators.

                The model is not allowed to make any in-place changes to the input `x`. The most common way this
                manifests itself is if someone writes something to the effect of `x += 3` or similar. Instead, write `x =
                x + 3`.

            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]

            parameter_guesses: a dict of fit parameters. Syntax is {param_name:param_initial_guess}.

                * Parameters will be initialized to the values set here; all parameters need an initial guess.

                * param_initial_guess is a float; note that only scalar parameters are allowed.

            parameter_bounds: Optional: a dict of bounds on fit parameters. Syntax is {"param_name":(min, max)}.

                * May contain only a subset of param_guesses if desired.

                * Use None to represent one-sided constraints (i.e. (None, 5)).

            residual_norm_type: What error norm should we minimize to optimize the fit parameters? Options:

                * "L1": minimize the L1 norm or sum(abs(error)). Less sensitive to outliers.

                * "L2": minimize the L2 norm, also known as the Euclidian norm, or sqrt(sum(error ** 2)). The default.

                * "Linf": minimize the L_infinty norm or max(abs(error)). More sensitive to outliers.

            fit_type: Should we find the model of best fit (i.e. the model that minimizes the specified residual norm),
            or should we look for a model that represents an upper/lower bound on the data (useful for robust surrogate
            modeling, so that you can put bounds on modeling error):

                * "best": finds the model of best fit. Usually, this is what you want.

                * "upper bound": finds a model that represents an upper bound on the data (while still trying to minimize
                the specified residual norm).

                * "lower bound": finds a model that represents a lower bound on the data (while still trying to minimize
                the specified residual norm).

            weights: Optional: weights for data points. If not supplied, weights are assumed to be uniform.

                * Weights are automatically normalized. [1D ndarray of length n]

            put_residuals_in_logspace: Whether to optimize using the logarithmic error as opposed to the absolute error
            (useful for minimizing percent error).

            Note: If any model outputs or data are negative, this will raise an error!

            verbose: Should the progress of the optimization solve that is part of the fitting be displayed? See
            `aerosandbox.Opti.solve(verbose=)` syntax for more details.

        Returns: A model in the form of a FittedModel object. Some things you can do:
            >>> y = FittedModel(x) # evaluate the FittedModel at new x points
            >>> FittedModel.parameters # directly examine the optimal values of the parameters that were found
            >>> FittedModel.plot() # plot the fit


        """
        super().__init__()

        ##### Prepare all inputs, check types/sizes.

        ### Flatten all inputs
        def flatten(input):
            return np.array(input).flatten()

        try:
            x_data = {k: flatten(v) for k, v in x_data.items()}
            x_data_is_dict = True
        except AttributeError:  # If it's not a dict or dict-like, assume it's a 1D ndarray dataset
            x_data = flatten(x_data)
            x_data_is_dict = False
        y_data = flatten(y_data)
        n_datapoints = np.length(y_data)

        ### Handle weighting
        if weights is None:
            weights = np.ones(n_datapoints)
        else:
            weights = flatten(weights)
        sum_weights = np.sum(weights)
        if sum_weights <= 0:
            raise ValueError("The weights must sum to a positive number!")
        if np.any(weights < 0):
            raise ValueError(
                "No entries of the weights vector are allowed to be negative!")
        weights = weights / np.sum(
            weights)  # Normalize weights so that they sum to 1.

        ### Check format of parameter_bounds input
        if parameter_bounds is None:
            parameter_bounds = {}
        for param_name, v in parameter_bounds.items():
            if param_name not in parameter_guesses.keys():
                raise ValueError(
                    f"A parameter name (key = \"{param_name}\") in parameter_bounds was not found in parameter_guesses."
                )
            if not np.length(v) == 2:
                raise ValueError(
                    "Every value in parameter_bounds must be a tuple in the format (lower_bound, upper_bound). "
                    "For one-sided bounds, use None for the unbounded side.")

        ### If putting residuals in logspace, check positivity
        if put_residuals_in_logspace:
            if not np.all(y_data > 0):
                raise ValueError(
                    "You can't fit a model with residuals in logspace if y_data is not entirely positive!"
                )

        ### Check dimensionality of inputs to fitting algorithm
        relevant_inputs = {
            "y_data": y_data,
            "weights": weights,
        }
        try:
            relevant_inputs.update(x_data)
        except TypeError:
            relevant_inputs.update({"x_data": x_data})

        for key, value in relevant_inputs.items():
            # Check that the length of the inputs are consistent
            series_length = np.length(value)
            if not series_length == n_datapoints:
                raise ValueError(
                    f"The supplied data series \"{key}\" has length {series_length}, but y_data has length {n_datapoints}."
                )

        ##### Formulate and solve the fitting optimization problem

        ### Initialize an optimization environment
        opti = Opti()

        ### Initialize the parameters as optimization variables
        params = {}
        for param_name, param_initial_guess in parameter_guesses.items():
            if param_name in parameter_bounds:
                params[param_name] = opti.variable(
                    init_guess=param_initial_guess,
                    lower_bound=parameter_bounds[param_name][0],
                    upper_bound=parameter_bounds[param_name][1],
                )
            else:
                params[param_name] = opti.variable(
                    init_guess=param_initial_guess, )

        ### Evaluate the model at the data points you're trying to fit
        x_data_original = copy.deepcopy(
            x_data
        )  # Make a copy of x_data so that you can determine if the model did in-place operations on x and tattle on the user.

        try:
            y_model = model(x_data, params)  # Evaluate the model
        except Exception:
            raise Exception("""
            There was an error when evaluating the model you supplied with the x_data you supplied.
            Likely possible causes:
                * Your model() does not have the call syntax model(x, p), where x is the x_data and p are parameters.
                * Your model should take in p as a dict of parameters, but it does not.
                * Your model assumes x is an array-like but you provided x_data as a dict, or vice versa.
            See the docstring of FittedModel() if you have other usage questions or would like to see examples.
            """)

        try:  ### If the model did in-place operations on x_data, throw an error
            x_data_is_unchanged = np.all(x_data == x_data_original)
        except ValueError:
            x_data_is_unchanged = np.all([
                x_series == x_series_original
                for x_series, x_series_original in zip(x_data, x_data_original)
            ])
        if not x_data_is_unchanged:
            raise TypeError(
                "model(x_data, parameter_guesses) did in-place operations on x, which is not allowed!"
            )
        if y_model is None:  # Make sure that y_model actually returned something sensible
            raise TypeError(
                "model(x_data, parameter_guesses) returned None, when it should've returned a 1D ndarray."
            )

        ### Compute how far off you are (error)
        if not put_residuals_in_logspace:
            error = y_model - y_data
        else:
            y_model = np.fmax(
                y_model, 1e-300
            )  # Keep y_model very slightly always positive, so that log() doesn't NaN.
            error = np.log(y_model) - np.log(y_data)

        ### Set up the optimization problem to minimize some norm(error), which looks different depending on the norm used:
        if residual_norm_type.lower() == "l1":  # Minimize the L1 norm
            abs_error = opti.variable(init_guess=0, n_vars=np.length(
                y_data))  # Make the abs() of each error entry an opt. var.
            opti.subject_to([
                abs_error >= error,
                abs_error >= -error,
            ])
            opti.minimize(np.sum(weights * abs_error))

        elif residual_norm_type.lower() == "l2":  # Minimize the L2 norm
            opti.minimize(np.sum(weights * error**2))

        elif residual_norm_type.lower(
        ) == "linf":  # Minimize the L-infinity norm
            linf_value = opti.variable(
                init_guess=0
            )  # Make the value of the L-infinity norm an optimization variable
            opti.subject_to([
                linf_value >= weights * error, linf_value >= -weights * error
            ])
            opti.minimize(linf_value)

        else:
            raise ValueError("Bad input for the 'residual_type' parameter.")

        ### Add in the constraints specified by fit_type, which force the model to stay above / below the data points.
        if fit_type == "best":
            pass
        elif fit_type == "upper bound":
            opti.subject_to(y_model >= y_data)
        elif fit_type == "lower bound":
            opti.subject_to(y_model <= y_data)
        else:
            raise ValueError("Bad input for the 'fit_type' parameter.")

        ### Solve
        sol = opti.solve(verbose=verbose)

        ##### Construct a FittedModel

        ### Create a vector of solved parameters
        params_solved = {}
        for param_name in params:
            try:
                params_solved[param_name] = sol.value(params[param_name])
            except:
                params_solved[param_name] = np.NaN

        ### Store all the data and inputs
        self.model = model
        self.x_data = x_data
        self.y_data = y_data
        self.parameters = params_solved
        self.parameter_guesses = parameter_guesses
        self.parameter_bounds = parameter_bounds
        self.residual_norm_type = residual_norm_type
        self.fit_type = fit_type
        self.weights = weights
        self.put_residuals_in_logspace = put_residuals_in_logspace