Repository: PlasmaPy/PlasmaPy

Issue:
Adding Langmuir probe data analysis
The current direction of PlasmaPy seems to be mostly theoretical/computational. I think it would be good to cater to experimentalists too, for example by providing data analysis functionality. There have been previous discussions about this (eg. https://github.com/PlasmaPy/PlasmaPy/issues/86) but I don't know if those ever went anywhere.

In any case, I'd like to begin implementing some basic Langmuir probe data analysis functionality as a start. **It is one of the most common diagnostics and therefore integral to plasma physics as a whole.**

The overall functionality would be rather simple; taking potential and current measurements along with some probe and species properties and returning the obtained plasma parameters. This could look something like `[T_e, n_e, V_B, V_P, ...] = plasmapy.langmuir.analyze(potential, current, species, probe_properties)`. Of course there is a large amount of theory written on the subject and many different methods, depending on ie. plasma regime and magnetization. Implementation of the most common methods, along with good documentation, would probably be sufficient initially.

Thoughts?


Discussion:
* agreed 100%
* Pandas is neat for experimental data and it's probably as close as python
comes to a standard on that, does it make sense to accept dataframes and
have the function logic separate the columns as need be?
* How about uncertainties? The first version probably doesn't need to
include them but it'd be nice to have
* I'd love to have some sample data and expected parameters for that, it'd
make testing way better!
* plasmapy.langmuir sounds neat!

I've got a code for triple Langmuir probe analysis in the "current-mode", as opposed to the more common "voltage-mode", that I could eventually clean up and contribute.
@StanczakDominik 
* I am not very familiar with pandas, but I think I get what you're saying. It sounds like it would help clearing up the data handling a bit, but at the cost of having to include an entirely new module and requiring users to deliver their data in a format they might not know.
* Uncertainties are highly important academically and should definitely be included at some point, where possible
* Yeah, we should gather some V-I traces with known plasma parameters!
* Maybe we should consider bundling all experimental data analysis into a single module? As a start `plasmapy.langmuir` would probably be fine though.

@lemmatum 
Sounds good, could you send some background or theory for that method? Is it widely used?
> I am not very familiar with pandas, but I think I get what you're saying. It sounds like it would help clearing up the data handling a bit, but at the cost of having to include an entirely new module and requiring users to deliver their data in a format they might not know.

Pandas ships with Anaconda and is basically the bread to python data science's butter, adding it as a dependency wouldn't hurt much. The main functionality could be independent of pandas and we could then find some way of adding that, maybe via a wrapper to that main functionality.

> Maybe we should consider bundling all experimental data analysis into a single module? As a start plasmapy.langmuir would probably be fine though.

Sure, I was thinking about that as well. So we'd end up having `plasmapy.experiment.langmuir` or some such path. Seems enough reasonable to me to start with just `plasmapy.langmuir` for now, we're not exactly anywhere close to feature stable yet!
@StanczakDominik I agree with all you said. As a start I'll try to find some Langmuir V-I traces and make a rough outline of what the submodule could look like.
Awesome! Looking forward to that!
Might I suggest `plasmapy.diagnostics.langmuir` instead? I think splitting between experiment and theory/simulation is not so clear as those are intimately tied together. For example, is spectroscopy experimental or simulation? You often need simulations to extract plasma parameters from a measured spectrum.

[Here](http://ieeexplore.ieee.org/abstract/document/1347276/?reload=true) is the paper I based my triple probe code on. I'm not sure how widely used it is, but it is cited by 30 articles, and i imagine most of the functions in my code are usable for other theories. The main difference between current-mode and voltage-mode probes is that with current-mode probes you measure 3 time-resolved currents and use those to simultaneously solve 3 equations for temperature, density, and plasma potential. As I understand it, voltage-mode probes often have a floating tip which measures voltage, so some approximations can be used in getting the plasma parameters.
Sure, calling the subpackage diagnostics makes sense.

I can't dig into the paper just now, but it seems reasonable to include
both voltage and current modes. Given you guys seem more knowledgeable
about this kind of diagnostics, could I ask you two to collaborate on this,
especially when it comes to code review? I can help with the Python side of
things, but I lack the experience to easily say whether the analysis makes
sense.

Sure! Just ping me when the initial module gets setup and I can contribute my code to it and review code that @jasperbeckers  and others contribute.
@StanczakDominik @lemmatum Sounds like a plan. I'll make a fork for us to work on and try to find some known V-I curves for the simplest single probe case (I am most familiar with that one).
@StanczakDominik would it be reasonable to start a "diagnostics" project over in the projects tab? I have no idea how this functionality works. I assume it's an admin thing?
Sure, it would be. Already done :) By the way, I went ahead and figured out how to let @PlasmaPy/plasmapy-diagnostics notify you guys, should you accept the invitation!
Dziękuje!
@StanczakDominik @lemmatum Please see https://github.com/jasperbeckers/PlasmaPy/commit/4508c96d64e78ae4c639ebeb6b56bf3237b995f6 for the initial commit of `plasmapy.diagnostics.langmuir`! The file outline consists of the class Probe to capture the electrode area(s) and configuration, and functions to obtain the plasma parameters, EEDF and EEPF. Thoughts?

I haven't been able to find example data with known parameters yet.

Also, shall I add you guys as collaborators there?
Sure, that's probably the best way to do it. That way we can all contribute to that. Another approach would be to get a pull request for the main repository going - there's an option for repository maintainers to be able to push their own code to a contributor's branch. I'm not sure if that works for you guys as team members though, so it might be best to just turn on collaborator access for this one!

A pull request for this one would be handy anyway, for centralized code review etc. :)
I'd say pull it into upstream so I can start contributing my own stuff as well 😛 
@jasperbeckers what's the motivation for adding me to collaborate on your fork? Wouldn't it be better to just work on upstream directly?
@lemmatum Oh, I thought that'd require me to add you. I'm not very familiar with how GitHub works yet.
@jasperbeckers This [diagram](http://www.dalescott.net/wp-content/uploads/2012/09/centralized-github-4.png) might help. In this scheme PlasmaPy is "upstream", each of us contributors creates a fork of PlasmPy to our accounts, which we call "origin". We then clone from origin to our local machines (Alice, Bob, etc.). When a feature you want to add is ready, you push to origin on your account and then to upstream. Git then manages all of these pull requests from these different accounts and internally figures out if they conflict. So no need to add me to your origin, we each have our own and then deal with merges and conflicts when pulling into upstream.
Does that make sense?
@lemmatum Thanks for the explanation! But if I understand correctly I had to add you as collaborator to my `langmuir_analysis` branch for you to be able to add commits to the pull request? I guess what you mean is that we merge the pull request (with fixed function name) and continue development afterwards.
@jasperbeckers Ah, I see what you're getting at, but I don't think that's the right way to git (though I may be wrong). I shouldn't be adding commits to your pull request. Whatever gets pulled doesn't have to be the final version of the code. Once an initial version of the diagnostics part of the code is built by you, you can do a pull request, we'll merge it upstream, and then I can work on it as well.
I suppose either of those ways is fine. I for one wouldn't mind collaborating on a designated branch of Jasper's, but if you're more comfortable with working alternately via PRs on upstream, that's perfectly fine.
Some theory stuff to add
- [ ] ABR
- [ ] BRL
- [ ] Laframboise
- [ ] Kiel (collisional theory)
- [ ] Current-mode probes
- [ ] thermionic emission (super hot probes like to emit particles and it's a pain!)

I think the functions should be available for both forward and inverse analysis.

Resolving patch:
diff --git a/plasmapy/diagnostics/langmuir.py b/plasmapy/diagnostics/langmuir.py
--- a/plasmapy/diagnostics/langmuir.py
+++ b/plasmapy/diagnostics/langmuir.py
@@ -1,153 +1,1166 @@
-"""
-Defines the Langmuir analysis module as part of the diagnostics package.
-"""
+"""Defines the Langmuir analysis module as part of the diagnostics package."""
 
 import numpy as np
 import astropy.units as u
+from plasmapy import utils
+import plasmapy.constants as const
+import copy
+from astropy.visualization import quantity_support
+import matplotlib.pyplot as plt
+from scipy.optimize import curve_fit
 
 
-class Probe():
-    r"""
-    Class containing various Langmuir probe properties.
+def _fit_func_lin(x, x0, y0, c0):
+    r"""Linear fitting function."""
 
-    For the moment cylindrical probes are assumed as spherical ones are
-    generally impractical to make.
+    return y0 + c0 * (x - x0)
+
+
+def _fit_func_lin_inverse(x, x0, y0, T0):
+    r"""Linear fitting function with inverse slope parameter for use in fitting
+    of the electron current growth region.
+    """
+
+    return y0 + (x - x0) / T0
+
+
+def _fit_func_double_lin_inverse(x, x0, y0, T0, Delta_T):
+    r"""Piecewise linear fitting function with inverse slope parameters and
+    an offset for use in fitting a bi-Maxwellian electron current growth
+    region. (x0, y0) denotes the location of the knee of the transition,
+    with T0 and T0 + Delta_T being the cold and hot temperatures, respectively.
+    """
+
+    hot_T_func = lambda x: y0 + (x - x0) / (T0 + Delta_T)
+    cold_T_func = lambda x: y0 + (x - x0) / T0
+    return np.piecewise(x, x < x0, [hot_T_func, cold_T_func])
+
+
+class Characteristic:
+    r"""Class representing a single I-V probe characteristic for convenient
+    experimental data access and computation. Supports units.
 
     Attributes
     ----------
-    area : ~astropy.units.Quantity or ~numpy.ndarray
-        Area or array of areas of the probe(s) in units convertible to m^2.
+    bias : Quantity, ndarray
+        Array of applied probe biases in units convertible to V.
+
+    current : Quantity, ndarray
+        Array of applied probe currents in units convertible to A.
+
+    """
+
+    @utils.check_quantity({'bias': {'units': u.V,
+                                    'can_be_negative': True,
+                                    'can_be_complex': False,
+                                    'can_be_inf': False,
+                                    'can_be_nan': True},
+                           'current': {'units': u.A,
+                                       'can_be_negative': True,
+                                       'can_be_complex': False,
+                                       'can_be_inf': False,
+                                       'can_be_nan': True}})
+    def __init__(self, bias, current):
+        self.bias = bias
+        self.current = current
+
+        self.get_unique_bias(True)
+
+        self.check_validity()
+
+    def __getitem__(self, key):
+        r"""Allow array indexing operations directly on the Characteristic
+        object.
+
+        """
+
+        b = Characteristic(self.bias[key], self.current[key])
+        return b
+
+    def __sub__(self, other):
+        r"""Support current subtraction"""
+
+        b = copy.deepcopy(self)
+        b.current -= other.current
+        return b
+
+    def __add__(self, other):
+        r"""Support current addition"""
+
+        b = copy.deepcopy(self)
+        b.current += other.current
+        return b
+
+    def sort(self):
+        r"""Sort the characteristic by ascending bias."""
+
+        _sort = self.bias.argsort()
+        self.current = self.current[_sort]
+        self.bias = self.bias[_sort]
+
+    def get_unique_bias(self, inplace=False):
+        r"""Remove any duplicate bias values through averaging."""
+
+        if len(self.bias) != len(self.current):
+            raise ValueError(f"Unequal array lengths of bias "
+                             f"({len(self.bias)}) and current "
+                             f"({len(self.current)}).")
+
+        bias_unique = np.unique(self.bias)
+        current_unique = []
+        for bias in bias_unique:
+            current_unique = np.append(current_unique, np.mean(
+                    self.current[self.bias == bias].to(u.A).value))
+        current_unique *= u.A
+
+        if inplace:
+            self.bias = bias_unique
+            self.current = current_unique
+        else:
+            return Characteristic(bias_unique, current_unique)
+
+    def check_validity(self):
+        r"""Check the unit and value validity of the characteristic."""
+
+        if len(self.bias.shape) != 1:
+            raise ValueError("Non-1D array for voltage!")
+
+        if len(self.current.shape) != 1:
+            raise ValueError("Non-1D array for current!")
+
+        if len(self.bias) != len(self.current):
+            raise ValueError(f"Unequal array lengths of bias "
+                             f"({len(self.bias)}) and current "
+                             f"({len(self.current)}).")
+
+        if len(np.unique(self.bias)) != len(self.bias):
+            raise ValueError(f"Bias array contains duplicate values.")
+
+        utils._check_quantity(self.bias, 'bias', str(self), u.V,
+                              can_be_negative=True, can_be_complex=False,
+                              can_be_inf=False, can_be_nan=True)
+
+        utils._check_quantity(self.current, 'bias', str(self), u.A,
+                              can_be_negative=True, can_be_complex=False,
+                              can_be_inf=False, can_be_nan=True)
+
+    def get_padded_limit(self, padding, log=False):  # coveralls: ignore
+        r"""Return the limits of the current range for plotting, taking into
+        account padding. Matplotlib lacks this functionality.
+
+        Parameters
+        ----------
+        padding : float
+            The padding ratio as a float between 0.0 and 1.0.
+
+        log : boolean, optional
+            If True the calculation will be performed on a logarithmic scale.
+            Default is False.
+
+        """
+
+        ymax = np.max(self.current).to(u.A).value
+
+        if log:
+            ymin = np.min(np.abs(
+                    self.current[self.current != 0])).to(u.A).value
+            return [ymin * 10**(-padding * np.log10(ymax / ymin)),
+                    ymax * 10**(padding * np.log10(ymax / ymin))] * u.A
+        else:
+            ymin = np.min(self.current).to(u.A).value
+            return [ymin - padding * (ymax - ymin),
+                    ymax + padding * (ymax - ymin)] * u.A
+
+    def plot(self):  # coveralls: ignore
+        r"""Plot the characteristic in matplotlib."""
+
+        with quantity_support():
+            plt.figure()
+            plt.scatter(self.bias.to(u.V), self.current.to(u.mA),
+                        marker='.', color='k')
+            plt.title("Probe characteristic")
+
+
+@utils.check_quantity({'probe_area': {'units': u.m**2,
+                                      'can_be_negative': False,
+                                      'can_be_complex': False,
+                                      'can_be_inf': False,
+                                      'can_be_nan': False},
+                       'gas': {'units': u.u,
+                               'can_be_negative': False,
+                               'can_be_complex': False,
+                               'can_be_inf': False,
+                               'can_be_nan': False}})
+def swept_probe_analysis(probe_characteristic, probe_area, gas,
+                         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 : Characteristic
+        The swept probe characteristic that is to be analyzed.
+
+    probe_area : Quantity
+        The area of the probe exposed to plasma in units convertible to m^2.
+
+    gas : Quantity
+        The (mean) mass of the background gas in atomic mass units.
+
+    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
+    -------
+    A dictionary with the following entries:
+
+    'T_e' : Quantity
+        Best estimate of the electron temperature in units of eV. Contains
+        two values if bimaxwellian is True.
+
+    'n_e' : Quantity
+        Estimate of the electron density in units of m^-3. See the Notes on
+        plasma densities.
+
+    'n_i' : Quantity
+        Estimate of the ion density in units of m^-3. See the Notes on
+        plasma densities.
+
+    'n_i_OML' : Quantity
+        OML-theory estimate of the ion density in units of m^-3. See the Notes
+        on plasma densities.
+
+    'V_F' : Quantity
+        Estimate of the floating potential in units of V.
+
+    'V_P' : Quantity
+        Estimate of the plasma potential in units of V.
+
+    'I_es' : Quantity
+        Estimate of the electron saturation current in units of Am^-2.
+
+    'I_is' : 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.
+
+    """
+
+    # 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)
+    n_e = get_electron_density_LM(I_es, reduce_bimaxwellian_temperature(T_e,
+                                  hot_fraction), probe_area)
+
+    if visualize:  # coveralls: ignore
+        with quantity_support():
+            fig, (ax1, ax2) = plt.subplots(2, 1)
+            ax1.plot(probe_characteristic.bias,
+                     probe_characteristic.current,
+                     marker='.', color='k', linestyle='')
+            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='')
+            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')
+            ax1.plot(electron_current.bias, electron_current.current, c='c')
+            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), c='y')
+            ax2.plot(electron_current.bias, np.abs(electron_current.current),
+                     c='c')
+            ax2.plot(tot_current.bias, np.abs(tot_current.current), c='g')
+            ax2.set_yscale("log", nonposy='clip')
+
+            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:
+        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_plasma_potential(probe_characteristic, return_arg=False):
+    r"""Implement the simplest but crudest method for obtaining an estimate of
+    the plasma potential from the probe characteristic.
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    return_arg : bool, optional
+        Controls whether or not the argument of the plasma potential within the
+        characteristic array should be returned instead of the value of the
+        voltage. Default is False.
+
+    Returns
+    -------
+    V_P : Quantity
+        Estimate of the plasma potential in units convertible to V.
+
+    Notes
+    -----
+    The method used in the function takes the maximum gradient of the probe
+    current as the 'knee' of the transition from exponential increase into the
+    electron the saturation region.
+
+    """
+
+    probe_characteristic.check_validity()
+
+    # Sort the characteristic prior to differentiation
+    probe_characteristic.sort()
+
+    # Acquiring first derivative
+    dIdV = np.gradient(probe_characteristic.current.to(u.A).value,
+                       probe_characteristic.bias.to(u.V).value)
+
+    arg_V_P = np.argmax(dIdV)
+
+    if return_arg:
+        return probe_characteristic.bias[arg_V_P], arg_V_P
+
+    return probe_characteristic.bias[arg_V_P]
+
+
+def get_floating_potential(probe_characteristic, return_arg=False):
+    r"""Implement the simplest but crudest method for obtaining an estimate of
+    the floating potential from the probe characteristic.
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    return_arg : bool, optional
+        Controls whether or not the argument of the floating potential within
+        the characteristic array should be returned instead of the value of the
+        voltage. Default is False.
+
+    Returns
+    -------
+    V_F : Quantity
+        Estimate of the floating potential in units convertible to V.
+
+    Notes
+    -----
+    The method used in this function takes the probe current closest to zero
+    Amperes as the floating potential.
+
+    """
+
+    probe_characteristic.check_validity()
+
+    arg_V_F = np.argmin(np.abs(probe_characteristic.current))
+
+    if return_arg:
+        return probe_characteristic.bias[arg_V_F], arg_V_F
+
+    return probe_characteristic.bias[arg_V_F]
+
+
+def get_electron_saturation_current(probe_characteristic):
+    r"""Obtain an estimate of the electron saturation current corresponding
+    to the obtained plasma potential.
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    Returns
+    -------
+    I_es: Quantity
+        Estimate of the electron saturation current in units convertible to A.
+
+    Notes
+    -----
+    The function `get_plasma_potential` is used to obtain an estimate of the
+    plasma potential. The corresponding electron saturation current is
+    returned.
+
+    """
+
+    probe_characteristic.check_validity()
+
+    _, arg_V_P = get_plasma_potential(probe_characteristic, return_arg=True)
+
+    return probe_characteristic.current[arg_V_P]
+
+
+def get_ion_saturation_current(probe_characteristic):
+    r"""Implement the simplest but crudest method for obtaining an estimate of
+    the ion saturation current from the probe characteristic.
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    Returns
+    -------
+    I_is : Quantity
+        Estimate of the ion saturation current in units convertible to A.
+
+    Notes
+    -----
+    The method implemented in this function takes the ion saturation current
+    as the smallest probe current in the characteristic. This assumes the bias
+    range in the ion region is sufficiently negative for the ion current to
+    saturate.
+
+    """
+
+    probe_characteristic.check_validity()
+
+    return np.min(probe_characteristic.current)
+
+
+@utils.check_quantity({'ion_saturation_current': {'units': u.A,
+                                                  'can_be_negative': True,
+                                                  'can_be_complex': False,
+                                                  'can_be_inf': False,
+                                                  'can_be_nan': False},
+                       'T_e': {'units': u.eV,
+                               'can_be_negative': False,
+                               'can_be_complex': False,
+                               'can_be_inf': False,
+                               'can_be_nan': False},
+                       'probe_area': {'units': u.m**2,
+                                      'can_be_negative': False,
+                                      'can_be_complex': False,
+                                      'can_be_inf': False,
+                                      'can_be_nan': False},
+                       'gas': {'units': u.u,
+                               'can_be_negative': False,
+                               'can_be_complex': False,
+                               'can_be_inf': False,
+                               'can_be_nan': False}})
+def get_ion_density_LM(ion_saturation_current, T_e,
+                       probe_area, gas):
+    r"""Implement the Langmuir-Mottley (LM) method of obtaining the ion
+    density.
+
+    Parameters
+    ----------
+    ion_saturation_current : Quantity
+        The ion saturation current in units convertible to A.
+
+    T_e : Quantity
+        The electron temperature in units convertible to eV.
+
+    probe_area : Quantity
+        The area of the probe exposed to plasma in units convertible to m^2.
+
+    gas : Quantity
+        The (mean) mass of the background gas in atomic mass units.
+
+    Returns
+    -------
+    n_i : Quantity
+        Estimate of the ion density in units convertible to m^-3.
+
+    Notes
+    -----
+    The method implemented in this function obtains the ion density from the
+    ion saturation current density assuming that the ion current loss to the
+    probe is equal to the Bohm loss. The acoustic Bohm velocity is obtained
+    from the electron temperature and the ion mass.
+
+    """
+
+    # Calculate the acoustic (Bohm) velocity
+    c_s = np.sqrt(T_e / gas)
+
+    n_i = np.abs(ion_saturation_current) / (0.6 * const.e * probe_area * c_s)
+
+    return n_i.to(u.m**-3)
+
+
+@utils.check_quantity({'electron_saturation_current': {'units': u.A,
+                                                       'can_be_negative': True,
+                                                       'can_be_complex': False,
+                                                       'can_be_inf': False,
+                                                       'can_be_nan': False},
+                       'T_e': {'units': u.eV,
+                               'can_be_negative': False,
+                               'can_be_complex': False,
+                               'can_be_inf': False,
+                               'can_be_nan': False},
+                       'probe_area': {'units': u.m**2,
+                                      'can_be_negative': False,
+                                      'can_be_complex': False,
+                                      'can_be_inf': False,
+                                      'can_be_nan': False}})
+def get_electron_density_LM(electron_saturation_current, T_e,
+                            probe_area):
+    r"""Implement the Langmuir-Mottley (LM) method of obtaining the electron
+    density.
+
+    Parameters
+    ----------
+    electron_saturation_current : Quantity
+        The electron saturation current in units convertible to A.
+
+    T_e : Quantity
+        The electron temperature in units convertible to eV.
+
+    probe_area : Quantity
+        The area of the probe exposed to plasma in units convertible to m^2.
+
+    Returns
+    -------
+    n_e : Quantity
+        Estimate of the electron density in units convertible to m^-3.
+
+    Notes
+    -----
+    The method implemented in this function obtains the electron density from
+    the electron saturation current density, assuming a low plasma density.
+    Please note that the electron saturation current density is a hard
+    parameter to acquire and it is usually better to measure the ion density,
+    which should be identical to the electron density in quasineutral plasmas.
+
+    """
+
+    # Calculate the thermal electron velocity
+    v_th = np.sqrt(8 * T_e / (np.pi * const.m_e))
+
+    n_e = 4 * electron_saturation_current / (probe_area * const.e * v_th)
+
+    return n_e.to(u.m**-3)
+
+
+def extract_exponential_section(probe_characteristic, T_e=None,
+                                ion_current=None):
+    r"""Extract the section of exponential electron current growth from the
+    probe characteristic.
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    T_e : Quantity, optional
+        If given, the electron temperature can improve the accuracy of the
+        bounds of the exponential region.
+
+    ion_current : Characteristic, optional
+        If given, the ion current will be subtracted from the probe
+        characteristic to yield a better estimate of the electron current in
+        the exponential region.
+
+    Returns
+    -------
+    exponential_section : Characteristic
+        The exponential electron current growth section.
+
+    Notes
+    -----
+    This function extracts the region of exponential electron growth from the
+    probe characteristic under the assumption that this bias region is bounded
+    by the floating and plasma potentials. Additionally, an improvement in
+    accuracy can be made when the electron temperature is supplied.
+
+    """
+
+    probe_characteristic.check_validity()
+
+    V_F = get_floating_potential(probe_characteristic)
+
+    V_P = get_plasma_potential(probe_characteristic)
+
+    if T_e is not None:
+
+        # If a bi-Maxwellian electron temperature is supplied grab the first
+        # (cold) temperature
+        if np.array(T_e).size > 1:
+            T_e = np.min(T_e)
+
+        _filter = (probe_characteristic.bias > V_F + 1.5 * T_e / const.e) & (
+                probe_characteristic.bias < V_P - 0.2 * T_e / const.e)
+    else:
+        _filter = (probe_characteristic.bias > V_F) & (
+                probe_characteristic.bias < V_P)
+
+    exponential_section = probe_characteristic[_filter]
+
+    if ion_current is not None:
+        exponential_section = exponential_section - ion_current[_filter]
+
+    return exponential_section
+
+
+def extract_ion_section(probe_characteristic):
+    r"""Extract the section dominated by ion collection from the probe
+    characteristic.
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    Returns
+    -------
+    ion_section : Characteristic
+        The exponential electron current growth section.
 
-    config : str, optional
-        Electrode configuration. Possible options are `'single'`
-        (default), `'double'`, `'double-asym'`, `'triple'`, `'tetra'`,
-        `'penta'`. The electrode configuration must correspond to the
-        number of areas provided.
+    Notes
+    -----
+    This function extracts the region dominated by ion collection from the
+    probe characteristic under the assumption that this bias region is only
+    bounded by the floating potential on the right hand side.
 
     """
-    def __init__(self, area=u.m**2, config='single'):
-        # Initialize probe properties
-        self.area = area
-        self.config = config
 
+    probe_characteristic.check_validity()
+
+    V_F = get_floating_potential(probe_characteristic)
+
+    ion_section = probe_characteristic[probe_characteristic.bias < V_F]
+
+    return ion_section
 
-def swept_probe_analysis(potential_sweep,
-                         measured_current,
-                         probe,
-                         ion='p+',
-                         method='maxwellian',
-                         smoothing=None,
-                         polyorder=3):
-    r"""
-    Perform a Langmuir analysis of a given probe V-I profile in order to
-    obtain various plasma parameters.
+
+def get_electron_temperature(exponential_section, bimaxwellian=False,
+                             visualize=False, return_fit=False,
+                             return_hot_fraction=False):
+    r"""Obtain the Maxwellian or bi-Maxwellian electron temperature using the
+    exponential fit method.
 
     Parameters
     ----------
-    potential_sweep : ~astropy.units.Quantity
-        The swept electric potential applied to the probe in units
-        convertible to V.
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    bimaxwellian : bool, optional
+        If True the exponential section will be fit assuming bi-Maxwellian
+        electron populations, as opposed to Maxwellian. Default is False.
+
+    visualize : bool, optional
+        If True a plot of the exponential fit is shown. Default is False.
+
+    return_fit: bool, optional
+        If True the parameters of the fit will be returned in addition to the
+        electron temperature. Default is False.
+
+    return_hot_fraction: float, optional
+        If True the total fraction of hot electrons will be returned if the
+        population is bi-Maxwellian. Default is False.
+
+    Returns
+    -------
+    T_e : Quantity, (ndarray)
+        The estimated electron temperature in eV. In case of a bi-Maxwellian
+        plasma an array containing two Quantities is returned.
+
+    Notes
+    -----
+    The exponential section of the probe characteristic should be a straight
+    line if the plasma electrons are fully Maxwellian, or exhibit a knee in a
+    bi-Maxwellian case. The slope is inversely proportional to the
+    temperature of the respective electron population.
+
+    """
+
+    exponential_section.check_validity()
+
+    # Remove values in the section with a current equal to or smaller than
+    # zero.
+    exponential_section = exponential_section[
+            exponential_section.current.to(u.A).value > 0]
+
+    initial_guess = None  # for fitting
+
+    bounds = (-np.inf, np.inf)
+
+    # Instantiate the correct fitting equation, initial values and bounds.
+    if bimaxwellian:
+        max_exp_bias = np.max(exponential_section.bias)
+        min_exp_bias = np.min(exponential_section.bias)
+        x0 = min_exp_bias + 2/3 * (max_exp_bias - max_exp_bias)
+
+        initial_guess = [x0.to(u.V).value, 0.6, 2, 1]
+
+        bounds = ([-np.inf, -np.inf, 0, 0], np.inf)
+
+        fit_func = _fit_func_double_lin_inverse
+    else:
+        fit_func = _fit_func_lin_inverse
+
+    # Perform the actual fit of the data
+    fit, _ = curve_fit(fit_func, exponential_section.bias.to(u.V).value,
+                       np.log(exponential_section.current.to(u.A).value),
+                       p0=initial_guess,
+                       bounds=bounds)
+
+    hot_fraction = None
+
+    # Obtain the plasma parameters from the fit
+    if not bimaxwellian:
+        slope = fit[2]
+
+        T_e = slope**-1 * u.eV
+    else:
+        x0, y0 = fit[0], fit[1]
+        T0, Delta_T = [fit[2], fit[3]]
+
+        # In order to obtain the energetic electron fraction the fits of the
+        # cold and hot populations are extrapolated to the plasma potential
+        # (ie. the maximum bias of the exponential section). The logarithmic
+        # difference between these currents equates to the density difference.
 
-    measured_current : ~astropy.units.Quantity
-        The corresponding measured current in units convertible to A.
+        k1 = _fit_func_lin_inverse(
+            np.max(exponential_section.bias.to(u.V).value),
+            *[x0, y0, T0])
 
-    probe : ~plasmapy.diagnostics.langmuir.Probe
-        An instance of the Probe class containing probe properties.
+        k2 = _fit_func_lin_inverse(
+            np.max(exponential_section.bias.to(u.V).value),
+            *[x0, y0, T0 + Delta_T])
 
-    ion : str, optional
-        Representation of the ion species. Defaults to Hydrogen.
+        # Compute the total hot (energetic) fraction
+        hot_fraction = 1 / (1 + np.exp(k1 - k2))
 
-    method : str, optional
-        The preferred analysis method. Options are 'maxwellian' (default) and
-        'OML', which applies Orbital Motion Limit theory.
+        # If bi-Maxwellian, return main temperature first
+        T_e = np.array([T0, T0 + Delta_T]) * u.eV
 
-    smoothing : str, optional
-        The smoothing method applied to the measured current in order to
-        obtain clean derivatives. Options are None (default) and 'savgol',
-        which uses scipy.signal.savgol_filter. When `smoothing` is 'savgol',
-        the polynomial order is given by polyorder.
+    if visualize:  # coveralls: ignore
+        with quantity_support():
+            plt.figure()
 
-    polyorder : int
-        The order of the polynomial used to smooth the measured current and
-        derivatives if `smoothing` is 'savgol'. Default is 3.
+            plt.scatter(exponential_section.bias.to(u.V),
+                        np.log(exponential_section.current.to(u.A).value),
+                        color='k',
+                        marker='.')
+
+            if bimaxwellian:
+                plt.scatter(x0, y0, marker='o', c='g')
+                plt.plot(exponential_section.bias.to(u.V),
+                         _fit_func_lin_inverse(
+                             exponential_section.bias.to(u.V).value,
+                             fit[0], fit[1], fit[2] + fit[3]),
+                         c='g',
+                         linestyle='--')
+
+            plt.plot(exponential_section.bias.to(u.V),
+                     fit_func(exponential_section.bias.to(u.V).value,
+                     *fit),
+                     c='g')
+
+            plt.ylabel("Logarithmic current")
+            plt.title("Exponential fit")
+            plt.tight_layout()
+
+    k = [T_e]
+
+    if return_hot_fraction:
+        k.append(hot_fraction)
+
+    if return_fit:
+        k.append(fit)
+
+    return k
+
+
+def extrapolate_electron_current(probe_characteristic, fit,
+                                 bimaxwellian=False, visualize=False):
+    r"""Extrapolate the electron current from the Maxwellian electron
+    temperature obtained in the exponential growth region.
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
+
+    fit : ndarray
+        Polynomial fit coefficients returned by the electron temperature fit.
+
+    bimaxwellian : bool, optional
+        If True the electron current is extrapolated assuming bi-Maxwellian
+        electron populations, as opposed to Maxwellian. Default is False.
+
+    visualize : bool, optional
+        If True a plot of the extracted electron current is shown. Default is
+        False.
 
     Returns
     -------
-    T_e : ~astropy.units.Quantity
-        Best estimate of the electron temperature in units of eV.
+    electron_current : Characteristic
+        The extrapolated electron current characteristic.
+
+    Notes
+    -----
+    Assuming the electron population is fully Maxwellian the pure electron
+    current is extrapolated from the fit of the exponential region for the
+    entire bias range.
+
+    """
+
+    probe_characteristic.check_validity()
+
+    if bimaxwellian:
+        fit_func = _fit_func_double_lin_inverse
+    else:
+        fit_func = _fit_func_lin_inverse
 
-    n_e : ~astropy.units.Quantity
-        Best estimate of the electron density in units of m^-3.
+    electron_current = np.exp(fit_func(probe_characteristic.bias.to(u.V).value,
+                                       *fit))*u.A
 
-    n_i : ~astropy.units.Quantity
-        Best estimate of the ion density in units of m^-3. Equal to n_e
-        unless the OML method is used.
+    electron_current[electron_current >
+                     np.max(probe_characteristic.current)] = np.NaN
 
-    V_F : ~astropy.units.Quantity
-        Best estimate of the floating potential in units of V.
+    electron_characteristic = Characteristic(probe_characteristic.bias,
+                                             electron_current)
 
-    V_P : ~astropy.units.Quantity
-        Best estimate of the plasma potential in units of V.
+    if visualize:  # coveralls: ignore
+        with quantity_support():
+            plt.figure()
+            plt.scatter(probe_characteristic.bias,
+                        probe_characteristic.current.to(u.mA), marker='.',
+                        c='k')
+            plt.plot(electron_characteristic.bias,
+                     electron_characteristic.current.to(u.mA))
+
+    return electron_characteristic
+
+
+@utils.check_quantity({'T_e': {'units': u.eV,
+                               'can_be_negative': False,
+                               'can_be_complex': False,
+                               'can_be_inf': False,
+                               'can_be_nan': False}})
+def reduce_bimaxwellian_temperature(T_e, hot_fraction):
+    r"""Reduce a bi-Maxwellian (dual) temperature to a single mean temperature
+    for a given fraction.
+
+    Parameters
+    ----------
+    T_e : Quantity, ndarray
+        The bi-Maxwellian temperatures in eV. If a single temperature is
+        provided, this is returned.
+
+    hot_fraction : float
+        Fraction of hot to total population. If this parameter is None the
+        temperature is assumed to be singular Maxwellian.
+
+    Returns
+    -------
+    T_e : Quantity
+        The reduced (mean) temperature in units of eV.
 
     Notes
     -----
-    Due to the use of different methods to obtain `n_e` and `n_i`, the ratio
-    between these does not necessarily give an accurate average ionization.
+    This function aids methods that take a single electron temperature in
+    situations where the electron population is bi-Maxwellian. The reduced
+    temperature is obtained as the weighted mean.
+
     """
-    raise NotImplementedError
 
+    # Return the electron temperature itself if it is not bi-Maxwellian
+    # in the first place.
+    if hot_fraction is None or not np.array(T_e).size > 1:
+        return T_e
 
-def obtain_EEDF(potential_sweep, measured_current, probe):
-    r"""
-    Obtain the Electron Energy Distribution Function of the plasma
-    from the second derivative of the measured current response using
-    Druyvestein's equations.
+    return T_e[0] * (1 - hot_fraction) + T_e[1] * hot_fraction
+
+
+@utils.check_quantity({'probe_area': {'units': u.m**2,
+                                      'can_be_negative': False,
+                                      'can_be_complex': False,
+                                      'can_be_inf': False,
+                                      'can_be_nan': False},
+                       'gas': {'units': u.u,
+                               'can_be_negative': False,
+                               'can_be_complex': False,
+                               'can_be_inf': False,
+                               'can_be_nan': False}})
+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
     ----------
-    potential_sweep : ~astropy.units.Quantity
-        The swept electric potential applied to the probe in units
-        convertible to V.
+    probe_characteristic : Characteristic
+        The swept probe characteristic that is to be analyzed.
+
+    probe_area : Quantity
+        The area of the probe exposed to plasma in units convertible to m^2.
 
-    measured_current : ~astropy.units.Quantity
-        The corresponding measured current in units convertible to A.
+    gas : Quantity
+        The (mean) mass of the background gas in atomic mass units.
 
-    probe : ~plasmapy.diagnostics.langmuir.Probe
-        An instance of the Probe class containing probe properties.
+    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
     -------
-    EEDF : numpy.ndarray
-        Array containing electron energies in units of eV and their
-        corresponding probabilities in units of eV^-1 m^-3.
+    n_i_OML : Quantity
+        Estimated ion density in m^-3.
+
+    Notes
+    -----
+    The method implemented in this function holds for cylindrical probes in a
+    cold ion plasma, ie. 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:
+
+    .. math::
+        I = A_p n e \frac{\sqrt{2}}{\pi} \left( \frac{|eV_b|}{m_i}
+        \right)^{1/2}
+
     """
-    raise NotImplementedError
 
+    probe_characteristic.check_validity()
+
+    ion_section = extract_ion_section(probe_characteristic)
 
-def obtain_EEPF(potential_sweep, measured_current, probe):
-    r"""
-    Obtain the Electron Energy Probability Function of the plasma
-    from the second derivative of the measured current response using
-    Druyvestein's equations.
+    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]
+
+    n_i_OML = np.sqrt(-slope * u.mA ** 2 / u.V * np.pi ** 2 * gas /
+                      (probe_area ** 2 * const.e ** 3 * 2))
+
+    if visualize:  # coveralls: 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)
+
+
+def extrapolate_ion_current_OML(probe_characteristic, fit,
+                                visualize=False):
+    r"""Extrapolate the ion current from the ion density obtained with the
+    OML method.
 
     Parameters
     ----------
-    potential_sweep : ~astropy.units.Quantity
-        The swept electric potential applied to the probe in units
-        convertible to V.
+    probe_characteristic : Characteristic
+        The probe characteristic that is being analyzed.
 
-    measured_current : ~astropy.units.Quantity
-        The corresponding measured current in units convertible to A.
+    fit : ndarray
+        Fit coefficients returned by the OML method.
 
-    probe : ~plasmapy.diagnostics.langmuir.Probe
-        An instance of the `~plasmapy.diagnostics.langmuir.Probe`
-        class containing probe properties.
+    visualize : bool, optional
+        If True a plot of the extracted electron current is shown. Default is
+        False.
 
     Returns
     -------
-    EEPF : numpy.ndarray
-        Array of shape (2, N) containing electron energies in units of eV and
-        their corresponding probabilities in units of eV^-3/2 m^-3.
+    ion_section : Characteristic
+        The exponential electron current growth section.
+
+    Notes
+    -----
+    The exponential section of the probe characteristic should be a straight
+    line if the plasma electrons are fully Maxwellian. The slope is then
+    inversely proportional to the electron temperature.
 
     """
-    raise NotImplementedError
+
+    probe_characteristic.check_validity()
+
+    slope = fit[0] * u.mA**2 / u.V
+    offset = fit[1] * u.mA**2
+
+    ion_current = -np.sqrt(np.clip(slope * probe_characteristic.bias + offset,
+                                   0.0, None))
+
+    ion_characteristic = Characteristic(probe_characteristic.bias, ion_current)
+
+    if visualize:  # coveralls: ignore
+        with quantity_support():
+            plt.figure()
+            plt.scatter(probe_characteristic.bias,
+                        probe_characteristic.current.to(u.mA), marker='.',
+                        c='k')
+            plt.plot(probe_characteristic.bias,
+                     ion_characteristic.current.to(u.mA), c='y')
+
+    return ion_characteristic
+
+
+def get_EEDF(probe_characteristic, visualize=False):
+    r"""Implement the Druyvesteyn method of obtaining the normalized
+    Electron Energy Distribution Function (EEDF).
+
+    Parameters
+    ----------
+    probe_characteristic : Characteristic
+        The swept probe characteristic that is to be analyzed.
+
+    visualize : bool, optional
+        If True a plot of the extracted electron current is shown. Default is
+        False.
+
+    Returns
+    -------
+    energy : Quantity, ndarray
+        Array of potentials in V.
+
+    probability : float, ndarray
+        Array of floats corresponding to the potentials representing the EEDF
+        in normalized probabilities.
+
+    Notes
+    -----
+    The Druyvesteyn method requires the second derivative of the probe I-V
+    characteristic, which inherently amplifies noise and measurement errors.
+    Therefore it is advisable to smooth the I-V prior to the use of this
+    function.
+
+    """
+
+    probe_characteristic.check_validity()
+
+    probe_characteristic.sort()
+
+    V_F = get_floating_potential(probe_characteristic)
+
+    V_P = get_plasma_potential(probe_characteristic)
+
+    probe_bias = probe_characteristic.bias
+    probe_current = probe_characteristic.current
+
+    # Obtain the correct EEDF energy range from the probe characteristic.
+    _filter = (probe_bias > V_F) & (probe_bias < V_P)
+    energy = const.e * (V_P - probe_bias[_filter])
+    energy = energy.to(u.eV)
+
+    # Obtain the second derivative of the I-V curve.
+    dIdV = np.gradient(probe_current.to(u.A).value,
+                       probe_bias.to(u.V).value)
+    dIdV2 = np.gradient(dIdV, probe_bias.to(u.V).value)
+
+    # Division by the Druyvesteyn factor. Since the result will be normalized
+    # all constant values are irrelevant.
+    probability = dIdV2[_filter] / energy.to(u.eV).value
+
+    # Integration of the EEDF for the purpose of normalization.
+    integral = np.trapz(probability, x=energy.to(u.eV).value)
+    probability = np.abs(probability / integral)
+
+    if visualize:  # coveralls: ignore
+        with quantity_support():
+            plt.figure()
+            plt.semilogy(energy, probability, c='k')
+            plt.title("EEDF")
+
+    return energy, probability
diff --git a/plasmapy/examples/plot_langmuir_analysis.py b/plasmapy/examples/plot_langmuir_analysis.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/examples/plot_langmuir_analysis.py
@@ -0,0 +1,112 @@
+# coding: utf-8
+"""
+Langmuir probe data analysis
+============================
+
+Let's analyze a few Langmuir probe characteristics using the
+`diagnostics.langmuir` subpackage. First we need to import the module and some
+basics.
+"""
+
+from plasmapy.diagnostics.langmuir import Characteristic, swept_probe_analysis
+import astropy.units as u
+import numpy as np
+import pickle
+import os
+
+######################################################
+# The first characteristic we analyze is a simple single-probe measurement in
+# a low (ion) temperature, low density plasma with a cylindrical probe. This
+# allows us to utilize OML theory implemented in `swept_probe_analysis`.
+# The data has been preprocessed with some smoothing, which allows us to obtain
+# a Electron Energy Distribution Function (EEDF) as well.
+
+# Load the bias and current values stored in the .p pickle file.
+path = os.path.join("langmuir_samples", "Beckers2017.p")
+bias, current = pickle.load(open(path, 'rb'))
+
+# Create the Characteristic object, taking into account the correct units
+characteristic = Characteristic(np.array(bias) * u.V,
+                                np.array(current)*1e3 * u.mA)
+
+# Calculate the cylindrical probe surface area
+probe_length = 1.145 * u.mm
+probe_diameter = 1.57 * u.mm
+probe_area = (probe_length * np.pi * probe_diameter +
+              np.pi * 0.25 * probe_diameter**2)
+
+######################################################
+# Now we can actually perform the analysis. Since the plasma is in Helium an
+# ion mass number of 4 is entered. The results are visualized and the obtained
+# EEDF is also shown.
+print(swept_probe_analysis(characteristic,
+                           probe_area, 4 * u.u,
+                           visualize=True,
+                           plot_EEDF=True))
+
+######################################################
+# The cyan and yellow lines indicate the fitted electron and ion currents,
+# respectively. The green line is the sum of these and agrees nicely with the
+# data. This indicates a succesfull analysis.
+
+######################################################
+# The next sample probe data is provided by David Pace. is also obtained from a low relatively ion
+# temperature and density plasma, in Argon.
+
+# Load the data from a file and create the Characteristic object
+path = os.path.join("langmuir_samples", "Pace2015.p")
+bias, current = pickle.load(open(path, 'rb'))
+characteristic = Characteristic(np.array(bias) * u.V,
+                                np.array(current) * 1e3 * u.mA)
+
+######################################################
+# Initially the electrons are assumed to be Maxwellian. To check this the fit
+# of the electron growth region will be plotted.
+swept_probe_analysis(characteristic,
+                     0.738 * u.cm**2,
+                     40 * u.u,
+                     bimaxwellian=False,
+                     plot_electron_fit=True)
+
+######################################################
+# It can be seen that this plasma is slightly bi-Maxwellian, as there are two
+# distinct slopes in the exponential section. The analysis is now performed
+# with bimaxwellian set to True, which yields improved results.
+print(swept_probe_analysis(characteristic,
+                           0.738 * u.cm**2,
+                           40 * u.u,
+                           bimaxwellian=True,
+                           visualize=True,
+                           plot_electron_fit=True))
+
+######################################################
+# The probe current resolution of the raw data is relatively poor, but the
+# analysis still performs well in the ion current region. The bi-Maxwellian
+# properties are not significant but do make a difference. Check this analysis
+# without setting `bimaxwellian` to True!
+# This is reflected in the results, which indicate that the temperatures of
+# the cold and hot electron population are indeed different, but relatively
+# close.
+
+######################################################
+# This Helium plasma is fully bi-Maxwellian.
+
+# Import probe data and calculate probe surface area.
+path = os.path.join("langmuir_samples", "Beckers2017b.p")
+bias, current = pickle.load(open(path, 'rb'))
+characteristic = Characteristic(np.array(bias) * u.V,
+                                np.array(current) * 1e3 * u.mA)
+probe_length = 1.145 * u.mm
+probe_diameter = 1.57 * u.mm
+probe_area = (probe_length * np.pi * probe_diameter +
+              np.pi * 0.25 * probe_diameter**2)
+
+######################################################
+# `plot_electron_fit` is set to True to check the bi-Maxwellian properties.
+# The fit converges nicely to the two slopes of the electron growth region.
+print(swept_probe_analysis(characteristic,
+                           probe_area,
+                           4 * u.u,
+                           bimaxwellian=True,
+                           plot_electron_fit=True,
+                           visualize=True))


Test patch:
diff --git a/plasmapy/diagnostics/tests/test_langmuir.py b/plasmapy/diagnostics/tests/test_langmuir.py
--- a/plasmapy/diagnostics/tests/test_langmuir.py
+++ b/plasmapy/diagnostics/tests/test_langmuir.py
@@ -1,40 +1,186 @@
-"""Tests for functions that use Langmuir analysis functions."""
+# coding=utf-8
+"""Tests for Langmuir probe analysis functions."""
 
 import numpy as np
+from astropy import units as u
+import pytest
+import plasmapy.constants as const
 
-from plasmapy.diagnostics.langmuir import (swept_probe_analysis,
-                                           obtain_EEDF,
-                                           obtain_EEPF)
-
-
-class Test_swept_probe_analysis():
-    def test_no_signal(self):
-        """
-        Checks whether an error is raised when zero signal is passed to the
-        function.
-        """
-        errStr = ("Performing a swept probe analysis should return an error "
-                  "when zero signal is passed, but doesn't.")
-        assert True, errStr
-
-
-class Test_obtain_EEDF():
-    def test_no_signal(self):
-        """
-        Checks whether an error is raised when zero signal is passed to the
-        function.
-        """
-        errStr = ("Attempting to obtain the EEDF should return an error when "
-                  "zero signal is passed, but doesn't.")
-        assert True, errStr
-
-
-class Test_obtain_EEPF():
-    def test_no_signal(self):
-        """
-        Checks whether an error is raised when zero signal is passed to the
-        function.
-        """
-        errStr = ("Attempting to obtain the EEPF should return an error when "
-                  "zero signal is passed, but doesn't.")
-        assert True, errStr
+from plasmapy.diagnostics.langmuir import (Characteristic,
+                                           swept_probe_analysis)
+
+np.random.seed(42)
+N = 30  # array length of dummy probe characteristic
+
+current_arr = np.random.rand(N) * u.A
+bias_arr = np.random.rand(N) * u.V
+
+
+class Test__characteristic_errors:
+    r"""Test the Characteristic class constructor in langmuir.py"""
+
+    bias_infarr = np.append(np.random.rand(N - 1), np.inf) * u.V
+    current_infarr = np.append(np.random.rand(N - 1), np.inf) * u.A
+
+    bias_longarr = np.random.rand(N + 1) * u.V
+    current_longarr = np.random.rand(N + 1) * u.A
+
+    current_arr2 = np.random.rand(N) * u.A
+
+    def test_infinite_I(self):
+        r"""Test error upon an infinite current value"""
+
+        with pytest.raises(ValueError):
+            Characteristic(bias_arr, self.current_infarr)
+
+    def test_infinite_U(self):
+        r"""Test error upon an infinite bias value"""
+
+        with pytest.raises(ValueError):
+            Characteristic(self.bias_infarr, current_arr)
+
+    def test_unequal_arrays(self):
+        r"""Test errors upon unequal array lengths"""
+
+        with pytest.raises(ValueError):
+            Characteristic(self.bias_longarr, current_arr)
+
+        with pytest.raises(ValueError):
+            Characteristic(bias_arr, self.current_longarr)
+
+    def test_addition(self):
+        r"""Test addition of characteristic objects"""
+
+        a = Characteristic(bias_arr, current_arr)
+
+        b = Characteristic(bias_arr, self.current_arr2)
+
+        ab_sum = Characteristic(bias_arr, current_arr + self.current_arr2)
+
+        errStr = (f"Addition of characteristic objects is not behaving as it "
+                  f"should.")
+        assert (a.current + b.current == ab_sum.current).all(), errStr
+
+    def test_subtraction(self):
+        r"""Test addition of characteristic objects"""
+
+        a = Characteristic(bias_arr, current_arr)
+
+        b = Characteristic(bias_arr, self.current_arr2)
+
+        ab_sub = Characteristic(bias_arr, current_arr - self.current_arr2)
+
+        errStr = (f"Subtraction of characteristic objects is not behaving as "
+                  f"it should.")
+        assert (a.current - b.current == ab_sub.current).all(), errStr
+
+
+@pytest.fixture
+def characteristic():
+    r""""Create a dummy characteristic with random values"""
+    return Characteristic(bias_arr, current_arr)
+
+
+@pytest.fixture
+def characteristic_simulated():
+    r""""Create a simulated probe characteristic (provisional)"""
+
+    T_e_sim = 1 * u.eV
+    n_e_sim = 1e18 * u.m**-3
+    probe_area_sim = 1 * u.cm**2
+    I_es_sim = (n_e_sim * probe_area_sim * const.e *
+                np.sqrt(T_e_sim / (2 * np.pi * const.m_e)))
+
+    # Create bias array
+    bias_simarr = np.arange(-20, 15, 0.1) * u.V
+
+    # Calculate electron current and limit to electron saturation current
+    current_simarr = np.exp(const.e * bias_simarr / T_e_sim) * u.A
+    current_simarr[current_simarr > I_es_sim] = I_es_sim
+
+    # Add simulated linear sheath expansion current
+    current_simarr[current_simarr == I_es_sim] += (
+        bias_simarr[current_simarr == I_es_sim] * 5e-4 * u.A/u.V)
+
+    # Add simulated linear ion collection current
+    current_simarr[current_simarr < I_es_sim] += (
+        bias_simarr[current_simarr < I_es_sim] * 1e-4 * u.A/u.V)
+
+    return Characteristic(bias_simarr, current_simarr)
+
+
+@pytest.fixture
+def shuffle_characteristic(characteristic):
+    r""""Shuffle a given characteristic"""
+
+    _shuffle = sorted(np.arange(len(characteristic.bias)),
+                      key=lambda k: np.random.random())
+    U_shuffled = characteristic.bias[_shuffle]
+    I_shuffled = characteristic.current[_shuffle]
+    return Characteristic(U_shuffled, I_shuffled)
+
+
+class Test__swept_probe_analysis:
+    r"""Test the swept_probe_analysis function in langmuir.py"""
+
+    @staticmethod
+    def test_nan_area():
+        r"""Test error upon NaN area"""
+
+        with pytest.raises(ValueError):
+            swept_probe_analysis(characteristic, np.nan * u.cm**2, 40 * u.u)
+
+    @staticmethod
+    def test_unit_conversion_error():
+        r"""Test error upon incorrect probe area unit"""
+
+        with pytest.raises(u.UnitConversionError):
+            swept_probe_analysis(characteristic, 1 * u.cm, 40 * u.u)
+
+    @staticmethod
+    def test_negative_area():
+        r"""Test error upon negative probe area"""
+
+        with pytest.raises(ValueError):
+            swept_probe_analysis(characteristic, -1 * u.cm**2, 40 * u.u)
+
+    @staticmethod
+    def test_ion_mass_unit():
+        r"""Test equality of float and a.m.u. ion mass"""
+
+        sim = characteristic_simulated()
+
+        sim_result1 = swept_probe_analysis(
+            sim, 1 * u.cm**2, 40)
+
+        sim_result2 = swept_probe_analysis(
+            sim, 1 * u.cm**2, 40 * u.u)
+
+        errStr = (f"`swept_probe_analysis` should accept both floats and "
+                  f"a.m.u. Quantities as atomic gas mass input.")
+        for key in sim_result1:
+            assert (sim_result1[key] == sim_result2[key]).all(), errStr
+
+    @staticmethod
+    @pytest.mark.parametrize("bimaxwellian", [True, False])
+    def test_ordering_invariance(bimaxwellian):
+        r"""Test invariance to ordering of the bias and current values"""
+
+        sim = characteristic_simulated()
+
+        sim_result = swept_probe_analysis(
+            sim,
+            1 * u.cm**2,
+            40 * u.u,
+            bimaxwellian=bimaxwellian)
+
+        sim_result_shuffled = swept_probe_analysis(
+            shuffle_characteristic(sim),
+            1 * u.cm**2,
+            40 * u.u,
+            bimaxwellian=bimaxwellian)
+
+        errStr = (f"Analysis should be invariant to the ordering of the "
+                  f"input data.")
+        for key in sim_result:
+            assert (sim_result[key] == sim_result_shuffled[key]).all(), errStr