Skip to content

Conversation

@d-chambers
Copy link
Contributor

@d-chambers d-chambers commented Sep 22, 2025

Description

This PR adds the Patch.where method, which behaves like numpy or xarray's where.

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings or appropriate doc page.
  • included a test. See testing guidelines.
  • your name has been added to the contributors page (docs/contributors.md).
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Summary by CodeRabbit

  • New Features

    • Added a "where" processing operation to select data by condition with configurable replacement for non-matching elements.
    • Supports boolean arrays or patch-like conditions with automatic alignment and broadcasting; accepts scalars, arrays, or patch-like inputs.
    • Added a convenience shortcut on Patch objects to call this operation.
    • Preserves shape and metadata and enforces boolean conditions.
  • Tests

    • Added comprehensive tests covering behavior, broadcasting, metadata, and error cases.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Sep 22, 2025

Walkthrough

Adds Patch.where alias exposing dascore.proc.where; implements dascore.proc.basic.where to handle array- or patch-based conditions and replacements with alignment and boolean validation; adds tests covering behavior, broadcasting, metadata, and error cases.

Changes

Cohort / File(s) Summary
Core API alias
dascore/core/patch.py
Adds Patch.where attribute referencing dascore.proc.where, exposing the where processing operation as a Patch method/shortcut.
Processing function
dascore/proc/basic.py
Adds public where(patch, cond, other=np.nan) which accepts array- or patch-based cond/other, aligns patch inputs via align_patch_coords, validates cond as boolean, computes output with numpy.where, and updates imports/exports.
Tests for where
tests/test_proc/test_basic.py
Adds TestWhere test suite covering boolean and patch-based conditions, replacement values, broadcasting/alignment behavior, metadata preservation, complex-data handling, and error cases for non-boolean conditions.

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title "Patch.where" succinctly names the primary change — adding a where method to the Patch API — and directly corresponds to edits in dascore/core/patch.py, dascore/proc/basic.py, and tests. It is concise, specific, and free of noise or vague wording. A reviewer scanning PR history can understand the main intent from the title alone.
Description Check ✅ Passed The PR description follows the repository template by providing a short "Description" and including the checklist section, and it clearly states that a Patch.where method was added. The description is concise and on-topic, but several checklist items remain unchecked and the description lacks links to related issues or documentation. Overall the template structure is present and the intent is clear, though some required follow-ups are missing.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch patch_where

📜 Recent review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a763e43 and 26f520f.

📒 Files selected for processing (2)
  • dascore/proc/basic.py (3 hunks)
  • tests/test_proc/test_basic.py (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • tests/test_proc/test_basic.py
🧰 Additional context used
🧬 Code graph analysis (1)
dascore/proc/basic.py (2)
dascore/compat.py (1)
  • array (31-37)
dascore/utils/patch.py (2)
  • align_patch_coords (820-870)
  • patch_function (180-286)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (16)
  • GitHub Check: test_code (windows-latest, 3.12)
  • GitHub Check: test_code (windows-latest, 3.11)
  • GitHub Check: test_code (windows-latest, 3.10)
  • GitHub Check: test_code (windows-latest, 3.13)
  • GitHub Check: test_code (macos-latest, 3.10)
  • GitHub Check: test_code (macos-latest, 3.12)
  • GitHub Check: test_code (macos-latest, 3.13)
  • GitHub Check: test_code (ubuntu-latest, 3.12)
  • GitHub Check: test_code (ubuntu-latest, 3.13)
  • GitHub Check: test_code (ubuntu-latest, 3.11)
  • GitHub Check: test_code (ubuntu-latest, 3.10)
  • GitHub Check: test_code_min_deps (ubuntu-latest, 3.13)
  • GitHub Check: test_code_min_deps (windows-latest, 3.12)
  • GitHub Check: test_code_min_deps (windows-latest, 3.13)
  • GitHub Check: test_code_min_deps (macos-latest, 3.13)
  • GitHub Check: test_code_min_deps (ubuntu-latest, 3.12)
🔇 Additional comments (4)
dascore/proc/basic.py (4)

13-13: LGTM on using dascore.compat.array

Using the immutable array wrapper here is appropriate given subsequent read-only usage.


23-23: LGTM on importing align_patch_coords

Required for safe union-alignment across Patch inputs.


671-680: Alignment base handling looks correct now

You carry forward the aligned base Patch and re-align cond after aligning other. This resolves the earlier union-shape mismatch risk.


681-690: No change required — Patch implements array

Patch.array is defined (dascore/core/patch.py:180–191) and will be called by array(cond)/array(other), so the suggested .data fallback is unnecessary.

Likely an incorrect or invalid review comment.


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
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

🧹 Nitpick comments (4)
tests/test_proc/test_basic.py (3)

603-716: Good coverage for core scenarios; add a misaligned-coords case.

Consider a test where cond (or other) is a Patch with shifted/partially overlapping coords so alignment (union) is required; this will catch shape/coord sync bugs.

I can draft a test using a time-shifted boolean Patch if helpful.


649-664: Use model_dump to compare attrs (avoid relying on dict).

More robust and future-proof than iterating __dict__.

Apply:

-        # Check that attributes are preserved (except possibly history)
-        for key in random_patch.attrs.__dict__:
-            if key != "history":
-                assert getattr(result.attrs, key) == getattr(random_patch.attrs, key)
+        # Check that attributes are preserved (except history)
+        assert (
+            result.attrs.model_dump(exclude={"history"})
+            == random_patch.attrs.model_dump(exclude={"history"})
+        )

702-716: Also assert replacements match broadcasted other Patch.

Strengthen the test by checking the False-mask against other after broadcasting.

Apply:

         for castable in [broadcastable_patch1, broadcastable_patch2]:
             result = random_patch.where(condition, other=castable)
             assert result.shape == random_patch.shape
             # Check that values where condition is True are preserved
             assert np.allclose(result.data[condition], random_patch.data[condition])
+            # And where False they equal broadcasted `other`
+            bcast_other = castable.make_broadcastable_to(random_patch)
+            false_mask = ~condition
+            assert np.allclose(result.data[false_mask], bcast_other.data[false_mask])
dascore/proc/basic.py (1)

653-669: Fix docstring example typos and clarity.

Minor syntax error and variable naming issue in the examples.

Apply:

-    >>> # Use another patch as condition
-    >>> other = patch.data.mean()).astype(bool)
-    >>> boolean_patch = patch.new(data=(patch.data > other))
+    >>> # Use another patch as condition
+    >>> threshold = patch.data.mean()
+    >>> boolean_patch = patch.new(data=(patch.data > threshold))
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 04cd9f8 and 674e36a.

📒 Files selected for processing (3)
  • dascore/core/patch.py (1 hunks)
  • dascore/proc/basic.py (2 hunks)
  • tests/test_proc/test_basic.py (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
dascore/proc/basic.py (2)
dascore/utils/patch.py (2)
  • align_patch_coords (820-870)
  • patch_function (180-286)
dascore/core/patch.py (2)
  • Patch (28-443)
  • data (237-239)
tests/test_proc/test_basic.py (2)
dascore/core/patch.py (6)
  • data (237-239)
  • shape (242-244)
  • coords (232-234)
  • dims (212-214)
  • attrs (227-229)
  • dtype (252-254)
dascore/proc/basic.py (1)
  • where (630-689)
dascore/core/patch.py (1)
dascore/proc/basic.py (1)
  • where (630-689)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (17)
  • GitHub Check: test_code (windows-latest, 3.11)
  • GitHub Check: test_code (windows-latest, 3.12)
  • GitHub Check: test_code (macos-latest, 3.13)
  • GitHub Check: test_code (ubuntu-latest, 3.13)
  • GitHub Check: test_code (windows-latest, 3.10)
  • GitHub Check: test_code (windows-latest, 3.13)
  • GitHub Check: test_code (macos-latest, 3.12)
  • GitHub Check: test_code (macos-latest, 3.10)
  • GitHub Check: test_code (ubuntu-latest, 3.12)
  • GitHub Check: test_code (ubuntu-latest, 3.11)
  • GitHub Check: test_code (ubuntu-latest, 3.10)
  • GitHub Check: test_code_min_deps (windows-latest, 3.12)
  • GitHub Check: test_code_min_deps (windows-latest, 3.13)
  • GitHub Check: test_code_min_deps (ubuntu-latest, 3.12)
  • GitHub Check: test_code_min_deps (ubuntu-latest, 3.13)
  • GitHub Check: test_code_min_deps (macos-latest, 3.13)
  • GitHub Check: test_code_min_deps (macos-latest, 3.12)
🔇 Additional comments (2)
dascore/proc/basic.py (1)

22-25: Import of align_patch_coords is appropriate.

dascore/core/patch.py (1)

358-358: Expose Patch.where — package-level re-export confirmed.
dascore/proc/init.py does from .basic import * and dascore/proc/basic.py defines def where(...) (around line 630), so the where = dascore.proc.where alias is valid.

Comment on lines 671 to 689
def _get_array(possible_array):
"""Get array from patch or array."""
if isinstance(possible_array, dc.Patch):
_, aligned = align_patch_coords(patch, possible_array)
out = aligned.data
else:
out = possible_array
return np.asarray(out)

cond_array = _get_array(cond)
# Ensure condition is boolean
if not np.issubdtype(cond_array.dtype, np.bool_):
msg = "Condition must be a boolean array or patch with boolean data"
raise ValueError(msg)

other_array = _get_array(other)
# Use numpy.where to apply condition
new_data = np.where(cond_array, patch.data, other_array)
return patch.new(data=new_data)
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

❓ Verification inconclusive

Alignment bug: ignoring the aligned base patch can mis-shape arrays.

When cond or other is a Patch with different/partially overlapping coords, align_patch_coords may change the base Patch’s shape (union). Current code discards the aligned base, risking shape mismatch in np.where.

Apply:

 @patch_function()
 def where(
     patch: PatchType, cond: ArrayLike | PatchType, other: Any | PatchType = np.nan
 ) -> PatchType:
@@
-    def _get_array(possible_array):
-        """Get array from patch or array."""
-        if isinstance(possible_array, dc.Patch):
-            _, aligned = align_patch_coords(patch, possible_array)
-            out = aligned.data
-        else:
-            out = possible_array
-        return np.asarray(out)
-
-    cond_array = _get_array(cond)
+    base = patch
+
+    def _as_array(x):
+        return np.asarray(x)
+
+    # Align cond to base if it's a Patch
+    cond_is_patch = isinstance(cond, dc.Patch)
+    other_is_patch = isinstance(other, dc.Patch)
+
+    if cond_is_patch:
+        base, cond = align_patch_coords(base, cond)
+        cond_array = cond.data
+    else:
+        cond_array = _as_array(cond)
@@
-    other_array = _get_array(other)
+    # Align other to (possibly updated) base if it's a Patch
+    if other_is_patch:
+        base, other = align_patch_coords(base, other)
+        other_array = other.data
+    else:
+        other_array = _as_array(other)
+
+    # If base changed after aligning `other`, re-align cond if it was a Patch.
+    if cond_is_patch and cond_array.shape != base.data.shape:
+        base, cond = align_patch_coords(base, cond)
+        cond_array = cond.data
@@
-    new_data = np.where(cond_array, patch.data, other_array)
-    return patch.new(data=new_data)
+    new_data = np.where(cond_array, base.data, other_array)
+    return base.new(data=new_data)

Add a test where cond is a boolean Patch with a shifted coord (e.g., time offset by one step) to ensure shapes/coords are union-aligned and where succeeds.


Alignment bug — update base when aligning cond/other Patches

align_patch_coords can return an updated base with unioned coords; the current code discards that base, which can cause shape mismatches and np.where errors.

 @patch_function()
 def where(
     patch: PatchType, cond: ArrayLike | PatchType, other: Any | PatchType = np.nan
 ) -> PatchType:
@@
-    def _get_array(possible_array):
-        """Get array from patch or array."""
-        if isinstance(possible_array, dc.Patch):
-            _, aligned = align_patch_coords(patch, possible_array)
-            out = aligned.data
-        else:
-            out = possible_array
-        return np.asarray(out)
-
-    cond_array = _get_array(cond)
+    base = patch
+
+    def _as_array(x):
+        return np.asarray(x)
+
+    # Align cond to base if it's a Patch
+    cond_is_patch = isinstance(cond, dc.Patch)
+    other_is_patch = isinstance(other, dc.Patch)
+
+    if cond_is_patch:
+        base, cond = align_patch_coords(base, cond)
+        cond_array = cond.data
+    else:
+        cond_array = _as_array(cond)
@@
-    other_array = _get_array(other)
+    # Align other to (possibly updated) base if it's a Patch
+    if other_is_patch:
+        base, other = align_patch_coords(base, other)
+        other_array = other.data
+    else:
+        other_array = _as_array(other)
+
+    # If base changed after aligning `other`, re-align cond if it was a Patch.
+    if cond_is_patch and cond_array.shape != base.data.shape:
+        base, cond = align_patch_coords(base, cond)
+        cond_array = cond.data
@@
-    new_data = np.where(cond_array, patch.data, other_array)
-    return patch.new(data=new_data)
+    new_data = np.where(cond_array, base.data, other_array)
+    return base.new(data=new_data)

Add a test where cond is a boolean Patch with a shifted coord (e.g., time offset by one step) to ensure union-alignment and that where succeeds.

📝 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 _get_array(possible_array):
"""Get array from patch or array."""
if isinstance(possible_array, dc.Patch):
_, aligned = align_patch_coords(patch, possible_array)
out = aligned.data
else:
out = possible_array
return np.asarray(out)
cond_array = _get_array(cond)
# Ensure condition is boolean
if not np.issubdtype(cond_array.dtype, np.bool_):
msg = "Condition must be a boolean array or patch with boolean data"
raise ValueError(msg)
other_array = _get_array(other)
# Use numpy.where to apply condition
new_data = np.where(cond_array, patch.data, other_array)
return patch.new(data=new_data)
base = patch
def _as_array(x):
return np.asarray(x)
# Align cond to base if it's a Patch
cond_is_patch = isinstance(cond, dc.Patch)
other_is_patch = isinstance(other, dc.Patch)
if cond_is_patch:
base, cond = align_patch_coords(base, cond)
cond_array = cond.data
else:
cond_array = _as_array(cond)
# Ensure condition is boolean
if not np.issubdtype(cond_array.dtype, np.bool_):
msg = "Condition must be a boolean array or patch with boolean data"
raise ValueError(msg)
# Align other to (possibly updated) base if it's a Patch
if other_is_patch:
base, other = align_patch_coords(base, other)
other_array = other.data
else:
other_array = _as_array(other)
# If base changed after aligning `other`, re-align cond if it was a Patch.
if cond_is_patch and cond_array.shape != base.data.shape:
base, cond = align_patch_coords(base, cond)
cond_array = cond.data
# Use numpy.where to apply condition
new_data = np.where(cond_array, base.data, other_array)
return base.new(data=new_data)

@ahmadtourei
Copy link
Collaborator

Cool! it's very useful!

@codecov
Copy link

codecov bot commented Sep 22, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.92%. Comparing base (603cff8) to head (26f520f).
⚠️ Report is 2 commits behind head on master.

Additional details and impacted files
@@           Coverage Diff           @@
##           master     #550   +/-   ##
=======================================
  Coverage   99.92%   99.92%           
=======================================
  Files         126      126           
  Lines       10455    10472   +17     
=======================================
+ Hits        10447    10464   +17     
  Misses          8        8           
Flag Coverage Δ
unittests 99.92% <100.00%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@d-chambers d-chambers merged commit b115a76 into master Sep 23, 2025
22 checks passed
@d-chambers d-chambers deleted the patch_where branch September 23, 2025 20:21
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.

3 participants