from plasmapy.particles.particle_collections import ParticleList from plasmapy.particles.serialization import ( json_load_particle, json_loads_particle, ParticleJSONDecoder, ) from plasmapy.particles.special_particles import ParticleZoo from plasmapy.particles.symbols import ( atomic_symbol, element_name, ionic_symbol, isotope_symbol, particle_symbol, ) proton = Particle("p+") """A `Particle` instance representing a proton.""" electron = Particle("e-") """A `Particle` instance representing an electron.""" neutron = Particle("n") """A `Particle` instance representing a neutron.""" positron = Particle("e+") """A `Particle` instance representing a positron.""" deuteron = Particle("D 1+") """A `Particle` instance representing a positively charged deuterium ion.""" triton = Particle("T 1+")
def ionic_fractions(self, inputs: Union[Dict, List, Tuple]): """ Set the ionic fractions. Notes ----- The ionic fractions are initialized during instantiation of `~plasmapy.particles.IonizationStates`. After this, the only way to reset the ionic fractions via the ``ionic_fractions`` attribute is via a `dict` with elements or isotopes that are a superset of the previous elements or isotopes. However, you may use item assignment of the `~plasmapy.particles.IonizationState` instance to assign new ionic fractions one element or isotope at a time. Raises ------ AtomicError If the ionic fractions cannot be set. TypeError If ``inputs`` is not a `list`, `tuple`, or `dict` during instantiation, or if ``inputs`` is not a `dict` when it is being set. """ # A potential problem is that using item assignment on the # ionic_fractions attribute could cause the original attributes # to be overwritten without checks being performed. We might # eventually want to create a new class or subclass of UserDict # that goes through these checks. In the meantime, we should # make it clear to users to set ionic_fractions by using item # assignment on the IonizationStates instance as a whole. An # example of the problem is `s = IonizationStates(["He"])` being # followed by `s.ionic_fractions["He"] = 0.3`. if hasattr(self, "_ionic_fractions"): if not isinstance(inputs, dict): raise TypeError( "Can only reset ionic_fractions with a dict if " "ionic_fractions has been set already.") old_particles = set(self.base_particles) new_particles = {particle_symbol(key) for key in inputs.keys()} missing_particles = old_particles - new_particles if missing_particles: raise AtomicError( "Can only reset ionic fractions with a dict if " "the new base particles are a superset of the " "prior base particles. To change ionic fractions " "for one base particle, use item assignment on the " "IonizationStates instance instead.") if isinstance(inputs, dict): original_keys = inputs.keys() ionfrac_types = {type(inputs[key]) for key in original_keys} inputs_have_quantities = u.Quantity in ionfrac_types if inputs_have_quantities and len(ionfrac_types) != 1: raise TypeError( "Ionic fraction information may only be inputted " "as a Quantity object if all ionic fractions are " "Quantity arrays with units of inverse volume.") try: particles = {key: Particle(key) for key in original_keys} except (InvalidParticleError, TypeError) as exc: raise AtomicError( "Unable to create IonizationStates instance " "because not all particles are valid.") from exc # The particles whose ionization states are to be recorded # should be elements or isotopes but not ions or neutrals. for key in particles.keys(): is_element = particles[key].is_category("element") has_charge_info = particles[key].is_category( any_of=["charged", "uncharged"]) if not is_element or has_charge_info: raise AtomicError( f"{key} is not an element or isotope without " f"charge information.") # We are sorting the elements/isotopes by atomic number and # mass number since we will often want to plot and analyze # things and this is the most sensible order. sorted_keys = sorted( original_keys, key=lambda k: ( particles[k].atomic_number, particles[k].mass_number if particles[k].isotope else 0, ), ) _elements_and_isotopes = [] _particle_instances = [] new_ionic_fractions = {} if inputs_have_quantities: n_elems = {} for key in sorted_keys: new_key = particles[key].particle _particle_instances.append(particles[key]) if new_key in _elements_and_isotopes: raise AtomicError( "Repeated particles in IonizationStates.") nstates_input = len(inputs[key]) nstates = particles[key].atomic_number + 1 if nstates != nstates_input: raise AtomicError( f"The ionic fractions array for {key} must " f"have a length of {nstates}.") _elements_and_isotopes.append(new_key) if inputs_have_quantities: try: number_densities = inputs[key].to(u.m**-3) n_elem = np.sum(number_densities) new_ionic_fractions[new_key] = np.array( number_densities / n_elem) n_elems[key] = n_elem except u.UnitConversionError as exc: raise AtomicError( "Units are not inverse volume.") from exc elif (isinstance(inputs[key], np.ndarray) and inputs[key].dtype.kind == "f"): new_ionic_fractions[particles[key].particle] = inputs[key] else: try: new_ionic_fractions[ particles[key].particle] = np.array(inputs[key], dtype=np.float) except ValueError as exc: raise AtomicError( f"Inappropriate ionic fractions for {key}." ) from exc for key in _elements_and_isotopes: fractions = new_ionic_fractions[key] if not np.all(np.isnan(fractions)): if np.min(fractions) < 0 or np.max(fractions) > 1: raise AtomicError( f"Ionic fractions for {key} are not between 0 and 1." ) if not np.isclose( np.sum(fractions), 1, atol=self.tol, rtol=0): raise AtomicError( f"Ionic fractions for {key} are not normalized to 1." ) # When the inputs provide the densities, the abundances must # not have been provided because that would be redundant # or contradictory information. The number density scaling # factor might or might not have been provided. Have the # number density scaling factor default to the total number # of neutrals and ions across all elements and isotopes, if # it was not provided. Then go ahead and calculate the # abundances based on that. However, we need to be careful # that the abundances are not overwritten during the # instantiation of the class. if inputs_have_quantities: if np.isnan(self.n): new_n = 0 * u.m**-3 for key in _elements_and_isotopes: new_n += n_elems[key] self.n = new_n new_abundances = {} for key in _elements_and_isotopes: new_abundances[key] = np.float(n_elems[key] / self.n) self._pars["abundances"] = new_abundances elif isinstance(inputs, (list, tuple)): try: _particle_instances = [ Particle(particle) for particle in inputs ] except (InvalidParticleError, TypeError) as exc: raise AtomicError( "Invalid inputs to IonizationStates.") from exc _particle_instances.sort(key=lambda p: ( p.atomic_number, p.mass_number if p.isotope else 0)) _elements_and_isotopes = [ particle.particle for particle in _particle_instances ] new_ionic_fractions = { particle.particle: np.full(particle.atomic_number + 1, fill_value=np.nan, dtype=np.float64) for particle in _particle_instances } else: raise TypeError("Incorrect inputs to set ionic_fractions.") for i in range(1, len(_particle_instances)): if _particle_instances[ i - 1].element == _particle_instances[i].element: if (not _particle_instances[i - 1].isotope and _particle_instances[i].isotope): raise AtomicError( "Cannot have an element and isotopes of that element.") self._particle_instances = _particle_instances self._base_particles = _elements_and_isotopes self._ionic_fractions = new_ionic_fractions
def particle(request): return Particle(request.param)
def get_particle(argname, params, already_particle, funcname): argval, Z, mass_numb = params # Convert the argument to a Particle object if it is not # already one. if not already_particle: if not isinstance(argval, (numbers.Integral, str, tuple, list)): raise TypeError( f"The argument {argname} to {funcname} must be " f"a string, an integer or a tuple or list of them " f"corresponding to an atomic number, or a " f"Particle object.") try: particle = Particle(argval, Z=Z, mass_numb=mass_numb) except InvalidParticleError as e: raise InvalidParticleError( _particle_errmsg(argname, argval, Z, mass_numb, funcname)) from e # We will need to do the same error checks whether or not the # argument is already an instance of the Particle class. if already_particle: particle = argval # If the name of the argument annotated with Particle in the # decorated function is element, isotope, or ion; then this # decorator should raise the appropriate exception when the # particle ends up not being an element, isotope, or ion. cat_table = [ ("element", particle.element, InvalidElementError), ("isotope", particle.isotope, InvalidIsotopeError), ("ion", particle.ionic_symbol, InvalidIonError), ] for category_name, category_symbol, CategoryError in cat_table: if argname == category_name and not category_symbol: raise CategoryError( f"The argument {argname} = {repr(argval)} to " f"{funcname} does not correspond to a valid " f"{argname}.") # Some functions require that particles be charged, or # at least that particles have charge information. _integer_charge = particle._attributes["integer charge"] must_be_charged = "charged" in require must_have_charge_info = set(any_of) == {"charged", "uncharged"} uncharged = _integer_charge == 0 lacks_charge_info = _integer_charge is None if must_be_charged and (uncharged or must_have_charge_info): raise ChargeError( f"A charged particle is required for {funcname}.") if must_have_charge_info and lacks_charge_info: raise ChargeError( f"Charge information is required for {funcname}.") # Some functions require particles that belong to more complex # classification schemes. Again, be sure to provide a # maximally useful error message. if not particle.is_category( require=require, exclude=exclude, any_of=any_of): raise AtomicError( _category_errmsg(particle, require, exclude, any_of, funcname)) return particle
def test_antiparticle_inversion(particle, antiparticle): """Test that antiparticles have the correct antiparticles.""" assert Particle(antiparticle).antiparticle == Particle(particle), ( f"The antiparticle of {antiparticle} is found to be " f"{~Particle(antiparticle)} instead of {particle}.")
def test_unary_operator_for_elements(): with pytest.raises(ParticleError): Particle("C").antiparticle
"symbol": "He-4 2+", "element": "He", "element_name": "helium", "isotope": "He-4", "isotope_name": "helium-4", "ionic_symbol": "He-4 2+", "roman_symbol": "He-4 III", "mass_energy": 5.971919969131517e-10 * u.J, "is_ion": True, "integer_charge": 2, "atomic_number": 2, "mass_number": 4, "baryon_number": 4, "lepton_number": 0, "half_life": np.inf * u.s, "recombine()": Particle("He-4 1+"), }, ), ( "He-4 0+", {}, { "symbol": "He-4 0+", "element": "He", "isotope": "He-4", "mass_energy": 5.971919969131517e-10 * u.J, }, ), ( "Li", {
def test_particle_bool_error(): with pytest.raises(ParticleError): bool(Particle("e-"))
def append(self, particle: ParticleLike): """Append a particle to the end of the `ParticleList`.""" # TODO: use particle_input when it works with CustomParticle and ParticleLike if not isinstance(particle, (Particle, CustomParticle)): particle = Particle(particle) self.data.append(particle)
def insert(self, index, particle: ParticleLike): """Insert a particle before an index.""" # TODO: use particle_input when it works with CustomParticle and ParticleLike if not isinstance(particle, (Particle, CustomParticle)): particle = Particle(particle) self.data.insert(index, particle)
"particle": "He-4 2+", "element": "He", "element_name": "helium", "isotope": "He-4", "isotope_name": "helium-4", "ionic_symbol": "He-4 2+", "roman_symbol": "He-4 III", "mass_energy": 5.971919969131517e-10 * u.J, "is_ion": True, "integer_charge": 2, "atomic_number": 2, "mass_number": 4, "baryon_number": 4, "lepton_number": 0, "half_life": np.inf * u.s, "recombine()": Particle("He-4 1+"), }, ), ( "He-4 0+", {}, { "particle": "He-4 0+", "element": "He", "isotope": "He-4", "mass_energy": 5.971919969131517e-10 * u.J, }, ), ( "Li", {"mass_numb": 7},
assert isinstance(empty_particle_list, ParticleList) assert len(empty_particle_list) == 0 def test_ion_list_example(): ions = ionic_levels("He-4") np.testing.assert_equal(ions.charge_number, [0, 1, 2]) assert ions.symbols == ["He-4 0+", "He-4 1+", "He-4 2+"] @pytest.mark.parametrize( "particle, min_charge, max_charge, expected_charge_numbers", [ ("H-1", 0, 1, [0, 1]), ("p+", 1, 1, [1]), (Particle("p+"), 0, 0, [0]), ("C", 3, 5, [3, 4, 5]), ], ) def test_ion_list(particle, min_charge, max_charge, expected_charge_numbers): """Test that inputs to ionic_levels are interpreted correctly.""" particle = Particle(particle) ions = ionic_levels(particle, min_charge, max_charge) np.testing.assert_equal(ions.charge_number, expected_charge_numbers) assert ions[0].element == particle.element if particle.is_category("isotope"): assert ions[0].isotope == particle.isotope @pytest.mark.parametrize( "element, min_charge, max_charge", [("Li", 0, 4), ("Li", 3, 2)]
def test_unary_operator_for_elements(): with pytest.raises(AtomicError): Particle('C').antiparticle
def test_particle_bool_error(): with pytest.raises(AtomicError): bool(Particle('e-'))
'particle': 'He-4 2+', 'element': 'He', 'element_name': 'helium', 'isotope': 'He-4', 'isotope_name': 'helium-4', 'ionic_symbol': 'He-4 2+', 'roman_symbol': 'He-4 III', 'mass_energy': 5.971919969131517e-10 * u.J, 'is_ion': True, 'integer_charge': 2, 'atomic_number': 2, 'mass_number': 4, 'baryon_number': 4, 'lepton_number': 0, 'half_life': np.inf * u.s, 'recombine()': Particle('He-4 1+') }), ('He-4 0+', {}, { 'particle': 'He-4 0+', 'element': 'He', 'isotope': 'He-4', 'mass_energy': 5.971919969131517e-10 * u.J, }), ('Li', { 'mass_numb': 7 }, { 'particle': 'Li-7', 'element': 'Li', 'element_name': 'lithium', 'isotope': 'Li-7', 'isotope_name': 'lithium-7',