def test_iteration(self, test_name: str): """Test that IonizationState instances iterate impeccably.""" try: states = [state for state in self.instances[test_name]] except Exception: raise AtomicError(f"Unable to perform iteration for {test_name}.") try: integer_charges = [state.integer_charge for state in states] ionic_fractions = np.array( [state.ionic_fraction for state in states]) ionic_symbols = [state.ionic_symbol for state in states] except Exception: raise AtomicError("An attribute may be misnamed or missing.") try: base_symbol = isotope_symbol(ionic_symbols[0]) except InvalidIsotopeError: base_symbol = atomic_symbol(ionic_symbols[0]) finally: atomic_numb = atomic_number(ionic_symbols[1]) errors = [] expected_charges = np.arange(atomic_numb + 1) if not np.all(integer_charges == expected_charges): errors.append( f"The resulting integer charges are {integer_charges}, " f"which are not equal to the expected integer charges, " f"which are {expected_charges}.") expected_fracs = test_cases[test_name]['ionic_fractions'] if isinstance(expected_fracs, u.Quantity): expected_fracs = (expected_fracs / expected_fracs.sum()).value if not np.allclose(ionic_fractions, expected_fracs): errors.append( f"The resulting ionic fractions are {ionic_fractions}, " f"which are not equal to the expected ionic fractions " f"of {expected_fracs}.") expected_particles = [ Particle(base_symbol, Z=charge) for charge in integer_charges ] expected_symbols = [ particle.ionic_symbol for particle in expected_particles ] if not ionic_symbols == expected_symbols: errors.append( f"The resulting ionic symbols are {ionic_symbols}, " f"which are not equal to the expected ionic symbols of " f"{expected_symbols}.") if errors: errors.insert( 0, (f"The test of IonizationState named '{test_name}' has " f"resulted in the following errors when attempting to " f"iterate.")) errmsg = " ".join(errors) raise AtomicError(errmsg)
def _particle_instances(self) -> List[Particle]: """ Return a list of the `~plasmapy.atomic.Particle` class instances corresponding to each ion. """ return [ Particle(self._particle_instance.particle, Z=i) for i in range(self.atomic_number + 1) ]
def test_keys(self, test): input_keys = tests[test]['inputs'].keys() particles = [Particle(input_key) for input_key in input_keys] expected_keys = [p.particle for p in particles] actual_keys = [ key for key in self.instances[test].ionic_fractions.keys() ] assert actual_keys == expected_keys, ( f"For test='{test}', the following should be equal:\n" f" actual_keys = {actual_keys}\n" f"expected_keys = {expected_keys}")
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 test_particle_instances(self, test_name): """ Test that `IonizationState` returns the correct `Particle` instances. """ instance = self.instances[test_name] atom = instance.base_particle nstates = instance.atomic_number + 1 expected_particles = [Particle(atom, Z=Z) for Z in range(nstates)] assert expected_particles == instance._particle_instances, ( f"The expected Particle instances of {expected_particles} " f"are not all equal to the IonizationState particles of " f"{instance._particle_instances} for test {test_name}.")
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 test_that_particles_were_set_correctly(self, test_name): input_particles = tests[test_name]['inputs'].keys() particles = [ Particle(input_particle) for input_particle in input_particles ] expected_particles = {p.particle for p in particles} actual_particles = { particle for particle in self.instances[test_name].ionic_fractions.keys() } assert actual_particles == expected_particles, ( f"For test='{test_name}', the following should be equal:\n" f" actual_particles = {actual_particles}\n" f"expected_particles = {expected_particles}")
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 swept_probe_analysis(probe_characteristic, probe_area, gas_argument, bimaxwellian=False, visualize=False, plot_electron_fit=False, plot_EEDF=False): r"""Attempt to perform a basic swept probe analysis based on the provided characteristic and probe data. Suitable for single cylindrical probes in low-pressure DC plasmas, since OML is applied. Parameters ---------- probe_characteristic : ~plasmapy.diagnostics.langmuir.Characteristic The swept probe characteristic that is to be analyzed. probe_area : ~astropy.units.Quantity The area of the probe exposed to plasma in units convertible to m^2. gas_argument : argument to instantiate the `Particle` class. `str`, `int`, or `~plasmapy.atomic.Particle` A string representing a particle, element, isotope, or ion; an integer representing the atomic number of an element; or a `Particle` instance. visualize : bool, optional Can be used to plot the characteristic and the obtained parameters. Default is False. plot_electron_fit : bool, optional If True, the fit of the electron current in the exponential section is shown. Default is False. plot_EEDF : bool, optional If True, the EEDF is computed and shown. Default is False. Returns ------- Results are returned as Dictionary "T_e" : `astropy.units.Quantity` Best estimate of the electron temperature in units of eV. Contains two values if bimaxwellian is True. "n_e" : `astropy.units.Quantity` Estimate of the electron density in units of m^-3. See the Notes on plasma densities. "n_i" : `astropy.units.Quantity` Estimate of the ion density in units of m^-3. See the Notes on plasma densities. "n_i_OML" : `astropy.units.Quantity` OML-theory estimate of the ion density in units of m^-3. See the Notes on plasma densities. "V_F" : `astropy.units.Quantity` Estimate of the floating potential in units of V. "V_P" : `astropy.units.Quantity` Estimate of the plasma potential in units of V. "I_es" : `astropy.units.Quantity` Estimate of the electron saturation current in units of Am^-2. "I_is" : `astropy.units.Quantity` Estimate of the ion saturation current in units of Am^-2. "hot_fraction" : float Estimate of the total hot (energetic) electron fraction. Notes ----- This function combines the separate probe analysis functions into a single analysis. Results are returned as a Dictionary. On plasma densities: in an ideal quasi-neutral plasma all densities should be equal. However, in practice this will not be the case. The electron density is the poorest estimate due to the hard to obtain knee in the electron current. The density provided by OML theory is likely the best estimate as it is not dependent on the obtained electron temperature, given that the conditions for OML theory hold. """ # Instantiate gas using the Particle class gas = Particle(argument=gas_argument) # Check (unit) validity of the probe characteristic probe_characteristic.check_validity() # Obtain the plasma and floating potentials V_P = get_plasma_potential(probe_characteristic) V_F = get_floating_potential(probe_characteristic) # Obtain the electron and ion saturation currents I_es = get_electron_saturation_current(probe_characteristic) I_is = get_ion_saturation_current(probe_characteristic) # The OML method is used to obtain an ion density without knowing the # electron temperature. This can then be used to obtain the ion current # and subsequently a better electron current fit. n_i_OML, fit = get_ion_density_OML(probe_characteristic, probe_area, gas, return_fit=True) ion_current = extrapolate_ion_current_OML(probe_characteristic, fit) # First electron temperature iteration exponential_section = extract_exponential_section(probe_characteristic, ion_current=ion_current) T_e, hot_fraction = get_electron_temperature(exponential_section, bimaxwellian=bimaxwellian, return_hot_fraction=True) # Second electron temperature iteration, using an electron temperature- # adjusted exponential section exponential_section = extract_exponential_section(probe_characteristic, T_e=T_e, ion_current=ion_current) T_e, hot_fraction, fit = get_electron_temperature( exponential_section, bimaxwellian=bimaxwellian, visualize=plot_electron_fit, return_fit=True, return_hot_fraction=True) # Extrapolate the fit of the exponential section to obtain the full # electron current. This has no use in the analysis except for # visualization. electron_current = extrapolate_electron_current(probe_characteristic, fit, bimaxwellian=bimaxwellian) # Using a good estimate of electron temperature, obtain the ion and # electron densities from the saturation currents. n_i = get_ion_density_LM( I_is, reduce_bimaxwellian_temperature(T_e, hot_fraction), probe_area, gas.mass) n_e = get_electron_density_LM( I_es, reduce_bimaxwellian_temperature(T_e, hot_fraction), probe_area) if visualize: # coverage: ignore with quantity_support(): fig, (ax1, ax2) = plt.subplots(2, 1) ax1.plot(probe_characteristic.bias, probe_characteristic.current, marker='.', color='k', linestyle='', label="Probe current") ax1.set_title("Probe characteristic") ax2.set_ylim(probe_characteristic.get_padded_limit(0.1)) ax2.plot(probe_characteristic.bias, np.abs(probe_characteristic.current), marker='.', color='k', linestyle='', label="Probe current") ax2.set_title("Logarithmic") ax2.set_ylim(probe_characteristic.get_padded_limit(0.1, log=True)) ax1.axvline(x=V_P.value, color='gray', linestyle='--') ax1.axhline(y=I_es.value, color='grey', linestyle='--') ax1.axvline(x=V_F.value, color='k', linestyle='--') ax1.axhline(y=I_is.value, color='r', linestyle='--') ax1.plot(ion_current.bias, ion_current.current, c='y', label="Ion current") ax1.plot(electron_current.bias, electron_current.current, c='c', label="Electron current") tot_current = ion_current + electron_current ax1.plot(tot_current.bias, tot_current.current, c='g') ax2.axvline(x=V_P.value, color='gray', linestyle='--') ax2.axhline(y=I_es.value, color='grey', linestyle='--') ax2.axvline(x=V_F.value, color='k', linestyle='--') ax2.axhline(y=np.abs(I_is.value), color='r', linestyle='--') ax2.plot(ion_current.bias, np.abs(ion_current.current), label="Ion current", c='y') ax2.plot(electron_current.bias, np.abs(electron_current.current), label="Electron current", c='c') ax2.plot(tot_current.bias, np.abs(tot_current.current), c='g') ax2.set_yscale("log", nonposy='clip') ax1.legend(loc='best') ax2.legend(loc='best') fig.tight_layout() # Obtain and show the EEDF. This is only useful if the characteristic data # has been preprocessed to be sufficiently smooth and noiseless. if plot_EEDF: # coverage: ignore get_EEDF(probe_characteristic, visualize=True) # Compile the results dictionary results = { 'V_P': V_P, 'V_F': V_F, 'I_es': I_es, 'I_is': I_is, 'n_e': n_e, 'n_i': n_i, 'T_e': T_e, 'n_i_OML': n_i_OML } if bimaxwellian: results['hot_fraction'] = hot_fraction return results
def get_ion_density_OML(probe_characteristic, probe_area, gas, visualize=False, return_fit=False): r"""Implement the Orbital Motion Limit (OML) method of obtaining an estimate of the ion density. Parameters ---------- probe_characteristic : ~plasmapy.diagnostics.langmuir.Characteristic The swept probe characteristic that is to be analyzed. probe_area : ~astropy.units.Quantity The area of the probe exposed to plasma in units convertible to m^2. gas : ~astropy.units.Quantity The (mean) mass of the background gas in atomic mass units. visualize : bool, optional If True a plot of the OML fit is shown. Default is False. return_fit: bool, optional If True the parameters of the fit will be returned in addition to the ion density. Default is False. Returns ------- n_i_OML : ~astropy.units.Quantity Estimated ion density in m^-3. Notes ----- The method implemented in this function holds for cylindrical probes in a cold ion plasma, ie. :math:T_i=0` eV. With OML theory an expression is found for the ion current as function of probe bias independent of the electron temperature [mott-smith.langmuir-1926]_: .. math:: I_i \xrightarrow[T_i = 0]{} A_p n_i e \frac{\sqrt{2}}{\pi} \sqrt{\frac{e \left( V_F - V \right)}{m_i}} References ---------- .. [mott-smith.langmuir-1926] H. M. Mott-Smith, I. Langmuir, Phys. Rev. 28, 727-763 (Oct. 1926) """ probe_characteristic.check_validity() ion_section = extract_ion_section(probe_characteristic) fit = np.polyfit( ion_section.bias.to(u.V).value, ion_section.current.to(u.mA).value**2, 1) poly = np.poly1d(fit) slope = fit[0] ion = Particle(argument=gas) n_i_OML = np.sqrt(-slope * u.mA**2 / u.V * np.pi**2 * ion.mass / (probe_area**2 * const.e**3 * 2)) if visualize: # coverage: ignore with quantity_support(): plt.figure() plt.scatter(ion_section.bias.to(u.V), ion_section.current.to(u.mA)**2, color='k', marker='.') plt.plot(ion_section.bias.to(u.V), poly(ion_section.bias.to(u.V).value), c='g') plt.title("OML fit") plt.tight_layout() if return_fit: return n_i_OML.to(u.m**-3), fit return n_i_OML.to(u.m**-3)
], [ return_quantity, (24), { 'should_warn': False }, (5 * u.m / u.s, UserWarning), MissingWarningError ], [return_arg, u.kg / u.K, {}, u.kg / u.K, None], [return_arg, u.kg / u.K, {}, u.kg / u.N, u.UnitsError], [return_arg, u.kg, {}, u.g, u.UnitsError], [return_arg, u.C, { 'should_warn': True }, (u.C, UserWarning), None], [adams_number, 1, { 'x': 1 }, u.pc, u.UnitsError], [return_arg, Particle('p+'), {}, Particle('proton'), None], [return_arg, Particle('e+'), {}, Particle('e-'), UnexpectedResultError], [return_arg, Particle('mu+'), {}, type, InconsistentTypeError], [return_arg, (2, ), {}, IOError, MissingExceptionError], ] @pytest.mark.parametrize( "f, args, kwargs, expected, whaterror", f_args_kwargs_expected_whaterror, ) def test_run_test(f, args, kwargs, expected, whaterror): """