Skip to content

Commit 76bc86d

Browse files
committed
Distinguish explicit gpu=True vs auto-detected in fallback warning
Two Copilot review notes on PR #1678: 1. The fallback warning at to_geotiff(..., gpu=True) dispatch hard-coded "to_geotiff(gpu=True) fell back to CPU" even when GPU was reached through the auto-detect branch (gpu is None and the data is CuPy). That tells the caller their explicit flag was dropped when they never set one, pointing them at the wrong knob. Capture auto_detected_gpu = (gpu is None) before resolving use_gpu and pick the wording in a single helper so the ImportError and RuntimeError branches stay in lockstep. 2. The inline comment on the ImportError branch claimed "cupy or nvCOMP not installed" but write_geotiff_gpu only raises ImportError for missing cupy. nvCOMP unavailability is handled inside _try_nvcomp_from_device_bufs (returns None, triggering CPU compression internally), so it never reaches this except block. Reworded the comment to match the actual code path. Tests: - Updated existing explicit-gpu assertions to pin the new template. - Strengthened the auto-detect test with full text assertions. - Added test_auto_detected_gpu_runtime_error_falls_back_with_warning to confirm both branches use the same template under auto-detect. - Added test_explicit_gpu_false_then_true_uses_explicit_template to pin that template selection follows gpu is None, not use_gpu (so gpu=True on numpy data still gets the explicit wording). Narrowing logic and strict-mode (XRSPATIAL_GEOTIFF_STRICT) behaviour are unchanged.
1 parent 97666cc commit 76bc86d

2 files changed

Lines changed: 145 additions & 25 deletions

File tree

xrspatial/geotiff/__init__.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,30 @@ def _geotiff_strict_mode() -> bool:
103103
'XRSPATIAL_GEOTIFF_STRICT', '').lower() in ('1', 'true', 'yes')
104104

105105

106+
def _gpu_fallback_warning_message(auto_detected: bool, exc: BaseException) -> str:
107+
"""Build the ``to_geotiff`` GPU-to-CPU fallback warning text.
108+
109+
``to_geotiff`` reaches the GPU writer two ways: an explicit
110+
``gpu=True`` argument, or the auto-detect branch when ``gpu is
111+
None`` and the data lives on a CuPy device. The wording differs
112+
because blaming the fallback on a flag the caller never set sends
113+
them to fix the wrong thing. Both routes share the exception
114+
payload format so callers can grep ``type(e).__name__: e`` either
115+
way.
116+
"""
117+
suffix = f"({type(exc).__name__}: {exc})."
118+
if auto_detected:
119+
return (
120+
"Data is on the GPU and was routed to the GPU writer, but "
121+
"the writer is unavailable; falling back to CPU and copying "
122+
"the array to host. " + suffix
123+
)
124+
return (
125+
"to_geotiff(gpu=True) was requested but the GPU writer is "
126+
"unavailable; falling back to CPU. " + suffix
127+
)
128+
129+
106130
def _wkt_to_epsg(wkt_or_proj: str) -> int | None:
107131
"""Try to extract an EPSG code from a WKT or PROJ string.
108132
@@ -1132,7 +1156,11 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
11321156
max_z_error=max_z_error)
11331157
return
11341158

1135-
# Auto-detect GPU data and dispatch to write_geotiff_gpu
1159+
# Auto-detect GPU data and dispatch to write_geotiff_gpu. ``gpu is
1160+
# None`` is the implicit "use whatever fits the data" path; preserve
1161+
# that distinction in the fallback warning below so users who never
1162+
# set ``gpu=True`` are not told their explicit request was dropped.
1163+
auto_detected_gpu = gpu is None
11361164
use_gpu = gpu if gpu is not None else _is_gpu_data(data)
11371165
if use_gpu and _path_is_file_like:
11381166
# write_geotiff_gpu's nvCOMP path materialises tile parts and then
@@ -1171,15 +1199,19 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
11711199
streaming_buffer_bytes=streaming_buffer_bytes)
11721200
return
11731201
except ImportError as e:
1174-
# cupy or nvCOMP not installed. Fall back to the CPU writer
1175-
# with a typed warning so callers see that gpu=True (or
1176-
# auto-detected CuPy data) was not honoured. Strict mode
1177-
# re-raises so CI can fail loudly on missing GPU stacks.
1202+
# ``write_geotiff_gpu`` raises ImportError when cupy itself
1203+
# can't be imported. nvCOMP absence doesn't surface here:
1204+
# ``_try_nvcomp_from_device_bufs`` returns None when the
1205+
# library can't load, and the writer drops to CPU
1206+
# compression internally instead of re-raising. Fall back
1207+
# to the CPU writer with a typed warning so callers see
1208+
# that gpu=True (or auto-detected CuPy data) didn't go
1209+
# through. Strict mode re-raises so CI can fail loudly on
1210+
# missing GPU stacks.
11781211
if _geotiff_strict_mode():
11791212
raise
11801213
warnings.warn(
1181-
f"to_geotiff(gpu=True) fell back to CPU "
1182-
f"({type(e).__name__}: {e}).",
1214+
_gpu_fallback_warning_message(auto_detected_gpu, e),
11831215
GeoTIFFFallbackWarning,
11841216
stacklevel=2,
11851217
)
@@ -1201,8 +1233,7 @@ def to_geotiff(data: xr.DataArray | np.ndarray,
12011233
if _geotiff_strict_mode():
12021234
raise
12031235
warnings.warn(
1204-
f"to_geotiff(gpu=True) fell back to CPU "
1205-
f"({type(e).__name__}: {e}).",
1236+
_gpu_fallback_warning_message(auto_detected_gpu, e),
12061237
GeoTIFFFallbackWarning,
12071238
stacklevel=2,
12081239
)

xrspatial/geotiff/tests/test_to_geotiff_gpu_fallback_1674.py

Lines changed: 105 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,13 @@ def test_import_error_falls_back_with_warning(
122122
tmp_path, cpu_data, clear_strict_env, monkeypatch):
123123
"""``ImportError`` from the GPU writer triggers a CPU fallback.
124124
125-
The user asked for ``gpu=True`` on a system without cupy / nvCOMP.
126-
A ``GeoTIFFFallbackWarning`` makes the substitution visible.
125+
The user asked for ``gpu=True`` on a system without cupy. A
126+
``GeoTIFFFallbackWarning`` makes the substitution visible and the
127+
text names the explicit request so users know which knob to tune.
127128
"""
128129
from xrspatial.geotiff import to_geotiff
129130

130-
_patch_gpu_writer_to_raise(monkeypatch, ImportError("no nvCOMP"))
131+
_patch_gpu_writer_to_raise(monkeypatch, ImportError("no cupy"))
131132

132133
path = tmp_path / "fallback.tif"
133134
with warnings.catch_warnings(record=True) as records:
@@ -141,8 +142,11 @@ def test_import_error_falls_back_with_warning(
141142
]
142143
assert len(fallback_warnings) == 1
143144
msg = str(fallback_warnings[0].message)
145+
# Explicit gpu=True wording: blame the request, not the data.
146+
assert 'to_geotiff(gpu=True)' in msg
147+
assert 'Data is on the GPU' not in msg
144148
assert 'ImportError' in msg
145-
assert 'no nvCOMP' in msg
149+
assert 'no cupy' in msg
146150

147151

148152
def test_import_error_strict_mode_reraises(
@@ -195,7 +199,12 @@ def test_runtime_error_with_gpu_signal_falls_back(
195199
if issubclass(w.category, GeoTIFFFallbackWarning)
196200
]
197201
assert len(fallback_warnings) == 1
198-
assert 'RuntimeError' in str(fallback_warnings[0].message)
202+
text = str(fallback_warnings[0].message)
203+
assert 'RuntimeError' in text
204+
# Explicit gpu=True branch shares the same template as ImportError;
205+
# the auto-detected wording must never appear here.
206+
assert 'to_geotiff(gpu=True)' in text
207+
assert 'Data is on the GPU' not in text
199208

200209

201210
def test_runtime_error_with_gpu_signal_strict_reraises(
@@ -215,14 +224,31 @@ def test_runtime_error_with_gpu_signal_strict_reraises(
215224
# Auto-detected gpu (from CuPy data): same fallback semantics.
216225
# ---------------------------------------------------------------------------
217226

227+
def _make_synthetic_gpu_data():
228+
"""Return a numpy-backed DataArray that ``_is_gpu_data`` will be
229+
patched to treat as GPU-resident."""
230+
arr = np.arange(64, dtype=np.float32).reshape(8, 8)
231+
return xr.DataArray(
232+
arr,
233+
dims=('y', 'x'),
234+
coords={
235+
'y': np.arange(8, dtype=np.float64),
236+
'x': np.arange(8, dtype=np.float64),
237+
},
238+
attrs={'crs': 4326},
239+
)
240+
241+
218242
def test_auto_detected_gpu_fallback_warns(
219243
tmp_path, clear_strict_env, monkeypatch):
220244
"""When ``gpu`` is auto-detected from CuPy-backed data, the same
221245
fallback rules apply: ``ImportError`` triggers the warning.
222246
223247
Users whose data was CuPy-backed deserve a warning every time the
224248
GPU writer failed so they know their array was copied to host
225-
before the CPU writer wrote it.
249+
before the CPU writer wrote it. The warning text must blame the
250+
auto-detect path, not an ``gpu=True`` argument the caller never
251+
passed.
226252
"""
227253
from xrspatial.geotiff import to_geotiff
228254

@@ -234,16 +260,7 @@ def test_auto_detected_gpu_fallback_warns(
234260

235261
_patch_gpu_writer_to_raise(monkeypatch, ImportError("no cupy"))
236262

237-
arr = np.arange(64, dtype=np.float32).reshape(8, 8)
238-
da = xr.DataArray(
239-
arr,
240-
dims=('y', 'x'),
241-
coords={
242-
'y': np.arange(8, dtype=np.float64),
243-
'x': np.arange(8, dtype=np.float64),
244-
},
245-
attrs={'crs': 4326},
246-
)
263+
da = _make_synthetic_gpu_data()
247264

248265
path = tmp_path / "auto.tif"
249266
with warnings.catch_warnings(record=True) as records:
@@ -257,3 +274,75 @@ def test_auto_detected_gpu_fallback_warns(
257274
if issubclass(w.category, GeoTIFFFallbackWarning)
258275
]
259276
assert len(fallback_warnings) == 1
277+
text = str(fallback_warnings[0].message)
278+
# Auto-detected branch wording: blame the data, not gpu=True.
279+
assert 'Data is on the GPU' in text
280+
assert 'to_geotiff(gpu=True)' not in text
281+
assert 'ImportError' in text
282+
assert 'no cupy' in text
283+
284+
285+
def test_auto_detected_gpu_runtime_error_falls_back_with_warning(
286+
tmp_path, clear_strict_env, monkeypatch):
287+
"""Same shape for the ``RuntimeError`` branch under auto-detect.
288+
289+
Both fallback branches (ImportError, RuntimeError-with-GPU-signal)
290+
must use the same template so call sites do not diverge over time.
291+
"""
292+
from xrspatial.geotiff import to_geotiff
293+
from xrspatial import geotiff as g
294+
295+
monkeypatch.setattr(g, '_is_gpu_data', lambda data: True, raising=True)
296+
_patch_gpu_writer_to_raise(
297+
monkeypatch, RuntimeError("CUDA not available"))
298+
299+
da = _make_synthetic_gpu_data()
300+
301+
path = tmp_path / "auto_rt.tif"
302+
with warnings.catch_warnings(record=True) as records:
303+
warnings.simplefilter("always")
304+
to_geotiff(da, str(path))
305+
306+
assert path.exists()
307+
fallback_warnings = [
308+
w for w in records
309+
if issubclass(w.category, GeoTIFFFallbackWarning)
310+
]
311+
assert len(fallback_warnings) == 1
312+
text = str(fallback_warnings[0].message)
313+
assert 'Data is on the GPU' in text
314+
assert 'to_geotiff(gpu=True)' not in text
315+
assert 'RuntimeError' in text
316+
assert 'CUDA not available' in text
317+
318+
319+
def test_explicit_gpu_false_then_true_uses_explicit_template(
320+
tmp_path, cpu_data, clear_strict_env, monkeypatch):
321+
"""``gpu=True`` plus non-CuPy data must use the explicit template
322+
even when ``_is_gpu_data`` would return False on its own.
323+
324+
This pins down that the template is selected from ``gpu is None``,
325+
not from the resolved ``use_gpu`` value -- so passing ``gpu=True``
326+
on numpy data still attributes the fallback to the explicit flag.
327+
"""
328+
from xrspatial.geotiff import to_geotiff
329+
from xrspatial import geotiff as g
330+
331+
# Even if auto-detect would say "not GPU", the explicit request
332+
# should drive the wording.
333+
monkeypatch.setattr(g, '_is_gpu_data', lambda data: False, raising=True)
334+
_patch_gpu_writer_to_raise(monkeypatch, ImportError("no cupy"))
335+
336+
path = tmp_path / "explicit.tif"
337+
with warnings.catch_warnings(record=True) as records:
338+
warnings.simplefilter("always")
339+
to_geotiff(cpu_data, str(path), gpu=True)
340+
341+
fallback_warnings = [
342+
w for w in records
343+
if issubclass(w.category, GeoTIFFFallbackWarning)
344+
]
345+
assert len(fallback_warnings) == 1
346+
text = str(fallback_warnings[0].message)
347+
assert 'to_geotiff(gpu=True)' in text
348+
assert 'Data is on the GPU' not in text

0 commit comments

Comments
 (0)