Repository: PlasmaPy/PlasmaPy
Pull request #1015 (2021-02-15 04:11:33)

Add symbol attribute to CustomParticle and DimensionlessParticle
To help `CustomParticle` and `DimensionlessParticle` behave a little bit more like `Particle`, I've added a `symbol` attribute to both of these.  This will give users the opportunity to define a symbol for particles that can be used in, say, plotting.


codecov[bot]: # [Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1015?src=pr&el=h1) Report
> Merging [#1015](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1015?src=pr&el=desc) (5cb9582) into [master](https://codecov.io/gh/PlasmaPy/PlasmaPy/commit/f52b2a820a0e0cdd0fe71273d7a0d74548bc9882?el=desc) (f52b2a8) will **decrease** coverage by `0.02%`.
> The diff coverage is `91.66%`.

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

```diff
@@            Coverage Diff             @@
##           master    #1015      +/-   ##
==========================================
- Coverage   96.62%   96.60%   -0.03%     
==========================================
  Files          64       64              
  Lines        6249     6271      +22     
==========================================
+ Hits         6038     6058      +20     
- Misses        211      213       +2     
```


| [Impacted Files](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1015?src=pr&el=tree) | Coverage Δ | |
|---|---|---|
| [plasmapy/particles/particle\_class.py](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1015/diff?src=pr&el=tree#diff-cGxhc21hcHkvcGFydGljbGVzL3BhcnRpY2xlX2NsYXNzLnB5) | `98.15% <91.66%> (-0.31%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1015?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/1015?src=pr&el=footer). Last update [f52b2a8...5cb9582](https://codecov.io/gh/PlasmaPy/PlasmaPy/pull/1015?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_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
index b32f1dcb..9472766a 100644
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -928,6 +928,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 +1021,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 +1280,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": "ξ"}}}}',
     ),
 ]