55from typing import TYPE_CHECKING , Any
66
77import numpy as np
8+ from pyproj import Proj
89
910# this caching module imports the geometries so this subset module
1011# must be imported inside functions in the geometry modules if needed
1112# to avoid circular dependencies
1213from pyresample ._caching import cache_to_json_if
1314from pyresample .boundary import Boundary
14- from pyresample .geometry import get_geostationary_bounding_box_in_lonlats , logger
15+ from pyresample .geometry import (
16+ DEFAULT_AREA_SLICE_SAMPLE_STEPS ,
17+ get_geostationary_bounding_box_in_lonlats ,
18+ logger ,
19+ )
1520from pyresample .utils import check_slice_orientation
1621
1722if TYPE_CHECKING :
1823 from pyresample import AreaDefinition
1924
2025
26+ MAX_POINTS_PER_CHUNK = 600_000
27+
28+
2129@cache_to_json_if ("cache_geometry_slices" )
2230def get_area_slices (
2331 src_area : AreaDefinition ,
2432 area_to_cover : AreaDefinition ,
2533 shape_divisible_by : int | None ,
34+ sample_steps : int | None = DEFAULT_AREA_SLICE_SAMPLE_STEPS ,
35+ sample_grid : bool = False ,
2636) -> tuple [slice , slice ]:
27- """Compute the slice to read based on an `area_to_cover`."""
37+ """Compute the slice to read based on an `area_to_cover`.
38+
39+ For geostationary source areas in cross-projection mode:
40+ - ``sample_steps`` >= 2 and ``sample_grid=False`` samples edge points only.
41+ - ``sample_steps`` >= 2 and ``sample_grid=True`` samples an interior grid.
42+ - ``sample_steps`` <= 0 or ``None`` and ``sample_grid=False`` samples all edge points.
43+ - ``sample_steps`` <= 0 or ``None`` and ``sample_grid=True`` samples all destination points.
44+ - ``sample_steps`` == 1 raises ``ValueError``.
45+ """
2846 if not _is_area_like (src_area ):
2947 raise NotImplementedError (f"Only AreaDefinitions are supported, not { type (src_area )} " )
3048 if not _is_area_like (area_to_cover ):
3149 raise NotImplementedError (f"Only AreaDefinitions are supported, not { type (area_to_cover )} " )
3250
33- # Intersection only required for two different projections
34- proj_def_to_cover = area_to_cover .crs
35- proj_def = src_area .crs
36- if proj_def_to_cover == proj_def :
51+ normalized_sample_steps = _normalize_sample_steps (sample_steps )
52+
53+ # Intersection is only required for two different projections.
54+ src_crs_wkt = getattr (src_area , "crs_wkt" , None )
55+ dst_crs_wkt = getattr (area_to_cover , "crs_wkt" , None )
56+ if src_crs_wkt is not None and src_crs_wkt == dst_crs_wkt :
57+ proj_def_to_cover = src_crs_wkt
58+ else :
59+ proj_def_to_cover = area_to_cover .crs
60+ if proj_def_to_cover != src_area .crs :
61+ proj_def_to_cover = None
62+ if proj_def_to_cover is not None :
3763 logger .debug ('Projections for data and slice areas are identical: %s' ,
3864 proj_def_to_cover )
3965 # Get slice parameters
@@ -45,6 +71,16 @@ def get_area_slices(
4571 y_slice = _ensure_integer_slice (y_slice )
4672 return x_slice , y_slice
4773
74+ if src_area .is_geostationary :
75+ coverage_slices = _get_covered_source_slices (
76+ src_area ,
77+ area_to_cover ,
78+ sample_steps = normalized_sample_steps ,
79+ sample_grid = sample_grid ,
80+ )
81+ if coverage_slices is not None :
82+ return _finalize_slices (src_area , coverage_slices [0 ], coverage_slices [1 ], shape_divisible_by )
83+
4884 data_boundary = _get_area_boundary (src_area )
4985 area_boundary = _get_area_boundary (area_to_cover )
5086 intersection = data_boundary .contour_poly .intersection (
@@ -57,6 +93,33 @@ def get_area_slices(
5793 np .rad2deg (intersection .lon ), np .rad2deg (intersection .lat ))
5894 x_slice = slice (np .ma .min (x ), np .ma .max (x ) + 1 )
5995 y_slice = slice (np .ma .min (y ), np .ma .max (y ) + 1 )
96+
97+ return _finalize_slices (src_area , x_slice , y_slice , shape_divisible_by )
98+
99+
100+ def _normalize_sample_steps (sample_steps : int | None ):
101+ """Normalize sampling config to sampled mode or ALL-mode fallback.
102+
103+ ``None`` and values ``<= 0`` map to ``ALL`` mode, whose exact behavior is
104+ determined later by ``sample_grid``.
105+ A value of ``1`` is rejected because it does not provide meaningful sampled
106+ coverage for edge or grid modes.
107+ """
108+ if sample_steps is None :
109+ return None
110+ try :
111+ normalized_sample_steps = int (sample_steps )
112+ except (TypeError , ValueError ) as err :
113+ raise ValueError (f"sample_steps must be an integer or None, got { sample_steps !r} " ) from err
114+ if normalized_sample_steps <= 0 :
115+ return None
116+ if normalized_sample_steps == 1 :
117+ raise ValueError ("sample_steps=1 is not supported; use <= 0/None for ALL mode or >= 2 for sampled modes." )
118+ return normalized_sample_steps
119+
120+
121+ def _finalize_slices (src_area : AreaDefinition , x_slice : slice , y_slice : slice , shape_divisible_by : int | None ):
122+ """Normalize slice bounds and apply orientation/divisibility rules."""
60123 x_slice = _ensure_integer_slice (x_slice )
61124 y_slice = _ensure_integer_slice (y_slice )
62125 if shape_divisible_by is not None :
@@ -105,6 +168,137 @@ def _get_area_boundary(area_to_cover: AreaDefinition) -> Boundary:
105168 raise NotImplementedError ("Can't determine boundary of area to cover" ) from err
106169
107170
171+ def _get_covered_source_slices (
172+ src_area : AreaDefinition ,
173+ area_to_cover : AreaDefinition ,
174+ sample_steps : int | None ,
175+ sample_grid : bool ,
176+ ):
177+ """Estimate covering source slices from sampled destination points.
178+
179+ Returns ``None`` when sampled points do not produce any valid source
180+ coverage, allowing the caller to fall back to boundary intersection.
181+ """
182+ min_col = None
183+ max_col = None
184+ min_row = None
185+ max_row = None
186+ try :
187+ src_proj = Proj (src_area .crs )
188+ for destination_lons , destination_lats in _iter_destination_lonlat_samples (
189+ area_to_cover = area_to_cover ,
190+ sample_steps = sample_steps ,
191+ sample_grid = sample_grid ,
192+ ):
193+ source_xs , source_ys = src_proj (
194+ destination_lons ,
195+ destination_lats ,
196+ )
197+ source_cols , source_rows = src_area .get_array_indices_from_projection_coordinates (
198+ source_xs ,
199+ source_ys ,
200+ )
201+ valid = ~ np .ma .getmaskarray (source_cols ) & ~ np .ma .getmaskarray (source_rows )
202+ if not valid .any ():
203+ continue
204+ chunk_cols = np .ma .getdata (source_cols )[valid ]
205+ chunk_rows = np .ma .getdata (source_rows )[valid ]
206+ chunk_min_col = int (chunk_cols .min ())
207+ chunk_max_col = int (chunk_cols .max ())
208+ chunk_min_row = int (chunk_rows .min ())
209+ chunk_max_row = int (chunk_rows .max ())
210+ min_col = chunk_min_col if min_col is None else min (min_col , chunk_min_col )
211+ max_col = chunk_max_col if max_col is None else max (max_col , chunk_max_col )
212+ min_row = chunk_min_row if min_row is None else min (min_row , chunk_min_row )
213+ max_row = chunk_max_row if max_row is None else max (max_row , chunk_max_row )
214+ except (RuntimeError , TypeError , ValueError ):
215+ logger .debug ("Failed to estimate covered source slices from sampled destination points." , exc_info = True )
216+ return None
217+ if min_col is None or min_row is None or max_col is None or max_row is None :
218+ return None
219+ col_start = max (0 , min_col )
220+ col_stop = min (src_area .width , max_col + 1 )
221+ row_start = max (0 , min_row )
222+ row_stop = min (src_area .height , max_row + 1 )
223+ if col_start >= col_stop or row_start >= row_stop :
224+ return None
225+ return slice (col_start , col_stop ), slice (row_start , row_stop )
226+
227+
228+ def _iter_destination_lonlat_samples (
229+ area_to_cover : AreaDefinition ,
230+ sample_steps : int | None ,
231+ sample_grid : bool ,
232+ ):
233+ """Yield destination lon/lat samples for ALL, grid, or edge mode."""
234+ if sample_steps is None :
235+ if sample_grid :
236+ yield from _iter_dense_lonlat_samples (area_to_cover )
237+ return
238+ yield _get_edge_lonlat_samples (area_to_cover , sample_steps = None )
239+ return
240+ if sample_grid :
241+ yield _get_grid_lonlat_samples (area_to_cover , sample_steps )
242+ return
243+ yield _get_edge_lonlat_samples (area_to_cover , sample_steps )
244+
245+
246+ def _iter_dense_lonlat_samples (area_to_cover : AreaDefinition ):
247+ """Yield full destination lon/lat coverage in row chunks."""
248+ row_block_size = max (1 , MAX_POINTS_PER_CHUNK // area_to_cover .width )
249+ for row_start in range (0 , area_to_cover .height , row_block_size ):
250+ row_stop = min (area_to_cover .height , row_start + row_block_size )
251+ yield area_to_cover .get_lonlats (
252+ data_slice = (slice (row_start , row_stop ), slice (None )),
253+ dtype = np .float32 ,
254+ )
255+
256+
257+ def _get_grid_lonlat_samples (area_to_cover : AreaDefinition , sample_steps : int ):
258+ """Return one evenly spaced interior destination sample grid."""
259+ sample_rows = _get_sample_indices (area_to_cover .height , sample_steps )
260+ sample_cols = _get_sample_indices (area_to_cover .width , sample_steps )
261+ return area_to_cover .get_lonlats (
262+ data_slice = (sample_rows [:, None ], sample_cols [None , :]),
263+ dtype = np .float32 ,
264+ )
265+
266+
267+ def _get_edge_lonlat_samples (area_to_cover : AreaDefinition , sample_steps : int | None ):
268+ """Return perimeter destination samples for edge-only sampling mode."""
269+ if area_to_cover .is_geostationary :
270+ # Use limb-aware geostationary boundary sampling instead of raw array
271+ # corners/edges. Projection corners may be off-earth and can under-cover.
272+ if sample_steps is None :
273+ nb_points = max (4 , area_to_cover .width * 2 + area_to_cover .height * 2 - 4 )
274+ else :
275+ nb_points = max (4 , sample_steps * 4 )
276+ return get_geostationary_bounding_box_in_lonlats (
277+ area_to_cover ,
278+ nb_points = nb_points ,
279+ )
280+ sample_rows = _get_sample_indices (area_to_cover .height , sample_steps )
281+ sample_cols = _get_sample_indices (area_to_cover .width , sample_steps )
282+ top_rows = np .zeros (sample_cols .size , dtype = np .int64 )
283+ top_cols = sample_cols
284+ right_rows = sample_rows [1 :]
285+ right_cols = np .full (right_rows .size , area_to_cover .width - 1 , dtype = np .int64 )
286+ bottom_cols = sample_cols [- 2 ::- 1 ]
287+ bottom_rows = np .full (bottom_cols .size , area_to_cover .height - 1 , dtype = np .int64 )
288+ left_rows = sample_rows [- 2 :0 :- 1 ]
289+ left_cols = np .zeros (left_rows .size , dtype = np .int64 )
290+ edge_rows = np .concatenate ((top_rows , right_rows , bottom_rows , left_rows ))
291+ edge_cols = np .concatenate ((top_cols , right_cols , bottom_cols , left_cols ))
292+ return area_to_cover .get_lonlats (data_slice = (edge_rows , edge_cols ), dtype = np .float32 )
293+
294+
295+ def _get_sample_indices (axis_size : int , sample_steps : int | None ):
296+ """Return evenly spaced integer sample indices including both endpoints."""
297+ if sample_steps is None or sample_steps >= axis_size :
298+ return np .arange (axis_size , dtype = np .int64 )
299+ return (np .arange (sample_steps , dtype = np .int64 ) * (axis_size - 1 )) // (sample_steps - 1 )
300+
301+
108302def _make_slice_divisible (sli , max_size , factor = 2 ):
109303 """Make the given slice even in size."""
110304 rem = (sli .stop - sli .start ) % factor
@@ -121,6 +315,7 @@ def _make_slice_divisible(sli, max_size, factor=2):
121315
122316
123317def _ensure_integer_slice (sli ):
318+ """Round slice bounds outward to conservatively preserve target coverage."""
124319 start = sli .start
125320 stop = sli .stop
126321 step = sli .step
0 commit comments