Skip to content

Commit 4c3e586

Browse files
committed
fix(imshow): surface facet_row limitations instead of failing silently
imshow with both facet_col and facet_row silently produced the wrong figure in two cases. On plotly < 6.7.0 (px.imshow has no facet_row), auto-assignment quietly dropped the slot, so a 4D array animated its fourth dimension instead of faceting subplot rows -- with no warning -- and a 5D array failed with an unrelated "Unassigned dimension(s)" error. The fallback now warns, naming the dimension and the plotly requirement, and the 5D case raises an error that explains the real cause. On any plotly version, px.imshow honours facet_col_wrap even when facet_row is set: the grid is built but the facet_row subplot titles are dropped. Every other px function ignores the wrap in that case, so drop it here too and warn. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TKhPQzJgQr7khmzDPYAXA9
1 parent c79ad91 commit 4c3e586

3 files changed

Lines changed: 125 additions & 17 deletions

File tree

‎tests/test_accessor.py‎

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -498,12 +498,73 @@ def test_imshow_auto_skips_facet_row_on_old_plotly(
498498
) -> None:
499499
"""Test that auto-assignment skips facet_row on old plotly (4th dim animates)."""
500500
monkeypatch.setattr(plotting, "_imshow_supports_facet_row", lambda: False)
501-
fig = self.da_4d.plotly.imshow()
501+
with pytest.warns(UserWarning, match=r"'year' is animated instead of faceted"):
502+
fig = self.da_4d.plotly.imshow()
502503
# year (4th dim) falls through to animation_frame instead of facet_row
503504
assert len(fig.frames) == 3
504505
# only the facet_col (scenario) produces subplots
505506
assert len(fig.data) == 2
506507

508+
def test_imshow_auto_facet_row_warning_mentions_plotly_version(
509+
self, monkeypatch: pytest.MonkeyPatch
510+
) -> None:
511+
"""Test that the fallback warning points at the plotly requirement."""
512+
monkeypatch.setattr(plotting, "_imshow_supports_facet_row", lambda: False)
513+
with pytest.warns(UserWarning, match=r"facet_row for imshow requires plotly>=6\.7\.0"):
514+
self.da_4d.plotly.imshow()
515+
516+
def test_imshow_no_animation_slot_left_on_old_plotly(
517+
self, monkeypatch: pytest.MonkeyPatch
518+
) -> None:
519+
"""Test the 5D case on old plotly: no slot left to absorb facet_row."""
520+
monkeypatch.setattr(plotting, "_imshow_supports_facet_row", lambda: False)
521+
da = xr.DataArray(
522+
np.random.rand(2, 2, 2, 2, 2),
523+
dims=["lat", "lon", "scenario", "year", "time"],
524+
)
525+
with pytest.raises(ValueError, match=r"already fills the animation slot"):
526+
da.plotly.imshow()
527+
528+
@requires_imshow_facet_row
529+
def test_imshow_facet_col_wrap_ignored_with_facet_row(self) -> None:
530+
"""Test that facet_col_wrap is dropped so facet_row titles survive."""
531+
with pytest.warns(UserWarning, match=r"facet_col_wrap is ignored"):
532+
fig = self.da_4d.plotly.imshow(facet_col_wrap=2)
533+
facet_titles = {a.text for a in fig.layout.annotations if "=" in (a.text or "")}
534+
assert facet_titles == {
535+
"scenario=low",
536+
"scenario=high",
537+
"year=2020",
538+
"year=2021",
539+
"year=2022",
540+
}
541+
542+
def test_imshow_facet_col_wrap_kept_without_facet_row(self) -> None:
543+
"""Test that facet_col_wrap still applies when there is no facet_row."""
544+
fig = self.da_3d.plotly.imshow(facet_col_wrap=2)
545+
assert len(fig.data) == 3
546+
# Wrapping at 2 columns stacks the 3 facets over two rows of subplots.
547+
domains = {tuple(fig.layout[k].domain) for k in fig.layout if k.startswith("yaxis")}
548+
assert len(domains) == 2
549+
550+
@requires_imshow_facet_row
551+
def test_imshow_facet_grid_places_data_in_right_subplot(self) -> None:
552+
"""Test that each (facet_col, facet_row) pair lands in its own subplot."""
553+
values = np.zeros((2, 3, 4, 5))
554+
for col in range(2):
555+
for row in range(3):
556+
values[col, row] = col * 10 + row
557+
da = xr.DataArray(
558+
values,
559+
dims=["scenario", "year", "lat", "lon"],
560+
coords={"scenario": ["low", "high"], "year": [2020, 2021, 2022]},
561+
)
562+
fig = da.plotly.imshow(x="lon", y="lat", facet_col="scenario", facet_row="year")
563+
assert len(fig.data) == 6
564+
# Every subplot holds exactly one constant value, and all six differ.
565+
constants = {float(np.unique(trace.z)[0]) for trace in fig.data}
566+
assert constants == {0.0, 1.0, 2.0, 10.0, 11.0, 12.0}
567+
507568

508569
class TestColorsParameter:
509570
"""Tests for the unified colors parameter."""

‎xarray_plotly/accessor.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -360,8 +360,10 @@ def imshow(
360360
y: Dimension for y-axis (rows). Default: first dimension.
361361
facet_col: Dimension for subplot columns. Default: third dimension.
362362
facet_row: Dimension for subplot rows. Default: fourth dimension.
363-
Requires plotly>=6.7.0; on older versions this slot is skipped
364-
during auto-assignment.
363+
Requires plotly>=6.7.0; on older versions an auto-assigned
364+
dimension animates instead (with a warning) and an explicitly
365+
named one raises `ValueError`. `facet_col_wrap` is ignored
366+
when `facet_row` is set.
365367
animation_frame: Dimension for animation. Default: fifth dimension.
366368
robust: If True, use 2nd/98th percentiles for color bounds (handles outliers).
367369
colors: Color scale name (e.g., "Viridis", "RdBu"). See module docs.

‎xarray_plotly/plotting.py‎

Lines changed: 59 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@
3131
)
3232

3333
if TYPE_CHECKING:
34+
from collections.abc import Hashable
35+
3436
import plotly.graph_objects as go
3537
from xarray import DataArray
3638

@@ -669,6 +671,45 @@ def _imshow_supports_facet_row() -> bool:
669671
return "facet_row" in inspect.signature(px.imshow).parameters
670672

671673

674+
def _handle_unsupported_facet_row(slots: dict[str, Hashable], *, explicit: bool) -> None:
675+
"""Resolve an imshow ``facet_row`` slot that the installed plotly cannot draw.
676+
677+
``px.imshow`` gained ``facet_row`` in plotly 6.7.0. On older versions an
678+
explicit request is an error, while an auto-assigned dimension falls back
679+
to animating (with a warning, so the missing subplot rows are not a
680+
silent surprise). If the animation slot is already taken there is nowhere
681+
to fall back to, so that case raises as well.
682+
683+
Args:
684+
slots: Slot assignment from :func:`assign_slots` (mutated in place).
685+
explicit: Whether the user named the ``facet_row`` dimension.
686+
"""
687+
import plotly
688+
689+
dim = slots["facet_row"]
690+
msg = f"facet_row for imshow requires plotly>=6.7.0 (installed: {plotly.__version__})."
691+
692+
if explicit:
693+
raise ValueError(msg)
694+
695+
if slots.get("animation_frame") is not None:
696+
msg = (
697+
f"{msg} Dimension {dim!r} cannot be faceted across subplot rows, and "
698+
f"{slots['animation_frame']!r} already fills the animation slot. "
699+
f"Upgrade plotly, or reduce a dimension with .sel(), .isel() or .mean()."
700+
)
701+
raise ValueError(msg)
702+
703+
warnings.warn(
704+
f"{msg} Dimension {dim!r} is animated instead of faceted across "
705+
f"subplot rows; upgrade plotly to facet it.",
706+
UserWarning,
707+
stacklevel=4,
708+
)
709+
slots["animation_frame"] = dim
710+
slots["facet_row"] = None
711+
712+
672713
def imshow(
673714
darray: DataArray,
674715
*,
@@ -709,10 +750,11 @@ def imshow(
709750
Dimension for subplot columns. Default: third dimension.
710751
facet_row
711752
Dimension for subplot rows. Default: fourth dimension.
712-
Requires plotly>=6.7.0; on older versions this slot is skipped
713-
during auto-assignment (the fourth dimension animates instead).
714-
Note: ``facet_col_wrap`` is ignored by plotly when ``facet_row``
715-
is set.
753+
Requires plotly>=6.7.0; on older versions an auto-assigned
754+
dimension animates instead and a ``UserWarning`` is emitted, while
755+
an explicitly named one raises ``ValueError``.
756+
Note: ``facet_col_wrap`` is ignored (with a warning) when
757+
``facet_row`` is set, matching the other plot types.
716758
animation_frame
717759
Dimension for animation. Default: fifth dimension.
718760
robust
@@ -736,11 +778,6 @@ def imshow(
736778
"""
737779
px_kwargs = resolve_colors(colors, px_kwargs)
738780

739-
# On plotly < 6.7.0, px.imshow has no facet_row: skip auto-assignment so
740-
# dimensions fall through to animation_frame instead.
741-
if facet_row is auto and not _imshow_supports_facet_row():
742-
facet_row = None
743-
744781
slots = assign_slots(
745782
list(darray.dims),
746783
"imshow",
@@ -751,14 +788,22 @@ def imshow(
751788
animation_frame=animation_frame,
752789
)
753790

791+
if slots.get("facet_row") is not None and not _imshow_supports_facet_row():
792+
_handle_unsupported_facet_row(slots, explicit=facet_row is not auto)
793+
754794
facet_row_kwargs: dict[str, Any] = {}
755795
if slots.get("facet_row") is not None:
756-
if not _imshow_supports_facet_row():
757-
import plotly
758-
759-
msg = f"facet_row for imshow requires plotly>=6.7.0 (installed: {plotly.__version__})."
760-
raise ValueError(msg)
761796
facet_row_kwargs["facet_row"] = slots["facet_row"]
797+
# px.imshow honours facet_col_wrap even when facet_row is set, which
798+
# builds the grid but silently drops the facet_row titles. Every other
799+
# px function ignores the wrap in that case; match them.
800+
if px_kwargs.pop("facet_col_wrap", None) is not None:
801+
warnings.warn(
802+
"facet_col_wrap is ignored when facet_row is set; "
803+
"px.imshow would otherwise drop the facet_row subplot titles.",
804+
UserWarning,
805+
stacklevel=3,
806+
)
762807

763808
# Transpose to: y (rows), x (cols), facet_col, facet_row, animation_frame
764809
transpose_order = [

0 commit comments

Comments
 (0)