def _extract_charge(arg: str): """ Receive a `str` representing an element, isotope, or ion. Return a `tuple` containing a `str` that should represent an element or isotope, and either an `int` representing the charge or `None` if no charge information is provided. Raise an `~plasmapy.particles.exceptions.InvalidParticleError` if charge information is inputted incorrectly. """ invalid_charge_errmsg = ( f"Invalid charge information in the particle string '{arg}'." ) if arg.count(" ") == 1: # Cases like 'H 1-' and 'Fe-56 1+' isotope_info, charge_info = arg.split(" ") sign_indicator_only_on_one_end = charge_info.endswith( ("-", "+") ) ^ charge_info.startswith(("-", "+")) just_one_sign_indicator = ( charge_info.count("-") == 1 and charge_info.count("+") == 0 ) or (charge_info.count("-") == 0 and charge_info.count("+") == 1) if not sign_indicator_only_on_one_end and just_one_sign_indicator: raise InvalidParticleError(invalid_charge_errmsg) from None charge_str = charge_info.strip("+-") try: if roman.is_roman_numeral(charge_info): Z_from_arg = roman.from_roman(charge_info) - 1 elif "-" in charge_info: Z_from_arg = -int(charge_str) elif "+" in charge_info: Z_from_arg = int(charge_str) else: raise InvalidParticleError(invalid_charge_errmsg) from None except ValueError: raise InvalidParticleError(invalid_charge_errmsg) from None elif arg.endswith(("-", "+")): # Cases like 'H-' and 'Pb-209+++' char = arg[-1] match = re.match(f"[{char}]*", arg[::-1]) Z_from_arg = match.span()[1] isotope_info = arg[0 : len(arg) - match.span()[1]] if char == "-": Z_from_arg = -Z_from_arg if isotope_info.endswith(("-", "+")): raise InvalidParticleError(invalid_charge_errmsg) from None else: isotope_info = arg Z_from_arg = None return isotope_info, Z_from_arg
def _list_of_particles_and_custom_particles( particles: Optional[Iterable[ParticleLike]], ) -> List[Union[Particle, CustomParticle]]: # TODO #687 """ Convert an iterable that provides `~plasmapy.particles.particle_class.ParticleLike` objects into a `list` containing `~plasmapy.particles.particle_class.Particle` and `~plasmapy.particles.particle_class.CustomParticle` instances. """ new_particles = [] if particles is None: return new_particles for obj in particles: if isinstance(obj, (Particle, CustomParticle)): new_particles.append(obj) elif isinstance(obj, DimensionlessParticle): raise TypeError( "ParticleList instances cannot include dimensionless particles." ) else: try: new_particles.append(Particle(obj)) except (TypeError, InvalidParticleError) as exc: raise InvalidParticleError( f"The object {obj} supplied to ParticleList is not a " f"particle-like object.") from exc return new_particles
def _extract_mass_number(isotope_info: str): """ Receives a string representing an element or isotope. Return a tuple containing a string that should represent an element, and either an integer representing the mass number or None if no mass number is available. Raises an `~plasmapy.particles.exceptions.InvalidParticleError` if the mass number information is inputted incorrectly. """ if isotope_info == "D": element_info, mass_numb = "H", 2 elif isotope_info == "T": element_info = "H" mass_numb = 3 elif isotope_info == "p": element_info = "H" mass_numb = 1 elif "-" not in isotope_info: element_info = isotope_info mass_numb = None elif isotope_info.count("-") == 1: element_info, mass_numb_str = isotope_info.split("-") try: mass_numb = int(mass_numb_str) except ValueError: raise InvalidParticleError( f"Invalid mass number in isotope string '{isotope_info}'." ) from None return element_info, mass_numb
def _reconstruct_isotope_symbol(element: str, mass_numb: Integral) -> str: """ Receive a `str` representing an atomic symbol and an `int` representing a mass number. Return the isotope symbol or `None` if no mass number information is available. Raises an `~plasmapy.particles.exceptions.InvalidParticleError` for isotopes that have not yet been discovered. """ if mass_numb is not None: isotope = f"{element}-{mass_numb}" if isotope == "H-2": isotope = "D" elif isotope == "H-3": isotope = "T" if isotope not in _Isotopes.keys(): raise InvalidParticleError( f"The string '{isotope}' does not correspond to " f"a valid isotope.") else: isotope = None return isotope
def __add__(self, other): try: other_as_particle_list = self._cast_other_as_particle_list(other) except (TypeError, InvalidParticleError) as exc: raise InvalidParticleError( f"Cannot add {repr(other)} to a ParticleList.") from exc return ParticleList(self.data + other_as_particle_list.data)
def opposite(particle): try: opposite_particle = ~particle except Exception as exc: raise InvalidParticleError( f"The unary ~ (invert) operator is unable to find the " f"antiparticle of {particle}.") from exc return opposite_particle
def _physical_particle_factory( *args, **kwargs) -> Union[Particle, CustomParticle, ParticleList]: """ Return a representation of one or more physical particles. This function will select the appropriate type among |Particle|, |CustomParticle|, and |ParticleList|. Parameters ---------- *args Positional arguments to be supplied to |Particle|, |CustomParticle|, or |ParticleList|. **kwargs Keyword arguments to be supplied to |Particle|, |CustomParticle|, or |ParticleList|. Raises ------ `InvalidParticleError` If an appropriate particle could not be constructed. `TypeError` If no positional arguments and no keyword arguments were provided. See Also -------- ~plasmapy.particles.particle_class.Particle ~plasmapy.particles.particle_class.CustomParticle ~plasmapy.particles.particle_class.ParticleList Examples -------- >>> from plasmapy.particles._factory import _physical_particle_factory >>> import astropy.units as u >>> _physical_particle_factory("p+") Particle("p+") >>> _physical_particle_factory(mass = 9e-26 * u.kg, charge = 8e20 * u.C) CustomParticle(mass=9e-26 kg, charge=8e+20 C) >>> _physical_particle_factory(["p+", "e-"]) ParticleList(['p+', 'e-']) """ if (len(args) == 1 and not kwargs and isinstance(args[0], (Particle, CustomParticle, ParticleList))): return args[0] if not args and not kwargs: raise TypeError("Particle information has not been provided.") for particle_type in (Particle, CustomParticle, ParticleList): with contextlib.suppress(TypeError, InvalidParticleError): return particle_type(*args, **kwargs) raise InvalidParticleError( f"Unable to create an appropriate particle object with " f"args={args} and kwargs={kwargs}.")
def _atomic_number_to_symbol(atomic_numb: Integral): """ Return the atomic symbol associated with an integer representing an atomic number, or raises an `~plasmapy.particles.exceptions.InvalidParticleError` if the atomic number does not represent a known element. """ if atomic_numb in _atomic_numbers_to_symbols.keys(): return _atomic_numbers_to_symbols[atomic_numb] else: raise InvalidParticleError(f"{atomic_numb} is not a valid atomic number.")
def _cast_other_as_particle_list(other): if isinstance(other, ParticleList): return other with contextlib.suppress(TypeError, InvalidParticleError): return ParticleList(other) try: return ParticleList([other]) except (InvalidParticleError, TypeError): raise InvalidParticleError(f"Cannot cast {other} into a ParticleList")
def _cast_other_as_particle_list(other): if isinstance(other, ParticleList): return other try: return ParticleList(other) except (InvalidParticleError, TypeError): pass try: return ParticleList([other]) except (InvalidParticleError, TypeError): raise InvalidParticleError(f"Cannot cast {other} into a ParticleList")
def _get_element(element_info: str) -> str: """ Receive a `str` representing an element's symbol or name, and returns a `str` representing the atomic symbol. """ if element_info.lower() in _element_names_to_symbols.keys(): element = _element_names_to_symbols[element_info.lower()] elif element_info in _atomic_numbers_to_symbols.values(): element = element_info else: raise InvalidParticleError( f"The string '{element_info}' does not correspond to " f"a valid element.") return element
def __getitem__(self, value) -> IonicLevel: """Return information for a single ionization level.""" if isinstance(value, slice): return [ IonicLevel( ion=Particle(self.base_particle, Z=val), ionic_fraction=self.ionic_fractions[val], number_density=self.number_densities[val], T_i=self.T_i[val], ) for val in range(0, self._number_of_particles)[value] ] if isinstance(value, Integral) and 0 <= value <= self.atomic_number: result = IonicLevel( ion=Particle(self.base_particle, Z=value), ionic_fraction=self.ionic_fractions[value], number_density=self.number_densities[value], T_i=self.T_i[value], ) else: if not isinstance(value, Particle): try: value = Particle(value) except InvalidParticleError as exc: raise InvalidParticleError( f"{value} is not a valid charge number or particle." ) from exc same_element = value.element == self.element same_isotope = value.isotope == self.isotope has_charge_info = value.is_category( any_of=["charged", "uncharged"]) if same_element and same_isotope and has_charge_info: Z = value.charge_number result = IonicLevel( ion=Particle(self.base_particle, Z=Z), ionic_fraction=self.ionic_fractions[Z], number_density=self.number_densities[Z], T_i=self.T_i[Z], ) else: if not same_element or not same_isotope: raise ParticleError("Inconsistent element or isotope.") elif not has_charge_info: raise ChargeError("No charge number provided.") return result
def __getitem__(self, value) -> State: """Return information for a single ionization level.""" if isinstance(value, slice): raise TypeError("IonizationState instances cannot be sliced.") if isinstance(value, Integral) and 0 <= value <= self.atomic_number: result = State( value, self.ionic_fractions[value], self.ionic_symbols[value], self.number_densities[value], ) else: if not isinstance(value, Particle): try: value = Particle(value) except InvalidParticleError as exc: raise InvalidParticleError( f"{value} is not a valid integer charge or " f"particle.") from exc same_element = value.element == self.element same_isotope = value.isotope == self.isotope has_charge_info = value.is_category( any_of=["charged", "uncharged"]) if same_element and same_isotope and has_charge_info: Z = value.integer_charge result = State( Z, self.ionic_fractions[Z], self.ionic_symbols[Z], self.number_densities[Z], ) else: if not same_element or not same_isotope: raise AtomicError("Inconsistent element or isotope.") elif not has_charge_info: raise ChargeError("No integer charge provided.") return result
def stable_isotopes(argument: Union[str, Integral] = None, unstable: bool = False) -> List[str]: """ Return a list of all stable isotopes of an element, or if no input is provided, a list of all such isotopes for every element. Parameters ---------- argument: `int` or `str` A string or integer representing an atomic number or element, or a string representing an isotope. unstable: `bool` If set to `True`, this function will return a list of the unstable isotopes instead of the stable isotopes. Returns ------- StableIsotopes: `list` of strings or empty list List of all stable isotopes of an element, sorted from lowest mass number. If an element has no stable isotopes, this function returns an empty list. Raises ------ `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` If the argument is not a string or integer. Notes ----- There are 254 isotopes for which no radioactive decay has been observed. It is possible that some isotopes will be discovered to be unstable but with extremely long half-lives. For example, bismuth-209 was recently discovered to have a half-life of about 1.9e19 years. However, such isotopes can be regarded as virtually stable for most applications. See Also -------- known_isotopes : returns a list of isotopes that have been discovered. common_isotopes : returns isotopes with non-zero isotopic abundances. Examples -------- >>> stable_isotopes('H') ['H-1', 'D'] >>> stable_isotopes(44) ['Ru-96', 'Ru-98', 'Ru-99', 'Ru-100', 'Ru-101', 'Ru-102', 'Ru-104'] >>> stable_isotopes('beryllium') ['Be-9'] >>> stable_isotopes('Pb-209') ['Pb-204', 'Pb-206', 'Pb-207', 'Pb-208'] >>> stable_isotopes(118) [] Find unstable isotopes using the ``unstable`` keyword. >>> stable_isotopes('U', unstable=True)[:5] # only first five ['U-217', 'U-218', 'U-219', 'U-220', 'U-221'] """ # TODO: Allow Particle objects representing elements to be inputs def stable_isotopes_for_element(argument: Union[str, int], stable_only: Optional[bool]) -> List[str]: KnownIsotopes = known_isotopes(argument) StableIsotopes = [ isotope for isotope in KnownIsotopes if _isotopes[isotope]["stable"] == stable_only ] return StableIsotopes if argument is not None: try: element = atomic_symbol(argument) isotopes_list = stable_isotopes_for_element(element, not unstable) except InvalidParticleError: raise InvalidParticleError("Invalid particle in stable_isotopes") except InvalidElementError: raise InvalidElementError( "stable_isotopes is unable to get isotopes " f"from an input of: {argument}") elif argument is None: isotopes_list = [] for atomic_numb in range(1, 119): isotopes_list += stable_isotopes_for_element( atomic_numb, not unstable) return isotopes_list
def common_isotopes(argument: Union[str, Integral] = None, most_common_only: bool = False) -> List[str]: """ Return a list of isotopes of an element with an isotopic abundances greater than zero, or if no input is provided, a list of all such isotopes for every element. Parameters ---------- argument: `int` or `str`, optional A string or integer representing an atomic number or element, or a string representing an isotope. most_common_only: `bool` If set to `True`, return only the most common isotope. Returns ------- isotopes_list: `list` of `str` or empty `list` List of all isotopes of an element with isotopic abundances greater than zero, sorted from most abundant to least abundant. If no isotopes have isotopic abundances greater than zero, this function will return an empty list. If no arguments are provided, then a list of all common isotopes of all elements will be provided that is sorted by atomic number, with entries for each element sorted from most abundant to least abundant. Raises ------ `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` If the argument is not a string or integer. Notes ----- The isotopic abundances are based on the terrestrial environment and may not be appropriate for space and astrophysical applications. See Also -------- known_isotopes : returns a list of isotopes that have been discovered. stable_isotopes : returns isotopes that are stable against radioactive decay. isotopic_abundance : returns the relative isotopic abundance. Examples -------- >>> common_isotopes('H') ['H-1', 'D'] >>> common_isotopes(44) ['Ru-102', 'Ru-104', 'Ru-101', 'Ru-99', 'Ru-100', 'Ru-96', 'Ru-98'] >>> common_isotopes('beryllium 2+') ['Be-9'] >>> common_isotopes('Fe') ['Fe-56', 'Fe-54', 'Fe-57', 'Fe-58'] >>> common_isotopes('Fe', most_common_only=True) ['Fe-56'] >>> common_isotopes()[0:7] ['H-1', 'D', 'He-4', 'He-3', 'Li-7', 'Li-6', 'Be-9'] """ # TODO: Allow Particle objects representing elements to be inputs def common_isotopes_for_element( argument: Union[str, int], most_common_only: Optional[bool]) -> List[str]: isotopes = known_isotopes(argument) CommonIsotopes = [ isotope for isotope in isotopes if "abundance" in _isotopes[isotope].keys() ] isotopic_abundances = [ _isotopes[isotope]["abundance"] for isotope in CommonIsotopes ] sorted_isotopes = [ iso_comp for (isotope, iso_comp) in sorted(zip(isotopic_abundances, CommonIsotopes)) ] sorted_isotopes.reverse() if most_common_only and len(sorted_isotopes) > 1: sorted_isotopes = sorted_isotopes[0:1] return sorted_isotopes if argument is not None: try: element = atomic_symbol(argument) isotopes_list = common_isotopes_for_element( element, most_common_only) except InvalidParticleError: raise InvalidParticleError("Invalid particle") except InvalidElementError: raise InvalidElementError( "common_isotopes is unable to get isotopes " f"from an input of: {argument}") elif argument is None: isotopes_list = [] for atomic_numb in range(1, 119): isotopes_list += common_isotopes_for_element( atomic_numb, most_common_only) return isotopes_list
def known_isotopes(argument: Union[str, Integral] = None) -> List[str]: """ Return a list of all known isotopes of an element, or a list of all known isotopes of every element if no input is provided. Parameters ---------- argument: `int` or `str`, optional A string representing an element, isotope, or ion or an integer representing an atomic number Returns ------- isotopes_list: `list` containing `str` items or an empty `list` List of all of the isotopes of an element that have been discovered, sorted from lowest mass number to highest mass number. If no argument is provided, then a list of all known isotopes of every element will be returned that is sorted by atomic number, with entries for each element sorted by mass number. Raises ------ `~plasmapy.particles.exceptions.InvalidElementError` If the argument is a valid particle but not a valid element. `~plasmapy.particles.exceptions.InvalidParticleError` If the argument does not correspond to a valid particle. `TypeError` If the argument is not a `str` or `int`. Notes ----- This list returns both natural and artificially produced isotopes. See Also -------- common_isotopes : returns isotopes with non-zero isotopic abundances. stable_isotopes : returns isotopes that are stable against radioactive decay. Examples -------- >>> known_isotopes('H') ['H-1', 'D', 'T', 'H-4', 'H-5', 'H-6', 'H-7'] >>> known_isotopes('helium 1+') ['He-3', 'He-4', 'He-5', 'He-6', 'He-7', 'He-8', 'He-9', 'He-10'] >>> known_isotopes()[0:10] ['H-1', 'D', 'T', 'H-4', 'H-5', 'H-6', 'H-7', 'He-3', 'He-4', 'He-5'] >>> len(known_isotopes()) # the number of known isotopes 3352 """ # TODO: Allow Particle objects representing elements to be inputs def known_isotopes_for_element(argument): element = atomic_symbol(argument) isotopes = [] for isotope in _isotopes.keys(): if element + "-" in isotope and isotope[0:len(element)] == element: isotopes.append(isotope) if element == "H": isotopes.insert(1, "D") isotopes.insert(2, "T") mass_numbers = [mass_number(isotope) for isotope in isotopes] sorted_isotopes = [ mass_number for (isotope, mass_number) in sorted(zip(mass_numbers, isotopes)) ] return sorted_isotopes if argument is not None: try: element = atomic_symbol(argument) isotopes_list = known_isotopes_for_element(element) except InvalidElementError: raise InvalidElementError("known_isotopes is unable to get " f"isotopes from an input of: {argument}") except InvalidParticleError: raise InvalidParticleError("Invalid particle in known_isotopes.") elif argument is None: isotopes_list = [] for atomic_numb in range(1, len(_elements.keys()) + 1): isotopes_list += known_isotopes_for_element(atomic_numb) return isotopes_list
def get_particle(argname, params, already_particle, funcname): argval, Z, mass_numb = params """ Convert the argument to a `~plasmapy.particles.particle_class.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. _charge_number = particle._attributes["charge number"] must_be_charged = "charged" in require must_have_charge_info = set(any_of) == {"charged", "uncharged"} uncharged = _charge_number == 0 lacks_charge_info = _charge_number 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 ParticleError( _category_errmsg(particle, require, exclude, any_of, funcname)) return particle
def extract_charge(arg: str): """ Receive a `str` representing an element, isotope, or ion. Return a `tuple` containing a `str` that should represent an element or isotope, and either an `int` representing the charge or `None` if no charge information is provided. Raise an `~plasmapy.particles.exceptions.InvalidParticleError` if charge information is inputted incorrectly. """ invalid_charge_errmsg = ( f"Invalid charge information in the particle string '{arg}'.") match = re.fullmatch( r"\s*(?P<isotope>\w+(-[0-9]+)*([+-]*)*)" r"(\s*(?P<charge>[+-]?[IVXLCDM0-9]+[+-]?)*)*\s*", arg, ) if match is None: raise InvalidParticleError(invalid_charge_errmsg) from None isotope_info = match.groupdict()["isotope"] charge_info = match.groupdict()["charge"] Z_from_arg = None if charge_info is not None and isotope_info.endswith(("-", "+")): # charge info is defined on both the charge_info and isotope_into strings raise InvalidParticleError(invalid_charge_errmsg) from None elif charge_info is not None: # Cases like 'H 1-' and 'Fe-56 1+' sign_indicator_only_on_one_end = charge_info.endswith( ("-", "+")) ^ charge_info.startswith(("-", "+")) just_one_sign_indicator = (charge_info.count("-") == 1 and charge_info.count("+") == 0) or (charge_info.count("-") == 0 and charge_info.count("+") == 1) if not sign_indicator_only_on_one_end and just_one_sign_indicator: raise InvalidParticleError(invalid_charge_errmsg) from None charge_str = charge_info.strip("+-") try: if roman.is_roman_numeral(charge_info): Z_from_arg = roman.from_roman(charge_info) - 1 elif "-" in charge_info: Z_from_arg = -int(charge_str) elif "+" in charge_info: Z_from_arg = int(charge_str) else: raise InvalidParticleError(invalid_charge_errmsg) from None except ValueError: raise InvalidParticleError(invalid_charge_errmsg) from None elif isotope_info.endswith(("-", "+")): # Cases like 'H-' and 'Pb-209+++' match = re.fullmatch( r"\s*(?P<isotope>\w+(-[0-9]+)?)(?P<charge>[-+]+)\s*", isotope_info, ) isotope_info = match.groupdict()["isotope"] charge_info = match.groupdict()["charge"] if len(set(charge_info)) != 1: raise InvalidParticleError(invalid_charge_errmsg) from None Z_from_arg = len(charge_info) if charge_info[0] == "-": Z_from_arg = -Z_from_arg return isotope_info, Z_from_arg
def _parse_and_check_atomic_input(argument: Union[str, Integral], mass_numb: Integral = None, Z: Integral = None): """ Parse information about a particle into a dictionary of standard symbols, and check the validity of the particle. Parameters ---------- argument : `str` or `int` String containing information for an element, isotope, or ion in any of the allowed formats; or an integer representing an atomic number. mass_numb : `int`, optional The mass number of an isotope. Z : `int`, optional The integer charge of an ion. Returns ------- nomenclature_dict : `dict` A dictionary containing information about the element, isotope, or ion. The key ``'symbol'`` corresponds to the particle symbol containing the most information, ``'element'`` corresponds to the atomic symbol, ``'isotope'`` corresponds to the isotope symbol, ``'ion'`` corresponds to the ion symbol, ``'mass_numb'`` corresponds to the mass number, and ``'Z'`` corresponds to the integer charge. The corresponding items will be given by `None` if the necessary information is not provided. Raises ------ `~plasmapy.particles.exceptions.InvalidParticleError` If the arguments do not correspond to a valid particle or antiparticle. `~plasmapy.particles.exceptions.InvalidElementError` If the particle is valid but does not correspond to an element, ion, or isotope. `TypeError` If the argument or any of the keyword arguments is not of the correct type. """ def _atomic_number_to_symbol(atomic_numb: Integral): """ Return the atomic symbol associated with an integer representing an atomic number, or raises an `~plasmapy.particles.exceptions.InvalidParticleError` if the atomic number does not represent a known element. """ if atomic_numb in _atomic_numbers_to_symbols.keys(): return _atomic_numbers_to_symbols[atomic_numb] else: raise InvalidParticleError( f"{atomic_numb} is not a valid atomic number.") def _extract_charge(arg: str): """ Receive a `str` representing an element, isotope, or ion. Return a `tuple` containing a `str` that should represent an element or isotope, and either an `int` representing the charge or `None` if no charge information is provided. Raise an `~plasmapy.particles.exceptions.InvalidParticleError` if charge information is inputted incorrectly. """ invalid_charge_errmsg = ( f"Invalid charge information in the particle string '{arg}'.") if arg.count(" ") == 1: # Cases like 'H 1-' and 'Fe-56 1+' isotope_info, charge_info = arg.split(" ") sign_indicator_only_on_one_end = charge_info.endswith( ("-", "+")) ^ charge_info.startswith(("-", "+")) just_one_sign_indicator = (charge_info.count("-") == 1 and charge_info.count("+") == 0) or ( charge_info.count("-") == 0 and charge_info.count("+") == 1) if not sign_indicator_only_on_one_end and just_one_sign_indicator: raise InvalidParticleError(invalid_charge_errmsg) from None charge_str = charge_info.strip("+-") try: if roman.is_roman_numeral(charge_info): Z_from_arg = roman.from_roman(charge_info) - 1 elif "-" in charge_info: Z_from_arg = -int(charge_str) elif "+" in charge_info: Z_from_arg = int(charge_str) else: raise InvalidParticleError(invalid_charge_errmsg) from None except ValueError: raise InvalidParticleError(invalid_charge_errmsg) from None elif arg.endswith(("-", "+")): # Cases like 'H-' and 'Pb-209+++' char = arg[-1] match = re.match(f"[{char}]*", arg[::-1]) Z_from_arg = match.span()[1] isotope_info = arg[0:len(arg) - match.span()[1]] if char == "-": Z_from_arg = -Z_from_arg if isotope_info.endswith(("-", "+")): raise InvalidParticleError(invalid_charge_errmsg) from None else: isotope_info = arg Z_from_arg = None return isotope_info, Z_from_arg def _extract_mass_number(isotope_info: str): """ Receives a string representing an element or isotope. Return a tuple containing a string that should represent an element, and either an integer representing the mass number or None if no mass number is available. Raises an `~plasmapy.particles.exceptions.InvalidParticleError` if the mass number information is inputted incorrectly. """ if isotope_info == "D": element_info, mass_numb = "H", 2 elif isotope_info == "T": element_info = "H" mass_numb = 3 elif isotope_info == "p": element_info = "H" mass_numb = 1 elif "-" not in isotope_info: element_info = isotope_info mass_numb = None elif isotope_info.count("-") == 1: element_info, mass_numb_str = isotope_info.split("-") try: mass_numb = int(mass_numb_str) except ValueError: raise InvalidParticleError( f"Invalid mass number in isotope string '{isotope_info}'." ) from None return element_info, mass_numb def _get_element(element_info: str) -> str: """ Receive a `str` representing an element's symbol or name, and returns a `str` representing the atomic symbol. """ if element_info.lower() in _element_names_to_symbols.keys(): element = _element_names_to_symbols[element_info.lower()] elif element_info in _atomic_numbers_to_symbols.values(): element = element_info else: raise InvalidParticleError( f"The string '{element_info}' does not correspond to " f"a valid element.") return element def _reconstruct_isotope_symbol(element: str, mass_numb: Integral) -> str: """ Receive a `str` representing an atomic symbol and an `int` representing a mass number. Return the isotope symbol or `None` if no mass number information is available. Raises an `~plasmapy.particles.exceptions.InvalidParticleError` for isotopes that have not yet been discovered. """ if mass_numb is not None: isotope = f"{element}-{mass_numb}" if isotope == "H-2": isotope = "D" elif isotope == "H-3": isotope = "T" if isotope not in _Isotopes.keys(): raise InvalidParticleError( f"The string '{isotope}' does not correspond to " f"a valid isotope.") else: isotope = None return isotope def _reconstruct_ion_symbol(element: str, isotope: Integral = None, Z: Integral = None): """ Receive a `str` representing an atomic symbol and/or a string representing an isotope, and an `int` representing the integer charge. Return a `str` representing the ion symbol, or `None` if no charge information is available. """ if Z is not None: if Z < 0: sign = "-" else: sign = "+" if isotope is None: base = element else: base = isotope ion = f"{base} {np.abs(Z)}{sign}" else: ion = None if ion == "H-1 1+": ion = "p+" return ion if not isinstance(argument, (str, Integral)): # coverage: ignore raise TypeError( f"The argument {argument} is not an integer or string.") arg = _dealias_particle_aliases(argument) if arg in ParticleZoo.everything - {"p+"}: if (mass_numb is not None) or (Z is not None): raise InvalidParticleError( f"The keywords mass_numb and Z should not be specified " f"for particle '{argument}', which is a special particle.") else: raise InvalidElementError(f"{argument} is not a valid element.") if isinstance(arg, str) and arg.isdigit(): arg = int(arg) if isinstance(arg, Integral): element = _atomic_number_to_symbol(arg) Z_from_arg = None mass_numb_from_arg = None elif isinstance(arg, str): isotope_info, Z_from_arg = _extract_charge(arg) element_info, mass_numb_from_arg = _extract_mass_number(isotope_info) element = _get_element(element_info) if mass_numb is not None and mass_numb_from_arg is not None: if mass_numb != mass_numb_from_arg: raise InvalidParticleError( "The mass number extracted from the particle string " f"'{argument}' is inconsistent with the keyword mass_numb = " f"{mass_numb}.") else: warnings.warn( "Redundant mass number information for particle " f"'{argument}' with mass_numb = {mass_numb}.", ParticleWarning, ) if mass_numb_from_arg is not None: mass_numb = mass_numb_from_arg if Z is not None and Z_from_arg is not None: if Z != Z_from_arg: raise InvalidParticleError( "The integer charge extracted from the particle string " f"'{argument}' is inconsistent with the keyword Z = {Z}.") else: warnings.warn( "Redundant charge information for particle " f"'{argument}' with Z = {Z}.", ParticleWarning, ) if Z_from_arg is not None: Z = Z_from_arg if isinstance(Z, Integral): if Z > _Elements[element]["atomic number"]: raise InvalidParticleError( f"The integer charge Z = {Z} cannot exceed the atomic number " f"of {element}, which is {_Elements[element]['atomic number']}." ) elif Z <= -3: warnings.warn( f"Particle '{argument}' has an integer charge " f"of Z = {Z}, which is unlikely to occur in " f"nature.", ParticleWarning, ) isotope = _reconstruct_isotope_symbol(element, mass_numb) ion = _reconstruct_ion_symbol(element, isotope, Z) if ion: symbol = ion elif isotope: symbol = isotope else: symbol = element nomenclature_dict = { "symbol": symbol, "element": element, "isotope": isotope, "ion": ion, "mass number": mass_numb, "integer charge": Z, } return nomenclature_dict
def parse_and_check_atomic_input(argument: Union[str, Integral], mass_numb: Integral = None, Z: Integral = None): """ Parse information about a particle into a dictionary of standard symbols, and check the validity of the particle. Parameters ---------- argument : `str` or `int` String containing information for an element, isotope, or ion in any of the allowed formats; or an integer representing an atomic number. mass_numb : `int`, optional The mass number of an isotope. Z : `int`, optional The charge number of an ion. Returns ------- nomenclature_dict : `dict` A dictionary containing information about the element, isotope, or ion. The key ``'symbol'`` corresponds to the particle symbol containing the most information, ``'element'`` corresponds to the atomic symbol, ``'isotope'`` corresponds to the isotope symbol, ``'ion'`` corresponds to the ion symbol, ``'mass_numb'`` corresponds to the mass number, and ``'Z'`` corresponds to the charge number. The corresponding items will be given by `None` if the necessary information is not provided. Raises ------ `~plasmapy.particles.exceptions.InvalidParticleError` If the arguments do not correspond to a valid particle or antiparticle. `~plasmapy.particles.exceptions.InvalidElementError` If the particle is valid but does not correspond to an element, ion, or isotope. `TypeError` If the argument or any of the keyword arguments is not of the correct type. """ def atomic_number_to_symbol(atomic_numb: Integral): """ Return the atomic symbol associated with an integer representing an atomic number, or raises an `~plasmapy.particles.exceptions.InvalidParticleError` if the atomic number does not represent a known element. """ if atomic_numb in _elements.atomic_numbers_to_symbols: return _elements.atomic_numbers_to_symbols[atomic_numb] else: raise InvalidParticleError( f"{atomic_numb} is not a valid atomic number.") def extract_mass_number(isotope_info: str): """ Receives a string representing an element or isotope. Return a tuple containing a string that should represent an element, and either an integer representing the mass number or None if no mass number is available. Raises an `~plasmapy.particles.exceptions.InvalidParticleError` if the mass number information is inputted incorrectly. """ if isotope_info == "D": element_info, mass_numb = "H", 2 elif isotope_info == "T": element_info = "H" mass_numb = 3 elif isotope_info == "p": element_info = "H" mass_numb = 1 elif "-" not in isotope_info: element_info = isotope_info mass_numb = None elif isotope_info.count("-") == 1: element_info, mass_numb_str = isotope_info.split("-") try: mass_numb = int(mass_numb_str) except ValueError: raise InvalidParticleError( f"Invalid mass number in isotope string '{isotope_info}'." ) from None return element_info, mass_numb def get_element(element_info: str) -> str: """ Receive a `str` representing an element's symbol or name, and returns a `str` representing the atomic symbol. """ if element_info.lower() in _elements.element_names_to_symbols: element = _elements.element_names_to_symbols[element_info.lower()] elif element_info in _elements.atomic_numbers_to_symbols.values(): element = element_info else: raise InvalidParticleError( f"The string '{element_info}' does not correspond to " f"a valid element.") return element def reconstruct_isotope_symbol(element: str, mass_numb: Integral) -> str: """ Receive a `str` representing an atomic symbol and an `int` representing a mass number. Return the isotope symbol or `None` if no mass number information is available. Raises an `~plasmapy.particles.exceptions.InvalidParticleError` for isotopes that have not yet been discovered. """ if mass_numb is not None: isotope = f"{element}-{mass_numb}" if isotope == "H-2": isotope = "D" elif isotope == "H-3": isotope = "T" if isotope not in _isotopes.data_about_isotopes: raise InvalidParticleError( f"The string '{isotope}' does not correspond to " f"a valid isotope.") else: isotope = None return isotope def reconstruct_ion_symbol(element: str, isotope: Integral = None, Z: Integral = None): """ Receive a `str` representing an atomic symbol and/or a string representing an isotope, and an `int` representing the charge number. Return a `str` representing the ion symbol, or `None` if no charge information is available. """ if Z is not None: sign = "-" if Z < 0 else "+" base = element if isotope is None else isotope ion = f"{base} {np.abs(Z)}{sign}" else: ion = None if ion == "H-1 1+": ion = "p+" return ion if not isinstance(argument, (str, Integral)): # coverage: ignore raise TypeError( f"The argument {argument} is not an integer or string.") arg = dealias_particle_aliases(argument) if arg in _special_particles.particle_zoo.everything - {"p+"}: if (mass_numb is not None) or (Z is not None): raise InvalidParticleError( f"The keywords mass_numb and Z should not be specified " f"for particle '{argument}', which is a special particle.") else: raise InvalidElementError(f"{argument} is not a valid element.") if isinstance(arg, str) and arg.isdigit(): arg = int(arg) if isinstance(arg, Integral): element = atomic_number_to_symbol(arg) Z_from_arg = None mass_numb_from_arg = None elif isinstance(arg, str): isotope_info, Z_from_arg = extract_charge(arg) element_info, mass_numb_from_arg = extract_mass_number(isotope_info) element = get_element(element_info) if mass_numb is not None and mass_numb_from_arg is not None: if mass_numb != mass_numb_from_arg: raise InvalidParticleError( "The mass number extracted from the particle string " f"'{argument}' is inconsistent with the keyword mass_numb = " f"{mass_numb}.") else: warnings.warn( "Redundant mass number information for particle " f"'{argument}' with mass_numb = {mass_numb}.", ParticleWarning, ) if mass_numb_from_arg is not None: mass_numb = mass_numb_from_arg if Z is not None and Z_from_arg is not None: if Z != Z_from_arg: raise InvalidParticleError( "The charge number extracted from the particle string " f"'{argument}' is inconsistent with the keyword Z = {Z}.") else: warnings.warn( "Redundant charge information for particle " f"'{argument}' with Z = {Z}.", ParticleWarning, ) if Z_from_arg is not None: Z = Z_from_arg if isinstance(Z, Integral): if Z > _elements.data_about_elements[element]["atomic number"]: raise InvalidParticleError( f"The charge number Z = {Z} cannot exceed the atomic number " f"of {element}, which is " f"{_elements.data_about_elements[element]['atomic number']}.") elif Z <= -3: warnings.warn( f"Particle '{argument}' has a charge number " f"of Z = {Z}, which is unlikely to occur in " f"nature.", ParticleWarning, ) isotope = reconstruct_isotope_symbol(element, mass_numb) ion = reconstruct_ion_symbol(element, isotope, Z) if ion: symbol = ion elif isotope: symbol = isotope else: symbol = element return { "symbol": symbol, "element": element, "isotope": isotope, "ion": ion, "mass number": mass_numb, "charge number": Z, }
def parse_and_check_molecule_input(argument: str, Z: Integral = None): """ Separate the constitutive elements and charge of a molecule symbol. Parameters ---------- argument : `str` The molecule symbol to be parsed. Z : `int`, optional The provided charge number. Returns ------- elements_dict : `dict` A dictionary with identified element symbols as keys and the number of each element that make up the molecule as values. For example, ``argument="CO2"`` would lead to ``elements_dict`` being ``{"C": 1, "O": 2}``. molecule_info : `str` The molecule symbol stripped of its charge. Z : `int` The charge number of the molecule. Raises ------ `InvalidParticleError` If ``argument`` could not be parsed as a molecule. Warns ----- : `ParticleWarning` If the charge is given both as an argument and in the symbol. """ molecule_info, z_from_arg = extract_charge(argument) if not re.fullmatch(r"(?:[A-Z][a-z]?\d*)+", molecule_info): raise InvalidParticleError( f"{molecule_info} is not recognized as a molecule symbol.") elements_dict = {} for match in re.finditer(r"([A-Z][a-z]?)(\d+)?", molecule_info): element, amount = match.groups(default="1") if element in elements_dict: elements_dict[element] += int(amount) else: elements_dict[element] = int(amount) if Z is not None and z_from_arg is not None: if Z != z_from_arg: raise InvalidParticleError( "The charge number extracted from the particle string " f"{argument!r} is inconsistent with the keyword Z = {Z}.") else: warnings.warn( "Redundant charge information for particle " f"'{argument}' with Z = {Z}.", ParticleWarning, ) if z_from_arg is not None: Z = z_from_arg return elements_dict, molecule_info, Z