def test_particle_list_sort_with_key_and_reverse(): """ Test that a `ParticleList` instance can be sorted if a key is provided, and that the ``reverse`` keyword argument works too. """ elements = ["He", "H", "Fe", "U"] particle_list = ParticleList(elements) particle_list.sort(key=atomic_number, reverse=True) assert particle_list.symbols == ["U", "Fe", "He", "H"]
def test_particle_list_gt_as_nuclear_reaction_energy(): """ Test that `ParticleList.__gt__` can be used to get the same result as `nuclear_reaction_energy`. """ reactants = ParticleList(["D+", "T+"]) products = ParticleList(["alpha", "n"]) expected_energy = nuclear_reaction_energy("D + T --> alpha + n") actual_energy = reactants > products assert u.allclose(expected_energy, actual_energy)
def test_mean_particle(): """ Test that ``ParticleList.average_particle()`` returns a particle with the mean mass and mean charge of a |ParticleList|. """ massless_uncharged_particle = CustomParticle(mass=0 * u.kg, charge=0 * u.C) particle_list = ParticleList([proton, electron, alpha, massless_uncharged_particle]) expected_mass = (proton.mass + electron.mass + alpha.mass) / 4 expected_charge = (proton.charge + electron.charge + alpha.charge) / 4 average_particle = particle_list.average_particle() assert u.isclose(average_particle.mass, expected_mass, rtol=1e-14) assert u.isclose(average_particle.charge, expected_charge, rtol=1e-14)
def test_weighted_mean_particle(): """ Test that ``ParticleList.average_particle()`` returns a particle with the weighted mean. """ custom_proton = CustomParticle(mass=proton.mass, charge=proton.charge) particle_list = ParticleList([proton, electron, alpha, custom_proton]) abundances = [1, 2, 0, 1] expected_mass = (proton.mass + electron.mass) / 2 expected_charge = 0 * u.C average_particle = particle_list.average_particle(abundances=abundances) assert u.isclose(average_particle.mass, expected_mass, rtol=1e-14) assert u.isclose(average_particle.charge, expected_charge, rtol=1e-14)
def test_weighted_averages_of_particles( particle_multiplicities: Dict[ParticleLike, int], use_rms_charge, use_rms_mass, ): """ Compare the mass and charge of the average particle for two |ParticleList| instances. The first |ParticleList| contains repeated particles. The second |ParticleList| contains only one of each kind of particle present in the first list, with the number of each particle recorded in a separate array. The unweighted averages of the first |ParticleList| should equal the weighted averages of the second |ParticleList|, with the number of each particle provided as the abundances. """ all_particles = ParticleList([]) for particle, multiplicity in particle_multiplicities.items(): all_particles.extend(ParticleList(multiplicity * [particle])) unique_particles = ParticleList(particle_multiplicities.keys()) number_of_each_particle = list(particle_multiplicities.values()) unweighted_mean_of_all_particles = all_particles.average_particle( use_rms_charge=use_rms_charge, use_rms_mass=use_rms_mass, ) weighted_mean_of_unique_particles = unique_particles.average_particle( use_rms_charge=use_rms_charge, use_rms_mass=use_rms_mass, abundances=number_of_each_particle, ) assert u.isclose( unweighted_mean_of_all_particles.mass, weighted_mean_of_unique_particles.mass, rtol=1e-14, equal_nan=True, ) assert u.isclose( unweighted_mean_of_all_particles.charge, weighted_mean_of_unique_particles.charge, rtol=1e-14, equal_nan=True, ) if len(unique_particles) == 1 and isinstance(unique_particles[0], Particle): assert isinstance(unweighted_mean_of_all_particles, Particle) assert isinstance(weighted_mean_of_unique_particles, Particle)
def test_comparison_to_equivalent_particle_list(physical_property, use_rms): """ Test that `IonizationState.average_ion` gives consistent results with `ParticleList.average_particle` when the ratios of different particles is the same between the `IonizationState` and the `ParticleList`. """ particles = ParticleList(2 * ["He-4 0+"] + 3 * ["He-4 1+"] + 5 * ["He-4 2+"]) ionization_state = IonizationState("He-4", [0.2, 0.3, 0.5]) kwargs = {f"use_rms_{physical_property}": True} expected_average_particle = particles.average_particle(**kwargs) expected_average_quantity = getattr(expected_average_particle, physical_property) actual_average_particle = ionization_state.average_ion(**kwargs) actual_average_quantity = getattr(actual_average_particle, physical_property) assert_quantity_allclose(actual_average_quantity, expected_average_quantity)
def test_particle_list_instantiate_with_invalid_particles(): """ Test that a `ParticleList` instance cannot be created when it is provided with invalid particles. """ with pytest.raises(InvalidParticleError): ParticleList(invalid_particles)
def test_particle_list_dimensionless_particles(): """ Test that a `ParticleList` cannot be instantiated with a `DimensionlessParticle`. """ with pytest.raises(TypeError): ParticleList([DimensionlessParticle()])
def test_particle_list_membership(args): """ Test that the particles in the `ParticleList` match the particles (or particle-like objects) that are passed to it. """ particle_list = ParticleList(args) for arg, particle in zip(args, particle_list): assert particle == arg assert _everything_is_particle_or_custom_particle(particle_list) assert _everything_is_particle_or_custom_particle(particle_list.data)
def test_root_mean_square_particle(use_rms_charge, use_rms_mass): """ Test that ``ParticleList.average_particle`` returns the mean or root mean square of the charge and mass, as appropriate. """ particle_list = ParticleList(["p+", "e-"]) average_particle = particle_list.average_particle( use_rms_charge=use_rms_charge, use_rms_mass=use_rms_mass ) expected_average_charge = (1 if use_rms_charge else 0) * proton.charge assert u.isclose(average_particle.charge, expected_average_charge, rtol=1e-14) if use_rms_mass: expected_average_mass = np.sqrt((proton.mass**2 + electron.mass**2) / 2) else: expected_average_mass = (proton.mass + electron.mass) / 2 assert u.isclose(average_particle.mass, expected_average_mass, atol=1e-35 * u.kg)
def various_particles(): """A sample `ParticleList` with several different valid particles.""" return ParticleList([ "H", "He", "e-", "alpha", "tau neutrino", CustomParticle(mass=3 * u.kg, charge=5 * u.C), CustomParticle(), CustomParticle(mass=7 * u.kg), CustomParticle(charge=11 * u.C), ])
def test_particle_list_attributes(attribute, various_particles): """ Test that the attributes of ParticleList correspond to the attributes of the listed particles. This class does not test ParticleList instances that include CustomParticle instances inside of them. """ particle_list_arguments = (electron, "e+", proton, neutron, alpha) particle_list = ParticleList(particle_list_arguments) expected_particles = [Particle(arg) for arg in particle_list_arguments] actual = getattr(particle_list, attribute) expected = [getattr(particle, attribute) for particle in expected_particles] assert u.allclose(actual, expected, equal_nan=True)
def test_particle_list_with_no_arguments(): """Test that `ParticleList()` returns an empty `ParticleList`.""" empty_particle_list = ParticleList() assert isinstance(empty_particle_list, ParticleList) assert len(empty_particle_list) == 0
if key not in params: raise ValueError( f"{p} was not provided in kwarg 'parameters', but is required." ) # ************** # ions # ************** ions = settings["ions"] # Condition ions # If a single value is provided, turn into a particle list if isinstance(ions, ParticleList): pass elif isinstance(ions, str): ions = ParticleList([Particle(ions)]) # If a list is provided, ensure all values are Particles, then convert # to a ParticleList elif isinstance(ions, list): for ii, ion in enumerate(ions): if isinstance(ion, Particle): continue ions[ii] = Particle(ion) ions = ParticleList(ions) else: raise ValueError("The type of object provided to the ``ions`` keyword " f"is not supported: {type(ions)}") # Validate ions if len(ions) == 0: raise ValueError("At least one ion species needs to be defined.")
def test_particle_list_is_category(particles, args, kwargs, expected): """ Test that ``ParticleList.is_category()`` behaves as expected. """ sample_list = ParticleList(particles) assert sample_list.is_category(*args, **kwargs) == expected
def spectral_density( wavelengths: u.nm, probe_wavelength: u.nm, n: u.m**-3, *, T_e: u.K, T_i: u.K, efract: np.ndarray = None, ifract: np.ndarray = None, ions: Union[str, List[str], Particle, List[Particle]] = "p", electron_vel: u.m / u.s = None, ion_vel: u.m / u.s = None, probe_vec=None, scatter_vec=None, instr_func=None, ) -> Tuple[Union[np.floating, np.ndarray], np.ndarray]: r""" Calculate the spectral density function for Thomson scattering of a probe laser beam by a multi-species Maxwellian plasma. See **Notes** section below for additional details. Parameters ---------- wavelengths : `~astropy.units.Quantity` Array of wavelengths over which the spectral density function will be calculated. (convertible to nm) probe_wavelength : `~astropy.units.Quantity` Wavelength of the probe laser. (convertible to nm) n : `~astropy.units.Quantity` Total combined number density of all electron populations. (convertible to cm\ :sup:`-3`) T_e : `~astropy.units.Quantity`, keyword-only, shape (Ne, ) Temperature of each electron component. Shape (Ne, ) must be equal to the number of electron populations Ne. (in K or convertible to eV) T_i : `~astropy.units.Quantity`, keyword-only, shape (Ni, ) Temperature of each ion component. Shape (Ni, ) must be equal to the number of ion populations Ni. (in K or convertible to eV) efract : array_like, shape (Ne, ), optional An array-like object representing :math:`F_e` (defined above). Must sum to 1.0. Default is [1.0], representing a single electron component. ifract : array_like, shape (Ni, ), optional An array-like object representing :math:`F_i` (defined above). Must sum to 1.0. Default is [1.0], representing a single ion component. ions : `str` or `~plasmapy.particles.particle_class.Particle` or `~plasmapy.particles.particle_collections.ParticleList`, shape (Ni, ), optional A list or single instance of `~plasmapy.particles.particle_class.Particle`, or strings convertible to `~plasmapy.particles.particle_class.Particle`, or a `~plasmapy.particles.particle_collections.ParticleList`. All ions must be positively charged. Default is ``'H+'`` corresponding to a single species of hydrogen ions. electron_vel : `~astropy.units.Quantity`, shape (Ne, 3), optional Velocity of each electron population in the rest frame. (convertible to m/s) If set, overrides ``electron_vdir`` and ``electron_speed``. Defaults to a stationary plasma [0, 0, 0] m/s. ion_vel : `~astropy.units.Quantity`, shape (Ni, 3), optional Velocity vectors for each electron population in the rest frame (convertible to m/s). If set, overrides ``ion_vdir`` and ``ion_speed``. Defaults to zero drift for all specified ion species. probe_vec : float `~numpy.ndarray`, shape (3, ) Unit vector in the direction of the probe laser. Defaults to [1, 0, 0]. scatter_vec : float `~numpy.ndarray`, shape (3, ) Unit vector pointing from the scattering volume to the detector. Defaults to [0, 1, 0] which, along with the default ``probe_vec``, corresponds to a 90° scattering angle geometry. instr_func : function A function representing the instrument function that takes a `~astropy.units.Quantity` of wavelengths (centered on zero) and returns the instrument point spread function. The resulting array will be convolved with the spectral density function before it is returned. Returns ------- alpha : `float` Mean scattering parameter, where ``alpha`` > 1 corresponds to collective scattering and ``alpha`` < 1 indicates non-collective scattering. The scattering parameter is calculated based on the total plasma density ``n``. Skw : `~astropy.units.Quantity` Computed spectral density function over the input ``wavelengths`` array with units of s/rad. Notes ----- This function calculates the spectral density function for Thomson scattering of a probe laser beam by a plasma consisting of one or more ion species and one or more thermal electron populations (the entire plasma is assumed to be quasi-neutral) .. math:: S(k,ω) = \sum_e \frac{2π}{k} \bigg |1 - \frac{χ_e}{ε} \bigg |^2 f_{e0,e} \bigg (\frac{ω}{k} \bigg ) + \sum_i \frac{2π Z_i}{k} \bigg |\frac{χ_e}{ε} \bigg |^2 f_{i0,i} \bigg ( \frac{ω}{k} \bigg ) where :math:`χ_e` is the electron component susceptibility of the plasma and :math:`ε = 1 + \sum_e χ_e + \sum_i χ_i` is the total plasma dielectric function (with :math:`χ_i` being the ion component of the susceptibility), :math:`Z_i` is the charge of each ion, :math:`k` is the scattering wavenumber, :math:`ω` is the scattering frequency, and :math:`f_{e0,e}` and :math:`f_{i0,i}` are the electron and ion velocity distribution functions respectively. In this function the electron and ion velocity distribution functions are assumed to be Maxwellian, making this function equivalent to Eq. 3.4.6 in :cite:t:`sheffield:2011`\ . The number density of the e\ :sup:`th` electron populations is defined as .. math:: n_e = F_e n where :math:`n` is total density of all electron population combined and :math:`F_e` is the fractional density of each electron population such that .. math:: \sum_e n_e = n .. math:: \sum_e F_e = 1 The plasma is assumed to be charge neutral, and therefore the number density of the i\ :sup:`th` ion population is .. math:: n_i = \frac{F_i n}{\sum_i F_i Z_i} with :math:`F_i` defined in the same way as :math:`F_e`. For details, see "Plasma Scattering of Electromagnetic Radiation" by :cite:t:`sheffield:2011`. This code is a modified version of the program described therein. For a concise summary of the relevant physics, see Chapter 5 of the :cite:t:`schaeffer:2014` thesis. """ # Validate efract if efract is None: efract = np.ones(1) else: efract = np.asarray(efract, dtype=np.float64) if np.sum(efract) != 1: raise ValueError( f"The provided efract does not sum to 1: {efract}") # Validate ifract if ifract is None: ifract = np.ones(1) else: ifract = np.asarray(ifract, dtype=np.float64) if np.sum(ifract) != 1: raise ValueError( f"The provided ifract does not sum to 1: {ifract}") if probe_vec is None: probe_vec = np.array([1, 0, 0]) if scatter_vec is None: scatter_vec = np.array([0, 1, 0]) # If electron velocity is not specified, create an array corresponding # to zero drift if electron_vel is None: electron_vel = np.zeros([efract.size, 3]) * u.m / u.s # Condition the electron velocity keywords if ion_vel is None: ion_vel = np.zeros([ifract.size, 3]) * u.m / u.s # Condition ions # If a single value is provided, turn into a particle list if isinstance(ions, ParticleList): pass elif isinstance(ions, str): ions = ParticleList([Particle(ions)]) # If a list is provided, ensure all values are Particles, then convert # to a ParticleList elif isinstance(ions, list): for ii, ion in enumerate(ions): if isinstance(ion, Particle): continue ions[ii] = Particle(ion) ions = ParticleList(ions) else: raise ValueError("The type of object provided to the ``ions`` keyword " f"is not supported: {type(ions)}") # Validate ions if len(ions) == 0: raise ValueError("At least one ion species needs to be defined.") try: if sum(ion.charge_number <= 0 for ion in ions): raise ValueError("All ions must be positively charged.") # Catch error if charge information is missing except ChargeError: raise ValueError("All ions must be positively charged.") # Condition T_i if T_i.size == 1: # If a single quantity is given, put it in an array so it's iterable # If T_i.size != len(ions), assume same temp. for all species T_i = np.array([T_i.value]) * T_i.unit # Make sure the sizes of ions, ifract, ion_vel, and T_i all match if ((len(ions) != ifract.size) or (ion_vel.shape[0] != ifract.size) or (T_i.size != ifract.size)): raise ValueError( f"Inconsistent number of ion species in ifract ({ifract}), " f"ions ({len(ions)}), T_i ({T_i.size}), " f"and/or ion_vel ({ion_vel.shape[0]}).") # Condition T_e if T_e.size == 1: # If a single quantity is given, put it in an array so it's iterable # If T_e.size != len(efract), assume same temp. for all species T_e = np.array([T_e.value]) * T_e.unit # Make sure the sizes of efract, electron_vel, and T_e all match if (electron_vel.shape[0] != efract.size) or (T_e.size != efract.size): raise ValueError( f"Inconsistent number of electron populations in efract ({efract.size}), " f"T_e ({T_e.size}), or electron velocity ({electron_vel.shape[0]})." ) # Create arrays of ion Z and mass from particles given ion_z = ions.charge_number ion_mass = ions.mass probe_vec = probe_vec / np.linalg.norm(probe_vec) scatter_vec = scatter_vec / np.linalg.norm(scatter_vec) # Apply the instrument function if instr_func is not None and callable(instr_func): # Create an array of wavelengths of the same size as wavelengths # but centered on zero wspan = (np.max(wavelengths) - np.min(wavelengths)) / 2 eval_w = np.linspace(-wspan, wspan, num=wavelengths.size) instr_func_arr = instr_func(eval_w) if type(instr_func_arr) != np.ndarray: raise ValueError("instr_func must be a function that returns a " "np.ndarray, but the provided function returns " f" a {type(instr_func_arr)}") if wavelengths.shape != instr_func_arr.shape: raise ValueError("The shape of the array returned from the " f"instr_func ({instr_func_arr.shape}) " "does not match the shape of the wavelengths " f"array ({wavelengths.shape}).") instr_func_arr /= np.sum(instr_func_arr) else: instr_func_arr = None alpha, Skw = spectral_density_lite( wavelengths.to(u.m).value, probe_wavelength.to(u.m).value, n.to(u.m**-3).value, T_e.to(u.K).value, T_i.to(u.K).value, efract=efract, ifract=ifract, ion_z=ion_z, ion_mass=ion_mass.to(u.kg).value, ion_vel=ion_vel.to(u.m / u.s).value, electron_vel=electron_vel.to(u.m / u.s).value, probe_vec=probe_vec, scatter_vec=scatter_vec, instr_func_arr=instr_func_arr, ) return alpha, Skw * u.s / u.rad
def test_particle_list_adding_particle_list(various_particles): """Test that a `ParticleList` can be added to another `ParticleList`.""" extra_particles = ParticleList(["H", "D", "T"]) new_particles_list = various_particles + extra_particles assert new_particles_list[-3:] == extra_particles assert isinstance(new_particles_list, ParticleList)
None, None, ), # List of Particles ( { "ions": [ Particle("p+"), ] }, None, None, ), # Particle list ({ "ions": ParticleList(["p+"]) }, None, None), # ValueError when an ion is negative ( { "ions": ParticleList(["p-"]) }, ValueError, "All ions must be positively charged.", ), # ValueError when an ion charge information is not provided ( { "ions": ParticleList(["He"]) }, ValueError,
def test_particle_list_extended_with_particle_list(various_particles): """Test that a `ParticleList` can be extended with another `ParticleList`.""" particle_list = ParticleList(["D", "T", CustomParticle()]) various_particles.extend(particle_list) assert various_particles[-3:] == particle_list
def test_particle_list_len(): """Test that using `len` on a `ParticleList` returns the expected number.""" original_list = ["n", "p", "e-"] particle_list = ParticleList(original_list) assert len(particle_list) == len(original_list)
import astropy.units as u import pytest from plasmapy.particles import deuteron, electron, proton from plasmapy.particles._factory import _physical_particle_factory from plasmapy.particles.exceptions import InvalidParticleError from plasmapy.particles.particle_class import CustomParticle, Particle from plasmapy.particles.particle_collections import ParticleList mass = 1e-26 * u.kg charge = 1e-29 * u.C custom_particle = CustomParticle(mass, charge) test_cases = [ ([[]], {}, ParticleList()), ([proton], {}, proton), (["p+"], {}, proton), (["H"], { "Z": 1, "mass_numb": 2 }, deuteron), (["muon"], {}, Particle("muon")), pytest.param([charge, mass], {}, custom_particle, marks=[pytest.mark.xfail]), ([mass, charge], {}, custom_particle), ([], { "symbol": "ξ" }, CustomParticle(symbol="ξ")), ([[proton, electron]], {}, ParticleList([proton, electron])), ([], {
def average_ion( self, *, include_neutrals: bool = True, use_rms_charge: bool = False, use_rms_mass: bool = False, ) -> CustomParticle: """ Return a |CustomParticle| representing the mean particle included across all ionization states. By default, this method will use the weighted mean to calculate the properties of the |CustomParticle|, where the weights for each ionic level is given by its ionic fraction multiplied by the abundance of the base element or isotope. If ``use_rms_charge`` or ``use_rms_mass`` is `True`, then this method will return the root mean square of the charge or mass, respectively. Parameters ---------- include_neutrals : `bool`, optional, keyword-only If `True`, include neutrals when calculating the mean values of the different particles. If `False`, exclude neutrals. Defaults to `True`. use_rms_charge : `bool`, optional, keyword-only If `True`, use the root mean square charge instead of the mean charge. Defaults to `False`. use_rms_mass : `bool`, optional, keyword-only If `True`, use the root mean square mass instead of the mean mass. Defaults to `False`. Raises ------ `~plasmapy.particles.exceptions.ParticleError` If the abundance of any of the elements or isotopes is not defined and the |IonizationStateCollection| instance includes more than one element or isotope. Returns ------- ~plasmapy.particles.particle_class.CustomParticle Examples -------- >>> states = IonizationStateCollection( ... {"H": [0.1, 0.9], "He": [0, 0.1, 0.9]}, ... abundances={"H": 1, "He": 0.1} ... ) >>> states.average_ion() CustomParticle(mass=2.12498...e-27 kg, charge=1.5876...e-19 C) >>> states.average_ion(include_neutrals=False, use_rms_charge=True, use_rms_mass=True) CustomParticle(mass=2.633...e-27 kg, charge=1.805...e-19 C) """ min_charge = 0 if include_neutrals else 1 all_particles = ParticleList() all_abundances = [] for base_particle in self.base_particles: ionization_state = self[base_particle] ionic_levels = ionization_state.to_list()[min_charge:] all_particles.extend(ionic_levels) base_particle_abundance = self.abundances[base_particle] if np.isnan(base_particle_abundance): if len(self) == 1: base_particle_abundance = 1 else: raise ParticleError( "Unable to provide an average particle without abundances." ) ionic_fractions = ionization_state.ionic_fractions[min_charge:] ionic_abundances = base_particle_abundance * ionic_fractions all_abundances.extend(ionic_abundances) return all_particles.average_particle( use_rms_charge=use_rms_charge, use_rms_mass=use_rms_mass, abundances=all_abundances, )