Skip to content

fix: treat unmasked nans as bad when the input is a masked array - #148

Open
matthiasschabel wants to merge 1 commit into
pyapp-kit:mainfrom
matthiasschabel:fix/masked-array-nan
Open

fix: treat unmasked nans as bad when the input is a masked array#148
matthiasschabel wants to merge 1 commit into
pyapp-kit:mainfrom
matthiasschabel:fix/masked-array-nan

Conversation

@matthiasschabel

Copy link
Copy Markdown
Contributor

src/cmap/_colormap.py:409 picks the mask or the nan test, never both:

mask_bad = x.mask if np.ma.is_masked(x) else np.isnan(xa)

So a masked array's unmasked nans are never found. Such a value survives to xa.astype(int), becomes INT_MIN, and lut.take(..., mode="clip") clips it to index 0. The nan renders as the colormap's first ramp color, silently, and the result looks like ordinary data.

Colormap("viridis", bad="red") applied to np.ma.masked_array([0.0, 0.25, nan, 1.0], mask=[True, False, False, False]):

index input rendered expected
0 0.0, masked red red
2 nan, not masked (0.267, 0.005, 0.329), viridis at 0.0 red

The same four values as a plain ndarray render the nan red, so the two input types disagree about identical data. The docs say bad is the color "when values are NaN or masked" (docs/faq.md), so this restores the documented behavior.

Both conditions are needed to hit it: np.ma.is_masked returns False for an all-false mask, so those arrays already take the nan path and are fine.

Two details in the fix worth flagging:

  • The nan test runs only when xa.dtype.kind == "f". A numeric object-dtype masked array works today precisely because it never reaches np.isnan, which raises on object dtype; the guard keeps that working.
  • The masks combine with |, not |=, so the caller's own mask array is not written to. The test asserts that.

matplotlib 3.11 has the same line in Colormap._get_rgba_and_mask, comment included, so this is a divergence. It seems worth taking: the current output is not a decision about masked arrays, it is an integer-cast overflow clipped to index 0, and it contradicts cmap's own documentation of bad.

🤖 Generated with Claude Code

The mask and the nan test were exclusive, so a masked array's unmasked
nans were never found. Such a value survived to the int cast, where it
became INT_MIN, and take(..., mode="clip") clipped it to index 0: nan
rendered as the colormap's first ramp color instead of `bad`, silently
and plausibly. The same values in a plain ndarray render correctly, so
the two input types disagreed about identical data.

`bad` is documented as the color for values that are "NaN or masked"
(docs/faq.md), so this restores the stated contract.

The nan test is applied only to float dtypes on the masked path,
keeping object-dtype masked arrays working, and the mask is combined
with `|` rather than `|=` so the caller's own mask is left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewed-By: Codex (gpt-5.6-sol, reasoning effort xhigh)
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.57%. Comparing base (8040ef7) to head (2cc7653).

Additional details and impacted files
@@           Coverage Diff           @@
##             main     #148   +/-   ##
=======================================
  Coverage   95.56%   95.57%           
=======================================
  Files         168      168           
  Lines        2186     2190    +4     
=======================================
+ Hits         2089     2093    +4     
  Misses         97       97           

☔ View full report in Codecov by Harness.
📢 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.

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