diff --git a/docs/api_static/plasmapy.analysis.swept_langmuir.plasma_potential.rst b/docs/api_static/plasmapy.analysis.swept_langmuir.plasma_potential.rst new file mode 100644 index 0000000000..4d04f058db --- /dev/null +++ b/docs/api_static/plasmapy.analysis.swept_langmuir.plasma_potential.rst @@ -0,0 +1,8 @@ +:orphan: + +`plasmapy.analysis.swept_langmuir.plasma_potential` +=================================================== + +.. currentmodule:: plasmapy.analysis.swept_langmuir.plasma_potential + +.. automodapi:: plasmapy.analysis.swept_langmuir.plasma_potential diff --git a/docs/notebooks/analysis/swept_langmuir/find_didv_peak_location.ipynb b/docs/notebooks/analysis/swept_langmuir/find_didv_peak_location.ipynb new file mode 100644 index 0000000000..306d676873 --- /dev/null +++ b/docs/notebooks/analysis/swept_langmuir/find_didv_peak_location.ipynb @@ -0,0 +1,275 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "881e5b44-992f-45fb-b7b0-0ec3db38512d", + "metadata": {}, + "source": [ + "# Swept Langmuir Analysis: Finding Peak Slope\n", + "\n", + "This notebook covers the use of the [**find_didv_peak_location()**](../../../api/plasmapy.analysis.swept_langmuir.plasma_potential.find_didv_peak_location.rst#find-didv-peak-location) function which finds the bias location associated with the peak slope of the swept Langmuir traces.\n", + "\n", + "The bias voltage at the peaks slope is often used as a rough estimate for the plasma potential, but will always be slightly lower than the actual plasma potential." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d60a04df-c4cc-4b16-956c-e1967d07d4c5", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "%matplotlib inline\n", + "\n", + "from pathlib import Path\n", + "\n", + "import matplotlib.pyplot as plt\n", + "import numpy as np\n", + "from scipy import signal\n", + "\n", + "from plasmapy.analysis import swept_langmuir as sla\n", + "\n", + "plt.rcParams[\"figure.figsize\"] = [10.5, 0.56 * 10.5]" + ] + }, + { + "cell_type": "markdown", + "id": "50e81f77-5218-43b4-aa4f-a5aece966c65", + "metadata": { + "editable": true, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "source": [ + "## Contents:\n", + "\n", + "1. [How find_didv_peak_location() works](#How-find_didv_peak_location()-works)\n", + " 1. [Notes about usage](#Notes-about-usage)\n", + " 1. [Knobs to turn](#Knobs-to-turn)\n", + "1. [Calculate the Floating Potential](#Calculate-the-Floating-Potential)\n", + " 1. [Interpreting results](#Interpreting-results)\n", + " 1. [Plotting results](#Plotting-results)" + ] + }, + { + "cell_type": "markdown", + "id": "d00a4864-f2c6-4606-ba19-a35ab420ac5b", + "metadata": {}, + "source": [ + "## How `find_didv_peak_location()` works" + ] + }, + { + "cell_type": "markdown", + "id": "c8f84fdd-6ec3-4633-bb0c-d827635f66cc", + "metadata": {}, + "source": [ + "## Calculate the Peak Slope" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ab816d3e-caf5-4654-a1e1-b4c7f15c1cb0", + "metadata": {}, + "outputs": [], + "source": [ + "# load data\n", + "filename = \"Beckers2017_noisy.npy\"\n", + "# filename = \"Pace2015.npy\"\n", + "filepath = (Path.cwd() / \"..\" / \"..\" / \"langmuir_samples\" / filename).resolve()\n", + "voltage, current = np.load(filepath)\n", + "\n", + "# voltage array needs to be monotonically increasing/decreasing\n", + "voltage, current = sla.sort_sweep_arrays(voltage, current)\n", + "\n", + "# grabbing floating potential for future use\n", + "vf, _ = sla.find_floating_potential(voltage, current)\n", + "print(f\"floating potential = {vf:.2f} V\")" + ] + }, + { + "cell_type": "markdown", + "id": "8963f972-1159-4058-b313-400023f0ad14", + "metadata": {}, + "source": [ + "### Interpreting results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e46204b7-a825-4c3a-b00a-46af218a7a05", + "metadata": {}, + "outputs": [], + "source": [ + "results = sla.find_didv_peak_location(voltage, current, voltage_window=[vf, None])\n", + "\n", + "# bias at peak slope\n", + "results[0]" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5a544261-3fef-4519-bcaa-489df4ab209a", + "metadata": {}, + "outputs": [], + "source": [ + "results[1].data_slice" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f20ecd4-5a8f-46e7-8094-c5f0a859277c", + "metadata": {}, + "outputs": [], + "source": [ + "np.where(voltage >= -30)[0][0]" + ] + }, + { + "cell_type": "markdown", + "id": "759e0477-6fb2-4bd4-88d2-29bcd833265f", + "metadata": {}, + "source": [ + "### Plotting results" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7ad1273f-69f8-49f2-bf5e-d9b63311e6fd", + "metadata": { + "editable": true, + "nbsphinx-thumbnail": { + "tooltip": "Peak dI/dV" + }, + "slideshow": { + "slide_type": "" + }, + "tags": [] + }, + "outputs": [], + "source": [ + "figwidth, figheight = plt.rcParams[\"figure.figsize\"]\n", + "figheight = 1.5 * figheight\n", + "fig, axs = plt.subplots(2, 1, figsize=[figwidth, figheight])\n", + "_font_size = 12\n", + "\n", + "bias = results[0]\n", + "extras = results[1]\n", + "\n", + "# first (I vs. V) plot\n", + "axs[0].set_xlabel(\"Bias Voltage (V)\", fontsize=_font_size)\n", + "axs[0].set_ylabel(\"Current (A)\", fontsize=_font_size)\n", + "\n", + "axs[0].plot(voltage, current, label=\"Sweep Data\")\n", + "\n", + "ylim = axs[0].get_ylim()\n", + "xlim = axs[0].get_xlim()\n", + "axs[0].set_ylim(ylim)\n", + "axs[0].axvline(bias, color=\"grey\")\n", + "axs[0].fill_between(\n", + " [bias - extras.std, bias + extras.std],\n", + " 2*[ylim[0]],\n", + " 2*[ylim[1]],\n", + " color=\"grey\",\n", + " alpha=0.2,\n", + ")\n", + "\n", + "txt = f\"${bias:.2f} \\\\pm {extras.std:.2f}$ V\\n\"\n", + "txt_loc = [bias, ylim[1]]\n", + "txt_loc = axs[0].transData.transform(txt_loc)\n", + "txt_loc = axs[0].transAxes.inverted().transform(txt_loc)\n", + "txt_loc[0] -= 0.02\n", + "txt_loc[1] -= 0.15\n", + "axs[0].text(\n", + " txt_loc[0],\n", + " txt_loc[1],\n", + " txt,\n", + " fontsize=\"large\",\n", + " transform=axs[0].transAxes,\n", + " ha=\"right\",\n", + ")\n", + "\n", + "# second (dI/dV vs V) plot\n", + "axs[1].set_xlabel(\"Bias Voltage (V)\", fontsize=_font_size)\n", + "axs[1].set_ylabel(\"$dI/dV$ (A / V)\", fontsize=_font_size)\n", + "\n", + "axs[1].set_xlim(xlim)\n", + "\n", + "voltage_merge, current_merge = sla.merge_voltage_clusters(voltage, current, voltage_step_size=0)\n", + "voltage_slice = voltage_merge[extras.data_slice]\n", + "current_slice = current_merge[extras.data_slice]\n", + "\n", + "_colors = plt.get_cmap(\"OrRd\")(np.linspace(0.2, 0.8, len(extras.savgol_windows)))\n", + "for ii, _window in enumerate(extras.savgol_windows):\n", + " v_smooth = signal.savgol_filter(voltage_slice, _window, 1)\n", + " c_smooth = signal.savgol_filter(current_slice, _window, 1)\n", + "\n", + " didv = np.gradient(c_smooth, v_smooth)\n", + "\n", + " axs[1].plot(\n", + " v_smooth[_window:-_window],\n", + " didv[_window:-_window],\n", + " color=_colors[ii],\n", + " label=f\"Savgol window {_window}\",\n", + " )\n", + "\n", + "ylim = axs[1].get_ylim()\n", + "axs[1].set_ylim(ylim)\n", + "\n", + "axs[1].axvline(bias, color=\"grey\", zorder=100)\n", + "axs[1].fill_between(\n", + " [bias - extras.std, bias + extras.std],\n", + " 2*[ylim[0]],\n", + " 2*[ylim[1]],\n", + " color=\"grey\",\n", + " alpha=0.2,\n", + " zorder=0\n", + ")\n", + "\n", + "axs[1].legend();" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "50eefb5e-788c-4796-a76f-c27f702eca3b", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.13.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/pyproject.toml b/pyproject.toml index d72cddfc6f..1697559487 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -326,7 +326,7 @@ lint.per-file-ignores."docs/notebooks/plasma/grids_nonuniform.ipynb" = [ "NPY002 lint.per-file-ignores."setup.py" = [ "D100" ] lint.per-file-ignores."src/plasmapy/analysis/fit_functions.py" = [ "D301" ] -lint.per-file-ignores."src/plasmapy/analysis/swept_langmuir/__init__.py" = [ "E402" ] +lint.per-file-ignores."src/plasmapy/analysis/swept_langmuir/**" = [ "E402" ] lint.per-file-ignores."src/plasmapy/formulary/braginskii.py" = [ "C901", "RET503", "RET504" ] lint.per-file-ignores."src/plasmapy/plasma/sources/*.py" = [ "D102" ] lint.per-file-ignores."src/plasmapy/utils/_pytest_helpers/pytest_helpers.py" = [ "BLE001", "PLR", "SLF001" ] diff --git a/src/plasmapy/analysis/swept_langmuir/__init__.py b/src/plasmapy/analysis/swept_langmuir/__init__.py index 3da6d9bacb..be428289e1 100644 --- a/src/plasmapy/analysis/swept_langmuir/__init__.py +++ b/src/plasmapy/analysis/swept_langmuir/__init__.py @@ -7,8 +7,10 @@ "check_sweep", "find_floating_potential", "find_ion_saturation_current", + "find_didv_peak_location", "merge_voltage_clusters", "sort_sweep_arrays", + "dIdVExtras", "ISatExtras", "VFExtras", ] @@ -30,3 +32,7 @@ find_ion_saturation_current, find_isat_, ) +from plasmapy.analysis.swept_langmuir.plasma_potential import ( + dIdVExtras, + find_didv_peak_location, +) diff --git a/src/plasmapy/analysis/swept_langmuir/helpers.py b/src/plasmapy/analysis/swept_langmuir/helpers.py index 2a78d65a11..94f8e49ac8 100644 --- a/src/plasmapy/analysis/swept_langmuir/helpers.py +++ b/src/plasmapy/analysis/swept_langmuir/helpers.py @@ -4,6 +4,7 @@ import numbers import warnings +from collections.abc import Sequence from typing import Literal import astropy.units as u @@ -565,3 +566,95 @@ def merge_voltage_clusters( # noqa: C901, PLR0912 ) return new_voltage, new_current + + +def _condition_voltage_window( # noqa: C901, PLR0912 + voltage, voltage_window +) -> slice: + """ + Condition ``voltage_window`` and return resulting `slice` object to + index ``voltage``. + + Parameters + ---------- + voltage : `numpy.ndarray` + 1-D numpy array of monotonically increasing probe biases + (should be in volts). + + voltage_window : `list[float | None]` | `None`, default: `None` + A two-element list ``[v_min, v_max]`` that specifies the voltage + range in which the peak slope will be looked for. Specifying + `None` for either the first or second element will result in a + window using the lower or upper bound of ``voltage`` + respectively. If set to `None` (default), then the whole + ``voltage`` window will be used. + + Returns + ------- + voltage_window : `slice` + A `slice` object representing the window originally defined + by the input ``voltage_window``. + + """ + if isinstance(voltage_window, np.ndarray): + voltage_window = voltage_window.tolist() + + if voltage_window is None: + voltage_window = [None, None] + elif not isinstance(voltage_window, Sequence) or isinstance(voltage_window, str): + raise TypeError( + f"Expected a 2-element list of floats or None for 'voltage_window', " + f"but got type {type(voltage_window)}." + ) + elif len(voltage_window) != 2: + raise ValueError( + f"Expected a 2-element list of floats or None for 'voltage_window', " + f"but got type {len(voltage_window)} elements." + ) + elif not all( + isinstance(element, numbers.Real) or element is None + for element in voltage_window + ): + raise TypeError("Not all elements of 'voltage_window' are floats or None.") + elif None not in voltage_window: + voltage_window = np.sort(voltage_window).tolist() + + # determine data window + if voltage_window[0] is not None and voltage_window[0] >= voltage[-1]: + raise ValueError( + f"The min value for the voltage window ({voltage_window[0]}) " + f"is larger than the max value of the langmuir trace " + f"({voltage[-1]})." + ) + + if voltage_window[1] is not None and voltage_window[1] <= voltage[0]: + raise ValueError( + f"The max value for the voltage window ({voltage_window[1]}) " + f"is smaller than the min value of the langmuir trace " + f"({voltage[0]})." + ) + + if all(isinstance(element, numbers.Real) for element in voltage_window): + voltage_window = np.sort(voltage_window).tolist() + + first_index = ( + None + if voltage_window[0] is None + else int(np.where(voltage >= voltage_window[0])[0][0]) + ) + if first_index == 0: + first_index = None + + last_index = ( + None + if voltage_window[1] is None + else int(np.where(voltage <= voltage_window[1])[0][-1]) + ) + if last_index is None: + pass + elif last_index == voltage.size-1: + last_index = None + else: + last_index += 1 + + return slice(first_index, last_index, 1) diff --git a/src/plasmapy/analysis/swept_langmuir/plasma_potential.py b/src/plasmapy/analysis/swept_langmuir/plasma_potential.py new file mode 100644 index 0000000000..ebb00f0a15 --- /dev/null +++ b/src/plasmapy/analysis/swept_langmuir/plasma_potential.py @@ -0,0 +1,223 @@ +""" +Functionality for determining the plasma potential of a Langmuir sweep. +""" + +__all__ = ["find_didv_peak_location", "dIdVExtras"] +__aliases__ = [] +__all__ += __aliases__ + +from collections.abc import Sequence +from typing import NamedTuple + +import numpy as np +from scipy import signal + +from plasmapy.analysis.swept_langmuir.helpers import ( + _condition_voltage_window, + check_sweep, + merge_voltage_clusters, +) + + +class dIdVExtras(NamedTuple): # noqa: N801 + """ + `~typing.NamedTuple` structured to contain the extra parameters + calculated by + `~plasmapy.analysis.swept_langmuir.plasma_potential.find_didv_peak_location`. + """ + + std: float | None + """ + Alias for field number 0, standard deviation of all the computed + peak slope bias locations for each Savitzky-Golay filtered Langmuir + trace. Standard deviation of :attr:`savgol_peaks`. + """ + + data_slice: slice | None + """ + Alias for field number 1, `slice` objected corresponding to the + sub-arrays of the Langmuir trace used for the calculation. + """ + + savgol_windows: list[int] | None + """ + Alias for field number 2, list of windows sizes used for each + Savitzky-Golay filtered Langmuir trace. + """ + + savgol_peaks: list[float] | None + """ + Alias for field number 3, list of computed peak slope bias locations + for each Savitzky-Golay filtered Langmuir trace. + """ + + +def _condition_smooth_fractions(smooth_fractions, data_size): + """ + Condition ``smooth_fractions`` and return the resulting + Savitzky-Golay filter windows sizes, based on the ``data_size``. + """ + if smooth_fractions is None: + # Note: If this default value is changed, then the docstring entry + # for smooth_fractions in the find_didv_peak() docstring + # needs to be updated accordingly. + smooth_fractions = np.linspace(0.01, 0.25, num=30) + elif ( + isinstance(smooth_fractions, Sequence) + and not isinstance(smooth_fractions, np.ndarray) + ): + smooth_fractions = np.array(smooth_fractions) + + if not isinstance(smooth_fractions, np.ndarray): + raise TypeError( + "Expected a 1-D list of floats in the interval (0, 1] for argument " + f"'smooth_fractions', but got type {type(smooth_fractions)}." + ) + elif smooth_fractions.ndim != 1: + raise ValueError( + "Expected a 1-D list of floats in the interval (0, 1] for argument " + f"'smooth_fractions', but got a {smooth_fractions.ndim}-D list.") + elif not np.issubdtype(smooth_fractions.dtype, np.floating): + raise ValueError( + "Expected a 1-D list of floats in the interval (0, 1] for argument " + "'smooth_fractions', not all elements are floats." + ) + + smooth_fractions = np.unique(np.sort(smooth_fractions)) + mask1 = smooth_fractions > 0 + mask2 = smooth_fractions <= 1 + mask = np.logical_and(mask1, mask2) + if np.count_nonzero(mask) == 0: + raise ValueError( + "Expected a 1-D list of floats in the interval (0, 1] for argument " + f"'smooth_fractions', no elements are within this interval " + f"{smooth_fractions.tolist()}." + ) + + # create bin sizes (savgol_windows) for the savgol_filter + savgol_windows = np.unique(np.rint(smooth_fractions * data_size).astype(int)) + + # windows need to have at least 2 points + mask = savgol_windows > 2 + savgol_windows = savgol_windows[mask] + + # force windows sizes to be odd + mask = savgol_windows % 2 == 0 + if np.count_nonzero(mask) > 0: + savgol_windows[mask] = savgol_windows[mask] + 1 + savgol_windows = np.unique(savgol_windows) + + # do not let windows sizes exceed data_size + mask = savgol_windows <= data_size + savgol_windows = savgol_windows[mask] + + # check savgol_windows is not null + if savgol_windows.size == 0: + raise ValueError( + f"The given smooth_fractions ({smooth_fractions}) and " + f"window size ({data_size}) resulted in no valid Savitzky-Golay " + f"filter windows. Computed windows must be odd, greater than 3, " + f"and less than or equal to the windows size." + ) + + return savgol_windows + + +def find_didv_peak_location( + voltage: np.ndarray, + current: np.ndarray, + *, + voltage_window: list[float | None] | None = None, + smooth_fractions: list[float] | None = None, +) -> tuple[float, dIdVExtras]: + """ + Find the bias voltage at which the peak slope (:math:`dI/dV_{max}`) + of the swept Langmuir trace occurs. + + The peak slope bias voltage is often used as a rough estimate of + the plasma potential. However, the value will always be slightly + less than the actual plasma potential. + + Parameters + ---------- + voltage : `numpy.ndarray` + 1-D numpy array of monotonically increasing probe biases + (should be in volts). + + current : `numpy.ndarray` + 1-D numpy array of probe current (should be in amperes) + corresponding to the ``voltage`` array. + + voltage_window : `list[float | None]` | `None`, default: `None` + A two-element list ``[v_min, v_max]`` that specifies the voltage + range in which the peak slope will be looked for. Specifying + `None` for either the first or second element will result in a + window using the lower or upper bound of ``voltage`` + respectively. If set to `None` (default), then the whole + ``voltage`` window will be used. + + smooth_fractions : `list[float]` | `None`, default: `None` + An order list of fractions in the interval :math:`(0, 1]` used + to compute the Savitzky-Golay filter window sizes. For example, + if the ``voltage_windows`` had a size of 50, then a + ``smooth_fraction`` value of 0.5 would result in a + Savitzky-Golay window size of 25. If `None` (default), then + ``smooth_fractions`` will default to + ``numpy.linspace(0.01, 0.25, num=30)``. + + Notes + ----- + Add details about algorithm. + """ + rtn_extras = dIdVExtras( + std=None, + data_slice=None, + savgol_windows=None, + savgol_peaks=None, + )._asdict() + + # check voltage and current arrays + voltage, current = check_sweep(voltage, current, strip_units=True) + voltage, current = merge_voltage_clusters(voltage, current, voltage_step_size=0) + + # condition voltage_window + _slice = _condition_voltage_window(voltage, voltage_window) + rtn_extras["data_slice"] = _slice + data_size = voltage[_slice].size + if data_size < 3: + raise ValueError( + f"The specified voltage_window ({voltage_window}) would result " + f"in a null window or a window with fewer than 3 elements." + ) + + # define starting savgol windows + savgol_windows = _condition_smooth_fractions(smooth_fractions, data_size) + + voltage_slice = voltage[_slice] + current_slice = current[_slice] + plasma_potentials = [] + rtn_extras["savgol_windows"] = [] + for _window in savgol_windows: + v_smooth = signal.savgol_filter(voltage_slice, _window, 1) + c_smooth = signal.savgol_filter(current_slice, _window, 1) + + didv = np.gradient(c_smooth, v_smooth) + imax = np.argmax(didv) + if imax.size > 1: + if np.all(np.diff(imax) == 1): + vp = np.average(voltage_slice[imax]) + else: + continue + elif np.isscalar(imax): + vp = voltage_slice[imax] + else: + vp = voltage_slice[imax[0]] + + plasma_potentials.append(float(vp)) + rtn_extras["savgol_windows"].append(int(_window)) + + rtn_extras["savgol_peaks"] = plasma_potentials + rtn_extras["std"] = float(np.std(plasma_potentials)) + + vp = float(np.average(plasma_potentials)) + return vp, dIdVExtras(**rtn_extras) diff --git a/tests/analysis/swept_langmuir/test_helpers__condition_voltage_window.py b/tests/analysis/swept_langmuir/test_helpers__condition_voltage_window.py new file mode 100644 index 0000000000..d99cd8f5e0 --- /dev/null +++ b/tests/analysis/swept_langmuir/test_helpers__condition_voltage_window.py @@ -0,0 +1,59 @@ +""" +Tests for +`plasmapy.analysis.swept_langmuir.helpers._condition_voltage_window`. +""" + +import numpy as np +import pytest + +from plasmapy.analysis.swept_langmuir.helpers import _condition_voltage_window + + +class TestConditionVoltageWindow: + @pytest.mark.parametrize( + ("_raises", "voltage", "voltage_window"), + [ + # voltage_window is not a list-like or None + (pytest.raises(TypeError), np.arange(10.0), {"one": 1, "two": 2}), + (pytest.raises(TypeError), np.arange(10.0), "invalid window"), + (pytest.raises(TypeError), np.arange(10.0), 5.0), + # voltage_window is wrong size + (pytest.raises(ValueError), np.arange(10.0), []), + (pytest.raises(ValueError), np.arange(10.0), [5.0]), + (pytest.raises(ValueError), np.arange(10.0), (-1.0, 2.0, 5.0)), + # voltage_window does not have Real numbers or None + (pytest.raises(TypeError), np.arange(10.0), ["one", 2]), + (pytest.raises(TypeError), np.arange(10.0), ["one", "two"]), + (pytest.raises(TypeError), np.arange(10.0), [(1.0, 2.0), 2.0]), + # voltage_window is out of range + (pytest.raises(ValueError), np.arange(10.0), [-5, -2]), + (pytest.raises(ValueError), np.arange(10.0), [11, 18]), + ], + ) + def test_raises(self, _raises, voltage, voltage_window): + with _raises: + _condition_voltage_window(voltage, voltage_window) + + @pytest.mark.parametrize( + ("voltage", "voltage_window", "expected"), + [ + # voltage_window as list and numpy array + (np.arange(10), [3.3, 4.5], slice(4, 5, 1)), + (np.arange(10), np.array([3.3, 4.5]), slice(4, 5, 1)), + (np.arange(10), [3.3, 7.2], slice(4, 8, 1)), + (np.arange(10), np.array([3.3, 7.2]), slice(4, 8, 1)), + # voltage_window has None values + (np.arange(10), None, slice(None, None, 1)), + (np.arange(10), [None, None], slice(None, None, 1)), + (np.arange(10), [3.3, None], slice(4, None, 1)), + (np.arange(10), [None, 7.2], slice(None, 8, 1)), + # voltage_window is unsorted + (np.arange(10), [7.2, 3.3], slice(4, 8, 1)), + # voltage_window has one index out of bounds + (np.arange(10), [-5, 7.2], slice(None, 8, 1)), + (np.arange(10), [3.3, 20], slice(4, None, 1)), + ], + ) + def test_expected(self, voltage, voltage_window, expected): + result = _condition_voltage_window(voltage, voltage_window) + assert result == expected diff --git a/tests/analysis/swept_langmuir/test_plasma_potential_didv.py b/tests/analysis/swept_langmuir/test_plasma_potential_didv.py new file mode 100644 index 0000000000..9150ee54b6 --- /dev/null +++ b/tests/analysis/swept_langmuir/test_plasma_potential_didv.py @@ -0,0 +1,53 @@ +""" +Tests for functionality contained in +`plasmapy.analysis.swept_langmuir.plasma_potential` that calculates the +peak slope of the langmuir drives (i.e. peak dI/dV). +""" + +from unittest import mock + +import operator +import numpy as np +import pytest + +from plasmapy.analysis import fit_functions as ffuncs +from plasmapy.analysis import swept_langmuir as sla +from plasmapy.analysis.swept_langmuir.plasma_potential import ( + dIdVExtras, + find_didv_peak_location, +) +from plasmapy.utils.exceptions import PlasmaPyWarning + + +@pytest.mark.parametrize( + ("assert_op", "arg1", "expected"), + [ + (issubclass, dIdVExtras, tuple), + (hasattr, dIdVExtras, "_fields"), + (hasattr, dIdVExtras, "_field_defaults"), + ( + operator.eq, + set(dIdVExtras._fields), + {"std", "data_slice", "savgol_windows", "savgol_peaks"}, + ), + (operator.eq, dIdVExtras._field_defaults, {}), + ], +) +def test_didv_namedtuple(assert_op, arg1, expected): + assert assert_op(arg1, expected) + + +@pytest.mark.parametrize( + ("index", "field_name"), + [(0, "std"), (1, "data_slice"), (2, "savgol_windows"), (3, "savgol_peaks")], +) +def test_didv_namedtuple_index_field_mapping(index, field_name): + extras = dIdVExtras( + std=0.1, + data_slice=slice(4, 20, 2), + savgol_windows=[4, 6, 8], + savgol_peaks=[1.1, 1.2, 1.3], + ) + + assert extras[index] == getattr(extras, field_name) +