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`` samples all destination points.
43+ - ``sample_steps`` == 1 raises ``ValueError``.
44+ """
2845 if not _is_area_like (src_area ):
2946 raise NotImplementedError (f"Only AreaDefinitions are supported, not { type (src_area )} " )
3047 if not _is_area_like (area_to_cover ):
3148 raise NotImplementedError (f"Only AreaDefinitions are supported, not { type (area_to_cover )} " )
3249
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 :
50+ normalized_sample_steps = _normalize_sample_steps (sample_steps )
51+
52+ # Intersection is only required for two different projections.
53+ src_crs_wkt = getattr (src_area , "crs_wkt" , None )
54+ dst_crs_wkt = getattr (area_to_cover , "crs_wkt" , None )
55+ if src_crs_wkt is not None and src_crs_wkt == dst_crs_wkt :
56+ proj_def_to_cover = src_crs_wkt
57+ else :
58+ proj_def_to_cover = area_to_cover .crs
59+ if proj_def_to_cover != src_area .crs :
60+ proj_def_to_cover = None
61+ if proj_def_to_cover is not None :
3762 logger .debug ('Projections for data and slice areas are identical: %s' ,
3863 proj_def_to_cover )
3964 # Get slice parameters
@@ -45,6 +70,16 @@ def get_area_slices(
4570 y_slice = _ensure_integer_slice (y_slice )
4671 return x_slice , y_slice
4772
73+ if src_area .is_geostationary :
74+ coverage_slices = _get_covered_source_slices (
75+ src_area ,
76+ area_to_cover ,
77+ sample_steps = normalized_sample_steps ,
78+ sample_grid = sample_grid ,
79+ )
80+ if coverage_slices is not None :
81+ return _finalize_slices (src_area , coverage_slices [0 ], coverage_slices [1 ], shape_divisible_by )
82+
4883 data_boundary = _get_area_boundary (src_area )
4984 area_boundary = _get_area_boundary (area_to_cover )
5085 intersection = data_boundary .contour_poly .intersection (
@@ -57,6 +92,32 @@ def get_area_slices(
5792 np .rad2deg (intersection .lon ), np .rad2deg (intersection .lat ))
5893 x_slice = slice (np .ma .min (x ), np .ma .max (x ) + 1 )
5994 y_slice = slice (np .ma .min (y ), np .ma .max (y ) + 1 )
95+
96+ return _finalize_slices (src_area , x_slice , y_slice , shape_divisible_by )
97+
98+
99+ def _normalize_sample_steps (sample_steps : int | None ):
100+ """Normalize sampling config to sampled mode or dense fallback.
101+
102+ ``None`` and values ``<= 0`` map to dense destination sampling.
103+ A value of ``1`` is rejected because it does not provide meaningful sampled
104+ coverage for edge or grid modes.
105+ """
106+ if sample_steps is None :
107+ return None
108+ try :
109+ normalized_sample_steps = int (sample_steps )
110+ except (TypeError , ValueError ) as err :
111+ raise ValueError (f"sample_steps must be an integer or None, got { sample_steps !r} " ) from err
112+ if normalized_sample_steps <= 0 :
113+ return None
114+ if normalized_sample_steps == 1 :
115+ raise ValueError ("sample_steps=1 is not supported; use <= 0/None for dense or >= 2 for sampled modes." )
116+ return normalized_sample_steps
117+
118+
119+ def _finalize_slices (src_area : AreaDefinition , x_slice : slice , y_slice : slice , shape_divisible_by : int | None ):
120+ """Normalize slice bounds and apply orientation/divisibility rules."""
60121 x_slice = _ensure_integer_slice (x_slice )
61122 y_slice = _ensure_integer_slice (y_slice )
62123 if shape_divisible_by is not None :
@@ -105,6 +166,130 @@ def _get_area_boundary(area_to_cover: AreaDefinition) -> Boundary:
105166 raise NotImplementedError ("Can't determine boundary of area to cover" ) from err
106167
107168
169+ def _get_covered_source_slices (
170+ src_area : AreaDefinition ,
171+ area_to_cover : AreaDefinition ,
172+ sample_steps : int | None ,
173+ sample_grid : bool ,
174+ ):
175+ """Estimate covering source slices from sampled destination points.
176+
177+ Returns ``None`` when sampled points do not produce any valid source
178+ coverage, allowing the caller to fall back to boundary intersection.
179+ """
180+ min_col = None
181+ max_col = None
182+ min_row = None
183+ max_row = None
184+ try :
185+ src_proj = Proj (src_area .crs )
186+ for destination_lons , destination_lats in _iter_destination_lonlat_samples (
187+ area_to_cover = area_to_cover ,
188+ sample_steps = sample_steps ,
189+ sample_grid = sample_grid ,
190+ ):
191+ source_xs , source_ys = src_proj (
192+ destination_lons ,
193+ destination_lats ,
194+ )
195+ source_cols , source_rows = src_area .get_array_indices_from_projection_coordinates (
196+ source_xs ,
197+ source_ys ,
198+ )
199+ valid = ~ np .ma .getmaskarray (source_cols ) & ~ np .ma .getmaskarray (source_rows )
200+ if not valid .any ():
201+ continue
202+ chunk_cols = np .ma .getdata (source_cols )[valid ]
203+ chunk_rows = np .ma .getdata (source_rows )[valid ]
204+ chunk_min_col = int (chunk_cols .min ())
205+ chunk_max_col = int (chunk_cols .max ())
206+ chunk_min_row = int (chunk_rows .min ())
207+ chunk_max_row = int (chunk_rows .max ())
208+ min_col = chunk_min_col if min_col is None else min (min_col , chunk_min_col )
209+ max_col = chunk_max_col if max_col is None else max (max_col , chunk_max_col )
210+ min_row = chunk_min_row if min_row is None else min (min_row , chunk_min_row )
211+ max_row = chunk_max_row if max_row is None else max (max_row , chunk_max_row )
212+ except (RuntimeError , TypeError , ValueError ):
213+ logger .debug ("Failed to estimate covered source slices from sampled destination points." , exc_info = True )
214+ return None
215+ if min_col is None or min_row is None or max_col is None or max_row is None :
216+ return None
217+ col_start = max (0 , min_col )
218+ col_stop = min (src_area .width , max_col + 1 )
219+ row_start = max (0 , min_row )
220+ row_stop = min (src_area .height , max_row + 1 )
221+ if col_start >= col_stop or row_start >= row_stop :
222+ return None
223+ return slice (col_start , col_stop ), slice (row_start , row_stop )
224+
225+
226+ def _iter_destination_lonlat_samples (
227+ area_to_cover : AreaDefinition ,
228+ sample_steps : int | None ,
229+ sample_grid : bool ,
230+ ):
231+ """Yield destination lon/lat samples for dense, grid, or edge mode."""
232+ if sample_steps is None :
233+ yield from _iter_dense_lonlat_samples (area_to_cover )
234+ return
235+ if sample_grid :
236+ yield _get_grid_lonlat_samples (area_to_cover , sample_steps )
237+ return
238+ yield _get_edge_lonlat_samples (area_to_cover , sample_steps )
239+
240+
241+ def _iter_dense_lonlat_samples (area_to_cover : AreaDefinition ):
242+ """Yield full destination lon/lat coverage in row chunks."""
243+ row_block_size = max (1 , MAX_POINTS_PER_CHUNK // area_to_cover .width )
244+ for row_start in range (0 , area_to_cover .height , row_block_size ):
245+ row_stop = min (area_to_cover .height , row_start + row_block_size )
246+ yield area_to_cover .get_lonlats (
247+ data_slice = (slice (row_start , row_stop ), slice (None )),
248+ dtype = np .float32 ,
249+ )
250+
251+
252+ def _get_grid_lonlat_samples (area_to_cover : AreaDefinition , sample_steps : int ):
253+ """Return one evenly spaced interior destination sample grid."""
254+ sample_rows = _get_sample_indices (area_to_cover .height , sample_steps )
255+ sample_cols = _get_sample_indices (area_to_cover .width , sample_steps )
256+ return area_to_cover .get_lonlats (
257+ data_slice = (sample_rows [:, None ], sample_cols [None , :]),
258+ dtype = np .float32 ,
259+ )
260+
261+
262+ def _get_edge_lonlat_samples (area_to_cover : AreaDefinition , sample_steps : int ):
263+ """Return perimeter destination samples for edge-only sampling mode."""
264+ if area_to_cover .is_geostationary :
265+ # Use limb-aware geostationary boundary sampling instead of raw array
266+ # corners/edges. Projection corners may be off-earth and can under-cover.
267+ return get_geostationary_bounding_box_in_lonlats (
268+ area_to_cover ,
269+ nb_points = max (4 , sample_steps * 4 ),
270+ )
271+ sample_rows = _get_sample_indices (area_to_cover .height , sample_steps )
272+ sample_cols = _get_sample_indices (area_to_cover .width , sample_steps )
273+ top_rows = np .zeros (sample_cols .size , dtype = np .int64 )
274+ top_cols = sample_cols
275+ right_rows = sample_rows [1 :]
276+ right_cols = np .full (right_rows .size , area_to_cover .width - 1 , dtype = np .int64 )
277+ bottom_cols = sample_cols [- 2 ::- 1 ]
278+ bottom_rows = np .full (bottom_cols .size , area_to_cover .height - 1 , dtype = np .int64 )
279+ left_rows = sample_rows [- 2 :0 :- 1 ]
280+ left_cols = np .zeros (left_rows .size , dtype = np .int64 )
281+ edge_rows = np .concatenate ((top_rows , right_rows , bottom_rows , left_rows ))
282+ edge_cols = np .concatenate ((top_cols , right_cols , bottom_cols , left_cols ))
283+ return area_to_cover .get_lonlats (data_slice = (edge_rows , edge_cols ), dtype = np .float32 )
284+
285+
286+ def _get_sample_indices (axis_size : int , sample_steps : int ):
287+ """Return evenly spaced integer sample indices including both endpoints."""
288+ if sample_steps >= axis_size :
289+ return np .arange (axis_size , dtype = np .int64 )
290+ return (np .arange (sample_steps , dtype = np .int64 ) * (axis_size - 1 )) // (sample_steps - 1 )
291+
292+
108293def _make_slice_divisible (sli , max_size , factor = 2 ):
109294 """Make the given slice even in size."""
110295 rem = (sli .stop - sli .start ) % factor
@@ -121,6 +306,7 @@ def _make_slice_divisible(sli, max_size, factor=2):
121306
122307
123308def _ensure_integer_slice (sli ):
309+ """Round slice bounds outward to conservatively preserve target coverage."""
124310 start = sli .start
125311 stop = sli .stop
126312 step = sli .step
0 commit comments