def _normalize(_phi, _k1, _k2, _r, _mu): # First calculate the sound speed, radius at the sonic point and the # density at the sonic point. They will be useful to change the units of # the calculation aiming to avoid numerical overflows _vs = parker.sound_speed(temperature, _mu) _rs = parker.radius_sonic_point(planet_mass, _vs) _rhos = parker.density_sonic_point(mass_loss_rate, _rs, _vs) # And now normalize everything phi_unit = _vs * 1E5 / _rs / 7.1492E+09 # 1 / s phi_norm = _phi / phi_unit k1_unit = 1 / (_rhos * _rs * 7.1492E+09) # cm ** 2 / g k1_norm = _k1 / k1_unit k2_unit = _vs * 1E5 / _rs / 7.1492E+09 / _rhos # cm ** 3 / g / s k2_norm = _k2 / k2_unit r_norm = (_r * planet_radius / _rs) # The differential r will be useful at some point dr_norm = np.diff(r_norm) dr_norm = np.concatenate((dr_norm, np.array([ dr_norm[-1], ]))) # The structure of the atmosphere v_norm, rho_norm = parker.structure(r_norm) return phi_norm, k1_norm, k2_norm, r_norm, dr_norm, v_norm, rho_norm
def _fun(_r, _f, _phi, _k2): if exact_phi: _phi_prime = _phi_prime_fun(np.array([ _r, ]))[0] else: _t = _tau_fun(np.array([ _r, ]))[0] _phi_prime = np.exp(-_t) * _phi _v_guess = _v_fun(_r) _v, _rho = parker.structure(_r, _v_guess) # In terms 1 and 2 we use the values of k2 and phi from above term1 = (1. - _f) / _v * _phi_prime term2 = _k2 * _rho * _f**2 / _v df_dr = term1 - term2 return df_dr
def test_structure(r=1.0, precision_threshold=1E-6): velocity, density = parker.structure(r) assert abs((velocity - 1.0) / velocity) < precision_threshold assert abs((density - 1.0) / density) < precision_threshold
def ion_fraction(radius_profile, planet_radius, temperature, h_fraction, mass_loss_rate, planet_mass, mean_molecular_weight_0=1.0, spectrum_at_planet=None, flux_euv=None, initial_f_ion=0.0, relax_solution=False, convergence=0.01, max_n_relax=10, exact_phi=False, return_mu=False, **options_solve_ivp): """ Calculate the fraction of ionized hydrogen in the upper atmosphere in function of the radius in unit of planetary radius. Parameters ---------- radius_profile (``numpy.ndarray``): Radius in unit of planetary radii. planet_radius (``float``): Planetary radius in unit of Jupiter radius. temperature (``float``): Isothermal temperature of the upper atmosphere in unit of Kelvin. h_fraction (``float``): Total (ion + neutral) H number fraction of the atmosphere. mass_loss_rate (``float``): Mass loss rate of the planet in units of g / s. planet_mass (``float``): Planetary mass in unit of Jupiter mass. mean_molecular_weight_0 (``float``): Initial mean molecular weight of the atmosphere in unit of proton mass. Default value is 1.0 (100% neutral H). Since its final value depend on the H ion fraction itself, the mean molecular weight can be self-consistently calculated by setting `relax_solution` to `True`. spectrum_at_planet (``dict``, optional): Spectrum of the host star arriving at the planet covering fluxes at least up to the wavelength corresponding to the energy to ionize hydrogen (13.6 eV, or 911.65 Angstrom). Can be generated using ``tools.make_spectrum_dict``. If ``None``, then ``flux_euv`` must be provided instead. Default is ``None``. flux_euv (``float``, optional): Extreme-ultraviolet (0-911.65 Angstrom) flux arriving at the planet in units of erg / s / cm ** 2. If ``None``, then ``spectrum_at_planet`` must be provided instead. Default is ``None``. initial_f_ion (``float``, optional): The initial ionization fraction at the layer near the surface of the planet. Default is 0.0, i.e., 100% neutral. relax_solution (``bool``, optional): The first solution is calculating by initially assuming the entire atmosphere is in neutral state. If ``True``, the solution will be re-calculated in a loop until it converges to a delta_f of 1%, or for a maximum of 10 loops (default parameters). Default is ``False``. convergence (``float``, optional): Value of delta_f at which to stop the relaxation of the solution for ``f_r``. Default is 0.01. max_n_relax (``int``, optional): Maximum number of loops to perform the relaxation of the solution for ``f_r``. Default is 10. return_mu (``bool``, optional): If ``True``, then this function returns a second variable ``mu_bar``, which is the self-consistent, density-averaged mean molecular weight of the atmosphere. Equivalent to the ``mu_bar`` of Eq. A.3 in Lampón et al. 2020. **options_solve_ivp: Options to be passed to the ``scipy.integrate.solve_ivp()`` solver. You may want to change the options ``method`` (integration method; default is ``'RK45'``), ``atol`` (absolute tolerance; default is 1E-6) or ``rtol`` (relative tolerance; default is 1E-3). If you are having numerical issues, you may want to decrease the tolerance by a factor of 10 or 100, or 1000 in extreme cases. Returns ------- f_r (``numpy.ndarray``): Values of the fraction of ionized hydrogen in function of the radius. mu_bar (``float``): Mean molecular weight of the atmosphere, in unit of proton mass, averaged across the radial distance using according to the function `average_molecular_weight` in the `parker` module. Only returned when ``return_mu`` is set to ``True``. """ # Hydrogen recombination rate alpha_rec = recombination(temperature) # Hydrogen mass in g m_h = 1.67262192E-24 # Photoionization rate at null optical depth at the distance of the planet # from the host star, in unit of 1 / s. if exact_phi and spectrum_at_planet is not None: vs = parker.sound_speed(temperature, mean_molecular_weight_0) rs = parker.radius_sonic_point(planet_mass, vs) rhos = parker.density_sonic_point(mass_loss_rate, rs, vs) _, rho_norm = parker.structure(radius_profile * planet_radius / rs) f_outer = 0.0 # Assume completely ionized at the top of atmosphere phi_abs = radiative_processes_exact( spectrum_at_planet, (radius_profile * planet_radius * u.Rjup).to(u.cm).value, rho_norm * rhos, f_outer, h_fraction) a_0 = 0. elif spectrum_at_planet is not None: phi_abs, a_0 = radiative_processes(spectrum_at_planet) elif flux_euv is not None: phi_abs, a_0 = radiative_processes_mono(flux_euv) else: raise ValueError('Either `spectrum_at_planet` or `flux_euv` must be ' 'provided.') # Multiplicative factor of Eq. 11 of Oklopcic & Hirata 2018, unit of # cm ** 2 / g # We assume that the remaining of the number fraction is pure He he_fraction = 1 - h_fraction he_h_fraction = he_fraction / h_fraction k1_abs = h_fraction * a_0 / (h_fraction + 4 * he_fraction) / m_h # Multiplicative factor of the second term in the right-hand side of Eq. # 13 of Oklopcic & Hirata 2018, unit of cm ** 3 / s / g k2_abs = h_fraction / (h_fraction + 4 * he_fraction) * alpha_rec / m_h # In order to avoid numerical overflows, we need to normalize a few key # variables. Since the normalization may need to be repeated to relax the # solution, we have a function to do it. def _normalize(_phi, _k1, _k2, _r, _mu): # First calculate the sound speed, radius at the sonic point and the # density at the sonic point. They will be useful to change the units of # the calculation aiming to avoid numerical overflows _vs = parker.sound_speed(temperature, _mu) _rs = parker.radius_sonic_point(planet_mass, _vs) _rhos = parker.density_sonic_point(mass_loss_rate, _rs, _vs) # And now normalize everything phi_unit = _vs * 1E5 / _rs / 7.1492E+09 # 1 / s phi_norm = _phi / phi_unit k1_unit = 1 / (_rhos * _rs * 7.1492E+09) # cm ** 2 / g k1_norm = _k1 / k1_unit k2_unit = _vs * 1E5 / _rs / 7.1492E+09 / _rhos # cm ** 3 / g / s k2_norm = _k2 / k2_unit r_norm = (_r * planet_radius / _rs) # The differential r will be useful at some point dr_norm = np.diff(r_norm) dr_norm = np.concatenate((dr_norm, np.array([ dr_norm[-1], ]))) # The structure of the atmosphere v_norm, rho_norm = parker.structure(r_norm) return phi_norm, k1_norm, k2_norm, r_norm, dr_norm, v_norm, rho_norm phi, k1, k2, r, dr, velocity, density = _normalize( phi_abs, k1_abs, k2_abs, radius_profile, mean_molecular_weight_0) if exact_phi: _phi_prime_fun = interp1d(r, phi, fill_value="extrapolate") else: # To start the calculations we need the optical depth, but technically # we don't know it yet, because it depends on the ion fraction in the # atmosphere, which is what we want to obtain. However, the optical # depth depends more strongly on the densities of H than the ion # fraction, so a good approximation is to assume the whole atmosphere is # neutral at first. column_density = np.flip(np.cumsum(np.flip(dr * density))) tau_initial = k1 * column_density # We do a dirty hack to make tau_initial and velocity a callable # function so it's easily parsed inside the differential equation solver _tau_fun = interp1d(r, tau_initial, fill_value="extrapolate") _v_fun = interp1d(r, velocity, fill_value="extrapolate") # Now let's solve the differential eq. 13 of Oklopcic & Hirata 2018 # The differential equation in function of r def _fun(_r, _f, _phi, _k2): if exact_phi: _phi_prime = _phi_prime_fun(np.array([ _r, ]))[0] else: _t = _tau_fun(np.array([ _r, ]))[0] _phi_prime = np.exp(-_t) * _phi _v_guess = _v_fun(_r) _v, _rho = parker.structure(_r, _v_guess) # In terms 1 and 2 we use the values of k2 and phi from above term1 = (1. - _f) / _v * _phi_prime term2 = _k2 * _rho * _f**2 / _v df_dr = term1 - term2 return df_dr # We solve it using `scipy.solve_ivp` sol = solve_ivp(_fun, ( r[0], r[-1], ), np.array([ initial_f_ion, ]), t_eval=r, args=(phi, k2), **options_solve_ivp) f_r = sol['y'][0] # When `solve_ivp` has problems, it may return an array with different # size than `r`. So we raise an exception if this happens if len(f_r) != len(r): raise RuntimeError('The solver ``solve_ivp`` failed to obtain a' ' solution.') # Calculate the average mean molecular weight using Eq. A.3 from Lampón et # al. 2020 mu_bar = parker.average_molecular_weight(f_r, radius_profile, velocity, planet_mass, temperature, he_h_fraction) # For the sake of self-consistency, there is the option of repeating the # calculation of f_r by updating the optical depth with the new ion # fractions. if relax_solution is True: for i in range(max_n_relax): previous_f_r = np.copy(f_r) if exact_phi: # phi_abs will need to be recomputed here with the new density # structure vs = parker.sound_speed(temperature, mu_bar) rs = parker.radius_sonic_point(planet_mass, vs) rhos = parker.density_sonic_point(mass_loss_rate, rs, vs) _, rho_norm = parker.structure(radius_profile * planet_radius / rs) phi_abs = radiative_processes_exact( spectrum_at_planet, (radius_profile * planet_radius * u.Rjup).to(u.cm).value, rho_norm * rhos, f_r, h_fraction) # We re-normalize key parameters because the newly-calculated f_ion # changes the value of the mean molecular weight of the atmosphere phi, k1, k2, r, dr, velocity, density = _normalize( phi_abs, k1_abs, k2_abs, radius_profile, mu_bar) if exact_phi: _phi_prime_fun = interp1d(r, phi, fill_value="extrapolate") else: # Re-calculate the column densities column_density = np.flip( np.cumsum(np.flip(dr * density * (1 - f_r)))) tau = k1 * column_density _tau_fun = interp1d(r, tau, fill_value="extrapolate") _v_fun = interp1d(r, velocity, fill_value="extrapolate") # And solve it again sol = solve_ivp(_fun, ( r[0], r[-1], ), np.array([ initial_f_ion, ]), t_eval=r, args=(phi, k2), **options_solve_ivp) f_r = sol['y'][0] # Raise an error if the length of `f_r` is different from the length # of `r` if len(f_r) != len(r): raise RuntimeError( 'The solver ``solve_ivp`` failed to obtain a' ' solution.') # Here we update the average mean molecular weight mu_bar = parker.average_molecular_weight(f_r, radius_profile, velocity, planet_mass, temperature, he_h_fraction) # Calculate the relative change of f_ion in the outer shell of the # atmosphere (where we expect the most important change) # relative_delta_f = abs(f_r[-1] - previous_f_r_outer_layer) \ # / previous_f_r_outer_layer relative_delta_f = abs( np.sum(f_r - previous_f_r) / np.sum(previous_f_r)) # Break the loop if convergence is achieved if relative_delta_f < convergence: break else: pass else: pass if return_mu is False: return f_r else: return f_r, mu_bar
def test_population_fraction_spectrum(): units = { 'wavelength': u.angstrom, 'flux': u.erg / u.s / u.cm**2 / u.angstrom } spectrum = tools.make_spectrum_from_file(data_test_url, units) # First calculate the hydrogen ion fraction f_r, mu_bar = hydrogen.ion_fraction(r, R_pl, T_0, h_fraction, m_dot, M_pl, average_mu, spectrum_at_planet=spectrum, relax_solution=True, exact_phi=True, return_mu=True) # Calculate the structure vs = parker.sound_speed(T_0, mu_bar) # Speed of sound (km/s, assumed to be # constant) rs = parker.radius_sonic_point( M_pl, vs) # Radius at the sonic point (jupiterRad) rhos = parker.density_sonic_point( m_dot, rs, vs) # Density at the sonic point (g/cm^3) # Some useful arrays for the modeling r_array = r * R_pl / rs # Radius in unit of radius at # sonic point v_array, rho_array = parker.structure(r_array) # Now calculate the population of helium f_he_1_odeint, f_he_3_odeint = helium.population_fraction( r, v_array, rho_array, f_r, R_pl, T_0, h_fraction, vs, rs, rhos, spectrum_at_planet=spectrum, initial_state=initial_state, relax_solution=True) # Assert if all values of the fractions are between 0 and 1 n_neg = len(np.where(f_he_1_odeint < 0)[0]) + \ len(np.where(f_he_3_odeint < 0)[0]) n_one = len(np.where(f_he_1_odeint > 1)[0]) + \ len(np.where(f_he_3_odeint > 1)[0]) assert n_neg == 0 assert n_one == 0 f_he_1_ivp, f_he_3_ivp = helium.population_fraction( r, v_array, rho_array, f_r, R_pl, T_0, h_fraction, vs, rs, rhos, spectrum_at_planet=spectrum, initial_state=initial_state, relax_solution=True, method='Radau', atol=1E-8, rtol=1E-8) # Assert if all values of the fractions are between 0 and 1 n_neg = len(np.where(f_he_1_ivp < 0)[0]) + \ len(np.where(f_he_3_ivp < 0)[0]) n_one = len(np.where(f_he_1_ivp > 1)[0]) + \ len(np.where(f_he_3_ivp > 1)[0]) assert n_neg == 0 assert n_one == 0