Repository: scikit-hep/vector

Issue:
Revisiting vector dimension projecting/embedding
We had another conversation today that reopens issue #412 (implemented by PR #413). When adding/subtracting vectors of different spatial/momentum dimensions—i.e. 2D vs 3D vs 4D, not array shape—there are three possibilities:

1. the low-D vector gets promoted to the high-D dimensionality, filling the missing slots with zeros (so that they don't contribute to the sum)
2. the high-D vector gets demoted to the low-D dimensionality, ignoring the information in its extra dimensions
3. it's a TypeError that explains how to promote or demote in the error message.

@Saransh-cpp, @nsmith-, @iasonkrom, @lgray, and @martindurant were involved in that discussion. Nick restated his opinion in #412 (option 2), but it seemed that the majority were in favor of option 3, as long as it's easy to promote/demote.

Since spatial/momentum dimensionality is expressed as record fields, high-D vectors are subtypes of low-D vectors (high-D vectors have a superset of the fields that a low-D vector has), and high-D → low-D is _upcasting_ (removing those extra fields from the high-D vector). Upcasting requires no extra information, but downcasting requires extra information: the values of the new fields that are being added (though we'd make them 0, in a way that is neutral for addition and subtraction).

There's no ambiguity about _which_ dimensions are dropped or added when going between low-D and high-D, since our vectors have a special 3rd component (cylindrically symmetric, not spherically symmetric) and a special 4th component: 2D is always azimuthal, the 3rd dimension in 3D is always longitudinal, and the 4th dimension in 4D is always temporal.

If we're going with option 3, the methods that _project_ high-D → low-D and the methods that _embed_ low-D → high-D have to be short and not clutter up a mathematical expression (like NumPy's `.T`, just two characters to be just-visible-enough). It would be nice to have a single word for this, instead of "project" and "embed," although those are the correct mathematical terms.

We could have `.to_2D()`, `.to_3D()`, `.to_4D()`, since we don't have an ambiguity about which dimensions are being added or removed. These would be shorter synonyms of functions that already exist to do this.

Iason suggested `.like(other_array)`, so that we don't need to specify which "D". I like that and would +1 that. (If you want to specify which "D", there are already functions for that; the new convenience is being able to fit it to a given `other_array`.) The error message in option 3 could be

```
TypeError: cannot add or subtract vectors of different dimensionality; use

    a.like(b) + b

or

    a + b.like(a)

to project or embed one of the vectors to match the other's dimensionality
```

This also ties into CoffeaTeam/coffea#991, since the same thing needs to be implemented in Coffea, to smooth the transition from Coffea vectors to Vector vectors.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scikit-hep/vector/pull/432?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep) Report
Attention: Patch coverage is `50.00000%` with `12 lines` in your changes are missing coverage. Please review.
> Project coverage is 82.75%. Comparing base [(`b1e1df4`)](https://app.codecov.io/gh/scikit-hep/vector/commit/b1e1df4b3f820c484662360c9ab5271e9d417053?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep) to head [(`b6b87fd`)](https://app.codecov.io/gh/scikit-hep/vector/pull/432?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep).
> Report is 1 commits behind head on main.

| [Files](https://app.codecov.io/gh/scikit-hep/vector/pull/432?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep) | Patch % | Lines |
|---|---|---|
| [src/vector/\_methods.py](https://app.codecov.io/gh/scikit-hep/vector/pull/432?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep#diff-c3JjL3ZlY3Rvci9fbWV0aG9kcy5weQ==) | 50.00% | [12 Missing :warning: ](https://app.codecov.io/gh/scikit-hep/vector/pull/432?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #432      +/-   ##
==========================================
- Coverage   82.81%   82.75%   -0.07%     
==========================================
  Files          96       96              
  Lines       11557    11583      +26     
==========================================
+ Hits         9571     9585      +14     
- Misses       1986     1998      +12     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/scikit-hep/vector/pull/432?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep).


Patch:
diff --git a/README.md b/README.md
index 7cdb076..437af10 100644
--- a/README.md
+++ b/README.md
@@ -630,7 +630,7 @@ The (current) list of properties and methods is:
 - `count_nonzero()`: can also use `numpy.count_nonzero` or `awkward.count_nonzero`, only for NumPy and Awkward vectors
 - `count()`: can also use `awkward.count`, only for Awkward vectors
 - `isclose(vector, rtol=1e-5, atol=1e-8, equal_nan=False)`: works like [np.isclose](https://numpy.org/doc/stable/reference/generated/numpy.isclose.html); arrays also have an [allclose](https://numpy.org/doc/stable/reference/generated/numpy.allclose.html) method
-- `to_Vector*D(coordinates)`: replace `*` with the reuquired vector dimension
+- `to_VectorND(coordinates)`/`to_ND(coordinates)`: replace `N` with the required vector dimension
 - `to_{coordinate-names}`: for example - `to_rhophietatau`
 - `like(other)`: projects the vector into the dimensions of `other`, for example - `two_d_vector.like(three_d_vector)`
 
diff --git a/docs/usage/intro.ipynb b/docs/usage/intro.ipynb
index f289a94..89bfcb6 100644
--- a/docs/usage/intro.ipynb
+++ b/docs/usage/intro.ipynb
@@ -2616,7 +2616,7 @@
     "   * `count_nonzero()`: can also use `numpy.count_nonzero` or `awkward.count_nonzero`, only for NumPy and Awkward vectors\n",
     "   * `count()`: can also use `awkward.count`, only for Awkward vectors\n",
     "   * `isclose(vector, rtol=1e-5, atol=1e-8, equal_nan=False)`: works like [np.isclose](https://numpy.org/doc/stable/reference/generated/numpy.isclose.html); arrays also have an [allclose](https://numpy.org/doc/stable/reference/generated/numpy.allclose.html) method\n",
-    "   * `to_Vector*D(coordinates)`: replace `*` with the reuquired vector dimension\n",
+    "   * `to_VectorND(coordinates)`/`to_ND(coordinates)`: replace `N` with the required vector dimension\n",
     "   * `to_{coordinate-names}`: for example - `to_rhophietatau`\n",
     "   * `like(other)`: projects the vector into the dimensions of `other`, for example - `two_d_vector.like(three_d_vector)`\n"
    ]
diff --git a/src/vector/_methods.py b/src/vector/_methods.py
index 8c8d382..528059b 100644
--- a/src/vector/_methods.py
+++ b/src/vector/_methods.py
@@ -204,6 +204,43 @@ class VectorProtocol:
         """
         raise AssertionError
 
+    def to_2D(self) -> VectorProtocolPlanar:
+        """
+        Projects this vector/these vectors onto azimuthal coordinates only.
+
+        Alias for :meth:`vector._methods.VectorProtocol.to_Vector2D`.
+        """
+        raise AssertionError
+
+    def to_3D(self) -> VectorProtocolSpatial:
+        """
+        Projects this vector/these vectors onto azimuthal and longitudinal
+        coordinates only.
+
+        If 2D, a default $z$ component of $0$ is imputed.
+
+        The longitudinal coordinate can be passed as a named argument.
+
+        Alias for :meth:`vector._methods.VectorProtocol.to_Vector3D`.
+        """
+        raise AssertionError
+
+    def to_4D(self) -> VectorProtocolLorentz:
+        """
+        Projects this vector/these vectors onto azimuthal, longitudinal,
+        and temporal coordinates.
+
+        If 3D, a default $t$ component of $0$ is imputed.
+
+        If 2D, a $z$ component of $0$ is imputed along with a default
+        $t$ component of $0$.
+
+        The longitudinal and temporal coordinates can be passed as named arguments.
+
+        Alias for :meth:`vector._methods.VectorProtocol.to_Vector4D`.
+        """
+        raise AssertionError
+
     def to_xy(self) -> VectorProtocolPlanar:
         """
         Converts to $x$-$y$ coordinates, possibly eliminating dimensions with a
@@ -3322,6 +3359,24 @@ class Vector2D(Vector, VectorProtocolPlanar):
             1,
         )
 
+    def to_2D(self) -> VectorProtocolPlanar:
+        """
+        Alias for :meth:`vector._methods.Vector2D.to_Vector2D`.
+        """
+        return self.to_Vector2D()
+
+    def to_3D(self, **kwargs: float | None) -> VectorProtocolSpatial:
+        """
+        Alias for :meth:`vector._methods.Vector2D.to_Vector3D`.
+        """
+        return self.to_Vector3D(**kwargs)
+
+    def to_4D(self, **kwargs: float | None) -> VectorProtocolLorentz:
+        """
+        Alias for :meth:`vector._methods.Vector2D.to_Vector4D`.
+        """
+        return self.to_Vector4D(**kwargs)
+
 
 class Vector3D(Vector, VectorProtocolSpatial):
     def to_Vector2D(self) -> VectorProtocolPlanar:
@@ -3382,6 +3437,24 @@ class Vector3D(Vector, VectorProtocolSpatial):
             1,
         )
 
+    def to_2D(self) -> VectorProtocolPlanar:
+        """
+        Alias for :meth:`vector._methods.Vector3D.to_Vector2D`.
+        """
+        return self.to_Vector2D()
+
+    def to_3D(self) -> VectorProtocolSpatial:
+        """
+        Alias for :meth:`vector._methods.Vector3D.to_Vector3D`.
+        """
+        return self.to_Vector3D()
+
+    def to_4D(self, **kwargs: float | None) -> VectorProtocolLorentz:
+        """
+        Alias for :meth:`vector._methods.Vector3D.to_Vector4D`.
+        """
+        return self.to_Vector4D(**kwargs)
+
 
 class Vector4D(Vector, VectorProtocolLorentz):
     def to_Vector2D(self) -> VectorProtocolPlanar:
@@ -3403,6 +3476,24 @@ class Vector4D(Vector, VectorProtocolLorentz):
     def to_Vector4D(self) -> VectorProtocolLorentz:
         return self
 
+    def to_2D(self) -> VectorProtocolPlanar:
+        """
+        Alias for :meth:`vector._methods.Vector4D.to_Vector2D`.
+        """
+        return self.to_Vector2D()
+
+    def to_3D(self) -> VectorProtocolSpatial:
+        """
+        Alias for :meth:`vector._methods.Vector4D.to_Vector3D`.
+        """
+        return self.to_Vector3D()
+
+    def to_4D(self) -> VectorProtocolLorentz:
+        """
+        Alias for :meth:`vector._methods.Vector4D.to_Vector4D`.
+        """
+        return self.to_Vector4D()
+
 
 class Planar(VectorProtocolPlanar):
     @property


Test patch:
diff --git a/tests/backends/test_awkward.py b/tests/backends/test_awkward.py
index cf8a757..bfa8614 100644
--- a/tests/backends/test_awkward.py
+++ b/tests/backends/test_awkward.py
@@ -32,9 +32,10 @@ def test_dimension_conversion():
             [],
         ]
     )
-    assert ak.all(vec.to_Vector3D(z=1).z == 1)
-    assert ak.all(vec.to_Vector3D(eta=1).eta == 1)
-    assert ak.all(vec.to_Vector3D(theta=1).theta == 1)
+    # test alias
+    assert ak.all(vec.to_3D(z=1).z == 1)
+    assert ak.all(vec.to_3D(eta=1).eta == 1)
+    assert ak.all(vec.to_3D(theta=1).theta == 1)
 
     assert ak.all(vec.to_Vector3D(z=1).x == vec.x)
     assert ak.all(vec.to_Vector3D(z=1).y == vec.y)
@@ -53,8 +54,9 @@ def test_dimension_conversion():
     assert ak.all(vec.to_Vector4D(theta=1, tau=1).theta == 1)
     assert ak.all(vec.to_Vector4D(theta=1, tau=1).tau == 1)
 
-    assert ak.all(vec.to_Vector4D(z=1, t=1).x == vec.x)
-    assert ak.all(vec.to_Vector4D(z=1, t=1).y == vec.y)
+    # test alias
+    assert ak.all(vec.to_4D(z=1, t=1).x == vec.x)
+    assert ak.all(vec.to_4D(z=1, t=1).y == vec.y)
 
     # 3D -> 4D
     vec = vector.Array(
diff --git a/tests/compute/test_conversions.py b/tests/compute/test_conversions.py
index 8aa6f2b..f8dd139 100644
--- a/tests/compute/test_conversions.py
+++ b/tests/compute/test_conversions.py
@@ -500,8 +500,9 @@ def test_conversion_with_coords_object():
     assert vec.to_Vector3D(eta=1).eta == 1
     assert vec.to_Vector3D(theta=1).theta == 1
 
-    assert vec.to_Vector3D(z=1).x == vec.x
-    assert vec.to_Vector3D(z=1).y == vec.y
+    # test alias
+    assert vec.to_3D(z=1).x == vec.x
+    assert vec.to_3D(z=1).y == vec.y
 
     # 2D -> 4D
     assert vec.to_Vector4D(z=1, t=1).z == 1
@@ -517,13 +518,16 @@ def test_conversion_with_coords_object():
     assert vec.to_Vector4D(theta=1, tau=1).theta == 1
     assert vec.to_Vector4D(theta=1, tau=1).tau == 1
 
-    assert vec.to_Vector4D(z=1, t=1).x == vec.x
-    assert vec.to_Vector4D(z=1, t=1).y == vec.y
+    # test alias
+    assert vec.to_4D(z=1, t=1).x == vec.x
+    assert vec.to_4D(z=1, t=1).y == vec.y
 
     # 3D -> 4D
     vec = vector.VectorObject3D(x=1, y=2, z=3)
-    assert vec.to_Vector4D(t=1).t == 1
-    assert vec.to_Vector4D(tau=1).tau == 1
+
+    # test alias
+    assert vec.to_4D(t=1).t == 1
+    assert vec.to_4D(tau=1).tau == 1
 
     assert vec.to_Vector4D(t=1).x == vec.x
     assert vec.to_Vector4D(t=1).y == vec.y
@@ -533,18 +537,21 @@ def test_conversion_with_coords_object():
     vec = vector.MomentumObject2D(px=1, py=2)
     assert vec.to_Vector3D(pz=1).pz == 1
 
-    assert vec.to_Vector4D(pz=1, m=1).pz == 1
-    assert vec.to_Vector4D(pz=1, m=1).m == 1
-    assert vec.to_Vector4D(pz=1, mass=1).mass == 1
-    assert vec.to_Vector4D(pz=1, M=1).M == 1
+    # test both alias and original methods
+    assert vec.to_4D(pz=1, m=1).pz == 1
+    assert vec.to_4D(pz=1, m=1).m == 1
+    assert vec.to_4D(pz=1, mass=1).mass == 1
+    assert vec.to_4D(pz=1, M=1).M == 1
     assert vec.to_Vector4D(pz=1, e=1).e == 1
     assert vec.to_Vector4D(pz=1, energy=1).energy == 1
     assert vec.to_Vector4D(pz=1, E=1).E == 1
 
     vec = vector.MomentumObject3D(px=1, py=2, pz=3)
-    assert vec.to_Vector4D(m=1).m == 1
-    assert vec.to_Vector4D(mass=1).mass == 1
-    assert vec.to_Vector4D(M=1).M == 1
+
+    # test both alias and original methods
+    assert vec.to_4D(m=1).m == 1
+    assert vec.to_4D(mass=1).mass == 1
+    assert vec.to_4D(M=1).M == 1
     assert vec.to_Vector4D(e=1).e == 1
     assert vec.to_Vector4D(energy=1).energy == 1
     assert vec.to_Vector4D(E=1).E == 1
@@ -560,8 +567,9 @@ def test_conversion_with_coords_numpy():
     assert all(vec.to_Vector3D(eta=1).eta == 1)
     assert all(vec.to_Vector3D(theta=1).theta == 1)
 
-    assert all(vec.to_Vector3D(z=1).x == vec.x)
-    assert all(vec.to_Vector3D(z=1).y == vec.y)
+    # test alias
+    assert all(vec.to_3D(z=1).x == vec.x)
+    assert all(vec.to_3D(z=1).y == vec.y)
 
     # 2D -> 4D
     assert all(vec.to_Vector4D(z=1, t=1).t == 1)
@@ -577,8 +585,9 @@ def test_conversion_with_coords_numpy():
     assert all(vec.to_Vector4D(theta=1, tau=1).theta == 1)
     assert all(vec.to_Vector4D(theta=1, tau=1).tau == 1)
 
-    assert all(vec.to_Vector4D(z=1, t=1).x == vec.x)
-    assert all(vec.to_Vector4D(z=1, t=1).y == vec.y)
+    # test alias
+    assert all(vec.to_4D(z=1, t=1).x == vec.x)
+    assert all(vec.to_4D(z=1, t=1).y == vec.y)
 
     # 3D -> 4D
     vec = vector.VectorNumpy3D(
@@ -588,9 +597,10 @@ def test_conversion_with_coords_numpy():
     assert all(vec.to_Vector4D(t=1).t == 1)
     assert all(vec.to_Vector4D(tau=1).tau == 1)
 
-    assert all(vec.to_Vector4D(t=1).x == vec.x)
-    assert all(vec.to_Vector4D(t=1).y == vec.y)
-    assert all(vec.to_Vector4D(t=1).z == vec.z)
+    # test alias
+    assert all(vec.to_4D(t=1).x == vec.x)
+    assert all(vec.to_4D(t=1).y == vec.y)
+    assert all(vec.to_4D(t=1).z == vec.z)
 
     # check if momentum coords work
     vec = vector.MomentumNumpy2D(
@@ -599,10 +609,11 @@ def test_conversion_with_coords_numpy():
     )
     assert all(vec.to_Vector3D(pz=1).pz == 1)
 
-    assert all(vec.to_Vector4D(pz=1, m=1).pz == 1)
-    assert all(vec.to_Vector4D(pz=1, m=1).m == 1)
-    assert all(vec.to_Vector4D(pz=1, mass=1).mass == 1)
-    assert all(vec.to_Vector4D(pz=1, M=1).M == 1)
+    # test both alias and original methods
+    assert all(vec.to_4D(pz=1, m=1).pz == 1)
+    assert all(vec.to_4D(pz=1, m=1).m == 1)
+    assert all(vec.to_4D(pz=1, mass=1).mass == 1)
+    assert all(vec.to_4D(pz=1, M=1).M == 1)
     assert all(vec.to_Vector4D(pz=1, e=1).e == 1)
     assert all(vec.to_Vector4D(pz=1, energy=1).energy == 1)
     assert all(vec.to_Vector4D(pz=1, E=1).E == 1)
@@ -611,9 +622,11 @@ def test_conversion_with_coords_numpy():
         [(1.0, 1.0, 1.0), (2.0, 2.0, 2.0)],
         dtype=[("px", float), ("py", float), ("pz", float)],
     )
-    assert all(vec.to_Vector4D(m=1).m == 1)
-    assert all(vec.to_Vector4D(mass=1).mass == 1)
-    assert all(vec.to_Vector4D(M=1).M == 1)
+
+    # test both alias and original methods
+    assert all(vec.to_4D(m=1).m == 1)
+    assert all(vec.to_4D(mass=1).mass == 1)
+    assert all(vec.to_4D(M=1).M == 1)
     assert all(vec.to_Vector4D(e=1).e == 1)
     assert all(vec.to_Vector4D(energy=1).energy == 1)
     assert all(vec.to_Vector4D(E=1).E == 1)