Skip to content

Fix 566: coordinate arithmetic#608

Open
d-chambers wants to merge 2 commits intomasterfrom
codex/fix-issue-#566
Open

Fix 566: coordinate arithmetic#608
d-chambers wants to merge 2 commits intomasterfrom
codex/fix-issue-#566

Conversation

@d-chambers
Copy link
Contributor

@d-chambers d-chambers commented Feb 6, 2026

Motivation

Fixes #566, allows array functions and basic arithmetic on coordinates.


Codex Task

Summary by CodeRabbit

  • New Features

    • Coordinate objects now support arithmetic operations (add, subtract, multiply, divide, power, modulo, floor-divide) and behave like NumPy arrays while preserving units.
    • NumPy compatibility improved: ufuncs and array-function protocol produce coordinate outputs where appropriate.
  • Tests

    • Added comprehensive tests covering binary ops, NumPy ufuncs/array functions, accumulates, and conversions to ensure correct values and unit preservation.

@d-chambers d-chambers changed the title Rename coord conversion test per review feedback Fix 566 Feb 6, 2026
@d-chambers d-chambers changed the title Fix 566 Fix 566: coordinate arithmetic Feb 6, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 6, 2026

Walkthrough

NumPy-friendly array and operator protocols were implemented on BaseCoord, adding binary operator overloads, ufunc and array-function support, and result-normalization to ensure coordinate outputs with appropriate units.

Changes

Cohort / File(s) Summary
Coordinate arithmetic core
dascore/core/coords.py
Added __array_priority__, __array__, _get_coord_output, _binary_coord_op, binary dunder/operator overloads and reflected variants, __array_ufunc__, and __array_function__ to enable NumPy-style operations and unit-aware result wrapping.
Tests for coordinate arithmetic
tests/test_core/test_coords.py
Added TestCoordinateArithmetic suite covering binary ops (including reflected), NumPy ufuncs and accumulate, NumPy array-function dispatch, and conversions (tuple/list/dict/concatenate/mean) verifying BaseCoord outputs and unit preservation.
🚥 Pre-merge checks | ✅ 3 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive The PR description is quite sparse. It mentions fixing #566 and links to a Codex task, but lacks detail about the implementation approach and checklist items are not completed. Expand the description to explain the implementation approach, confirm testing was added, document any new public APIs, and complete or explicitly address the checklist items.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Fix 566: coordinate arithmetic' clearly and concisely summarizes the main change - implementing arithmetic operations for coordinate objects to address issue #566.
Linked Issues check ✅ Passed The PR fully addresses issue #566 by implementing arithmetic operations (add, sub, mul, etc.), array functions (array_ufunc, array_function), unit preservation, and comprehensive tests covering scalars, coordinates, numpy ufuncs, and array functions.
Out of Scope Changes check ✅ Passed All changes are in-scope: modifications to BaseCoord class for arithmetic/array support and corresponding test coverage directly address issue #566 requirements.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch codex/fix-issue-#566

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fb527a1220

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

return data
if hasattr(data, "magnitude") and hasattr(data, "units"):
return get_coord(data=data.magnitude, units=data.units)
return get_coord(data=data, units=units)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return scalar NumPy results instead of wrapping as coords

The output helper always calls get_coord for non-BaseCoord values, so scalar-returning NumPy operations now produce invalid coordinates rather than numbers. In practice, reductions like np.sum(coord) and scalar linear algebra calls such as np.dot(coord, coord) are routed through this path and become CoordPartial objects with NaN-filled data instead of the expected scalar value, which corrupts numerical results.

Useful? React with 👍 / 👎.

Comment on lines +512 to +513
if isinstance(obj, BaseCoord):
return obj.data

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate units before stripping BaseCoord args in array API

The array-function conversion replaces each BaseCoord input with raw .data and drops its units, then the result is labeled with self.units. For multi-input calls this can silently relabel incompatible values: for example np.concatenate((coord_m, coord_s)) appends second-based values unchanged but returns a coordinate tagged as meters. This is a data-integrity bug because mixed-unit inputs are neither converted nor rejected.

Useful? React with 👍 / 👎.

Comment on lines +453 to +454
lhs, rhs = (other_data, self.data) if reversed else (self.data, other_data)
out = operator(lhs, rhs)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep self units in non-additive quantity arithmetic

Binary ops compute using self.data (unitless ndarray), so when other is a quantity in multiply/divide/power/modulo paths, NumPy derives units from other alone and loses the coordinate’s units. A concrete case is get_coord(..., units='m') * (3 * s), which currently yields units of s instead of m*s. This introduces incorrect dimensional metadata for quantity arithmetic.

Useful? React with 👍 / 👎.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

🤖 Fix all issues with AI agents
In `@dascore/core/coords.py`:
- Around line 498-504: The long single-line ternary that sets units in
__array_ufunc__ exceeds line-length; split the expression into clear
intermediate steps: convert out to an array (e.g., out_arr = np.asarray(out)),
compute a boolean flag using np.issubdtype(out_arr.dtype, np.bool_), then set
units = self.units if not that flag else None, and finally call
self._get_coord_output(out, units=units). Reference symbols: __array_ufunc__,
BaseCoord, _get_coord_output, np.asarray, np.issubdtype.
- Around line 447-456: The long lines in _binary_coord_op come from the compound
conditional and the inline conditional determining units; fix by splitting into
intermediate variables: extract the operator set (e.g., ops = (np.add,
np.subtract)) and use if hasattr(other_data, "units") and operator in ops to
shorten that check, and compute a boolean like is_bool =
np.issubdtype(np.asarray(out).dtype, np.bool_) then set units = self.units if
not is_bool else None; update references to other_data, operator, out, and units
accordingly in _binary_coord_op.
- Around line 506-524: The line in __array_function__ that computes units is
over the length limit; refactor the boolean dtype check into a short
intermediate variable and then set units using that variable to keep lines under
88 chars. Specifically, in __array_function__ (use symbols __array_function__,
_convert, _get_coord_output) extract np.issubdtype(np.asarray(out).dtype,
np.bool_) into a named variable (e.g., is_bool) on its own line and then assign
units = self.units if not is_bool else None before returning
self._get_coord_output(out, units=units).

In `@tests/test_core/test_coords.py`:
- Around line 1917-1924: The test asserts units are preserved for np.sqrt which
is dimensionally wrong; update the behavior in BaseCoord.__array_ufunc__ to
propagate unit exponents for root/power ufuncs (detect np.sqrt / ufunc.__name__
== "sqrt" or ufunc == np.sqrt and compute new_units = self.units ** 0.5, then
set the result's units to new_units) and adjust the test
test_numpy_ufunc_returns_coord to expect units "m**0.5" (or the units object's
equivalent) and values [1.0,2.0,3.0]; reference BaseCoord.__array_ufunc__ and
the test function test_numpy_ufunc_returns_coord when making changes.
- Around line 1935-1951: There are not two blank lines separating the top-level
test function test_tuple_list_dict_conversions and the next top-level class
TestIssues; add the required blank lines per PEP8 by inserting one additional
blank line so there are two blank lines between the end of
test_tuple_list_dict_conversions and the declaration of class TestIssues (edit
the area around the end of test_tuple_list_dict_conversions and the start of
class TestIssues to ensure two blank lines).
🧹 Nitpick comments (1)
dascore/core/coords.py (1)

455-455: Duplicated "is result boolean?" logic across three methods — consider extracting a helper.

The pattern self.units if not np.issubdtype(np.asarray(out).dtype, np.bool_) else None appears identically in _binary_coord_op, __array_ufunc__, and __array_function__. Extracting a small helper (e.g., _units_for_output(self, out)) would reduce duplication and make the line-length fix apply in one place.

Also applies to: 503-503, 523-523

Comment on lines 498 to 504
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
"""Support numpy ufunc operations and return coordinate outputs."""
method_func = ufunc if method == "__call__" else getattr(ufunc, method)
converted = [x.data if isinstance(x, BaseCoord) else x for x in inputs]
out = method_func(*converted, **kwargs)
units = self.units if not np.issubdtype(np.asarray(out).dtype, np.bool_) else None
return self._get_coord_output(out, units=units)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix line-length violation flagged by CI (line 503).

Same E501 pattern as above.

🔧 Proposed fix
-        units = self.units if not np.issubdtype(np.asarray(out).dtype, np.bool_) else None
+        is_bool = np.issubdtype(np.asarray(out).dtype, np.bool_)
+        units = self.units if not is_bool else None
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
"""Support numpy ufunc operations and return coordinate outputs."""
method_func = ufunc if method == "__call__" else getattr(ufunc, method)
converted = [x.data if isinstance(x, BaseCoord) else x for x in inputs]
out = method_func(*converted, **kwargs)
units = self.units if not np.issubdtype(np.asarray(out).dtype, np.bool_) else None
return self._get_coord_output(out, units=units)
def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
"""Support numpy ufunc operations and return coordinate outputs."""
method_func = ufunc if method == "__call__" else getattr(ufunc, method)
converted = [x.data if isinstance(x, BaseCoord) else x for x in inputs]
out = method_func(*converted, **kwargs)
is_bool = np.issubdtype(np.asarray(out).dtype, np.bool_)
units = self.units if not is_bool else None
return self._get_coord_output(out, units=units)
🧰 Tools
🪛 GitHub Actions: LintCode

[error] 503-503: E501 Line too long (90 > 88).

🤖 Prompt for AI Agents
In `@dascore/core/coords.py` around lines 498 - 504, The long single-line ternary
that sets units in __array_ufunc__ exceeds line-length; split the expression
into clear intermediate steps: convert out to an array (e.g., out_arr =
np.asarray(out)), compute a boolean flag using np.issubdtype(out_arr.dtype,
np.bool_), then set units = self.units if not that flag else None, and finally
call self._get_coord_output(out, units=units). Reference symbols:
__array_ufunc__, BaseCoord, _get_coord_output, np.asarray, np.issubdtype.

Comment on lines 506 to 524
def __array_function__(self, func, types, args, kwargs):
"""Support NumPy array-function protocol for coordinates."""
if not any(issubclass(t, BaseCoord) for t in types):
return NotImplemented

def _convert(obj):
if isinstance(obj, BaseCoord):
return obj.data
if isinstance(obj, tuple):
return tuple(_convert(x) for x in obj)
if isinstance(obj, list):
return [_convert(x) for x in obj]
if isinstance(obj, dict):
return {k: _convert(v) for k, v in obj.items()}
return obj

out = func(*_convert(args), **_convert(kwargs))
units = self.units if not np.issubdtype(np.asarray(out).dtype, np.bool_) else None
return self._get_coord_output(out, units=units)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, locate and read the file
git ls-files | grep -E "(coords\.py|core/coords\.py)"

Repository: DASDAE/dascore

Length of output: 170


🏁 Script executed:

# Read the specific lines mentioned in the review
cat -n dascore/core/coords.py | sed -n '500,530p'

Repository: DASDAE/dascore

Length of output: 1568


🏁 Script executed:

# Check the exact character count of line 523
sed -n '523p' dascore/core/coords.py | wc -c

Repository: DASDAE/dascore

Length of output: 60


🏁 Script executed:

python3 << 'EOF'
import numpy as np

# Test case 1: np.asarray on tuple of arrays
print("Test 1: np.asarray on tuple of arrays")
arr1 = np.array([1, 2, 3])
arr2 = np.array([4, 5, 6])
result_tuple = (arr1, arr2)
asarray_result = np.asarray(result_tuple)
print(f"  Input: tuple of 2 arrays")
print(f"  Result dtype: {asarray_result.dtype}")
print(f"  Result shape: {asarray_result.shape}")

# Test case 2: np.asarray on scalar
print("\nTest 2: np.asarray on scalar")
scalar = 5
asarray_scalar = np.asarray(scalar)
print(f"  Input: scalar 5")
print(f"  Result dtype: {asarray_scalar.dtype}")

# Test case 3: np.issubdtype with object dtype
print("\nTest 3: np.issubdtype on object dtype")
obj_arr = np.asarray([(1, 2), (3, 4)])
print(f"  Object array dtype: {obj_arr.dtype}")
is_bool = np.issubdtype(obj_arr.dtype, np.bool_)
print(f"  np.issubdtype(object, np.bool_) = {is_bool}")

# Test case 4: Real numpy function returning tuple
print("\nTest 4: np.unique with return_index=True (returns tuple)")
data = np.array([1, 2, 1, 3, 2])
result = np.unique(data, return_index=True)
print(f"  np.unique output type: {type(result)}")
print(f"  np.unique output: {result}")
asarray_unique = np.asarray(result)
print(f"  np.asarray(result).dtype: {asarray_unique.dtype}")
is_bool_unique = np.issubdtype(asarray_unique.dtype, np.bool_)
print(f"  np.issubdtype on this object dtype: {is_bool_unique}")

# Test case 5: Boolean array as output
print("\nTest 5: np.isnan returns boolean array")
data = np.array([1.0, np.nan, 3.0])
result = np.isnan(data)
asarray_bool = np.asarray(result)
print(f"  np.isnan output dtype: {asarray_bool.dtype}")
is_bool_result = np.issubdtype(asarray_bool.dtype, np.bool_)
print(f"  np.issubdtype(bool_, np.bool_) = {is_bool_result}")

EOF

Repository: DASDAE/dascore

Length of output: 175


🏁 Script executed:

# Find the _get_coord_output method to understand what it expects
rg -A 10 "def _get_coord_output" dascore/core/coords.py

Repository: DASDAE/dascore

Length of output: 638


🏁 Script executed:

pip install numpy -q && python3 << 'EOF'
import numpy as np

# Test case 1: np.asarray on tuple of arrays
print("Test 1: np.asarray on tuple of arrays")
arr1 = np.array([1, 2, 3])
arr2 = np.array([4, 5, 6])
result_tuple = (arr1, arr2)
asarray_result = np.asarray(result_tuple)
print(f"  Input: tuple of 2 arrays")
print(f"  Result dtype: {asarray_result.dtype}")
print(f"  Result shape: {asarray_result.shape}")

# Test case 2: np.asarray on scalar
print("\nTest 2: np.asarray on scalar")
scalar = 5
asarray_scalar = np.asarray(scalar)
print(f"  Input: scalar 5")
print(f"  Result dtype: {asarray_scalar.dtype}")

# Test case 3: np.issubdtype with object dtype
print("\nTest 3: np.issubdtype on object dtype")
obj_arr = np.asarray([(1, 2), (3, 4)])
print(f"  Object array dtype: {obj_arr.dtype}")
is_bool = np.issubdtype(obj_arr.dtype, np.bool_)
print(f"  np.issubdtype(object, np.bool_) = {is_bool}")

# Test case 4: Real numpy function returning tuple
print("\nTest 4: np.unique with return_index=True (returns tuple)")
data = np.array([1, 2, 1, 3, 2])
result = np.unique(data, return_index=True)
print(f"  np.unique output type: {type(result)}")
print(f"  np.unique output: {result}")
asarray_unique = np.asarray(result)
print(f"  np.asarray(result).dtype: {asarray_unique.dtype}")
is_bool_unique = np.issubdtype(asarray_unique.dtype, np.bool_)
print(f"  np.issubdtype on this object dtype: {is_bool_unique}")

# Test case 5: Boolean array as output
print("\nTest 5: np.isnan returns boolean array")
data = np.array([1.0, np.nan, 3.0])
result = np.isnan(data)
asarray_bool = np.asarray(result)
print(f"  np.isnan output dtype: {asarray_bool.dtype}")
is_bool_result = np.issubdtype(asarray_bool.dtype, np.bool_)
print(f"  np.issubdtype(bool_, np.bool_) = {is_bool_result}")

EOF

Repository: DASDAE/dascore

Length of output: 935


🏁 Script executed:

rg -B 2 -A 20 "^def get_coord" dascore/core/coords.py | head -50

Repository: DASDAE/dascore

Length of output: 596


Fix line-length violation on line 523 (exceeds 88-character limit).

Line 523 is 91 characters and must be shortened. The proposed refactoring improves both compliance and readability:

🔧 Proposed fix
-        units = self.units if not np.issubdtype(np.asarray(out).dtype, np.bool_) else None
+        is_bool = np.issubdtype(np.asarray(out).dtype, np.bool_)
+        units = self.units if not is_bool else None
🧰 Tools
🪛 GitHub Actions: LintCode

[error] 523-523: E501 Line too long (90 > 88).

🤖 Prompt for AI Agents
In `@dascore/core/coords.py` around lines 506 - 524, The line in
__array_function__ that computes units is over the length limit; refactor the
boolean dtype check into a short intermediate variable and then set units using
that variable to keep lines under 88 chars. Specifically, in __array_function__
(use symbols __array_function__, _convert, _get_coord_output) extract
np.issubdtype(np.asarray(out).dtype, np.bool_) into a named variable (e.g.,
is_bool) on its own line and then assign units = self.units if not is_bool else
None before returning self._get_coord_output(out, units=units).

Comment on lines 1917 to 1924
def test_numpy_ufunc_returns_coord(self):
"""Ensure numpy ufunc dispatch returns coordinates."""
coord = get_coord(data=[1, 4, 9], units="m")
out = np.sqrt(coord)

assert isinstance(out, BaseCoord)
assert out.units == coord.units
np.testing.assert_allclose(out.values, np.array([1.0, 2.0, 3.0]))
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unit preservation after np.sqrt is dimensionally incorrect.

np.sqrt on a coordinate with units "m" should yield units of m^(1/2), but the implementation (and this test on line 1923) asserts the original units "m" are preserved unchanged. If the intent is to treat units as opaque labels rather than performing dimensional analysis, this works — but it may surprise users expecting physically meaningful unit propagation. Worth documenting this limitation on the __array_ufunc__ method.

🤖 Prompt for AI Agents
In `@tests/test_core/test_coords.py` around lines 1917 - 1924, The test asserts
units are preserved for np.sqrt which is dimensionally wrong; update the
behavior in BaseCoord.__array_ufunc__ to propagate unit exponents for root/power
ufuncs (detect np.sqrt / ufunc.__name__ == "sqrt" or ufunc == np.sqrt and
compute new_units = self.units ** 0.5, then set the result's units to new_units)
and adjust the test test_numpy_ufunc_returns_coord to expect units "m**0.5" (or
the units object's equivalent) and values [1.0,2.0,3.0]; reference
BaseCoord.__array_ufunc__ and the test function test_numpy_ufunc_returns_coord
when making changes.

Comment on lines +1935 to +1951
def test_tuple_list_dict_conversions(self):
"""Ensure tuple/list/dict conversion paths are exercised."""
coord1 = get_coord(data=[1, 2], units="m")
coord2 = get_coord(data=[3, 4], units="m")

out_tuple = np.concatenate((coord1, coord2))
assert isinstance(out_tuple, BaseCoord)
np.testing.assert_array_equal(out_tuple.values, np.array([1, 2, 3, 4]))

out_list = np.concatenate([coord1, coord2])
assert isinstance(out_list, BaseCoord)
np.testing.assert_array_equal(out_list.values, np.array([1, 2, 3, 4]))

out_kwargs = np.mean(a=coord1)
assert isinstance(out_kwargs, BaseCoord)
np.testing.assert_allclose(out_kwargs.values, np.array([1.5]))

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Good coverage of conversion paths; missing blank lines before next class.

The test name test_tuple_list_dict_conversions clearly reflects the intent (per PR objective). PEP 8 expects two blank lines between top-level class definitions, but there appear to be none between lines 1950 and class TestIssues at line 1952.

🔧 Proposed fix
         out_kwargs = np.mean(a=coord1)
         assert isinstance(out_kwargs, BaseCoord)
         np.testing.assert_allclose(out_kwargs.values, np.array([1.5]))
+
+
 class TestIssues:
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_tuple_list_dict_conversions(self):
"""Ensure tuple/list/dict conversion paths are exercised."""
coord1 = get_coord(data=[1, 2], units="m")
coord2 = get_coord(data=[3, 4], units="m")
out_tuple = np.concatenate((coord1, coord2))
assert isinstance(out_tuple, BaseCoord)
np.testing.assert_array_equal(out_tuple.values, np.array([1, 2, 3, 4]))
out_list = np.concatenate([coord1, coord2])
assert isinstance(out_list, BaseCoord)
np.testing.assert_array_equal(out_list.values, np.array([1, 2, 3, 4]))
out_kwargs = np.mean(a=coord1)
assert isinstance(out_kwargs, BaseCoord)
np.testing.assert_allclose(out_kwargs.values, np.array([1.5]))
def test_tuple_list_dict_conversions(self):
"""Ensure tuple/list/dict conversion paths are exercised."""
coord1 = get_coord(data=[1, 2], units="m")
coord2 = get_coord(data=[3, 4], units="m")
out_tuple = np.concatenate((coord1, coord2))
assert isinstance(out_tuple, BaseCoord)
np.testing.assert_array_equal(out_tuple.values, np.array([1, 2, 3, 4]))
out_list = np.concatenate([coord1, coord2])
assert isinstance(out_list, BaseCoord)
np.testing.assert_array_equal(out_list.values, np.array([1, 2, 3, 4]))
out_kwargs = np.mean(a=coord1)
assert isinstance(out_kwargs, BaseCoord)
np.testing.assert_allclose(out_kwargs.values, np.array([1.5]))
🤖 Prompt for AI Agents
In `@tests/test_core/test_coords.py` around lines 1935 - 1951, There are not two
blank lines separating the top-level test function
test_tuple_list_dict_conversions and the next top-level class TestIssues; add
the required blank lines per PEP8 by inserting one additional blank line so
there are two blank lines between the end of test_tuple_list_dict_conversions
and the declaration of class TestIssues (edit the area around the end of
test_tuple_list_dict_conversions and the start of class TestIssues to ensure two
blank lines).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@dascore/core/coords.py`:
- Around line 447-462: _binary_coord_op currently strips unit info by setting
other_data = other.data when other is a BaseCoord; add logic to detect when
other is a BaseCoord and operator is np.add or np.subtract and then convert
other.data from other.units into self.units via convert_units (use other.units
and other.data.magnitude or plain array as appropriate), handling the reversed
flag consistently (i.e., convert rhs when reversed or lhs otherwise), and then
call _get_coord_output with the proper units; reference symbols:
_binary_coord_op, BaseCoord, other.data, other.units, convert_units,
_get_coord_output, operator, reversed.
🧹 Nitpick comments (1)
dascore/core/coords.py (1)

504-512: Multi-output ufuncs (e.g., np.modf, np.divmod) will produce unexpected results.

When a ufunc returns a tuple of arrays, np.asarray(out) creates a 2D array and _get_coord_output wraps it into a single coordinate rather than returning a tuple of coordinates. This is unlikely to be hit in normal usage now, but worth a guard:

if isinstance(out, tuple):
    return tuple(self._get_coord_output(o, units=units) for o in out)

Not blocking since the PR scope is basic arithmetic, but it would prevent confusing errors if users call ufuncs like np.modf(coord).

Comment on lines +447 to +462
def _binary_coord_op(self, operator, other, reversed=False):
"""Apply a binary operator and return a new coordinate."""
other_data = other.data if isinstance(other, BaseCoord) else other
# Addition/subtraction treat scalars as values in current units.
if hasattr(other_data, "units") and operator in (np.add, np.subtract):
other_data = convert_units(
other_data.magnitude,
self.units,
other_data.units,
)
lhs, rhs = (other_data, self.data) if reversed else (self.data, other_data)
out = operator(lhs, rhs)
units = (
self.units if not np.issubdtype(np.asarray(out).dtype, np.bool_) else None
)
return self._get_coord_output(out, units=units)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Unit handling for coord + coord with differing units is not addressed.

When other is a BaseCoord, other_data = other.data strips the unit information. The unit-conversion branch (line 451) only triggers for objects with a .units attribute on the data itself (e.g., pint Quantities), not for BaseCoord operands whose .data is a plain ndarray. This means coord_m + coord_ft silently adds raw arrays without converting, producing incorrect values.

Consider adding a branch like:

Proposed fix
     def _binary_coord_op(self, operator, other, reversed=False):
         """Apply a binary operator and return a new coordinate."""
         other_data = other.data if isinstance(other, BaseCoord) else other
+        # Convert coord-to-coord units for add/sub
+        if (
+            isinstance(other, BaseCoord)
+            and other.units is not None
+            and self.units is not None
+            and operator in (np.add, np.subtract)
+        ):
+            other_data = convert_units(other_data, self.units, other.units)
         # Addition/subtraction treat scalars as values in current units.
-        if hasattr(other_data, "units") and operator in (np.add, np.subtract):
+        elif hasattr(other_data, "units") and operator in (np.add, np.subtract):
🤖 Prompt for AI Agents
In `@dascore/core/coords.py` around lines 447 - 462, _binary_coord_op currently
strips unit info by setting other_data = other.data when other is a BaseCoord;
add logic to detect when other is a BaseCoord and operator is np.add or
np.subtract and then convert other.data from other.units into self.units via
convert_units (use other.units and other.data.magnitude or plain array as
appropriate), handling the reversed flag consistently (i.e., convert rhs when
reversed or lhs otherwise), and then call _get_coord_output with the proper
units; reference symbols: _binary_coord_op, BaseCoord, other.data, other.units,
convert_units, _get_coord_output, operator, reversed.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Coords do not support basic arthimetic

1 participant