Skip to content

feat(vector): add 3D support for coordinate transforms and vector ops#948

Open
khan-u wants to merge 2 commits intoneuroinformatics-unit:mainfrom
khan-u:feat-vector-add-3D-support
Open

feat(vector): add 3D support for coordinate transforms and vector ops#948
khan-u wants to merge 2 commits intoneuroinformatics-unit:mainfrom
khan-u:feat-vector-add-3D-support

Conversation

@khan-u
Copy link
Copy Markdown

@khan-u khan-u commented Apr 4, 2026

Description

Adds 3D Support for Coordinate Transforms & Vector Operations

What is this PR

  • Bug fix
  • Addition of a new feature
  • Other

Why is this PR needed?

The movement library currently only supports 2D coordinate transforms and vector operations in vector.py. To enable 3D motion analysis, the vector utilities need to handle 3D Cartesian, cylindrical, and spherical coordinate systems.

What does this PR do?

Extends existing functions and adds new functions in movement/utils/vector.py:

Function Change
compute_norm Extended to handle 3D Cartesian ["x", "y", "z"] and 3D cylindrical ["rho", "phi", "z"]
convert_to_unit Extended to handle 3D Cartesian and 3D cylindrical coordinates
cart2pol Extended to handle 3D Cartesian → cylindrical (z passes through unchanged)
pol2cart Extended to handle 3D cylindrical → Cartesian (z passes through unchanged)
cart2sph New: 3D Cartesian → spherical ["rho", "azimuth", "elevation"]
sph2cart New: Spherical → 3D Cartesian
_raise_error_for_invalid_spatial_dim_length New: Helper for validation errors when spatial dimension has unexpected length

Implementation details:

  • Added dimension existence checks in cart2pol/pol2cart before accessing coords (raises ValueError with clear message)
  • Uses drop=True on .sel() calls to avoid retaining scalar coordinates
  • Uses coords="minimal" in xr.concat for cart2sph/sph2cart to handle coordinate alignment
  • Helper function _raise_error_for_invalid_spatial_dim_length for consistent error messages

References

How has this PR been tested?

Added 7 new test methods and 2 new fixtures in tests/test_unit/test_vector.py:

New fixtures:

  • cart_pol_dataset_3d - 3D Cartesian + cylindrical test data (5 points including null vector edge case)
  • cart_sph_dataset - 3D Cartesian + spherical test data (5 points including poles and axis-aligned vectors)

New tests:

Test What it verifies
test_cart2pol_3d 3D Cartesian → cylindrical conversion, z passthrough
test_pol2cart_3d 3D cylindrical → Cartesian conversion
test_compute_norm_3d 3D norm: sqrt(x²+y²+z²) for Cartesian, rho for cylindrical
test_convert_to_unit_3d 3D unit vectors have norm = 1
test_cart2sph 3D Cartesian → spherical conversion
test_sph2cart Spherical → 3D Cartesian conversion
test_cart2sph_sph2cart_roundtrip Roundtrip consistency

Existing 2D tests continue to pass, ensuring no regression.

Is this a breaking change?

No. All existing 2D functionality is preserved. The changes extend the existing functions to accept 3D data while maintaining full backward compatibility with 2D data.

Does this PR require an update to the documentation?

No. API reference is auto-generated from docstrings. The following docstrings have been added/updated:

  1. cart2pol and pol2cart: Updated to document both 2D and 3D modes, including:
    • Input coordinates (["x", "y"] or ["x", "y", "z"])
    • Output coordinates (["rho", "phi"] or ["rho", "phi", "z"])
    • Note that z passes through unchanged in 3D mode
  2. compute_norm and convert_to_unit: Accept both 2D and 3D implicitly via existing space/space_pol dimension handling (no docstring changes needed)
  3. cart2sph and sph2cart (new):
    • Parameters and return types
    • Coordinate conventions (rho, azimuth, elevation)
    • Angle ranges and units (radians)
    • Cross-references via See Also

Next Steps

A subsequent PR (once #875 is merged) can then extend collective.py to support 3D polarization by utilizing the new vector utilities.

Current state of collective.py:

from movement.utils.vector import (
    compute_norm,
    compute_signed_angle_2d,  # 2D only
    convert_to_unit,
)
  • Uses return_angle: bool and in_degrees: bool parameters
  • Returns single mean_angle (2D only, uses compute_signed_angle_2d)
  • Ignores z coordinate (line 55-56: "polarization is computed in the x/y plane")

Planned changes:

  1. Update imports - Add cart2pol and cart2sph:

    from movement.utils.vector import (
        cart2pol,
        cart2sph,
        compute_norm,
        convert_to_unit,
    )
  2. Replace return_angle/in_degrees with return_direction:

    • False (default): return only polarization
    • "unit_vector": also return mean heading/orientation as unit vector
    • "angle_radians": also return angle(s) in radians
    • "angle_degrees": also return angle(s) in degrees
  3. Use cart2pol/cart2sph for angle conversion (replaces compute_signed_angle_2d):

    # 2D: use cart2pol → return (polarization, azimuth)
    angles_pol = cart2pol(mean_unit_vector)
    azimuth = angles_pol.sel(space_pol="phi")
    
    # 3D: use cart2sph → return (polarization, azimuth, elevation)
    angles_sph = cart2sph(mean_unit_vector)
    azimuth = angles_sph.sel(space_sph="azimuth")
    elevation = angles_sph.sel(space_sph="elevation")

Example API after changes:

from movement.kinematics import compute_polarization

# Orientation polarization (body-axis mode) - default, no direction
pol = compute_polarization(data, body_axis_keypoints=("tail_base", "neck"))

# Heading polarization (displacement mode) - default, no direction
pol = compute_polarization(data.sel(keypoints="thorax"))
pol = compute_polarization(data.sel(keypoints="thorax"), displacement_frames=5)

# Return mean heading/orientation as unit vector (works for 2D and 3D)
pol, unit_vec = compute_polarization(
  data,
  body_axis_keypoints=("tail_base", "neck"),
  return_direction="unit_vector"
)

# 2D data: return (polarization, azimuth)
pol, azimuth = compute_polarization(
  data_2d,
  body_axis_keypoints=("tail_base", "neck"),
  return_direction="angle_degrees"
)

# 3D data: return (polarization, azimuth, elevation)
pol, azimuth, elevation = compute_polarization(
  data_3d,
  body_axis_keypoints=("tail_base", "neck"),
  return_direction="angle_degrees"
)

Checklist:

  • The code has been tested locally
  • Tests have been added to cover all new functionality
  • The documentation has been updated to reflect any changes
  • The code has been formatted with pre-commit

@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Apr 4, 2026

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant