Repository: scikit-hep/vector

Issue:
`VectorNumpy3D`'s `azimuthal` and `longitudinal` properties throw an error (similar for `VectorNumpy4D`)
## Reproducible example
```py
import vector

vec = vector.array(
    [
        (1.1, 2.1, 3.1),
        (1.2, 2.2, 3.2),
        (1.3, 2.3, 3.3),
        (1.4, 2.4, 4.4),
        (1.5, 2.5, 5.5)
    ], dtype=[("x", float), ("y", float), ("z", float)]
)
print(vec.azimuthal)
```
Similarly for -
- `vec.longitudinal`
- `vec.azimuthal` for a 4D NumPy vector (`VectorNumpy4D`)
- `vec.longitudinal` for a 4D NumPy vector (`VectorNumpy4D`)
- `vec.temporal` for a 4D NumPy vector (`VectorNumpy4D`)

## Error
```
>>> vec.azimuthal
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\numpy\core\arrayprint.py", line 1488, in _array_repr_implementation
    lst = array2string(arr, max_line_width, precision, suppress_small,
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\numpy\core\arrayprint.py", line 736, in array2string
    return _array2string(a, options, separator, prefix)
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\numpy\core\arrayprint.py", line 513, in wrapper
    return f(self, *args, **kwargs)
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\numpy\core\arrayprint.py", line 546, in _array2string
    lst = _formatArray(a, format_function, options['linewidth'],
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\numpy\core\arrayprint.py", line 889, in _formatArray
    return recurser(index=(),
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\numpy\core\arrayprint.py", line 853, in recurser
    word = recurser(index + (-1,), next_hanging_indent, next_width)
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\numpy\core\arrayprint.py", line 799, in recurser
    return format_function(a[index])
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\vector\_backends\numpy_.py", line 218, in __getitem__
    return _getitem(self, where, self.__class__._IS_MOMENTUM)  # type: ignore[arg-type]
  File "D:\extras\IRIS-HEP\.env\lib\site-packages\vector\_backends\numpy_.py", line 143, in _getitem
    return array.ObjectClass(*out.view(numpy.ndarray))  # type: ignore[misc, return-value]
TypeError: <lambda>() takes 3 positional arguments but 4 were given
```

## Cause
Going through the stack trace I looked into the function `_getitem`, which was throwing an error related to the number of arguments. After adding some debug statements, I discovered that the `if-else` statements written to initialize `azimuthal`, `longitudinal`, and `temporal` are never executed when running the example.

This is due to the fact that the type of the passed `array` argument is `AzimuthalNumpyXY` (in the case of calling `vec.azimuthal` on a structured array having `"x"` and `"y"` as the datatypes). As `array` belongs to `AzimuthalNumpyXY`, it never has an attribute named `_azimuthal_type`, rather it has attributes named `x` and `y`.

The main intention here was to pass in a `VectorNumpy2D`, `VectorNumpy3D`, or `VectorNumpy4D` object but I can't really figure out why the `__getitem` method in the class `GetItem` is picking up `AzimuthalNumpyXY`. 

I think the case of calling `azimuthal` on a `VectorNumpy2D` object works coincidentally, through the last `else` condition.

### _getitem
```py
def _getitem(
    array: typing.Union["VectorNumpy2D", "VectorNumpy3D", "VectorNumpy4D"],
    where: typing.Any,
    is_momentum: bool,
) -> typing.Union[float, FloatArray]:
    if isinstance(where, str):
        if is_momentum:
            where = _repr_momentum_to_generic.get(where, where)
        return array.view(numpy.ndarray)[where]
    else:
        out = numpy.ndarray.__getitem__(array, where)
        if not isinstance(out, numpy.void):
            return out

        azimuthal, longitudinal, temporal = None, None, None
        if hasattr(array, "_azimuthal_type"):
            azimuthal = array._azimuthal_type.ObjectClass(
                *(out[x] for x in _coordinate_class_to_names[_aztype(array)])
            )
        if hasattr(array, "_longitudinal_type"):
            longitudinal = array._longitudinal_type.ObjectClass(  # type: ignore[union-attr]
                *(out[x] for x in _coordinate_class_to_names[_ltype(array)])  # type: ignore[arg-type]
            )
        if hasattr(array, "_temporal_type"):
            temporal = array._temporal_type.ObjectClass(  # type: ignore[union-attr]
                *(out[x] for x in _coordinate_class_to_names[_ttype(array)])  # type: ignore[arg-type]
            )
        if temporal is not None:
            return array.ObjectClass(azimuthal, longitudinal, temporal)  # type: ignore[call-arg, arg-type, return-value]
        elif longitudinal is not None:
            return array.ObjectClass(azimuthal, longitudinal)  # type: ignore[call-arg, arg-type, return-value]
        elif azimuthal is not None:
            return array.ObjectClass(azimuthal)  # type: ignore[call-arg, return-value]
        else:
            return array.ObjectClass(*out.view(numpy.ndarray))  # type: ignore[misc, return-value]
```

## A possible fix
I tried fixing this by modifying the function but ended up breaking some tests -
### Modified function
```py
def _getitem(
    array: typing.Union["VectorNumpy2D", "VectorNumpy3D", "VectorNumpy4D"],
    where: typing.Any,
    is_momentum: bool,
) -> typing.Union[float, FloatArray]:
    if isinstance(where, str):
        if is_momentum:
            where = _repr_momentum_to_generic.get(where, where)
        return array.view(numpy.ndarray)[where]
    else:
        out = numpy.ndarray.__getitem__(array, where)
        if not isinstance(out, numpy.void):
            return out
        azimuthal, longitudinal, temporal = None, None, None

        if (hasattr(array, "x") and hasattr(array, "y")):
            azimuthal = vector._backends.object_.AzimuthalObjectXY(*(out[x] for x in _coordinate_class_to_names[AzimuthalXY]))
        elif (hasattr(array, "rho") and hasattr(array, "phi")):
            azimuthal = vector._backends.object_.AzimuthalObjectRhoPhi(*(out[x] for x in _coordinate_class_to_names[AzimuthalRhoPhi]))
        
        if hasattr(array, "z"):
            longitudinal = vector._backends.object_.LongitudinalObjectZ(*(out[x] for x in _coordinate_class_to_names[LongitudinalZ]))  # type: ignore[union-attr]
        elif hasattr(array, "eta"):
            longitudinal = vector._backends.object_.LongitudinalObjectEta(*(out[x] for x in _coordinate_class_to_names[LongitudinalEta]))  # type: ignore[union-attr]
        elif hasattr(array, "theta"):
            longitudinal = vector._backends.object_.LongitudinalObjectTheta(*(out[x] for x in _coordinate_class_to_names[LongitudinalTheta]))  # type: ignore[union-attr]

        if hasattr(array, "t"):
            temporal = vector._backends.object_.TemporalObjectT(*(out[x] for x in _coordinate_class_to_names[TemporalT]))  # type: ignore[union-attr]
        elif hasattr(array, "tau"):
            temporal = vector._backends.object_.TemporalObjectTau(*(out[x] for x in _coordinate_class_to_names[TemporalTau]))  # type: ignore[union-attr]

        if temporal is not None:
            return temporal  # type: ignore[call-arg, arg-type, return-value]
        elif longitudinal is not None:
            return longitudinal  # type: ignore[call-arg, arg-type, return-value]
        elif azimuthal is not None:
            return azimuthal  # type: ignore[call-arg, return-value]
        else:
            return array.ObjectClass(*out.view(numpy.ndarray))  # type: ignore[misc, return-value]
```
This works somewhat fine. An example -
```py
import vector

vec = vector.array(
    [
        (1.1, 2.1, 3.1),
        (1.2, 2.2, 3.2),
        (1.3, 2.3, 3.3),
        (1.4, 2.4, 4.4),
        (1.5, 2.5, 5.5)
    ], dtype=[("x", float), ("y", float), ("z", float)]
)
print(vec.azimuthal)

# output
# AzimuthalNumpyXY([(1.1, 2.1), (1.2, 2.2), (1.3, 2.3), (1.4, 2.4),
#                 (1.5, 2.5)],
#                 dtype=[('x', '<f8'), ('y', '<f8'), ('z', '<f8')])

print(vec.longitudinal)
# output
# LongitudinalNumpyZ([(3.1,), (3.2,), (3.3,), (4.4,), (5.5,)],
#                    dtype=[('x', '<f8'), ('y', '<f8'), ('z', '<f8')])
```

### Broken tests
These are most probably failing as I did a lot of hard coding inside `_getitem` -
```
=============================================================================================== FAILURES =============================================================================================== 
__________________________________________________________________________________________ test_spatial_numpy __________________________________________________________________________________________ 

    def test_spatial_numpy():
        v1 = vector._backends.numpy_.VectorNumpy3D(
            [(0.1, 0.2, 0.3)],
            dtype=[("x", numpy.float64), ("y", numpy.float64), ("z", numpy.float64)],
        )
        v2 = vector._backends.numpy_.VectorNumpy3D(
            [(0.4, 0.5, 0.6)],
            dtype=[("x", numpy.float64), ("y", numpy.float64), ("z", numpy.float64)],
        )
        out = v1.cross(v2)
        assert isinstance(out, vector._backends.numpy_.VectorNumpy3D)
        assert out.dtype.names == ("x", "y", "z")
>       assert (out[0].x, out[0].y, out[0].z) == pytest.approx((-0.03, 0.06, -0.03))
E       AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'

tests\compute\spatial\test_cross.py:55: AttributeError
__________________________________________________________________________________________ test_lorentz_numpy __________________________________________________________________________________________

    def test_lorentz_numpy():
        v1 = vector._backends.numpy_.VectorNumpy4D(
            [(0.1, 0.2, 0.3, 99)],
            dtype=[
                ("x", numpy.float64),
                ("y", numpy.float64),
                ("z", numpy.float64),
                ("t", numpy.float64),
            ],
        )
        v2 = vector._backends.numpy_.VectorNumpy4D(
            [(0.4, 0.5, 0.6, 99)],
            dtype=[
                ("x", numpy.float64),
                ("y", numpy.float64),
                ("z", numpy.float64),
                ("t", numpy.float64),
            ],
        )
        out = v1.cross(v2)
        assert isinstance(out, vector._backends.numpy_.VectorNumpy3D)
        assert out.dtype.names == ("x", "y", "z")
        assert out.tolist() == pytest.approx([(-0.03, 0.06, -0.030000000000000013)])

        for t1 in (
            "xyzt",
            "xythetat",
            "xyetat",
            "rhophizt",
            "rhophithetat",
            "rhophietat",
            "xyztau",
            "xythetatau",
            "xyetatau",
            "rhophiztau",
            "rhophithetatau",
            "rhophietatau",
        ):
            for t2 in (
                "xyzt",
                "xythetat",
                "xyetat",
                "rhophizt",
                "rhophithetat",
                "rhophietat",
                "xyztau",
                "xythetatau",
                "xyetatau",
                "rhophiztau",
                "rhophithetatau",
                "rhophietatau",
            ):
                transformed1, transformed2 = (
                    getattr(v1, "to_" + t1)(),
                    getattr(v2, "to_" + t2)(),
                )
                out = transformed1.cross(transformed2)
                assert isinstance(out, vector._backends.numpy_.VectorNumpy3D)
                assert out.dtype.names == ("x", "y", "z")
>               assert (out[0].x, out[0].y, out[0].z) == pytest.approx((-0.03, 0.06, -0.03))
E               AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'

tests\compute\spatial\test_cross.py:186: AttributeError
__________________________________________________________________________________________ test_spatial_numpy __________________________________________________________________________________________ 

    def test_spatial_numpy():
        vec = vector._backends.numpy_.VectorNumpy3D(
            [(0.1, 0.2, 0.3)],
            dtype=[("x", numpy.float64), ("y", numpy.float64), ("z", numpy.float64)],
        )
        out = vec.rotateX(0.25)
        assert isinstance(out.azimuthal, vector._methods.AzimuthalXY)
        assert isinstance(out.longitudinal, vector._methods.LongitudinalZ)
>       assert out[0].x == pytest.approx(0.1)
E       AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'

tests\compute\spatial\test_rotateX.py:43: AttributeError
__________________________________________________________________________________________ test_lorentz_numpy __________________________________________________________________________________________

    def test_lorentz_numpy():
        vec = vector._backends.numpy_.VectorNumpy4D(
            [(0.1, 0.2, 0.3, 99)],
            dtype=[
                ("x", numpy.float64),
                ("y", numpy.float64),
                ("z", numpy.float64),
                ("t", numpy.float64),
            ],
        )
        out = vec.rotateX(0.25)
        assert isinstance(out.azimuthal, vector._methods.AzimuthalXY)
        assert isinstance(out.longitudinal, vector._methods.LongitudinalZ)
>       assert out[0].x == pytest.approx(0.1)
E       AttributeError: 'TemporalObjectT' object has no attribute 'x'

tests\compute\spatial\test_rotateX.py:106: AttributeError
__________________________________________________________________________________________ test_spatial_numpy __________________________________________________________________________________________ 

    def test_spatial_numpy():
        vec = vector._backends.numpy_.VectorNumpy3D(
            [(0.1, 0.2, 0.3)],
            dtype=[("x", numpy.float64), ("y", numpy.float64), ("z", numpy.float64)],
        )
        out = vec.rotateY(0.25)
        assert isinstance(out.azimuthal, vector._methods.AzimuthalXY)
        assert isinstance(out.longitudinal, vector._methods.LongitudinalZ)
>       assert out[0].x == pytest.approx(0.17111242994742137)
E       AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'

tests\compute\spatial\test_rotateY.py:43: AttributeError
__________________________________________________________________________________________ test_lorentz_numpy __________________________________________________________________________________________

    def test_lorentz_numpy():
        vec = vector._backends.numpy_.VectorNumpy4D(
            [(0.1, 0.2, 0.3, 99)],
            dtype=[
                ("x", numpy.float64),
                ("y", numpy.float64),
                ("z", numpy.float64),
                ("t", numpy.float64),
            ],
        )
        out = vec.rotateY(0.25)
        assert isinstance(out.azimuthal, vector._methods.AzimuthalXY)
        assert isinstance(out.longitudinal, vector._methods.LongitudinalZ)
>       assert out[0].x == pytest.approx(0.17111242994742137)
E       AttributeError: 'TemporalObjectT' object has no attribute 'x'

tests\compute\spatial\test_rotateY.py:106: AttributeError
__________________________________________________________________________________________ test_spatial_numpy __________________________________________________________________________________________ 

    def test_spatial_numpy():
        axis = vector._backends.numpy_.VectorNumpy3D(
            [(0.1, 0.2, 0.3)],
            dtype=[("x", numpy.float64), ("y", numpy.float64), ("z", numpy.float64)],
        )
        vec = vector._backends.numpy_.VectorNumpy3D(
            [(0.4, 0.5, 0.6)],
            dtype=[("x", numpy.float64), ("y", numpy.float64), ("z", numpy.float64)],
        )
        out = vec.rotate_axis(axis, 0.25)
        assert isinstance(out, vector._backends.numpy_.VectorNumpy3D)
        assert out.dtype.names == ("x", "y", "z")
>       assert out[0].x == pytest.approx(0.37483425404335763)
E       AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'

tests\compute\spatial\test_rotate_axis.py:59: AttributeError
__________________________________________________________________________________________ test_lorentz_numpy __________________________________________________________________________________________ 

    def test_lorentz_numpy():
        axis = vector._backends.numpy_.VectorNumpy4D(
            [(0.1, 0.2, 0.3, 99)],
            dtype=[
                ("x", numpy.float64),
                ("y", numpy.float64),
                ("z", numpy.float64),
                ("t", numpy.float64),
            ],
        )
        vec = vector._backends.numpy_.VectorNumpy4D(
            [(0.4, 0.5, 0.6, 99)],
            dtype=[
                ("x", numpy.float64),
                ("y", numpy.float64),
                ("z", numpy.float64),
                ("t", numpy.float64),
            ],
        )
        out = vec.rotate_axis(axis, 0.25)
        assert isinstance(out, vector._backends.numpy_.VectorNumpy4D)
        assert out.dtype.names == ("x", "y", "z", "t")
>       assert out[0].x == pytest.approx(0.37483425404335763)
E       AttributeError: 'TemporalObjectT' object has no attribute 'x'

tests\compute\spatial\test_rotate_axis.py:163: AttributeError
=========================================================================================== warnings summary =========================================================================================== 
c:\users\saransh\saransh_softwares\python_3.9\lib\site-packages\pyreadline\py3k_compat.py:8
  c:\users\saransh\saransh_softwares\python_3.9\lib\site-packages\pyreadline\py3k_compat.py:8: DeprecationWarning: Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated since Python 3.3, and in 3.10 it will stop working
    return isinstance(x, collections.Callable)

-- Docs: https://docs.pytest.org/en/stable/warnings.html
======================================================================================= short test summary info ======================================================================================== 
SKIPPED [2404] tests\test_compute_features.py:99: Unsupported Python version 3.9 (canonic 3.9.0beta5)
FAILED tests/compute/spatial/test_cross.py::test_spatial_numpy - AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'
FAILED tests/compute/spatial/test_cross.py::test_lorentz_numpy - AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'
FAILED tests/compute/spatial/test_rotateX.py::test_spatial_numpy - AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'
FAILED tests/compute/spatial/test_rotateX.py::test_lorentz_numpy - AttributeError: 'TemporalObjectT' object has no attribute 'x'
FAILED tests/compute/spatial/test_rotateY.py::test_spatial_numpy - AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'
FAILED tests/compute/spatial/test_rotateY.py::test_lorentz_numpy - AttributeError: 'TemporalObjectT' object has no attribute 'x'
FAILED tests/compute/spatial/test_rotate_axis.py::test_spatial_numpy - AttributeError: 'LongitudinalObjectZ' object has no attribute 'x'
FAILED tests/compute/spatial/test_rotate_axis.py::test_lorentz_numpy - AttributeError: 'TemporalObjectT' object has no attribute 'x'
```

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scikit-hep/vector/pull/466?dropdown=coverage&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 `48.57143%` with `36 lines` in your changes are missing coverage. Please review.
> Project coverage is 86.59%. Comparing base [(`fb13df8`)](https://app.codecov.io/gh/scikit-hep/vector/commit/fb13df8a06dc36049211b8a7783adf62ffd8d4c9?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep) to head [(`5026638`)](https://app.codecov.io/gh/scikit-hep/vector/pull/466?dropdown=coverage&src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep).

| [Files](https://app.codecov.io/gh/scikit-hep/vector/pull/466?dropdown=coverage&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/backends/numpy.py](https://app.codecov.io/gh/scikit-hep/vector/pull/466?src=pr&el=tree&filepath=src%2Fvector%2Fbackends%2Fnumpy.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scikit-hep#diff-c3JjL3ZlY3Rvci9iYWNrZW5kcy9udW1weS5weQ==) | 48.57% | [36 Missing :warning: ](https://app.codecov.io/gh/scikit-hep/vector/pull/466?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     #466      +/-   ##
==========================================
- Coverage   86.77%   86.59%   -0.19%     
==========================================
  Files          98       98              
  Lines       11927    11996      +69     
==========================================
+ Hits        10350    10388      +38     
- Misses       1577     1608      +31     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/scikit-hep/vector/pull/466?dropdown=coverage&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).

Saransh-cpp: Thanks for the review!

Patch:
diff --git a/src/vector/backends/numpy.py b/src/vector/backends/numpy.py
index 56c5afd..679afc4 100644
--- a/src/vector/backends/numpy.py
+++ b/src/vector/backends/numpy.py
@@ -231,8 +231,22 @@ def _getitem(
             return array.ObjectClass(azimuthal=azimuthal, longitudinal=longitudinal)  # type: ignore[arg-type, return-value]
         elif azimuthal is not None:
             return array.ObjectClass(azimuthal=azimuthal)  # type: ignore[return-value]
+        elif issubclass(array.ObjectClass, vector.backends.object.AzimuthalObject):
+            return array.ObjectClass(*tuple(out)[:2])  # type: ignore[arg-type, return-value]
+        elif issubclass(array.ObjectClass, vector.backends.object.LongitudinalObject):
+            coords = (
+                out.view(numpy.ndarray)[0]
+                if len(out) == 1  # type: ignore[arg-type]
+                else out.view(numpy.ndarray)[2]
+            )
+            return array.ObjectClass(coords)  # type: ignore[return-value]
         else:
-            return array.ObjectClass(*out.view(numpy.ndarray))  # type: ignore[misc, return-value]
+            coords = (
+                out.view(numpy.ndarray)[0]
+                if len(out) == 1  # type: ignore[arg-type]
+                else out.view(numpy.ndarray)[3]
+            )
+            return array.ObjectClass(coords)  # type: ignore[return-value]
 
 
 def _array_repr(
@@ -429,8 +443,13 @@ class AzimuthalNumpyXY(AzimuthalNumpy, AzimuthalXY, GetItem, FloatArray):  # typ
 
     ObjectClass = vector.backends.object.AzimuthalObjectXY
     _IS_MOMENTUM = False
+    dtype: numpy.dtype[typing.Any] = numpy.dtype(
+        [("x", numpy.float64), ("y", numpy.float64)]
+    )
 
     def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> AzimuthalNumpyXY:
+        if "dtype" in kwargs:
+            AzimuthalNumpyXY.dtype = numpy.dtype(kwargs["dtype"])
         return numpy.array(*args, **kwargs).view(cls)
 
     def __array_finalize__(self, obj: typing.Any) -> None:
@@ -440,6 +459,16 @@ class AzimuthalNumpyXY(AzimuthalNumpy, AzimuthalXY, GetItem, FloatArray):  # typ
                 'fields ("x", "y")'
             )
 
+    def __eq__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, AzimuthalNumpyXY):
+            return False
+        return all(coord1 == coord2 for coord1, coord2 in zip(self, other))
+
+    def __ne__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, AzimuthalNumpyXY):
+            return True
+        return any(coord1 != coord2 for coord1, coord2 in zip(self, other))
+
     @property
     def elements(self) -> tuple[FloatArray, FloatArray]:
         """
@@ -480,8 +509,13 @@ class AzimuthalNumpyRhoPhi(AzimuthalNumpy, AzimuthalRhoPhi, GetItem, FloatArray)
 
     ObjectClass = vector.backends.object.AzimuthalObjectRhoPhi
     _IS_MOMENTUM = False
+    dtype: numpy.dtype[typing.Any] = numpy.dtype(
+        [("rho", numpy.float64), ("phi", numpy.float64)]
+    )
 
     def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> AzimuthalNumpyRhoPhi:
+        if "dtype" in kwargs:
+            AzimuthalNumpyRhoPhi.dtype = numpy.dtype(kwargs["dtype"])
         return numpy.array(*args, **kwargs).view(cls)
 
     def __array_finalize__(self, obj: typing.Any) -> None:
@@ -491,6 +525,16 @@ class AzimuthalNumpyRhoPhi(AzimuthalNumpy, AzimuthalRhoPhi, GetItem, FloatArray)
                 'fields ("rho", "phi")'
             )
 
+    def __eq__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, AzimuthalNumpyRhoPhi):
+            return False
+        return all(coord1 == coord2 for coord1, coord2 in zip(self, other))
+
+    def __ne__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, AzimuthalNumpyRhoPhi):
+            return True
+        return any(coord1 != coord2 for coord1, coord2 in zip(self, other))
+
     @property
     def elements(self) -> tuple[FloatArray, FloatArray]:
         """
@@ -530,8 +574,11 @@ class LongitudinalNumpyZ(LongitudinalNumpy, LongitudinalZ, GetItem, FloatArray):
 
     ObjectClass = vector.backends.object.LongitudinalObjectZ
     _IS_MOMENTUM = False
+    dtype: numpy.dtype[typing.Any] = numpy.dtype([("z", numpy.float64)])
 
     def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> LongitudinalNumpyZ:
+        if "dtype" in kwargs:
+            LongitudinalNumpyZ.dtype = numpy.dtype(kwargs["dtype"])
         return numpy.array(*args, **kwargs).view(cls)
 
     def __array_finalize__(self, obj: typing.Any) -> None:
@@ -541,6 +588,16 @@ class LongitudinalNumpyZ(LongitudinalNumpy, LongitudinalZ, GetItem, FloatArray):
                 'field "z"'
             )
 
+    def __eq__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, LongitudinalNumpyZ):
+            return False
+        return all(coord1 == coord2 for coord1, coord2 in zip(self, other))
+
+    def __ne__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, LongitudinalNumpyZ):
+            return True
+        return any(coord1 != coord2 for coord1, coord2 in zip(self, other))
+
     @property
     def elements(self) -> tuple[FloatArray]:
         """
@@ -575,8 +632,11 @@ class LongitudinalNumpyTheta(LongitudinalNumpy, LongitudinalTheta, GetItem, Floa
 
     ObjectClass = vector.backends.object.LongitudinalObjectTheta
     _IS_MOMENTUM = False
+    dtype: numpy.dtype[typing.Any] = numpy.dtype([("theta", numpy.float64)])
 
     def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> LongitudinalNumpyTheta:
+        if "dtype" in kwargs:
+            LongitudinalNumpyTheta.dtype = numpy.dtype(kwargs["dtype"])
         return numpy.array(*args, **kwargs).view(cls)
 
     def __array_finalize__(self, obj: typing.Any) -> None:
@@ -586,6 +646,16 @@ class LongitudinalNumpyTheta(LongitudinalNumpy, LongitudinalTheta, GetItem, Floa
                 'field "theta"'
             )
 
+    def __eq__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, LongitudinalNumpyTheta):
+            return False
+        return all(coord1 == coord2 for coord1, coord2 in zip(self, other))
+
+    def __ne__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, LongitudinalNumpyTheta):
+            return True
+        return any(coord1 != coord2 for coord1, coord2 in zip(self, other))
+
     @property
     def elements(self) -> tuple[FloatArray]:
         """
@@ -620,8 +690,11 @@ class LongitudinalNumpyEta(LongitudinalNumpy, LongitudinalEta, GetItem, FloatArr
 
     ObjectClass = vector.backends.object.LongitudinalObjectEta
     _IS_MOMENTUM = False
+    dtype: numpy.dtype[typing.Any] = numpy.dtype([("eta", numpy.float64)])
 
     def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> LongitudinalNumpyEta:
+        if "dtype" in kwargs:
+            LongitudinalNumpyEta.dtype = numpy.dtype(kwargs["dtype"])
         return numpy.array(*args, **kwargs).view(cls)
 
     def __array_finalize__(self, obj: typing.Any) -> None:
@@ -631,6 +704,16 @@ class LongitudinalNumpyEta(LongitudinalNumpy, LongitudinalEta, GetItem, FloatArr
                 'field "eta"'
             )
 
+    def __eq__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, LongitudinalNumpyEta):
+            return False
+        return all(coord1 == coord2 for coord1, coord2 in zip(self, other))
+
+    def __ne__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, LongitudinalNumpyEta):
+            return True
+        return any(coord1 != coord2 for coord1, coord2 in zip(self, other))
+
     @property
     def elements(self) -> tuple[FloatArray]:
         """
@@ -665,8 +748,11 @@ class TemporalNumpyT(TemporalNumpy, TemporalT, GetItem, FloatArray):  # type: ig
 
     ObjectClass = vector.backends.object.TemporalObjectT
     _IS_MOMENTUM = False
+    dtype: numpy.dtype[typing.Any] = numpy.dtype([("t", numpy.float64)])
 
     def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> TemporalNumpyT:
+        if "dtype" in kwargs:
+            TemporalNumpyT.dtype = numpy.dtype(kwargs["dtype"])
         return numpy.array(*args, **kwargs).view(cls)
 
     def __array_finalize__(self, obj: typing.Any) -> None:
@@ -676,6 +762,16 @@ class TemporalNumpyT(TemporalNumpy, TemporalT, GetItem, FloatArray):  # type: ig
                 'field "t"'
             )
 
+    def __eq__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, TemporalNumpyT):
+            return False
+        return all(coord1 == coord2 for coord1, coord2 in zip(self, other))
+
+    def __ne__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, TemporalNumpyT):
+            return True
+        return any(coord1 != coord2 for coord1, coord2 in zip(self, other))
+
     @property
     def elements(self) -> tuple[FloatArray]:
         """
@@ -702,8 +798,11 @@ class TemporalNumpyTau(TemporalNumpy, TemporalTau, GetItem, FloatArray):  # type
 
     ObjectClass = vector.backends.object.TemporalObjectTau
     _IS_MOMENTUM = False
+    dtype: numpy.dtype[typing.Any] = numpy.dtype([("tau", numpy.float64)])
 
     def __new__(cls, *args: typing.Any, **kwargs: typing.Any) -> TemporalNumpyTau:
+        if "dtype" in kwargs:
+            TemporalNumpyTau.dtype = numpy.dtype(kwargs["dtype"])
         return numpy.array(*args, **kwargs).view(cls)
 
     def __array_finalize__(self, obj: typing.Any) -> None:
@@ -713,6 +812,16 @@ class TemporalNumpyTau(TemporalNumpy, TemporalTau, GetItem, FloatArray):  # type
                 'field "tau"'
             )
 
+    def __eq__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, TemporalNumpyTau):
+            return False
+        return all(coord1 == coord2 for coord1, coord2 in zip(self, other))
+
+    def __ne__(self, other: typing.Any) -> bool:
+        if self.dtype != other.dtype or not isinstance(other, TemporalNumpyTau):
+            return True
+        return any(coord1 != coord2 for coord1, coord2 in zip(self, other))
+
     @property
     def elements(self) -> tuple[FloatArray]:
         """


Test patch:
diff --git a/tests/test_issues.py b/tests/test_issues.py
index 88c36d7..3f5663c 100644
--- a/tests/test_issues.py
+++ b/tests/test_issues.py
@@ -63,6 +63,112 @@ def test_issue_443():
     assert vector.obj(E=1, px=1, py=1, pz=1) ** 2 == -2
 
 
+def test_issue_194():
+    vec2d = vector.VectorNumpy2D(
+        {
+            "x": [1.1, 1.2, 1.3, 1.4, 1.5],
+            "y": [2.1, 2.2, 2.3, 2.4, 2.5],
+        }
+    )
+    az1 = vector.backends.numpy.AzimuthalNumpyXY(
+        [(1.1, 2.1), (1.2, 2.2), (1.3, 2.3), (1.4, 2.4), (1.5, 2.5)],
+        dtype=[("x", float), ("y", float)],
+    )
+    az2 = vector.backends.numpy.AzimuthalNumpyXY(
+        [(1.1, 3.1), (1.2, 2.2), (1.3, 2.3), (1.4, 2.4), (1.5, 2.5)],
+        dtype=[("x", float), ("y", float)],
+    )
+    azp1 = vector.backends.numpy.AzimuthalNumpyRhoPhi(
+        [(1.1, 2.1), (1.2, 2.2), (1.3, 2.3), (1.4, 2.4), (1.5, 2.5)],
+        dtype=[("rho", float), ("phi", float)],
+    )
+    azp2 = vector.backends.numpy.AzimuthalNumpyRhoPhi(
+        [(2.1, 2.1), (1.2, 2.2), (1.3, 2.3), (1.4, 2.4), (1.5, 2.5)],
+        dtype=[("rho", float), ("phi", float)],
+    )
+    assert vec2d.azimuthal == az1
+    assert vec2d.azimuthal != az2
+    assert vec2d.azimuthal != azp1
+    assert az1 != az2
+    assert not az1 == azp1  # noqa: SIM201
+    assert not azp1 == az1  # noqa: SIM201
+    assert azp1 != az1
+    assert azp1 == azp1  # noqa: PLR0124
+    assert azp1 != azp2
+
+    vec3d = vector.VectorNumpy3D(
+        {
+            "x": [1.1, 1.2, 1.3, 1.4, 1.5],
+            "y": [2.1, 2.2, 2.3, 2.4, 2.5],
+            "z": [3.1, 3.2, 3.3, 3.4, 3.5],
+        }
+    )
+    lg1 = vector.backends.numpy.LongitudinalNumpyZ(
+        [(3.1,), (3.2,), (3.3,), (3.4,), (3.5,)], dtype=[("z", float)]
+    )
+    lg2 = vector.backends.numpy.LongitudinalNumpyZ(
+        [(4.1,), (3.2,), (3.3,), (3.4,), (3.5,)], dtype=[("z", float)]
+    )
+    lgeta1 = vector.backends.numpy.LongitudinalNumpyEta(
+        [(3.1,), (3.2,), (3.3,), (3.4,), (3.5,)], dtype=[("eta", float)]
+    )
+    lgeta2 = vector.backends.numpy.LongitudinalNumpyEta(
+        [(4.1,), (3.2,), (3.3,), (3.4,), (3.5,)], dtype=[("eta", float)]
+    )
+    lgtheta1 = vector.backends.numpy.LongitudinalNumpyTheta(
+        [(3.1,), (3.2,), (3.3,), (3.4,), (3.5,)], dtype=[("theta", float)]
+    )
+    lgtheta2 = vector.backends.numpy.LongitudinalNumpyTheta(
+        [(4.1,), (3.2,), (3.3,), (3.4,), (3.5,)], dtype=[("theta", float)]
+    )
+    assert vec3d.azimuthal == az1
+    assert vec3d.longitudinal == lg1
+    assert vec3d.longitudinal != lg2
+    assert vec3d.longitudinal != lgeta1
+    assert lg1 != lg2
+    assert not lg1 == lgeta1  # noqa: SIM201
+    assert not lgeta1 == lg1  # noqa: SIM201
+    assert lgeta1 != lg1
+    assert lgeta1 == lgeta1  # noqa: PLR0124
+    assert lgeta1 != lgeta2
+    assert lgtheta1 == lgtheta1  # noqa: PLR0124
+    assert lgtheta1 != lgtheta2
+    assert lgtheta1 != lgeta1
+    assert not lgtheta1 == lgeta1  # noqa: SIM201
+
+    vec4d = vector.VectorNumpy4D(
+        {
+            "x": [1.1, 1.2, 1.3, 1.4, 1.5],
+            "y": [2.1, 2.2, 2.3, 2.4, 2.5],
+            "z": [3.1, 3.2, 3.3, 3.4, 3.5],
+            "t": [4.1, 4.2, 4.3, 4.4, 4.5],
+        }
+    )
+    tm1 = vector.backends.numpy.TemporalNumpyT(
+        [(4.1,), (4.2,), (4.3,), (4.4,), (4.5,)], dtype=[("t", float)]
+    )
+    tm2 = vector.backends.numpy.TemporalNumpyT(
+        [(5.1,), (4.2,), (4.3,), (4.4,), (4.5,)], dtype=[("t", float)]
+    )
+    tmtau1 = vector.backends.numpy.TemporalNumpyTau(
+        [(4.1,), (4.2,), (4.3,), (4.4,), (4.5,)], dtype=[("tau", float)]
+    )
+    tmtau2 = vector.backends.numpy.TemporalNumpyTau(
+        [(5.1,), (4.2,), (4.3,), (4.4,), (4.5,)], dtype=[("tau", float)]
+    )
+    assert vec4d.azimuthal == az1
+    assert vec4d.longitudinal == lg1
+    assert vec4d.temporal == tm1
+    assert vec4d.temporal != tm2
+    assert vec4d.temporal != tmtau1
+    assert tm1 != tm2
+    assert not tm1 == tmtau1  # noqa: SIM201
+    assert not tmtau1 == tm1  # noqa: SIM201
+    assert tmtau1 != tm1
+    assert tmtau1 == tmtau1  # noqa: PLR0124
+    assert tmtau1 != tmtau2
+
+
 def test_issue_463():
     v = vector.obj(x=1, y=1, z=1)
     for transform in "xyz", "xytheta", "xyeta", "rhophiz", "rhophitheta", "rhophieta":