Repository: PlasmaPy/PlasmaPy

Issue:
Implement NIST STAR (Stopping Power & Ranging) calculations in PlasmaPy
### Feature description

Implement NIST STAR (Stopping Power & Ranging) calculations in PlasmaPy, something similar to the calculations in the NIST PSTAR website: https://physics.nist.gov/PhysRefData/Star/Text/PSTAR.html 

Something like 

`stopping_power =StoppingPower(test_particle, material, energies)`

Ideally this would work for as many materials as possible (not just elements): maybe there is a way to combine stopping_powers from elements to approximate a compound? 


### Motivation

The NIST PSTAR database data is currently used in the charged_particle_radiography.detector_stacks package (currently awkwardly being imported by users from the NIST website), and soon we would like to use it in charged_particle_radiography.Tracker. 

Having this data directly in PlasmaPy would be much more direct for users! 

### Implementation strategy

Start with an existing but unmaintained repository for this: 
https://github.com/Zelenyy/nist-calculators/tree/master

EDIT: This package doesn't actually do the calculations, it has all the NIST data downloaded into this HDF5 file and then just interpolates from that. That would also be a fine way to do it... 

The HDF5 is 2.2 MB, but we could put it in the plasmapy_data repo and then use the downloader to download it when called? 

### Additional context

Details on physics behind the NIST databases: 
https://physics.nist.gov/PhysRefData/Star/Text/programs.html 

Project for @JaydenR2305 ? 



Discussion:
@namurphy Let's discuss the best place for something like this within the package? 

Possibilities
- `formulary.stopping_power' 
- Somewhere in `Particles`? This will only be implemented for protons, electrons, and alphas though I think? 
@JaydenR2305 has volunteered to take a crack at this. Here's my outline of how this function should work:

Take the HDF5 from the packaged listed above, which I've copied into the PlasmaPy-data repo. 
https://github.com/PlasmaPy/PlasmaPy-data/blob/main/NIST_STAR.hdf5 

You can use the `get_file` function within plasmapy to get a path to this file, and it will automatically download it if necessary
https://docs.plasmapy.org/en/stable/api_static/plasmapy.utils.data.downloader.html#module-plasmapy.utils.data.downloader 

Create a function (in plasmapy.formulary? plasmapy.particles? or in a new plasmapy.data module?) @namurphy where should this go? 

```python
def stopping_power(  <Incident Particle obj>, <material string>, energies=None, component='total'  ):

   datafile = get_file( path_to_hdf5_in_data_repo ) # Download file if necessary

  #... validate incident particle is (p, e-, He-4), otherwise raise exception

  #... open file, find material if valid (in the list of included materials), otherwise raise exception

  #... extract the energies vector from the data 
  #... extract the requested column 

  if component == 'total':
     data =  electronic_stopping_power + nuclear_stopping_power
  elif component =='electronic'
     ...
  elif component == 'nuclear':
     ...
  else:
     exception 


 if energies is not None:
     ... # Interpolate the data at the requested array of energies
 #...otherwise just return the data with the energy array that came out of the file. 

  return energies, data
```

Then we should spot check a few datapoints against the data you can download from the NIST database just to make sure this HDF5 file looks accurate! 


Definitions of the different quantities: https://physics.nist.gov/PhysRefData/Star/Text/appendix.html 

We have decided to only implement PSTAR and ASTAR for now, since the ESTAR data in the HDF5 file is somewhat confusing.

Now or in the future, it would be great to calculate stopping powers for arbitrary compounds! This paper describes how this is done using the Bragg additivity rule (Eq. 3):

https://iopscience.iop.org/article/10.1088/1742-6596/1505/1/012012/pdf

So it looks like the stopping power is basically just a sum of the stopping power of the elements, weighted by their fractions and densities. 

Resolving patch:
diff --git a/src/plasmapy/particles/atomic.py b/src/plasmapy/particles/atomic.py
--- a/src/plasmapy/particles/atomic.py
+++ b/src/plasmapy/particles/atomic.py
@@ -19,12 +19,16 @@
     "reduced_mass",
     "stable_isotopes",
     "standard_atomic_weight",
+    "stopping_power",
 ]
 
 from numbers import Integral
 from typing import Any
 
 import astropy.units as u
+import h5py
+import numpy as np
+from scipy.interpolate import CubicSpline
 
 from plasmapy.particles import _elements, _isotopes
 from plasmapy.particles.decorators import particle_input
@@ -37,10 +41,13 @@
 from plasmapy.particles.particle_class import Particle, ParticleLike
 from plasmapy.particles.particle_collections import ParticleList
 from plasmapy.particles.symbols import atomic_symbol
+from plasmapy.utils.data.downloader import Downloader
 from plasmapy.utils.decorators import validate_quantities
 
 __all__.sort()
 
+_DEFAULT_DOWNLOADER = Downloader()
+
 
 @particle_input
 def atomic_number(element: ParticleLike) -> int:
@@ -1133,3 +1140,102 @@ def _is_electron(arg: Any) -> bool:
         if isinstance(arg, str)
         else False
     )
+
+
+@particle_input
+@validate_quantities(energies=u.MeV)
+def stopping_power(
+    incident_particle: Particle,
+    material: str,
+    energies: u.Quantity[u.MeV] | None = None,
+    component="total",
+) -> tuple[u.Quantity, u.Quantity]:
+    """Calculate stopping powers for a provided particle in a provided material.
+
+    Parameters
+    ----------
+    incident_particle : |Particle|
+        The incident particle. Only protons and alpha particles are currently supported.
+
+    material : `str`
+        The material the particle is being stopped in. See notes for details on supported materials.
+
+    energies : `~astropy.units.Quantity`, default: See notes.
+        The particle kinetic energies for which the stopping power is calculated.
+
+    component : `str`, default: ``total``
+        The component of the stopping power to be calculated. Supported values are ``electronic``,
+        ``nuclear``, and ``total`` for the electronic, nuclear, and total energies, respectively.
+
+    Returns
+    -------
+    ``Tuple[u.Quantity, u.Quantity[u.MeV * u.cm**2 / u.g]]``
+        A two-tuple where the first element represents the energy values. The
+        second element is an array of the associated stopping powers.
+
+    Notes
+    -----
+    The data for stopping power is taken from the National Institute of
+    Standards and Technology's Stopping-Power and Range Tables :cite:p:`niststar:2005`.
+    Valid materials can be found on the NIST STAR website. The default energies
+    are taken from the data points in the STAR database.
+
+    """
+
+    nist_data_path = _DEFAULT_DOWNLOADER.get_file("NIST_STAR.hdf5")
+
+    # Validate particle input. Currently, the only supported particles are protons and electrons.
+    with h5py.File(nist_data_path, "r") as nist_data:
+        if incident_particle == Particle("He-4"):
+            group_name = "helium_ions"
+        elif incident_particle == Particle("e-"):
+            raise NotImplementedError(
+                "Stopping calculations for electrons have not been implemented yet!"
+            )
+        elif incident_particle == Particle("H+"):
+            group_name = "protons"
+        else:
+            raise ValueError(
+                "Please pass a valid particle type for stopping power calculations."
+            )
+
+        group_data = nist_data[group_name]
+
+        if material not in group_data:
+            raise ValueError(
+                f"Please pass a valid material string! Material {material} not found in {group_name}."
+            )
+
+        # Energies are not included in the material data. They must be loaded from a separate data set.
+        # To differentiate from "energies" which refers to the user provided energies, we use "baseline_energies"
+        baseline_energies_data = group_data["energy"]
+        material_data = group_data[material]
+
+        if component == "total":
+            relevant_stopping_data = (
+                material_data["electronic_stopping_power"]
+                + material_data["nuclear_stopping_power"]
+            )
+        elif component == "electronic":
+            relevant_stopping_data = material_data["electronic_stopping_power"]
+        elif component == "nuclear":
+            relevant_stopping_data = material_data["nuclear_stopping_power"]
+        else:
+            raise ValueError(
+                f"Please specify one of: total, electronic, or nuclear for component! (Got {component}.)"
+            )
+
+        if energies is not None:
+            # Interpolate NIST data to the user-provided energy values. Uses log-log scale fed into a cubic spline.
+            cs = CubicSpline(
+                x=np.log(baseline_energies_data), y=np.log(relevant_stopping_data)
+            )
+
+            return energies, np.exp(
+                cs(np.log(energies.to("MeV").value))
+            ) * u.MeV * u.cm**2 / u.g
+
+        return (
+            baseline_energies_data * u.MeV,
+            relevant_stopping_data * u.MeV * u.cm**2 / u.g,
+        )
diff --git a/src/plasmapy/utils/data/downloader.py b/src/plasmapy/utils/data/downloader.py
--- a/src/plasmapy/utils/data/downloader.py
+++ b/src/plasmapy/utils/data/downloader.py
@@ -5,6 +5,7 @@
 
 import contextlib
 import json
+import os
 import time
 import warnings
 from pathlib import Path
@@ -14,6 +15,8 @@
 
 __all__ = ["Downloader"]
 
+_IS_CI = "GH_TOKEN" in os.environ
+
 
 # TODO: use a config file variable to allow users to set a location
 # for the data download folder?
@@ -71,6 +74,11 @@ def __init__(
         else:
             self._download_directory = Path(directory)
 
+        # If currently operating in a CI environment and no token was specified,
+        # take the token from the CI environment variables
+        if _IS_CI and api_token is None:
+            api_token = api_token if api_token is not None else os.getenv("GH_TOKEN")
+
         self._validate = validate
         self._api_token = api_token
 


Test patch:
diff --git a/tests/particles/test_atomic.py b/tests/particles/test_atomic.py
--- a/tests/particles/test_atomic.py
+++ b/tests/particles/test_atomic.py
@@ -24,6 +24,7 @@
     reduced_mass,
     stable_isotopes,
     standard_atomic_weight,
+    stopping_power,
 )
 from plasmapy.particles.exceptions import (
     ChargeError,
@@ -603,3 +604,102 @@ def test_ion_list2(particle, min_charge, max_charge, expected_charge_numbers) ->
 def test_invalid_inputs_to_ion_list2(element, min_charge, max_charge) -> None:
     with pytest.raises(ChargeError):
         ionic_levels(element, min_charge, max_charge)
+
+
+@pytest.mark.parametrize(
+    ("incident_particle", "material", "kwargs", "expected_error"),
+    [
+        # ESTAR not implemented
+        (Particle("e-"), "OXYGEN", {}, NotImplementedError),
+        # Invalid incident particle
+        (Particle("O"), "OXYGEN", {}, ValueError),
+        # Invalid material
+        (Particle("H+"), "BENZENE", {}, ValueError),
+        # Invalid component
+        (Particle("H+"), "OXYGEN", {"component": "Lorem Ipsum"}, ValueError),
+    ],
+)
+def test_stopping_power_errors(incident_particle, material, kwargs, expected_error):
+    with pytest.raises(expected_error):
+        stopping_power(incident_particle, material, **kwargs)
+
+
+@pytest.mark.parametrize(
+    (
+        "incident_particle",
+        "material",
+        "energies",
+        "component",
+        "expected_stopping_power",
+    ),
+    [
+        # Test ASTAR interpolation
+        (
+            Particle("He-4"),
+            "OXYGEN",
+            [1.25e-3, 1.25e-2, 1.25e-1, 1.25, 1.25e1, 1.25e2] * u.MeV,
+            "total",
+            [2.383e2, 4.055e2, 1.048e3, 1.669e3, 3.837e2, 6.273e1]
+            * u.MeV
+            * u.cm**2
+            / u.g,
+        ),
+        # Test PSTAR interpolation
+        (
+            Particle("H+"),
+            "HYDROGEN",
+            [1.25e-3, 1.25e-2, 1.25e-1, 1.25, 1.25e1, 1.25e2] * u.MeV,
+            "total",
+            [1.030e3, 2.621e3, 3.188e3, 5.673e2, 8.44e1, 1.295e1]
+            * u.MeV
+            * u.cm**2
+            / u.g,
+        ),
+        # Test electronic component
+        (
+            Particle("H+"),
+            "ACETYLENE",
+            [1.25e-3, 1.25e-2, 1.25e-1, 1.25, 1.25e1, 1.25e2] * u.MeV,
+            "electronic",
+            [2.073e2, 6.002e2, 8.871e2, 2.307e2, 3.859e1, 6.185]
+            * u.MeV
+            * u.cm**2
+            / u.g,
+        ),
+        # Test nuclear component
+        (
+            Particle("He-4"),
+            "POLYSTYRENE",
+            [1.25e-3, 1.25e-2, 1.25e-1, 1.25, 1.25e1, 1.25e2] * u.MeV,
+            "nuclear",
+            [2.143e2, 7.921e1, 1.526e1, 2.244, 2.936e-1, 3.450e-2]
+            * u.MeV
+            * u.cm**2
+            / u.g,
+        ),
+        # # Test no interpolation
+        # (
+        #     Particle("H+"),
+        #     "COPPER",
+        #     None,
+        #     "total",
+        #     [0, 0, 0, 0, 0, 0] * u.MeV * u.cm**2 / u.g,
+        # ),
+    ],
+)
+def test_stopping_power_interpolation(
+    incident_particle, material, energies, component, expected_stopping_power
+):
+    """Test the interpolation functionality of the stopping power function against NIST values"""
+    _, actual_stopping_power = stopping_power(
+        incident_particle, material, energies, component=component
+    )
+
+    # NIST data is given to four significant figures: use a tolerance of 1 part in 1000
+    assert np.isclose(actual_stopping_power, expected_stopping_power, rtol=0.001).all()
+
+
+def test_stopping_power_no_interpolation():
+    result = stopping_power(Particle("H+"), "COPPER")
+
+    assert type(result) is tuple