def Cd_wave_rae2822(Cl, mach, sweep=0.): r""" A curve fit I did to RAE2822 airfoil data. Within -0.4 < CL < 0.75 and 0 < mach < ~0.9, has R^2 = 0.9982. See: C:\Projects\GitHub\firefly_aerodynamics\MSES Interface\analysis\rae2822 :param Cl: Lift coefficient :param mach: Mach number :param sweep: Sweep angle, in deg :return: Wave drag coefficient. """ mach = np.fmax(mach, 0) mach_perpendicular = mach * np.cosd(sweep) # Relation from FVA Eq. 8.176 Cl_perpendicular = Cl / np.cosd(sweep)**2 # Relation from FVA Eq. 8.177 # Coeffs c2 = 4.5776476424519119e+00 mc0 = 9.5623337929607111e-01 mc1 = 2.0552787101770234e-01 mc2 = 1.1259268018737063e+00 mc3 = 1.9538856688443659e-01 m = mach_perpendicular l = Cl_perpendicular Cd_wave = np.fmax(m - (mc0 - mc1 * np.sqrt(mc2 + (l - mc3)**2)), 0)**2 * c2 return Cd_wave
def 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)
def expansion_ratio_from_pressure(chamber_pressure, exit_pressure, gamma, oxamide_fraction): """Find the nozzle expansion ratio from the chamber and exit pressures. See :ref:`expansion-ratio-tutorial-label` for a physical description of the expansion ratio. Reference: Rocket Propulsion Elements, 8th Edition, Equation 3-25 Arguments: chamber_pressure (scalar): Nozzle stagnation chamber pressure [units: pascal]. exit_pressure (scalar): Nozzle exit static pressure [units: pascal]. gamma (scalar): Exhaust gas ratio of specific heats [units: dimensionless]. Returns: scalar: Expansion ratio :math:`\\epsilon = A_e / A_t` [units: dimensionless] """ chamber_pressure = np.fmax( chamber_pressure, dubious_min_combustion_pressure(oxamide_fraction)) chamber_pressure = np.fmax(chamber_pressure, exit_pressure * 1.5) AtAe = ((gamma + 1) / 2) ** (1 / (gamma - 1)) \ * (exit_pressure / chamber_pressure) ** (1 / gamma) \ * np.sqrt((gamma + 1) / (gamma - 1) * (1 - (exit_pressure / chamber_pressure) ** ((gamma - 1) / gamma))) er = 1 / AtAe return er
def 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
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
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
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
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))
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
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)
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 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
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
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
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
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
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