Repository: PlasmaPy/PlasmaPy

Issue:
Add mechanism to instantiate `CustomParticle` based off of positional arguments
### Feature description

I'd like it to be possible to put the mass and charge of a `CustomParticle` in either order as positional arguments.  For example, I would like to be able to do:
```pycon
>>> CustomParticle(1 * u.C)
>>> CustomParticle(1 * u.kg)
>>> CustomParticle(1 * u.kg, 1 * u.C)
>>> CustomParticle(1 * u.C, 1 * u.kg)
```

### Motivation

While working on #1871 and #1872, I've been having to do a bunch of special handling for when the input to `CustomParticle` is a charge or a mass, including when writing tests. Code downstream of `CustomParticle` will be cleaner if we have this flexibility.

### Implementation strategy

Right now the signature of `CustomParticle.__init__` is:
```
def __init__(self, mass: u.kg = None, charge: (u.C, Real) = None, symbol=None):
```
I'd like to be able to do `CustomParticle(1e-27 * u.kg)` or `CustomParticle(1e-18 * u.C)`, which would correspond to a signature like:
```
def __init__(self, *quantities, symbol=None):
```

This change would lead to breaking changes, so we might want to retain `mass` and `charge` as keyword arguments, even if we deprecate them.  We could perhaps even make it so that if a string is provided in `quantities`, it would get interpreted as the symbol (though `quantities` might not be the best name in that case).

### Additional context

This is inspired by #1364 and discussed further in #1872.  This should be addressed after #1866 is merged since they're both working on the same section of code.


Discussion:
Thinking more broadly, we have a lot of formulary functions where we have arguments of different but unique physical types.  We could think about generalizing this approach to the formulary, since we can often uniquely identify which parameter a `Quantity` argument corresponds to based solely on its physical type.  This would wreak havoc with `validate_quantities`, though.
That last comment is interesting, but I don't think I fully grasp it just yet. Could you expand on it a little?
Happy to share more!  I can say a bit about what I'm thinking, most of which is still in the brainstorming stage.

We have a lot of formulary functions with signatures like:
```Python
def Stańczak_Marikin_number(B, T, n, particle):
```
If one argument is provided with units convertible to m<sup>-3</sup>, then we can safely conclude that this corresponds to `n`.  Similarly for `B` and `T`.  We could potentially create functionality such that we could do:
```pycon
>>> Stańczak_Marikin_number(1 * u.MK, 1e13 * u.m ** -3, 50 * u.G, "p+")
>>> Stańczak_Marikin_number(1e13 * u.m ** -3, 1 * u.MK, 50 * u.G, "p+")
>>> Stańczak_Marikin_number("p+", 1 * u.MK, 1e13 * u.m**-3, 50 * u.G)
```
The advantages would include greater flexibility for the user and less need to look up signatures.  The disadvantages would include possibly more complicated functions (unless we do this via a decorator), presumably a small performance penalty, and possibly less clear docstrings about what quantities are actually needed. I *think* this approach would lead to cleaner downstream code, but it could potentially lead to less clean code. In any case, we'd need to think about this pretty carefully.

Another alternative would be to have the approach be something like...
```
>>> plasma = Plasma(5 * u.T, 8e12 * u.m**-3, ...)
>>> Stańczak_Marikin_number(plasma)
```
...which is related to #812, and possibly #1008 also.

Personally, if I see a signature like the above, then seeing something like this in code:
> ```
> >>> Stańczak_Marikin_number(1 * u.MK, 1e13 * u.m ** -3, 50 * u.G, "p+")
> >>> Stańczak_Marikin_number(1e13 * u.m ** -3, 1 * u.MK, 50 * u.G, "p+")
> >>> Stańczak_Marikin_number("p+", 1 * u.MK, 1e13 * u.m**-3, 50 * u.G)
> ```

is going to be hellishly confusing, because on first instinct I don't remember or I'm not aware that this has a magical decorator that reshuffles the arguments so that they all fit the right slots. So suddenly all my knowledge of how Python handles arguments falls out the window.

And it gets worse, because most likely I'm not going to see that, I'm going to see this instead:

> ```
> >>> Stańczak_Marikin_number(1 * u.MK, 1e13 * u.m ** -3, 50 * u.G, "p+")
> >>> ... # other code
> >>> Stańczak_Marikin_number(1e13 * u.m ** -3, 1 * u.MK, 50 * u.G, "p+")
> >>> ... # yet other code
> >>> Stańczak_Marikin_number("p+", 1 * u.MK, 1e13 * u.m**-3, 50 * u.G)
> ```

So I see the second variation and I go, hey, isn't the first argument supposed to be a temperature? Why is this a volume...? 🤔

It's true that sometimes I don't want to remember the order of arguments. But if I roughly know what the function takes, Python has a delightful mechanism for that:

> ```
> >>> Stańczak_Marikin_number(T=1 * u.MK, n=1e13 * u.m ** -3, B=50 * u.G, particle="p+")
> ```

and I think we should instead promote writing code that uses that, instead.

So, all in all, you said yourself:

> In any case, we'd need to think about this pretty carefully.

And I think on careful thought, this would indeed result in making tests easier to write, but either be confusing to users or second-order-confusing to people who read code using our functionality. At this point, I'd just say, let's not do this.

Could you please point the special handling you wrote for #1871, #1872 instead? Perhaps there's another way to implement those that sidesteps the issue?
Another reason why I think danger lies this way... a magical decorator like this has failure modes, for example, a function that takes two densities. So we probably couldn't just use it everywhere, consistently - and I don't want to turn using the library into a game of "now which functions are magical enough for me to skip writing out the keyword names, and which aren't...?"!

Resolving patch:
diff --git a/plasmapy/particles/particle_class.py b/plasmapy/particles/particle_class.py
--- a/plasmapy/particles/particle_class.py
+++ b/plasmapy/particles/particle_class.py
@@ -38,6 +38,7 @@
     ParticleWarning,
 )
 from plasmapy.utils import PlasmaPyDeprecationWarning, roman
+from plasmapy.utils._units_helpers import _get_physical_type_dict
 
 _classification_categories = {
     "lepton",
@@ -2100,6 +2101,51 @@ def __init__(
                 f"{mass} and a charge of {charge}."
             ) from exc
 
+    @classmethod
+    def _from_quantities(
+        cls,
+        *quantities,
+        symbol: Optional[str] = None,
+        Z: Optional[Real] = None,
+    ) -> CustomParticle:
+        """
+        An alternate constructor for |CustomParticle| objects where the
+        positional arguments correspond to the mass and/or charge in
+        any order.
+
+        Parameters
+        ----------
+        *quantities : tuple of |Quantity|
+            The mass and/or electrical charge of the |CustomParticle|,
+            in any order.
+
+        symbol : str, |keyword-only|, optional
+            The symbol of the |CustomParticle|.
+
+        Z : real number, |keyword-only|, optional
+            The |charge number|, if not provided in ``quantities``.
+        """
+
+        if not quantities:
+            return CustomParticle(symbol=symbol, Z=Z)
+
+        physical_type_dict = _get_physical_type_dict(
+            quantities,
+            only_quantities=True,
+            # strict=True,
+            # allowed_physical_types={u.physical.mass, u.physical.electrical_charge},
+        )
+
+        quantity_kwargs = {}
+
+        if u.physical.mass in physical_type_dict:
+            quantity_kwargs["mass"] = physical_type_dict[u.physical.mass]
+
+        if u.physical.electrical_charge in physical_type_dict:
+            quantity_kwargs["charge"] = physical_type_dict[u.physical.electrical_charge]
+
+        return CustomParticle(**quantity_kwargs, symbol=symbol, Z=Z)
+
     def __repr__(self) -> str:
         """
         Return a string representation of a |CustomParticle|.


Test patch:
diff --git a/plasmapy/particles/tests/test_particle_class.py b/plasmapy/particles/tests/test_particle_class.py
--- a/plasmapy/particles/tests/test_particle_class.py
+++ b/plasmapy/particles/tests/test_particle_class.py
@@ -1501,6 +1501,23 @@ def test_CustomParticle_setters(attr, input):
 ]
 
 
+@pytest.mark.parametrize(
+    "args, kwargs, expected",
+    [
+        ([], {}, CustomParticle()),
+        ([1 * u.kg], {}, CustomParticle(mass=1 * u.kg)),
+        ([2 * u.C], {}, CustomParticle(charge=2 * u.C)),
+        ([3 * u.kg, 4 * u.C], {}, CustomParticle(mass=3 * u.kg, charge=4 * u.C)),
+        ([5 * u.C, 6 * u.kg], {}, CustomParticle(mass=6 * u.kg, charge=5 * u.C)),
+        ([7 * u.kg], {"Z": 8.9}, CustomParticle(mass=7 * u.kg, Z=8.9)),
+        ([], {"symbol": "..."}, CustomParticle(symbol="...")),
+    ],
+)
+def test_CustomParticle_from_quantities(args, kwargs, expected):
+    actual = CustomParticle._from_quantities(*args, **kwargs)
+    assert actual == expected
+
+
 @pytest.mark.parametrize("m, Z, symbol, m_symbol, m_Z", test_molecule_table)
 def test_molecule(m, Z, symbol, m_symbol, m_Z):
     """Test ``molecule`` function."""