Repository: PlasmaPy/PlasmaPy
Pull request #1014 (2021-02-15 01:14:03)

Add mostly failing tests for pickling various objects in plasmapy.particles
This PR adds new tests in `plasmapy.particles` regarding the pickling of particles.  Both `CustomParticle` and `DimensionlessParticle` instances can be pickled, but `Particle` instances (and objects that contain `Particle` instances) cannot.  Hence, most of these tests are marked as failing.  These tests should help later on when we address #1011 and make particles piclkealble.  (Yeah, I gave up on trying to spell that.)



codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014?src=pr&el=h1) Report
> Merging [#1014](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014?src=pr&el=desc) (50071ab) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/f52b2a820a0e0cdd0fe71273d7a0d74548bc9882?el=desc) (f52b2a8) will **increase** coverage by `0.01%`.
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014/graphs/tree.svg?width=650&height=150&src=pr&token=LzZ5mnXVad)](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014?src=pr&el=tree)

```diff
@@            Coverage Diff             @@
##           master    #1014      +/-   ##
==========================================
+ Coverage   96.62%   96.63%   +0.01%     
==========================================
  Files          64       64              
  Lines        6249     6249              
==========================================
+ Hits         6038     6039       +1     
+ Misses        211      210       -1     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/particles/ionization\_state.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL2lvbml6YXRpb25fc3RhdGUucHk=) | `93.69% <0.00%> (+0.31%)` | :arrow_up: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014?src=pr&el=footer). Last update [f52b2a8...50071ab](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1014?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/changelog/1015.feature.rst b/changelog/1015.feature.rst
new file mode 100644
index 00000000..a22b5ad2
--- /dev/null
+++ b/changelog/1015.feature.rst
@@ -0,0 +1,3 @@
+Both `plasmapy.particles.CustomParticle` and `plasmapy.particles.DimensionlessParticle`
+now allow users to define a custom symbol via the ``symbol`` keyword argument, which
+can then be accessed by the ``symbol`` attribute in each of these classes.
diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py
index 0db349e9..ec33ec24 100644
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -163,11 +163,6 @@ def json_dict(self) -> dict:
         }
         return json_dictionary
 
-    @property
-    def symbol(self):
-        """Return a string representation of the particle."""
-        return self.__repr__()
-
     def __bool__(self):
         """
         Raise an `~plasmapy.particles.exceptions.ParticleError` because particles
@@ -254,6 +249,11 @@ class Particle(AbstractParticle):
         Raised for attempts at converting a
         `~plasmapy.particles.Particle` object to a `bool`.
 
+    See Also
+    --------
+    ~plasmapy.particles.CustomParticle
+    ~plasmapy.particles.DimensionlessParticle
+
     Examples
     --------
     Particles may be defined using a wide variety of aliases:
@@ -1709,30 +1709,43 @@ class DimensionlessParticle(AbstractParticle):
     Parameters
     ----------
     mass : positive real number, keyword-only, optional
-        The mass of the dimensionless particle.
+        The mass of the dimensionless particle.  Defaults to `numpy.nan`.
 
     charge : real number, keyword-only, optional
-        The electric charge of the dimensionless particle.
+        The electric charge of the dimensionless particle.  Defaults to
+        `numpy.nan`.
+
+    symbol : str, optional
+        The symbol to be assigned to the dimensionless particle.
+
+    See Also
+    --------
+    ~plasmapy.particles.Particle
+    ~plasmapy.particles.CustomParticle
 
     Notes
     -----
-    If the charge or mass is not specified, then the corresponding value
-    will be set to ``numpy.nan``.
+    `DimensionlessParticle` instances are not considered `ParticleLike`
+    because dimensionless particles cannot uniquely identify a physical
+    particle without normalization information.
 
     Examples
     --------
     >>> from plasmapy.particles import DimensionlessParticle
-    >>> dimensionless_particle = DimensionlessParticle(mass=1.0, charge=-1.0)
+    >>> dimensionless_particle = DimensionlessParticle(mass=1.0, charge=-1.0, symbol="ξ")
     >>> dimensionless_particle.mass
     1.0
     >>> dimensionless_particle.charge
     -1.0
+    >>> dimensionless_particle.symbol
+    'ξ'
     """
 
-    def __init__(self, *, mass: Real = None, charge: Real = None):
+    def __init__(self, *, mass: Real = None, charge: Real = None, symbol: str = None):
         try:
             self.mass = mass
             self.charge = charge
+            self.symbol = symbol
         except Exception as exc:
             raise InvalidParticleError(
                 f"Unable to create a custom particle with a mass of "
@@ -1793,18 +1806,21 @@ def json_dict(self) -> dict:
         {'plasmapy_particle': {'type': 'DimensionlessParticle',
             'module': 'plasmapy.particles.particle_class',
             'date_created': '...',
-            '__init__': {'args': (), 'kwargs': {'mass': 1.0, 'charge': -1.0}}}}
+            '__init__': {'args': (), 'kwargs': {'mass': 1.0, 'charge': -1.0,
+            'symbol': 'DimensionlessParticle(mass=1.0, charge=-1.0)'}}}}
         >>> dimensionless_particle = DimensionlessParticle(mass=1.0)
         >>> dimensionless_particle.json_dict
         {'plasmapy_particle': {'type': 'DimensionlessParticle',
             'module': 'plasmapy.particles.particle_class',
             'date_created': '...',
-            '__init__': {'args': (), 'kwargs': {'mass': 1.0, 'charge': nan}}}}
+            '__init__': {'args': (), 'kwargs': {'mass': 1.0, 'charge': nan,
+            'symbol': 'DimensionlessParticle(mass=1.0, charge=nan)'}}}}
         """
         particle_dictionary = super().json_dict
         particle_dictionary["plasmapy_particle"]["__init__"]["kwargs"] = {
             "mass": self.mass,
             "charge": self.charge,
+            "symbol": self.symbol,
         }
         return particle_dictionary
 
@@ -1846,6 +1862,23 @@ def charge(self, q: Optional[Union[Real, u.Quantity]]):
                 "DimensionlessParticle charge set to NaN", MissingParticleDataWarning
             )
 
+    @property
+    def symbol(self) -> str:
+        """
+        Return the symbol assigned to the dimensionless particle.  If no
+        symbol was defined, then return the value given by `repr`.
+        """
+        return self._symbol
+
+    @symbol.setter
+    def symbol(self, new_symbol: str):
+        if new_symbol is None:
+            self._symbol = repr(self)
+        elif isinstance(new_symbol, str):
+            self._symbol = new_symbol
+        else:
+            raise TypeError("symbol needs to be a string.")
+
 
 class CustomParticle(AbstractParticle):
     """
@@ -1859,12 +1892,15 @@ class CustomParticle(AbstractParticle):
     mass : ~astropy.units.Quantity, optional
         The mass of the custom particle in units of mass.
 
-    charge : ~astropy.units.Quantity or ~numbers.Real
+    charge : ~astropy.units.Quantity or ~numbers.Real, optional
         The electric charge of the custom particle.  If provided as a
         `~astropy.units.Quantity`, then it must be in units of electric
         charge.  If provided as a real number, then it is treated as the
         ratio of the charge to the elementary charge.
 
+    symbol : str, optional
+        The symbol to be assigned to the custom particle.
+
     Raises
     ------
     InvalidParticleError
@@ -1885,17 +1921,20 @@ class CustomParticle(AbstractParticle):
     --------
     >>> from astropy import units as u
     >>> from plasmapy.particles import CustomParticle
-    >>> custom_particle = CustomParticle(mass=1.5e-26 * u.kg, charge=-1)
+    >>> custom_particle = CustomParticle(mass=1.5e-26 * u.kg, charge=-1, symbol="Ξ")
     >>> custom_particle.mass
     <Quantity 1.5e-26 kg>
     >>> custom_particle.charge
     <Quantity -1.60217...e-19 C>
+    >>> custom_particle.symbol
+    'Ξ'
     """
 
-    def __init__(self, mass: u.kg = None, charge: (u.C, Real) = None):
+    def __init__(self, mass: u.kg = None, charge: (u.C, Real) = None, symbol=None):
         try:
             self.mass = mass
             self.charge = charge
+            self.symbol = symbol
         except Exception as exc:
             raise InvalidParticleError(
                 f"Unable to create a custom particle with a mass of "
@@ -1922,23 +1961,26 @@ def json_dict(self) -> dict:
 
         Examples
         --------
-        >>> custom_particle = CustomParticle(mass=5.12 * u.kg, charge=6.2 * u.C)
+        >>> custom_particle = CustomParticle(mass=5.12 * u.kg, charge=6.2 * u.C, symbol="ξ")
         >>> custom_particle.json_dict
         {'plasmapy_particle': {'type': 'CustomParticle',
             'module': 'plasmapy.particles.particle_class',
             'date_created': '...',
-            '__init__': {'args': (), 'kwargs': {'mass': '5.12 kg', 'charge': '6.2 C'}}}}
+            '__init__': {'args': (), 'kwargs': {'mass': '5.12 kg', 'charge': '6.2 C',
+            'symbol': 'ξ'}}}}
         >>> custom_particle = CustomParticle(mass=1.5e-26 * u.kg)
         >>> custom_particle.json_dict
         {'plasmapy_particle': {'type': 'CustomParticle',
             'module': 'plasmapy.particles.particle_class',
             'date_created': '...',
-            '__init__': {'args': (), 'kwargs': {'mass': '1.5e-26 kg', 'charge': 'nan C'}}}}
+            '__init__': {'args': (), 'kwargs': {'mass': '1.5e-26 kg', 'charge': 'nan C',
+            'symbol': 'CustomParticle(mass=1.5e-26 kg, charge=nan C)'}}}}
         """
         particle_dictionary = super().json_dict
         particle_dictionary["plasmapy_particle"]["__init__"]["kwargs"] = {
             "mass": str(self.mass),
             "charge": str(self.charge),
+            "symbol": self.symbol,
         }
         return particle_dictionary
 
@@ -2022,6 +2064,37 @@ def mass(self, m: u.kg):
                     "The mass of a custom particle must have units of mass."
                 ) from exc
 
+    @property
+    def mass_energy(self) -> u.J:
+        """
+        Return the mass energy of the custom particle.
+
+        Examples
+        --------
+        >>> import astropy.units as u
+        >>> custom_particle = CustomParticle(mass = 2e-25 * u.kg, charge = 0 * u.C)
+        >>> custom_particle.mass_energy.to('GeV')
+        <Quantity 112.19177208 GeV>
+        """
+        return (self.mass * const.c ** 2).to(u.J)
+
+    @property
+    def symbol(self) -> str:
+        """
+        Return the symbol assigned to the custom particle.  If no symbol
+        was defined, then return the value given by `repr`.
+        """
+        return self._symbol
+
+    @symbol.setter
+    def symbol(self, new_symbol: str):
+        if new_symbol is None:
+            self._symbol = repr(self)
+        elif isinstance(new_symbol, str):
+            self._symbol = new_symbol
+        else:
+            raise TypeError("symbol needs to be a string.")
+
 
 # TODO: Describe valid particle representations in docstring of particle_like
 


Test patch:
diff --git a/plasmapy/particles/tests/test_ionization_state.py b/plasmapy/particles/tests/test_ionization_state.py
index 9eaf2b21..5d1b4106 100644
--- a/plasmapy/particles/tests/test_ionization_state.py
+++ b/plasmapy/particles/tests/test_ionization_state.py
@@ -1,6 +1,8 @@
 import astropy.units as u
 import collections
 import numpy as np
+import os
+import pickle
 import pytest
 
 from plasmapy.particles import (
diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
index b32f1dcb..3a0fde47 100644
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -3,6 +3,8 @@
 import io
 import json
 import numpy as np
+import os
+import pickle
 import pytest
 
 from astropy import constants as const
@@ -928,6 +930,20 @@ def test_customized_particles(cls, kwargs, attr, expected):
         )
 
 
+@pytest.mark.parametrize(
+    "cls, symbol, expected",
+    [
+        (CustomParticle, None, "CustomParticle(mass=nan kg, charge=nan C)"),
+        (CustomParticle, "η", "η"),
+        (DimensionlessParticle, None, "DimensionlessParticle(mass=nan, charge=nan)"),
+        (DimensionlessParticle, "η", "η"),
+    ],
+)
+def test_custom_particle_symbol(cls, symbol, expected):
+    instance = cls(symbol=symbol)
+    assert instance.symbol == expected
+
+
 customized_particle_errors = [
     (DimensionlessParticle, {"mass": -1e-36}, InvalidParticleError),
     (DimensionlessParticle, {"mass": [1, 1]}, InvalidParticleError),
@@ -1007,35 +1023,45 @@ def test_customized_particle_repr(cls, kwargs, expected_repr):
         )
 
 
+@pytest.mark.parametrize("cls", [CustomParticle, DimensionlessParticle])
+@pytest.mark.parametrize("not_a_str", [1, u.kg])
+def test_typeerror_redefining_symbol(cls, not_a_str):
+    """Test that the symbol attribute cannot be set to something besides a string"""
+    instance = cls()
+    with pytest.raises(TypeError):
+        instance.symbol = not_a_str
+
+
 custom_particles_from_json_tests = [
     (
         DimensionlessParticle,
-        {"mass": 5.2, "charge": 6.3},
+        {"mass": 5.2, "charge": 6.3, "symbol": "ξ"},
         '{"plasmapy_particle": {"type": "DimensionlessParticle", \
         "module": "plasmapy.particles.particle_class", \
         "date_created": "...", "__init__": { \
             "args": [], \
-            "kwargs": {"mass": 5.2, "charge": 6.3}}}}',
+            "kwargs": {"mass": 5.2, "charge": 6.3, "symbol": "ξ"}}}}',
         None,
     ),
     (
         DimensionlessParticle,
-        {"mass": 5.2},
+        {"mass": 5.2, "symbol": "ξ"},
         '{"plasmapy_particle": {"type": "DimensionlessParticle", \
         "module": "plasmapy.particles.particle_class", \
         "date_created": "...", "__init__": { \
             "args": [], \
-            "kwargs": {"mass": 5.2, "charge": NaN}}}}',
+            "kwargs": {"mass": 5.2, "charge": NaN, "symbol": "ξ"}}}}',
         None,
     ),
     (
         CustomParticle,
-        {"mass": 5.12 * u.kg, "charge": 6.2 * u.C},
+        {"mass": 5.12 * u.kg, "charge": 6.2 * u.C, "symbol": "ξ"},
         '{"plasmapy_particle": {"type": "CustomParticle", \
         "module": "plasmapy.particles.particle_class", \
         "date_created": "...", "__init__": { \
             "args": [], \
-            "kwargs": {"mass": "5.12 kg", "charge": "6.2 C"}}}}',
+            "kwargs": {"mass": "5.12 kg", "charge": "6.2 C", '
+        '"symbol": "ξ"}}}}',
         None,
     ),
     (
@@ -1256,21 +1282,21 @@ def test_particles_from_json_file(cls, kwargs, json_string, expected_exception):
     ),
     (
         CustomParticle,
-        {"mass": 5.12 * u.kg, "charge": 6.2 * u.C},
+        {"mass": 5.12 * u.kg, "charge": 6.2 * u.C, "symbol": "ξ"},
         '{"plasmapy_particle": {"type": "CustomParticle", \
         "module": "plasmapy.particles.particle_class", \
         "date_created": "...", "__init__": {\
             "args": [], \
-            "kwargs": {"mass": "5.12 kg", "charge": "6.2 C"}}}}',
+            "kwargs": {"mass": "5.12 kg", "charge": "6.2 C", "symbol": "ξ"}}}}',
     ),
     (
         DimensionlessParticle,
-        {"mass": 5.2, "charge": 6.3},
+        {"mass": 5.2, "charge": 6.3, "symbol": "ξ"},
         '{"plasmapy_particle": {"type": "DimensionlessParticle",\
         "module": "plasmapy.particles.particle_class",\
         "date_created": "...", "__init__": {\
             "args": [], \
-            "kwargs": {"mass": 5.2, "charge": 6.3}}}}',
+            "kwargs": {"mass": 5.2, "charge": 6.3, "symbol": "ξ"}}}}',
     ),
 ]
 
diff --git a/plasmapy/particles/tests/test_pickling.py b/plasmapy/particles/tests/test_pickling.py
new file mode 100644
index 00000000..3a685570
--- /dev/null
+++ b/plasmapy/particles/tests/test_pickling.py
@@ -0,0 +1,47 @@
+"""Test that objects in `plasmapy.particles` can be pickled."""
+
+import astropy.units as u
+import os
+import pickle
+import pytest
+
+from plasmapy.particles.ionization_state import IonicFraction, IonizationState
+from plasmapy.particles.ionization_state_collection import IonizationStateCollection
+from plasmapy.particles.particle_class import (
+    CustomParticle,
+    DimensionlessParticle,
+    Particle,
+)
+
+
+class TestPickling:
+    """
+    Test that different objects in `plasmapy.particles` can be pickled.
+    """
+
+    xfail = pytest.mark.xfail(reason="see issue #1011")
+
+    @pytest.mark.parametrize(
+        "instance",
+        [
+            CustomParticle(mass=1 * u.kg, charge=1 * u.C),
+            DimensionlessParticle(mass=5, charge=5),
+            pytest.param(Particle("p+"), marks=xfail),
+            pytest.param(IonicFraction("p+", 0.1, 1e9 * u.m ** -3), marks=xfail),
+            pytest.param(IonizationState("H", [0.5, 0.5]), marks=xfail),
+            pytest.param(IonizationStateCollection({"H": [0.5, 0.5]}), marks=xfail),
+        ],
+    )
+    def test_pickling(self, instance, tmp_path):
+        """
+        Test that different objects contained within `plasmapy.particles`
+        can be pickled and unpickled.
+        """
+        filename = tmp_path / "pickled_particles.p"
+        with open(filename, "wb") as pickle_file:
+            pickle.dump(instance, pickle_file)
+
+        with open(filename, "rb") as pickle_file:
+            loaded_particle = pickle.load(pickle_file)
+
+        assert str(instance) == str(loaded_particle)