def __init__(self, particle: Particle, ionic_fractions=None, *, T_e: u.K = np.nan * u.K, kappa: Real = np.inf, n_elem: u.m**-3 = np.nan * u.m**-3, tol: Union[float, int] = 1e-15): """Initialize an `~plasmapy.atomic.IonizationState` instance.""" self._particle_instance = particle try: self.tol = tol self.T_e = T_e self.kappa = kappa if not np.isnan(n_elem) and isinstance(ionic_fractions, u.Quantity) and \ ionic_fractions.si.unit == u.m ** -3: raise AtomicError( "Cannot simultaneously provide number density " "through both n_elem and ionic_fractions.") self.n_elem = n_elem self.ionic_fractions = ionic_fractions if ionic_fractions is None and not np.isnan(self.T_e): warnings.warn( "Collisional ionization equilibration has not yet " "been implemented in IonizationState; cannot set " "ionic fractions.") except Exception as exc: raise AtomicError(f"Unable to create IonizationState instance for " f"{particle.particle}.") from exc
def test_particle_input_simple(func, args, kwargs, symbol): """ Test that simple functions decorated by particle_input correctly return the correct Particle object. """ try: expected = Particle(symbol) except Exception as e: raise AtomicError( f"Cannot create Particle class from symbol {symbol}") from e try: result = func(*args, **kwargs) except Exception as e: raise AtomicError( f"An exception was raised while trying to execute " f"{func} with args = {args} and kwargs = {kwargs}.") from e assert result == expected, ( f"The result {repr(result)} does not equal the expected value of " f"{repr(expected)}.\n\n" f"func = {func}\n" f"args = {args}\n" f"kwargs = {kwargs}\nsymbol = {symbol}\n" f"{result._attributes}\n" f"{expected._attributes}\n")
def T_e(self, value: u.K): """Set the electron temperature.""" try: value = value.to(u.K, equivalencies=u.temperature_energy()) except (AttributeError, u.UnitsError, u.UnitConversionError): raise AtomicError("Invalid temperature.") from None else: if value < 0 * u.K: raise AtomicError("T_e cannot be negative.") self._T_e = value
def abundances(self, abundances_dict: Optional[Dict]): """ Set the elemental (or isotopic) abundances. The elements and isotopes must be the same as or a superset of the elements whose ionization states are being tracked. """ if abundances_dict is None: self._pars['abundances'] = { elem: np.nan for elem in self.base_particles } elif not isinstance(abundances_dict, dict): raise TypeError(f"The abundances attribute must be a dict with " f"elements or isotopes as keys and real numbers " f"representing relative abundances as values.") else: old_keys = abundances_dict.keys() try: new_keys_dict = { particle_symbol(old_key): old_key for old_key in old_keys } except Exception: raise AtomicError( f"The key {repr(old_key)} in the abundances " f"dictionary is not a valid element or isotope.") new_elements = new_keys_dict.keys() old_elements_set = set(self.base_particles) new_elements_set = set(new_elements) if old_elements_set - new_elements_set: raise AtomicError( f"The abundances of the following particles are " f"missing: {old_elements_set - new_elements_set}") new_abundances_dict = {} for element in new_elements: inputted_abundance = abundances_dict[new_keys_dict[element]] try: inputted_abundance = float(inputted_abundance) except Exception: raise TypeError( f"The abundance for {element} was provided as" f"{inputted_abundance}, which cannot be " f"converted to a real number.") from None if inputted_abundance < 0: raise AtomicError( f"The abundance of {element} is negative.") new_abundances_dict[element] = inputted_abundance self._pars['abundances'] = new_abundances_dict
def number_densities(self, value: u.m**-3): """Set the number densities for each state.""" if np.any(value.value < 0): raise AtomicError("Number densities cannot be negative.") if len(value) != self.atomic_number + 1: raise AtomicError(f"Incorrect number of charge states for " f"{self.base_particle}") value = value.to(u.m**-3) self._n_elem = value.sum() self._ionic_fractions = value / self._n_elem
def T_e(self, electron_temperature: u.K): """Set the electron temperature.""" try: temperature = electron_temperature.to( u.K, equivalencies=u.temperature_energy()) except (AttributeError, u.UnitsError): raise AtomicError( f"{electron_temperature} is not a valid temperature." ) from None if temperature < 0 * u.K: raise AtomicError("The electron temperature cannot be negative.") self._pars['T_e'] = temperature
def n(self, n: u.m**-3): """Set the number density scaling factor.""" try: n = n.to(u.m**-3) except u.UnitConversionError as exc: raise AtomicError( "Units cannot be converted to u.m ** -3.") from exc except Exception as exc: raise AtomicError(f"{n} is not a valid number density.") from exc if n < 0 * u.m**-3: raise AtomicError("Number density cannot be negative.") self._pars['n'] = n.to(u.m**-3)
def process_particles_list( unformatted_particles_list: List[Union[str, Particle]]) \ -> List[Particle]: """ Take an unformatted list of particles and puts each particle into standard form, while allowing an integer and asterisk immediately preceding a particle to act as a multiplier. A string argument will be treated as a list containing that string as its sole item. """ if isinstance(unformatted_particles_list, str): unformatted_particles_list = [unformatted_particles_list] if not isinstance(unformatted_particles_list, (list, tuple)): raise TypeError("The input to process_particles_list should be a " "string, list, or tuple.") particles = [] for original_item in unformatted_particles_list: try: item = original_item.strip() if item.count('*') == 1 and item[0].isdigit(): multiplier_str, item = item.split('*') multiplier = int(multiplier_str) else: multiplier = 1 try: particle = Particle(item) except (InvalidParticleError) as exc: raise AtomicError(errmsg) from exc if particle.element and not particle.isotope: raise AtomicError(errmsg) [particles.append(particle) for i in range(multiplier)] except Exception: raise AtomicError( f"{original_item} is not a valid reactant or " "product in a nuclear reaction.") from None return particles
def test_particle_input_classes(): instance = Test_particle_input() symbol = 'muon' expected = Particle(symbol) try: result_noparens = instance.method_noparens(symbol) except Exception as e: raise AtomicError("Problem with method_noparens") from e try: result_parens = instance.method_parens(symbol) except Exception as e: raise AtomicError("Problem with method_parens") from e assert result_parens == result_noparens == expected
def __init__(self, inputs: Union[Dict[str, np.ndarray], List, Tuple], *, T_e: u.K = np.nan * u.K, equilibrate: Optional[bool] = None, abundances: Optional[Dict[str, Real]] = None, log_abundances: Optional[Dict[str, Real]] = None, n: u.m**-3 = np.nan * u.m**-3, tol: Real = 1e-15, kappa: Real = np.inf): abundances_provided = abundances is not None or log_abundances is not None set_abundances = True if isinstance(inputs, dict): all_quantities = np.all( [isinstance(fracs, u.Quantity) for fracs in inputs.values()]) if all_quantities: right_units = np.all( [fracs[0].si.unit == u.m**-3 for fracs in inputs.values()]) if not right_units: raise AtomicError( "Units must be inverse volume for number densities.") if abundances_provided: raise AtomicError( "Abundances cannot be provided if inputs " "provides number density information.") set_abundances = False try: self._pars = collections.defaultdict(lambda: None) self.T_e = T_e self.n = n self.tol = tol self.ionic_fractions = inputs if set_abundances: self.abundances = abundances self.log_abundances = log_abundances self.kappa = kappa except Exception as exc: raise AtomicError( "Unable to create IonizationStates instance.") from exc if equilibrate: self.equilibrate() # for now, this raises a NotImplementedError
def __eq__(self, other): if not isinstance(other, IonizationStates): raise TypeError( "IonizationStates instance can only be compared with " "other IonizationStates instances.") if self.base_particles != other.base_particles: raise AtomicError( "Two IonizationStates instances can be compared only " "if the base particles are the same.") min_tol = np.min([self.tol, other.tol]) # Check any of a whole bunch of equality measures, recalling # that np.nan == np.nan is False. for attribute in ['T_e', 'n_e', 'kappa']: this = eval(f"self.{attribute}") that = eval(f"other.{attribute}") # TODO: Maybe create a function in utils called same_enough # TODO: that would take care of all of these disparate # TODO: equality measures. this_equals_that = np.any([ this == that, this is that, np.isnan(this) and np.isnan(that), np.isinf(this) and np.isinf(that), u.quantity.allclose(this, that, rtol=min_tol), ]) if not this_equals_that: return False for attribute in ['ionic_fractions', 'number_densities']: this_dict = eval(f"self.{attribute}") that_dict = eval(f"other.{attribute}") for particle in self.base_particles: this = this_dict[particle] that = that_dict[particle] this_equals_that = np.any([ this is that, np.all(np.isnan(this)) and np.all(np.isnan(that)), u.quantity.allclose(this, that, rtol=min_tol), ]) if not this_equals_that: return False return True
def function_to_test_annotations(particles: Union[Tuple, List], resulting_particles): """ Test that a function with an argument annotated with (Particle, Particle, ...) or [Particle] returns a tuple of expected Particle instances. Arguments ========= particles: tuple or list A collection containing many items, each of which may be a valid representation of a particle or a `~plasmapy.atomic.Particle` instance """ expected = [ particle if isinstance(particle, Particle) else Particle(particle) for particle in particles ] # Check that the returned values are Particle instances because # running: # Particle('p+') == 'p+' # will return True because of how Particle.__eq__ is set up. returned_particle_instances = all( [isinstance(p, Particle) for p in resulting_particles]) returned_correct_instances = all( [expected[i] == resulting_particles[i] for i in range(len(particles))]) if not returned_particle_instances: raise AtomicError( f"A function decorated by particle_input did not return " f"a collection of Particle instances for input of " f"{repr(particles)}, and instead returned" f"{repr(resulting_particles)}.") if not returned_correct_instances: raise AtomicError( f"A function decorated by particle_input did not return " f"{repr(expected)} as expected, and instead returned " f"{repr(resulting_particles)}.")
def ionic_fractions(self, fractions): """ Set the ionic fractions, while checking that the new values are valid and normalized to one. """ if fractions is None or np.all(np.isnan(fractions)): self._ionic_fractions = np.full(self.atomic_number + 1, np.nan, dtype=np.float64) return try: if np.min(fractions) < 0: raise AtomicError("Cannot have negative ionic fractions.") if len(fractions) != self.atomic_number + 1: raise AtomicError("The length of ionic_fractions must be " f"{self.atomic_number + 1}.") if isinstance(fractions, u.Quantity): fractions = fractions.to(u.m**-3) self.n_elem = np.sum(fractions) self._ionic_fractions = np.array(fractions / self.n_elem) else: fractions = np.array(fractions, dtype=np.float64) sum_of_fractions = np.sum(fractions) all_nans = np.all(np.isnan(fractions)) if not all_nans: if np.any(fractions < 0) or np.any(fractions > 1): raise AtomicError( "Ionic fractions must be between 0 and 1.") if not np.isclose( sum_of_fractions, 1, rtol=0, atol=self.tol): raise AtomicError("Ionic fractions must sum to one.") self._ionic_fractions = fractions except Exception as exc: raise AtomicError( f"Unable to set ionic fractions of {self.element} " f"to {fractions}.") from exc
def log_abundances(self, value: Optional[Dict[str, Real]]): """ Set the base 10 logarithm of the relative abundances. """ if value is not None: try: new_abundances_input = {} for key in value.keys(): new_abundances_input[key] = 10**value[key] self.abundances = new_abundances_input except Exception: raise AtomicError("Invalid log_abundances.") from None
def test_list_annotation(particles: Union[Tuple, List]): try: resulting_particles = function_with_list_annotation(particles, 'ignore', x='ignore') except Exception as exc2: raise AtomicError( f"Unable to evaluate a function decorated by particle_input" f" with an annotation of [Particle] for inputs of" f" {repr(particles)}.") from exc2 function_to_test_annotations(particles, resulting_particles)
def test_Particle_class(arg, kwargs, expected_dict): """ Test that `~plasmapy.atomic.Particle` objects for different subatomic particles, elements, isotopes, and ions return the expected properties. Provide a detailed error message that lists all of the inconsistencies with the expected results. """ call = call_string(Particle, arg, kwargs) errmsg = "" try: particle = Particle(arg, **kwargs) except Exception as exc: raise AtomicError(f"Problem creating {call}") from exc for key in expected_dict.keys(): expected = expected_dict[key] if inspect.isclass(expected) and issubclass(expected, Exception): # Exceptions are expected to be raised when accessing certain # attributes for some particles. For example, accessing a # neutrino's mass should raise a MissingAtomicDataError since # only upper limits of neutrino masses are presently available. # If expected_dict[key] is an exception, then check to make # sure that this exception is raised. try: with pytest.raises(expected): exec(f"particle.{key}") except pytest.fail.Exception: errmsg += f"\n{call}[{key}] does not raise {expected}." except Exception: errmsg += (f"\n{call}[{key}] does not raise {expected} but " f"raises a different exception.") else: try: result = eval(f"particle.{key}") assert result == expected except AssertionError: errmsg += (f"\n{call}.{key} returns {result} instead " f"of the expected value of {expected}.") except Exception: errmsg += f"\n{call}.{key} raises an unexpected exception." if len(errmsg) > 0: raise Exception(f"Problems with {call}:{errmsg}")
def test_that_ionic_fractions_are_set_correctly(self, test_name): errmsg = "" elements_actual = self.instances[test_name].base_particles inputs = tests[test_name]["inputs"] if isinstance(inputs, dict): input_keys = list(tests[test_name]["inputs"].keys()) input_keys = sorted(input_keys, key=lambda k: (atomic_number(k), mass_number(k) if Particle(k).isotope else 0)) for element, input_key in zip(elements_actual, input_keys): expected = tests[test_name]["inputs"][input_key] if isinstance(expected, u.Quantity): expected = np.array(expected.value / np.sum(expected.value)) actual = self.instances[test_name].ionic_fractions[element] if not np.allclose(actual, expected): errmsg += ( f"\n\nThere is a discrepancy in ionic fractions for " f"({test_name}, {element}, {input_key})\n" f" expected = {expected}\n" f" actual = {actual}") if not isinstance(actual, np.ndarray) or isinstance( actual, u.Quantity): raise AtomicError( f"\n\nNot a numpy.ndarray: ({test_name}, {element})") else: elements_expected = { particle_symbol(element) for element in inputs } assert set( self.instances[test_name].base_particles) == elements_expected for element in elements_expected: assert all( np.isnan( self.instances[test_name].ionic_fractions[element])) if errmsg: pytest.fail(errmsg)
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 Z_most_abundant(self) -> List[Integral]: """ Return a `list` of the integer charges with the highest ionic fractions. Examples -------- >>> He = IonizationState('He', [0.2, 0.5, 0.3]) >>> He.Z_most_abundant [1] >>> Li = IonizationState('Li', [0.4, 0.4, 0.2, 0.0]) >>> Li.Z_most_abundant [0, 1] """ if np.any(np.isnan(self.ionic_fractions)): raise AtomicError( f"Cannot find most abundant ion of {self.base_particle} " f"because the ionic fractions have not been defined.") return np.flatnonzero( self.ionic_fractions == self.ionic_fractions.max()).tolist()
def __eq__(self, other): """ Return `True` if the ionic fractions, number density scaling factor (if set), and electron temperature (if set) are all equal, and `False` otherwise. Raises ------ TypeError If ``other`` is not an `~plasmapy.atomic.IonizationState` instance. AtomicError If ``other`` corresponds to a different element or isotope. Examples -------- >>> IonizationState('H', [1, 0], tol=1e-6) == IonizationState('H', [1, 1e-6], tol=1e-6) True >>> IonizationState('H', [1, 0], tol=1e-8) == IonizationState('H', [1, 1e-6], tol=1e-5) False """ if not isinstance(other, IonizationState): raise TypeError( "An instance of the IonizationState class may only be " "compared with another IonizationState instance.") same_element = self.element == other.element same_isotope = self.isotope == other.isotope if not same_element or not same_isotope: raise AtomicError( "An instance of the IonizationState class may only be " "compared with another IonizationState instance if " "both correspond to the same element and/or isotope.") # Use the tighter of the two tolerances. For thermodynamic # quantities, use it as a relative tolerance because the values # may substantially depart from order unity. min_tol = np.min([self.tol, other.tol]) same_T_e = np.isnan(self.T_e) and np.isnan(other.T_e) or \ u.allclose(self.T_e, other.T_e, rtol=min_tol*u.K, atol=0*u.K) same_n_elem = np.isnan(self.n_elem) and np.isnan(other.n_elem) or \ u.allclose(self.n_elem, other.n_elem, rtol=min_tol*u.m**-3, atol=0*u.m**-3) # For the next line, recall that np.nan == np.nan is False (sigh) same_fractions = np.any([ np.allclose(self.ionic_fractions, other.ionic_fractions, rtol=0, atol=min_tol), np.all(np.isnan(self.ionic_fractions)) and np.all(np.isnan(other.ionic_fractions)), ]) return np.all([ same_element, same_isotope, same_T_e, same_n_elem, same_fractions ])
def wrapper(*args, **kwargs): annotations = wrapped_function.__annotations__ bound_args = wrapped_signature.bind(*args, **kwargs) default_arguments = bound_args.signature.parameters arguments = bound_args.arguments argnames = bound_args.signature.parameters.keys() # Handle optional-only arguments in function declaration for default_arg in default_arguments: # The argument is not contained in `arguments` if the # user does not explicitly pass an optional argument. # In such cases, manually add it to `arguments` with # the default value of parameter. if default_arg not in arguments: arguments[default_arg] = default_arguments[default_arg].default funcname = wrapped_function.__name__ args_to_become_particles = [] for argname in annotations.keys(): if isinstance(annotations[argname], tuple): if argname == 'return': continue annotated_argnames = annotations[argname] expected_params = len(annotated_argnames) received_params = len(arguments[argname]) if not expected_params == received_params: raise ValueError( f"Number of parameters allowed in the tuple " f"({expected_params} parameters) are " f"not equal to number of parameters passed in " f"the tuple ({received_params} parameters).") elif isinstance(annotations[argname], list): annotated_argnames = annotations[argname] expected_params = len(annotated_argnames) if expected_params > 1: raise TypeError( f"Put in [Particle] as the annotation to " f"accept arbitrary number of Particle arguments.") else: annotated_argnames = (annotations[argname],) for annotated_argname in annotated_argnames: is_particle = annotated_argname is Particle or \ annotated_argname is Optional[Particle] if is_particle and argname != 'return': args_to_become_particles.append(argname) if not args_to_become_particles: raise AtomicError( f"None of the arguments or keywords to {funcname} " f"have been annotated with Particle, as required " f"by the @particle_input decorator.") elif len(args_to_become_particles) > 1: if 'Z' in argnames or 'mass_numb' in argnames: raise AtomicError( f"The arguments Z and mass_numb in {funcname} are not " f"allowed when more than one argument or keyword is " f"annotated with Particle in functions decorated " f"with @particle_input.") for x in args_to_become_particles: if annotations[x] is Particle and \ isinstance(arguments[x], (tuple, list)) and \ len(arguments[x]) > 1: raise TypeError( f"You cannot pass a tuple or list containing " f"Particles when only single Particle was " f"expected, instead found {arguments[x]}. If you " f"intend to pass more than 1 Particle instance, " f"use a tuple or a list type. " f"That is use (Particle, Particle, ...) or " f"[Particle] in function declaration.") # If the number of arguments and keywords annotated with # Particle is exactly one, then the Z and mass_numb keywords # can be used without potential for ambiguity. Z = arguments.get('Z', None) mass_numb = arguments.get('mass_numb', None) # Go through the argument names and check whether or not they are # annotated with Particle. If they aren't, include the name and # value of the argument as an item in the new keyword arguments # dictionary unchanged. If they are annotated with Particle, then # either convert the representation of a Particle to a Particle if # it is not already a Particle and then do error checks. new_kwargs = {} for argname in argnames: raw_argval = arguments[argname] if isinstance(raw_argval, (tuple, list)): # Input argument value is a tuple or list # of correspoding particles or atomic values. argval_tuple = raw_argval particles = [] else: # Otherwise convert it to tuple anyway so it can work # with loops too. argval_tuple = (raw_argval,) for pos, argval in enumerate(argval_tuple): should_be_particle = argname in args_to_become_particles already_particle = isinstance(argval, Particle) # If the argument is not annotated with Particle, then we just # pass it through to the new keywords without doing anything. if not should_be_particle: new_kwargs[argname] = raw_argval continue # Occasionally there will be functions where it will be # useful to allow None as an argument. # In case annotations[argname] is a collection (which looks # like (Particle, Optional[Particle], ...) or [Particle]) if isinstance(annotations[argname], tuple): optional_particle = annotations[argname][pos] is Optional[Particle] elif isinstance(annotations[argname], list): optional_particle = annotations[argname] == [Optional[Particle], ] else: # Otherwise annotations[argname] must be a Particle itself optional_particle = annotations[argname] is Optional[Particle] if (optional_particle or none_shall_pass) and argval is None: particle = None else: params = (argval, Z, mass_numb) particle = get_particle(argname, params, already_particle, funcname) if isinstance(raw_argval, (tuple, list)): # If passed argument is a tuple or list, keep # appending them. particles.append(particle) # Set appended values if current iteration is the # last iteration. if (pos + 1) == len(argval_tuple): new_kwargs[argname] = tuple(particles) del particles else: # Otherwise directly set values new_kwargs[argname] = particle return wrapped_function(**new_kwargs)
def nuclear_reaction_energy(*args, **kwargs): """ Return the released energy from a nuclear reaction. Parameters ---------- reaction: `str` (optional, positional argument only) A string representing the reaction, like ``"D + T --> alpha + n"`` or ``"Be-8 --> 2 * He-4"``. reactants: `list`, `tuple`, or `str`, optional, keyword-only A `list` or `tuple` containing the reactants of a nuclear reaction (e.g., ``['D', 'T']``), or a string representing the sole reactant. products: `list`, `tuple`, or `str`, optional, keyword-only A list or tuple containing the products of a nuclear reaction (e.g., ``['alpha', 'n']``), or a string representing the sole product. Returns ------- energy: `~astropy.units.Quantity` The difference between the mass energy of the reactants and the mass energy of the products in a nuclear reaction. This quantity will be positive if the reaction is exothermic (releases energy) and negative if the reaction is endothermic (absorbs energy). Raises ------ `AtomicError`: If the reaction is not valid, there is insufficient information to determine an isotope, the baryon number is not conserved, or the charge is not conserved. `TypeError`: If the positional input for the reaction is not a string, or reactants and/or products is not of an appropriate type. See Also -------- `~plasmapy.atomic.nuclear_binding_energy` : finds the binding energy of an isotope Notes ----- This function requires either a string containing the nuclear reaction, or reactants and products as two keyword-only lists containing strings representing the isotopes and other particles participating in the reaction. Examples -------- >>> from astropy import units as u >>> nuclear_reaction_energy("D + T --> alpha + n") <Quantity 2.8181e-12 J> >>> triple_alpha1 = '2*He-4 --> Be-8' >>> triple_alpha2 = 'Be-8 + alpha --> carbon-12' >>> energy_triplealpha1 = nuclear_reaction_energy(triple_alpha1) >>> energy_triplealpha2 = nuclear_reaction_energy(triple_alpha2) >>> print(energy_triplealpha1, energy_triplealpha2) -1.471430e-14 J 1.1802573e-12 J >>> energy_triplealpha2.to(u.MeV) <Quantity 7.3665870 MeV> >>> nuclear_reaction_energy(reactants=['n'], products=['p+', 'e-']) <Quantity 1.25343e-13 J> """ # TODO: Allow for neutrinos, under the assumption that they have no mass. # TODO: Add check for lepton number conservation; however, we might wish # to have violation of lepton number issuing a warning since these are # often omitted from nuclear reactions when calculating the energy since # the mass is tiny. errmsg = f"Invalid nuclear reaction." def process_particles_list( unformatted_particles_list: List[Union[str, Particle]]) \ -> List[Particle]: """ Take an unformatted list of particles and puts each particle into standard form, while allowing an integer and asterisk immediately preceding a particle to act as a multiplier. A string argument will be treated as a list containing that string as its sole item. """ if isinstance(unformatted_particles_list, str): unformatted_particles_list = [unformatted_particles_list] if not isinstance(unformatted_particles_list, (list, tuple)): raise TypeError("The input to process_particles_list should be a " "string, list, or tuple.") particles = [] for original_item in unformatted_particles_list: try: item = original_item.strip() if item.count('*') == 1 and item[0].isdigit(): multiplier_str, item = item.split('*') multiplier = int(multiplier_str) else: multiplier = 1 try: particle = Particle(item) except (InvalidParticleError) as exc: raise AtomicError(errmsg) from exc if particle.element and not particle.isotope: raise AtomicError(errmsg) [particles.append(particle) for i in range(multiplier)] except Exception: raise AtomicError( f"{original_item} is not a valid reactant or " "product in a nuclear reaction.") from None return particles def total_baryon_number(particles: List[Particle]) -> int: """ Find the total number of baryons minus the number of antibaryons in a list of particles. """ total_baryon_number = 0 for particle in particles: total_baryon_number += particle.baryon_number return total_baryon_number def total_charge(particles: List[Particle]) -> int: """ Find the total integer charge in a list of nuclides (excluding bound electrons) and other particles. """ total_charge = 0 for particle in particles: if particle.isotope: total_charge += particle.atomic_number elif not particle.element: total_charge += particle.integer_charge return total_charge def add_mass_energy(particles: List[Particle]) -> u.Quantity: """ Find the total mass energy from a list of particles, while taking the masses of the fully ionized isotopes. """ total_mass_energy = 0.0 * u.J for particle in particles: total_mass_energy += particle.mass_energy return total_mass_energy.to(u.J) input_err_msg = ("The inputs to nuclear_reaction_energy should be either " "a string representing a nuclear reaction (e.g., " "'D + T -> He-4 + n') or the keywords 'reactants' and " "'products' as lists with the nucleons or particles " "involved in the reaction (e.g., reactants=['D', 'T'] " "and products=['He-4', 'n'].") reaction_string_is_input = args and not kwargs and len(args) == 1 reactants_products_are_inputs = kwargs and not args and len(kwargs) == 2 if reaction_string_is_input == reactants_products_are_inputs: raise AtomicError(input_err_msg) if reaction_string_is_input: reaction = args[0] if not isinstance(reaction, str): raise TypeError(input_err_msg) elif '->' not in reaction: raise AtomicError(f"The reaction '{reaction}' is missing a '->'" " or '-->' between the reactants and products.") try: LHS_string, RHS_string = re.split('-+>', reaction) LHS_list = re.split(r' \+ ', LHS_string) RHS_list = re.split(r' \+ ', RHS_string) reactants = process_particles_list(LHS_list) products = process_particles_list(RHS_list) except Exception as ex: raise AtomicError( f"{reaction} is not a valid nuclear reaction.") from ex elif reactants_products_are_inputs: try: reactants = process_particles_list(kwargs['reactants']) products = process_particles_list(kwargs['products']) except TypeError as t: raise TypeError(input_err_msg) from t except Exception as e: raise AtomicError(errmsg) from e if total_baryon_number(reactants) != total_baryon_number(products): raise AtomicError( "The baryon number is not conserved for " f"reactants = {reactants} and products = {products}.") if total_charge(reactants) != total_charge(products): raise AtomicError("Total charge is not conserved for reactants = " f"{reactants} and products = {products}.") released_energy = add_mass_energy(reactants) - add_mass_energy(products) return released_energy
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 ionic_fractions(self, inputs: Union[Dict, List, Tuple]): """ Set the ionic fractions. Notes ----- The ionic fractions are initialized during instantiation of `~plasmapy.atomic.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.atomic.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 T_e(self) -> u.K: """Return the electron temperature.""" if self._T_e is None: raise AtomicError("No electron temperature has been specified.") return self._T_e.to(u.K, equivalencies=u.temperature_energy())
def __setitem__(self, key, value): errmsg = (f"Cannot set item for this IonizationStates instance for " f"key = {repr(key)} and value = {repr(value)}") try: particle = particle_symbol(key) self.ionic_fractions[key] except (AtomicError, TypeError): raise KeyError( f"{errmsg} because {repr(key)} is an invalid particle." ) from None except KeyError: raise KeyError( f"{errmsg} because {repr(key)} is not one of the base " f"particles whose ionization state is being kept track " f"of.") from None if isinstance(value, u.Quantity) and value.unit != u.dimensionless_unscaled: try: new_number_densities = value.to(u.m**-3) except u.UnitConversionError: raise ValueError(f"{errmsg} because the units of value do not " f"correspond to a number density.") from None old_n_elem = np.sum(self.number_densities[particle]) new_n_elem = np.sum(new_number_densities) density_was_nan = np.all(np.isnan(self.number_densities[particle])) same_density = u.quantity.allclose(old_n_elem, new_n_elem, rtol=self.tol) if not same_density and not density_was_nan: raise ValueError( f"{errmsg} because the old element number density " f"of {old_n_elem} is not approximately equal to " f"the new element number density of {new_n_elem}.") value = (new_number_densities / new_n_elem).to( u.dimensionless_unscaled) # If the abundance of this particle has not been defined, # then set the abundance if there is enough (but not too # much) information to do so. abundance_is_undefined = np.isnan(self.abundances[particle]) isnan_of_abundance_values = np.isnan(list( self.abundances.values())) all_abundances_are_nan = np.all(isnan_of_abundance_values) n_is_defined = not np.isnan(self.n) if abundance_is_undefined: if n_is_defined: self._pars['abundances'][particle] = new_n_elem / self.n elif all_abundances_are_nan: self.n = new_n_elem self._pars['abundances'][particle] = 1 else: raise AtomicError( f"Cannot set number density of {particle} to " f"{value * new_n_elem} when the number density " f"scaling factor is undefined, the abundance " f"of {particle} is undefined, and some of the " f"abundances of other elements/isotopes is " f"defined.") try: new_fractions = np.array(value, dtype=np.float64) except Exception as exc: raise TypeError( f"{errmsg} because value cannot be converted into an " f"array that represents ionic fractions.") from exc # TODO: Create a separate function that makes sure ionic # TODO: fractions are valid to reduce code repetition. This # TODO: would probably best go as a private function in # TODO: ionization_state.py. required_nstates = atomic_number(particle) + 1 new_nstates = len(new_fractions) if new_nstates != required_nstates: raise ValueError( f"{errmsg} because value must have {required_nstates} " f"ionization levels but instead corresponds to " f"{new_nstates} levels.") all_nans = np.all(np.isnan(new_fractions)) if not all_nans and (new_fractions.min() < 0 or new_fractions.max() > 1): raise ValueError( f"{errmsg} because the new ionic fractions are not " f"all between 0 and 1.") normalized = np.isclose(np.sum(new_fractions), 1, rtol=self.tol) if not normalized and not all_nans: raise ValueError(f"{errmsg} because the ionic fractions are not " f"normalized to one.") self._ionic_fractions[particle][:] = new_fractions[:]