diff --git a/xrspatial/geotiff/_runtime.py b/xrspatial/geotiff/_runtime.py index 402558c8..01a893e7 100644 --- a/xrspatial/geotiff/_runtime.py +++ b/xrspatial/geotiff/_runtime.py @@ -62,6 +62,12 @@ _Y_DIM_NAMES = ('y', 'lat', 'latitude', 'row') _X_DIM_NAMES = ('x', 'lon', 'longitude', 'col') +# Temporal dim names. Used by the 3D writer validator (#1972) to refuse +# ``(y, x, )`` inputs that would otherwise be silently treated +# as multiband rasters. CF / xarray conventions cover ``time`` and ``t``; +# the rest match common upstream-pipeline aliases. +_TIME_DIM_NAMES = ('time', 't', 'date', 'datetime', 'times', 'dates') + class GeoTIFFFallbackWarning(UserWarning): """Warning emitted when a geotiff helper falls back to a slower path. diff --git a/xrspatial/geotiff/_validation.py b/xrspatial/geotiff/_validation.py index 790a3ea3..1dd34711 100644 --- a/xrspatial/geotiff/_validation.py +++ b/xrspatial/geotiff/_validation.py @@ -14,7 +14,18 @@ import numpy as np from ._coords import _BAND_DIM_NAMES -from ._runtime import _X_DIM_NAMES, _Y_DIM_NAMES +from ._runtime import _TIME_DIM_NAMES, _X_DIM_NAMES, _Y_DIM_NAMES + + +def _is_temporal_dim_name(name) -> bool: + """Return True if ``name`` is a known temporal dim alias. + + Compared case-insensitively against ``_TIME_DIM_NAMES`` so that + CF-style ``'TIME'`` / ``'Time'`` reach the friendly temporal error + in the 3D writer validator instead of slipping through the + ``(y, x, *)`` band-position fallback (#1972). + """ + return isinstance(name, str) and name.lower() in _TIME_DIM_NAMES def _validate_3d_writer_dims(dims) -> None: @@ -43,13 +54,41 @@ def _validate_3d_writer_dims(dims) -> None: and d2 in _BAND_DIM_NAMES) if band_layout or yxb_layout: return - # Bare (y, x, *) or (*, y, x) where the third dim is unnamed but - # spatial -- the writer's old behaviour treats the non-spatial axis - # as bands. Accept that only when the unknown dim is in the band - # position (last), which matches how raw numpy callers typically - # build a band-last array. + # Bare (y, x, *) where the third dim is unnamed but spatial -- the + # writer's old behaviour treats the non-spatial axis as bands. + # Accept that only when the unknown dim is in the band position + # (last), which matches how raw numpy callers typically build a + # band-last array. Refuse known *temporal* dim names so a + # ``(y, x, time)`` stack is rejected with a clear error instead of + # silently being written as a 3-band TIFF (issue #1972). The + # mirror case ``(time, y, x)`` was already caught -- this closes + # the asymmetry. if d0 in _Y_DIM_NAMES and d1 in _X_DIM_NAMES: + if _is_temporal_dim_name(d2): + raise ValueError( + f"3D writer input has temporal trailing dim {d2!r} in dims " + f"{dims!r}. The writer would otherwise treat the time axis " + f"as bands and silently write a multiband TIFF. Select a " + f"single time slice (e.g. ``data.isel({d2}=0)``), reduce " + f"with a stat (``data.mean({d2!r})``), or rename to one of " + f"{_BAND_DIM_NAMES} if you really intend the temporal " + f"axis to round-trip as TIFF bands (issue #1972)." + ) return + # Symmetrise the friendly temporal message for the leading-dim case + # ``(time, y, x)``. The generic ``ambiguous dims`` error below + # already rejects this layout, but the temporal-specific message + # tells the caller exactly how to fix it (#1972). + if _is_temporal_dim_name(d0) and d1 in _Y_DIM_NAMES and d2 in _X_DIM_NAMES: + raise ValueError( + f"3D writer input has temporal leading dim {d0!r} in dims " + f"{dims!r}. The writer would otherwise treat the time axis " + f"as bands and silently write a multiband TIFF. Select a " + f"single time slice (e.g. ``data.isel({d0}=0)``), reduce " + f"with a stat (``data.mean({d0!r})``), or rename to one of " + f"{_BAND_DIM_NAMES} if you really intend the temporal " + f"axis to round-trip as TIFF bands (issue #1972)." + ) raise ValueError( f"3D writer input has ambiguous dims {dims!r}. Expected " f"(band, y, x) or (y, x, band); accepted band-dim aliases are " diff --git a/xrspatial/geotiff/tests/test_temporal_3d_writer_rejection_1972.py b/xrspatial/geotiff/tests/test_temporal_3d_writer_rejection_1972.py new file mode 100644 index 00000000..5e3d2b76 --- /dev/null +++ b/xrspatial/geotiff/tests/test_temporal_3d_writer_rejection_1972.py @@ -0,0 +1,121 @@ +"""Refuse ``(y, x,