From e203899ebd4d9e8b07a34a3fe356ea2e29485519 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Tue, 10 Oct 2023 23:05:14 +0200 Subject: [PATCH 01/39] Add refactor --- pyresample/future/geometry/area.py | 2 +- pyresample/geometry.py | 147 ++++++++++----------- pyresample/gradient/__init__.py | 5 +- pyresample/test/test_geometry/test_area.py | 11 +- pyresample/test/test_gradient.py | 2 +- 5 files changed, 80 insertions(+), 87 deletions(-) diff --git a/pyresample/future/geometry/area.py b/pyresample/future/geometry/area.py index d0ff7c85b..8433439e1 100644 --- a/pyresample/future/geometry/area.py +++ b/pyresample/future/geometry/area.py @@ -28,7 +28,7 @@ DynamicAreaDefinition, get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, - get_geostationary_bounding_box_in_lonlats, + _get_geostationary_bounding_box_in_lonlats, get_geostationary_bounding_box_in_proj_coords, ignore_pyproj_proj_warnings, ) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 7586a06d8..bd02932dc 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -63,6 +63,7 @@ HashType = hashlib._hashlib.HASH + class DimensionError(ValueError): """Wrap ValueError.""" @@ -279,6 +280,10 @@ def get_boundary_lonlats(self): return (SimpleBoundary(s1_lon.squeeze(), s2_lon.squeeze(), s3_lon.squeeze(), s4_lon.squeeze()), SimpleBoundary(s1_lat.squeeze(), s2_lat.squeeze(), s3_lat.squeeze(), s4_lat.squeeze())) + @property + def is_geostationary(self): + return False + def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockwise: bool = True, frequency: Optional[int] = None) -> tuple: """Return the bounding box lons and lats. @@ -286,7 +291,8 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw Args: vertices_per_side: The number of points to provide for each side. By default (None) - the full width and height will be provided. + the full width and height will be provided, except for geostationary + projections where by default only 50 points are selected. frequency: Deprecated, use vertices_per_side force_clockwise: @@ -318,14 +324,46 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lons, lats = self._get_bbox_elements(self.get_lonlats, vertices_per_side) + if self.is_geostationary: + lon_sides, lat_sides = self._get_geo_boundary_sides(vertices_per_side=vertices_per_side) + else: + lon_sides, lat_sides = self._get_boundary_sides(self.get_lonlats, vertices_per_side) if force_clockwise and not self._corner_is_clockwise( - lons[0][-2], lats[0][-2], lons[0][-1], lats[0][-1], lons[1][1], lats[1][1]): + lon_sides[0][-2], lat_sides[0][-2], lon_sides[0][-1], + lat_sides[0][-1], lon_sides[1][1], lat_sides[1][1]): # going counter-clockwise - lons, lats = self._reverse_boundaries(lons, lats) - return lons, lats + lon_sides, lats = self._reverse_boundaries(lon_sides, lat_sides) + return lon_sides, lat_sides + + + def _get_geo_boundary_sides(self, vertices_per_side=None): + """Retrieve the boundary sides list for geostationary projections.""" + # Define default frequency + if vertices_per_side is None: + vertices_per_side = 50 + # Ensure at least 4 points are used + if vertices_per_side < 4: + vertices_per_side = 4 + # Ensure an even number of vertices for side creation + if (vertices_per_side % 2) != 0: + vertices_per_side = vertices_per_side + 1 + lons, lats = _get_geostationary_bounding_box_in_lonlats(self, nb_points=vertices_per_side) + # Retrieve dummy sides for GEO (side1 and side3 always of length 2) + side02_step = int(vertices_per_side / 2) - 1 + lon_sides = [lons[slice(0, side02_step + 1)], + lons[slice(side02_step, side02_step + 1 + 1)], + lons[slice(side02_step + 1, side02_step * 2 + 1 + 1)], + np.append(lons[side02_step * 2 + 1], lons[0]) + ] + lat_sides = [lats[slice(0, side02_step + 1)], + lats[slice(side02_step, side02_step + 1 + 1)], + lats[slice(side02_step + 1, side02_step * 2 + 1 + 1)], + np.append(lats[side02_step * 2 + 1], lats[0]) + ] + return lon_sides, lat_sides + - def _get_bbox_elements(self, coord_fun, vertices_per_side: Optional[int] = None) -> tuple: + def _get_boundary_sides(self, coord_fun, vertices_per_side: Optional[int] = None) -> tuple: s1_slice, s2_slice, s3_slice, s4_slice = self._get_bbox_slices(vertices_per_side) s1_dim1, s1_dim2 = coord_fun(data_slice=s1_slice) s2_dim1, s2_dim2 = coord_fun(data_slice=s2_slice) @@ -337,10 +375,10 @@ def _get_bbox_elements(self, coord_fun, vertices_per_side: Optional[int] = None) (s4_dim1.squeeze(), s4_dim2.squeeze())]) if hasattr(dim1[0], 'compute') and da is not None: dim1, dim2 = da.compute(dim1, dim2) - clean_dim1, clean_dim2 = self._filter_bbox_nans(dim1, dim2) + clean_dim1, clean_dim2 = self._filter_sides_nans(dim1, dim2) return clean_dim1, clean_dim2 - def _filter_bbox_nans( + def _filter_sides_nans( self, dim1_sides: list[np.ndarray], dim2_sides: list[np.ndarray], @@ -423,17 +461,18 @@ def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[in warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - x, y = self._get_bbox_elements(self.get_proj_coords, vertices_per_side) + x, y = self._get_boundary_sides(self.get_proj_coords, vertices_per_side) return np.hstack(x), np.hstack(y) - def boundary(self, vertices_per_side=None, force_clockwise=False, frequency=None): + def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None): """Retrieve the AreaBoundary object. Parameters ---------- vertices_per_side: (formerly `frequency`) The number of points to provide for each side. By default (None) - the full width and height will be provided. + the full width and height will be provided, except for geostationary + projection where by default only 50 points are selected. force_clockwise: Perform minimal checks and reordering of coordinates to ensure that the returned coordinates follow a clockwise direction. @@ -1570,65 +1609,6 @@ def is_geostationary(self): return False return 'geostationary' in coord_operation.method_name.lower() - def _get_geo_boundary_sides(self, vertices_per_side=None): - """Retrieve the boundary sides list for geostationary projections.""" - # Define default frequency - if vertices_per_side is None: - vertices_per_side = 50 - # Ensure at least 4 points are used - if vertices_per_side < 4: - vertices_per_side = 4 - # Ensure an even number of vertices for side creation - if (vertices_per_side % 2) != 0: - vertices_per_side = vertices_per_side + 1 - lons, lats = get_geostationary_bounding_box_in_lonlats(self, nb_points=vertices_per_side) - # Retrieve dummy sides for GEO (side1 and side3 always of length 2) - side02_step = int(vertices_per_side / 2) - 1 - lon_sides = [lons[slice(0, side02_step + 1)], - lons[slice(side02_step, side02_step + 1 + 1)], - lons[slice(side02_step + 1, side02_step * 2 + 1 + 1)], - np.append(lons[side02_step * 2 + 1], lons[0]) - ] - lat_sides = [lats[slice(0, side02_step + 1)], - lats[slice(side02_step, side02_step + 1 + 1)], - lats[slice(side02_step + 1, side02_step * 2 + 1 + 1)], - np.append(lats[side02_step * 2 + 1], lats[0]) - ] - return lon_sides, lat_sides - - def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None): - """Retrieve the AreaBoundary object. - - Parameters - ---------- - vertices_per_side: - The number of points to provide for each side. By default (None) - the full width and height will be provided, except for geostationary - projection where by default only 50 points are selected. - frequency: - Deprecated, use vertices_per_side - force_clockwise: - Perform minimal checks and reordering of coordinates to ensure - that the returned coordinates follow a clockwise direction. - This is important for compatibility with - :class:`pyresample.spherical.SphPolygon` where operations depend - on knowing the inside versus the outside of a polygon. These - operations assume that coordinates are clockwise. - Default is False. - """ - from pyresample.boundary import AreaBoundary - if frequency is not None: - warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", - PendingDeprecationWarning, stacklevel=2) - vertices_per_side = vertices_per_side or frequency - if self.is_geostationary: - lon_sides, lat_sides = self._get_geo_boundary_sides(vertices_per_side=vertices_per_side) - else: - lon_sides, lat_sides = self.get_bbox_lonlats(vertices_per_side=vertices_per_side, - force_clockwise=force_clockwise) - boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) - return boundary - @property def area_extent(self): """Tuple of this area's extent (xmin, ymin, xmax, ymax).""" @@ -2743,9 +2723,10 @@ def geocentric_resolution(self, ellps='WGS84', radius=None): def _get_area_boundary(area_to_cover: AreaDefinition) -> Boundary: try: if area_to_cover.is_geostationary: - return Boundary(*get_geostationary_bounding_box_in_lonlats(area_to_cover)) - boundary_shape = max(max(*area_to_cover.shape) // 100 + 1, 3) - return area_to_cover.boundary(frequency=boundary_shape, force_clockwise=True) + vertices_per_side = None + else: + vertices_per_side = max(max(*area_to_cover.shape) // 100 + 1, 3) + return area_to_cover.boundary(vertices_per_side=vertices_per_side, force_clockwise=True) except ValueError: raise NotImplementedError("Can't determine boundary of area to cover") @@ -2836,7 +2817,7 @@ def get_full_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): return x, y -def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): +def _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): """Get the bbox in lon/lats of the valid pixels inside `geos_area`. Args: @@ -2847,16 +2828,28 @@ def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): return lons, lats +def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): + """Get the bbox in lon/lats of the valid pixels inside `geos_area`. + + Args: + nb_points: Number of points on the polygon + """ + warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " + "'area.get_bbox_lonlats' instead.", + DeprecationWarning, stacklevel=2) + return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) + + def get_geostationary_bounding_box(geos_area, nb_points=50): """Get the bbox in lon/lats of the valid pixels inside `geos_area`. Args: nb_points: Number of points on the polygon """ - warnings.warn("'get_geostationary_bounding_box' is deprecated. Please use " - "'get_geostationary_bounding_box_in_lonlats' instead.", + warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " + "'area.get_bbox_lonlats' instead.", DeprecationWarning, stacklevel=2) - return get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) + return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) def combine_area_extents_vertical(area1, area2): diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index f86ca49b8..7550747d7 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -39,7 +39,7 @@ from pyresample.geometry import ( AreaDefinition, SwathDefinition, - get_geostationary_bounding_box_in_lonlats, + _get_geostationary_bounding_box, ) from pyresample.gradient._gradient_search import ( one_step_gradient_indices, @@ -376,8 +376,9 @@ def _check_input_coordinates(dst_x, dst_y, def get_border_lonlats(geo_def: AreaDefinition): """Get the border x- and y-coordinates.""" if geo_def.is_geostationary: - lon_b, lat_b = get_geostationary_bounding_box_in_lonlats(geo_def, 3600) + lon_b, lat_b = geo_def.get_bbox_lonlats(3600) else: + # TODO: lon_b, lat_b = geo_def.get_bbox_lonlats() lons, lats = geo_def.get_boundary_lonlats() lon_b = np.concatenate((lons.side1, lons.side2, lons.side3, lons.side4)) lat_b = np.concatenate((lats.side1, lats.side2, lats.side3, lats.side4)) diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index 4ed0b283c..6773df509 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -29,7 +29,6 @@ from pyresample.future.geometry.area import ( get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, - get_geostationary_bounding_box_in_lonlats, get_geostationary_bounding_box_in_proj_coords, ignore_pyproj_proj_warnings, ) @@ -1493,7 +1492,7 @@ def test_get_full_geostationary_bbox(self, truncated_geos_area): def test_get_geostationary_bbox_works_with_truncated_area(self, truncated_geos_area): """Ensure the geostationary bbox works when truncated.""" - lon, lat = get_geostationary_bounding_box_in_lonlats(truncated_geos_area, 20) + lon, lat = truncated_geos_area.get_bbox_lonlats(20) expected_lon = np.array( [-64.24072434653284, -68.69662326361153, -65.92516214783112, -60.726360278290336, @@ -1524,13 +1523,13 @@ def test_get_geostationary_bbox_works_with_truncated_area_proj_coords(self, trun def test_get_geostationary_bbox_does_not_contain_inf(self, truncated_geos_area): """Ensure the geostationary bbox does not contain np.inf.""" - lon, lat = get_geostationary_bounding_box_in_lonlats(truncated_geos_area, 20) + lon, lat = truncated_geos_area.get_bbox_lonlats(20) assert not any(np.isinf(lon)) assert not any(np.isinf(lat)) def test_get_geostationary_bbox_returns_empty_lonlats_in_space(self, truncated_geos_area_in_space): """Ensure the geostationary bbox is empty when in space.""" - lon, lat = get_geostationary_bounding_box_in_lonlats(truncated_geos_area_in_space, 20) + lon, lat = truncated_geos_area_in_space.get_bbox_lonlats(20) assert len(lon) == 0 assert len(lat) == 0 @@ -1547,7 +1546,7 @@ def test_get_geostationary_bbox(self): geos_area.crs = CRS(proj_dict) geos_area.area_extent = [-5500000., -5500000., 5500000., 5500000.] - lon, lat = get_geostationary_bounding_box_in_lonlats(geos_area, 20) + lon, lat = geos_area.get_bbox_lonlats(20) expected_lon = np.array([-78.19662326, -75.42516215, -70.22636028, -56.89851775, 0., 56.89851775, 70.22636028, 75.42516215, 78.19662326, 79.23372832, 78.19662326, @@ -1572,7 +1571,7 @@ def test_get_geostationary_bbox(self): geos_area.crs = CRS(proj_dict) geos_area.area_extent = [-5500000., -5500000., 5500000., 5500000.] - lon, lat = get_geostationary_bounding_box_in_lonlats(geos_area, 20) + lon, lat = geos_area.get_bbox_lonlats(20) np.testing.assert_allclose(lon, expected_lon + lon_0) def test_get_geostationary_angle_extent(self): diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index a283e63d1..0a15ddb8a 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -607,7 +607,7 @@ def test_get_border_lonlats_geos(): from pyresample.gradient import get_border_lonlats geo_def = AreaDefinition("", "", "", "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) - with mock.patch("pyresample.gradient.get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: + with mock.patch("pyresample.gradient._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: get_geostationary_bounding_box.return_value = 1, 2 res = get_border_lonlats(geo_def) assert res == (1, 2) From 95decd312a4433505aa1c4bfa77344e4517bf4f1 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Tue, 10 Oct 2023 23:29:41 +0200 Subject: [PATCH 02/39] Fix wrong import --- pyresample/gradient/__init__.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 7550747d7..941f6659e 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -39,7 +39,7 @@ from pyresample.geometry import ( AreaDefinition, SwathDefinition, - _get_geostationary_bounding_box, + _get_geostationary_bounding_box_in_lonlats, ) from pyresample.gradient._gradient_search import ( one_step_gradient_indices, @@ -378,11 +378,7 @@ def get_border_lonlats(geo_def: AreaDefinition): if geo_def.is_geostationary: lon_b, lat_b = geo_def.get_bbox_lonlats(3600) else: - # TODO: lon_b, lat_b = geo_def.get_bbox_lonlats() - lons, lats = geo_def.get_boundary_lonlats() - lon_b = np.concatenate((lons.side1, lons.side2, lons.side3, lons.side4)) - lat_b = np.concatenate((lats.side1, lats.side2, lats.side3, lats.side4)) - + lon_b, lat_b = geo_def.get_bbox_lonlats() return lon_b, lat_b From ae730f73e5109036e002eacc7fa8e927d0fb5f37 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Tue, 10 Oct 2023 23:31:38 +0200 Subject: [PATCH 03/39] Lint --- pyresample/future/geometry/area.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyresample/future/geometry/area.py b/pyresample/future/geometry/area.py index 8433439e1..6042debae 100644 --- a/pyresample/future/geometry/area.py +++ b/pyresample/future/geometry/area.py @@ -26,9 +26,9 @@ from pyresample.geometry import AreaDefinition as LegacyAreaDefinition # noqa from pyresample.geometry import ( # noqa DynamicAreaDefinition, + _get_geostationary_bounding_box_in_lonlats, get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, - _get_geostationary_bounding_box_in_lonlats, get_geostationary_bounding_box_in_proj_coords, ignore_pyproj_proj_warnings, ) From edcfcb4057991fb733e9c53c43d486307bc2d6d2 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 11 Oct 2023 11:05:52 +0200 Subject: [PATCH 04/39] Fix typo --- pyresample/geometry.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index bd02932dc..02846de6b 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -329,10 +329,11 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw else: lon_sides, lat_sides = self._get_boundary_sides(self.get_lonlats, vertices_per_side) if force_clockwise and not self._corner_is_clockwise( - lon_sides[0][-2], lat_sides[0][-2], lon_sides[0][-1], - lat_sides[0][-1], lon_sides[1][1], lat_sides[1][1]): + lon_sides[0][-2], lat_sides[0][-2], + lon_sides[0][-1], lat_sides[0][-1], + lon_sides[1][1], lat_sides[1][1]): # going counter-clockwise - lon_sides, lats = self._reverse_boundaries(lon_sides, lat_sides) + lon_sides, lat_sides = self._reverse_boundaries(lon_sides, lat_sides) return lon_sides, lat_sides From eae187365ce745ceda746e3424a74ec892303741 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 11 Oct 2023 12:10:07 +0200 Subject: [PATCH 05/39] Fix issues --- pyresample/test/test_geometry/test_area.py | 12 ++--- pyresample/test/test_gradient.py | 55 +++++++++++----------- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index 6773df509..e098fa22d 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -30,6 +30,7 @@ get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, get_geostationary_bounding_box_in_proj_coords, + _get_geostationary_bounding_box_in_lonlats, ignore_pyproj_proj_warnings, ) from pyresample.future.geometry.base import get_array_hashable @@ -1492,8 +1493,7 @@ def test_get_full_geostationary_bbox(self, truncated_geos_area): def test_get_geostationary_bbox_works_with_truncated_area(self, truncated_geos_area): """Ensure the geostationary bbox works when truncated.""" - lon, lat = truncated_geos_area.get_bbox_lonlats(20) - + lon, lat = _get_geostationary_bounding_box_in_lonlats(truncated_geos_area, 20) expected_lon = np.array( [-64.24072434653284, -68.69662326361153, -65.92516214783112, -60.726360278290336, -47.39851775032484, 9.500000000000018, 66.39851775032487, 79.72636027829033, @@ -1523,13 +1523,13 @@ def test_get_geostationary_bbox_works_with_truncated_area_proj_coords(self, trun def test_get_geostationary_bbox_does_not_contain_inf(self, truncated_geos_area): """Ensure the geostationary bbox does not contain np.inf.""" - lon, lat = truncated_geos_area.get_bbox_lonlats(20) + lon, lat = _get_geostationary_bounding_box_in_lonlats(truncated_geos_area, 20) assert not any(np.isinf(lon)) assert not any(np.isinf(lat)) def test_get_geostationary_bbox_returns_empty_lonlats_in_space(self, truncated_geos_area_in_space): """Ensure the geostationary bbox is empty when in space.""" - lon, lat = truncated_geos_area_in_space.get_bbox_lonlats(20) + lon, lat = _get_geostationary_bounding_box_in_lonlats(truncated_geos_area_in_space, 20) assert len(lon) == 0 assert len(lat) == 0 @@ -1546,7 +1546,7 @@ def test_get_geostationary_bbox(self): geos_area.crs = CRS(proj_dict) geos_area.area_extent = [-5500000., -5500000., 5500000., 5500000.] - lon, lat = geos_area.get_bbox_lonlats(20) + lon, lat = _get_geostationary_bounding_box_in_lonlats(geos_area, 20) expected_lon = np.array([-78.19662326, -75.42516215, -70.22636028, -56.89851775, 0., 56.89851775, 70.22636028, 75.42516215, 78.19662326, 79.23372832, 78.19662326, @@ -1571,7 +1571,7 @@ def test_get_geostationary_bbox(self): geos_area.crs = CRS(proj_dict) geos_area.area_extent = [-5500000., -5500000., 5500000., 5500000.] - lon, lat = geos_area.get_bbox_lonlats(20) + lon, lat = _get_geostationary_bounding_box_in_lonlats(geos_area, 20) np.testing.assert_allclose(lon, expected_lon + lon_0) def test_get_geostationary_angle_extent(self): diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index 0a15ddb8a..973ad73bc 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -602,33 +602,34 @@ def test_check_overlap(): assert check_overlap(poly1, poly2) is False -def test_get_border_lonlats_geos(): - """Test that correct methods are called in get_border_lonlats() with geos inputs.""" - from pyresample.gradient import get_border_lonlats - geo_def = AreaDefinition("", "", "", - "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) - with mock.patch("pyresample.gradient._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: - get_geostationary_bounding_box.return_value = 1, 2 - res = get_border_lonlats(geo_def) - assert res == (1, 2) - get_geostationary_bounding_box.assert_called_with(geo_def, 3600) - - -def test_get_border_lonlats(): - """Test that correct methods are called in get_border_lonlats().""" - from pyresample.boundary import SimpleBoundary - from pyresample.gradient import get_border_lonlats - lon_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), - side3=np.array([3]), side4=np.array([4])) - lat_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), - side3=np.array([3]), side4=np.array([4])) - geo_def = AreaDefinition("", "", "", - "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) - with mock.patch.object(geo_def, "get_boundary_lonlats") as get_boundary_lonlats: - get_boundary_lonlats.return_value = lon_sides, lat_sides - lon_b, lat_b = get_border_lonlats(geo_def) - assert np.all(lon_b == np.array([1, 2, 3, 4])) - assert np.all(lat_b == np.array([1, 2, 3, 4])) +# TODO: this not needed anymore I guess +# def test_get_border_lonlats_geos(): +# """Test that correct methods are called in get_border_lonlats() with geos inputs.""" +# from pyresample.gradient import get_border_lonlats +# geo_def = AreaDefinition("", "", "", +# "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) +# with mock.patch("pyresample.gradient._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: +# get_geostationary_bounding_box.return_value = 1, 2 +# res = get_border_lonlats(geo_def) +# assert res == (1, 2) +# get_geostationary_bounding_box.assert_called_with(geo_def, 3600) + + +# def test_get_border_lonlats(): +# """Test that correct methods are called in get_border_lonlats().""" +# from pyresample.boundary import SimpleBoundary +# from pyresample.gradient import get_border_lonlats +# lon_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), +# side3=np.array([3]), side4=np.array([4])) +# lat_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), +# side3=np.array([3]), side4=np.array([4])) +# geo_def = AreaDefinition("", "", "", +# "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) +# with mock.patch.object(geo_def, "get_boundary_lonlats") as get_boundary_lonlats: +# get_boundary_lonlats.return_value = lon_sides, lat_sides +# lon_b, lat_b = get_border_lonlats(geo_def) +# assert np.all(lon_b == np.array([1, 2, 3, 4])) +# assert np.all(lat_b == np.array([1, 2, 3, 4])) @mock.patch('pyresample.gradient.Polygon') From 1662d2db3fa02610a2e096e4f1177b55adeb508d Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 11 Oct 2023 12:10:29 +0200 Subject: [PATCH 06/39] Fix issues --- pyresample/test/test_geometry/test_area.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index e098fa22d..795250415 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -27,10 +27,10 @@ from pyresample import geo_filter, parse_area_file from pyresample.future.geometry import AreaDefinition, SwathDefinition from pyresample.future.geometry.area import ( + _get_geostationary_bounding_box_in_lonlats, get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, get_geostationary_bounding_box_in_proj_coords, - _get_geostationary_bounding_box_in_lonlats, ignore_pyproj_proj_warnings, ) from pyresample.future.geometry.base import get_array_hashable From 655fb72b4a9d320ef7969668ace6c2b33f40a933 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 11 Oct 2023 12:25:22 +0200 Subject: [PATCH 07/39] Fix issues --- pyresample/geometry.py | 5 +++-- pyresample/gradient/__init__.py | 7 +++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 02846de6b..e8a75b880 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -349,8 +349,10 @@ def _get_geo_boundary_sides(self, vertices_per_side=None): if (vertices_per_side % 2) != 0: vertices_per_side = vertices_per_side + 1 lons, lats = _get_geostationary_bounding_box_in_lonlats(self, nb_points=vertices_per_side) + # Retrieve dummy sides for GEO (side1 and side3 always of length 2) - side02_step = int(vertices_per_side / 2) - 1 + # - TODO: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! + side02_step = int(lons.shape[0] / 2) - 1 # lon_sides = [lons[slice(0, side02_step + 1)], lons[slice(side02_step, side02_step + 1 + 1)], lons[slice(side02_step + 1, side02_step * 2 + 1 + 1)], @@ -2814,7 +2816,6 @@ def get_full_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): y = -np.sin(points_around) * (y_max_angle - 0.0001) x *= h y *= h - return x, y diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 941f6659e..ea5824d7a 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -375,10 +375,13 @@ def _check_input_coordinates(dst_x, dst_y, def get_border_lonlats(geo_def: AreaDefinition): """Get the border x- and y-coordinates.""" + # TODO: we could use geo_def.boundary() if geo_def.is_geostationary: - lon_b, lat_b = geo_def.get_bbox_lonlats(3600) + lon_b, lat_b = _get_geostationary_bounding_box_in_lonlats(geo_def, 3600) else: - lon_b, lat_b = geo_def.get_bbox_lonlats() + lons, lats = geo_def.get_boundary_lonlats() + lon_b = np.concatenate((lons.side1, lons.side2, lons.side3, lons.side4)) + lat_b = np.concatenate((lats.side1, lats.side2, lats.side3, lats.side4)) return lon_b, lat_b From b7df9be705091d647c0cc87cbf1adf703159e5fa Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 11 Oct 2023 12:32:51 +0200 Subject: [PATCH 08/39] Improve doc --- pyresample/test/test_gradient.py | 56 +++++++++++++++----------------- 1 file changed, 27 insertions(+), 29 deletions(-) diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index 973ad73bc..14405b7f2 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -601,35 +601,33 @@ def test_check_overlap(): poly2 = Polygon(((5, 5), (6, 5), (6, 6), (5, 6))) assert check_overlap(poly1, poly2) is False - -# TODO: this not needed anymore I guess -# def test_get_border_lonlats_geos(): -# """Test that correct methods are called in get_border_lonlats() with geos inputs.""" -# from pyresample.gradient import get_border_lonlats -# geo_def = AreaDefinition("", "", "", -# "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) -# with mock.patch("pyresample.gradient._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: -# get_geostationary_bounding_box.return_value = 1, 2 -# res = get_border_lonlats(geo_def) -# assert res == (1, 2) -# get_geostationary_bounding_box.assert_called_with(geo_def, 3600) - - -# def test_get_border_lonlats(): -# """Test that correct methods are called in get_border_lonlats().""" -# from pyresample.boundary import SimpleBoundary -# from pyresample.gradient import get_border_lonlats -# lon_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), -# side3=np.array([3]), side4=np.array([4])) -# lat_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), -# side3=np.array([3]), side4=np.array([4])) -# geo_def = AreaDefinition("", "", "", -# "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) -# with mock.patch.object(geo_def, "get_boundary_lonlats") as get_boundary_lonlats: -# get_boundary_lonlats.return_value = lon_sides, lat_sides -# lon_b, lat_b = get_border_lonlats(geo_def) -# assert np.all(lon_b == np.array([1, 2, 3, 4])) -# assert np.all(lat_b == np.array([1, 2, 3, 4])) +def test_get_border_lonlats_geos(): + """Test that correct methods are called in get_border_lonlats() with geos inputs.""" + from pyresample.gradient import get_border_lonlats + geo_def = AreaDefinition("", "", "", + "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) + with mock.patch("pyresample.gradient._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: + get_geostationary_bounding_box.return_value = 1, 2 + res = get_border_lonlats(geo_def) + assert res == (1, 2) + get_geostationary_bounding_box.assert_called_with(geo_def, 3600) + + +def test_get_border_lonlats(): + """Test that correct methods are called in get_border_lonlats().""" + from pyresample.boundary import SimpleBoundary + from pyresample.gradient import get_border_lonlats + lon_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), + side3=np.array([3]), side4=np.array([4])) + lat_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), + side3=np.array([3]), side4=np.array([4])) + geo_def = AreaDefinition("", "", "", + "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) + with mock.patch.object(geo_def, "get_boundary_lonlats") as get_boundary_lonlats: + get_boundary_lonlats.return_value = lon_sides, lat_sides + lon_b, lat_b = get_border_lonlats(geo_def) + assert np.all(lon_b == np.array([1, 2, 3, 4])) + assert np.all(lat_b == np.array([1, 2, 3, 4])) @mock.patch('pyresample.gradient.Polygon') From a5322a659d7f19f9ec34ecefd05147270e164956 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 11 Oct 2023 12:33:40 +0200 Subject: [PATCH 09/39] Improve doc --- pyresample/geometry.py | 8 ++++---- pyresample/gradient/__init__.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index e8a75b880..e1c1c60cf 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -286,7 +286,7 @@ def is_geostationary(self): def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockwise: bool = True, frequency: Optional[int] = None) -> tuple: - """Return the bounding box lons and lats. + """Return the bounding box lons and lats sides. Args: vertices_per_side: @@ -2836,8 +2836,8 @@ def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): Args: nb_points: Number of points on the polygon """ - warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " - "'area.get_bbox_lonlats' instead.", + warnings.warn("'get_geostationary_bounding_box_in_lonlats' is deprecated. Please call " + "'area.boundary().contour()' instead.", DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) @@ -2849,7 +2849,7 @@ def get_geostationary_bounding_box(geos_area, nb_points=50): nb_points: Number of points on the polygon """ warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " - "'area.get_bbox_lonlats' instead.", + "'area.boundary().contour()' instead.", DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index ea5824d7a..36b8c5ec9 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -375,7 +375,7 @@ def _check_input_coordinates(dst_x, dst_y, def get_border_lonlats(geo_def: AreaDefinition): """Get the border x- and y-coordinates.""" - # TODO: we could use geo_def.boundary() + # TODO: we could use geo_def.boundary().contour() here if geo_def.is_geostationary: lon_b, lat_b = _get_geostationary_bounding_box_in_lonlats(geo_def, 3600) else: From 88065d78212f92d2560270163a34c6d6d1288e18 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 11 Oct 2023 13:13:24 +0200 Subject: [PATCH 10/39] Remove old use of frequency argument --- pyresample/geometry.py | 21 +++++++++++---------- pyresample/slicer.py | 4 ++-- pyresample/test/test_geometry/test_area.py | 10 +++++----- pyresample/test/test_geometry/test_swath.py | 4 ++-- 4 files changed, 20 insertions(+), 19 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index e1c1c60cf..3e0c772eb 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -326,16 +326,16 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw vertices_per_side = vertices_per_side or frequency if self.is_geostationary: lon_sides, lat_sides = self._get_geo_boundary_sides(vertices_per_side=vertices_per_side) - else: + else: lon_sides, lat_sides = self._get_boundary_sides(self.get_lonlats, vertices_per_side) if force_clockwise and not self._corner_is_clockwise( lon_sides[0][-2], lat_sides[0][-2], - lon_sides[0][-1], lat_sides[0][-1], + lon_sides[0][-1], lat_sides[0][-1], lon_sides[1][1], lat_sides[1][1]): # going counter-clockwise lon_sides, lat_sides = self._reverse_boundaries(lon_sides, lat_sides) return lon_sides, lat_sides - + def _get_geo_boundary_sides(self, vertices_per_side=None): """Retrieve the boundary sides list for geostationary projections.""" @@ -349,10 +349,11 @@ def _get_geo_boundary_sides(self, vertices_per_side=None): if (vertices_per_side % 2) != 0: vertices_per_side = vertices_per_side + 1 lons, lats = _get_geostationary_bounding_box_in_lonlats(self, nb_points=vertices_per_side) - + # Retrieve dummy sides for GEO (side1 and side3 always of length 2) - # - TODO: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! - side02_step = int(lons.shape[0] / 2) - 1 # + # - TODO: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! + # side02_step = int(vertices_per_side / 2) - 1 # old code + side02_step = int(lons.shape[0] / 2) - 1 lon_sides = [lons[slice(0, side02_step + 1)], lons[slice(side02_step, side02_step + 1 + 1)], lons[slice(side02_step + 1, side02_step * 2 + 1 + 1)], @@ -2727,7 +2728,7 @@ def _get_area_boundary(area_to_cover: AreaDefinition) -> Boundary: try: if area_to_cover.is_geostationary: vertices_per_side = None - else: + else: vertices_per_side = max(max(*area_to_cover.shape) // 100 + 1, 3) return area_to_cover.boundary(vertices_per_side=vertices_per_side, force_clockwise=True) except ValueError: @@ -2837,8 +2838,8 @@ def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): nb_points: Number of points on the polygon """ warnings.warn("'get_geostationary_bounding_box_in_lonlats' is deprecated. Please call " - "'area.boundary().contour()' instead.", - DeprecationWarning, stacklevel=2) + "'area.boundary().contour()' instead.", + DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) @@ -2849,7 +2850,7 @@ def get_geostationary_bounding_box(geos_area, nb_points=50): nb_points: Number of points on the polygon """ warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " - "'area.boundary().contour()' instead.", + "'area.boundary().contour()' instead.", DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 242f311c7..946d81fa1 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -152,7 +152,7 @@ class AreaSlicer(Slicer): def get_polygon_to_contain(self): """Get the shapely Polygon corresponding to *area_to_contain* in projection coordinates of *area_to_crop*.""" from shapely.geometry import Polygon - x, y = self.area_to_contain.get_edge_bbox_in_projection_coordinates(frequency=10) + x, y = self.area_to_contain.get_edge_bbox_in_projection_coordinates(vertices_per_side=10) if self.area_to_crop.is_geostationary: x_geos, y_geos = get_geostationary_bounding_box_in_proj_coords(self.area_to_crop, 360) x_geos, y_geos = self._transformer.transform(x_geos, y_geos, direction=TransformDirection.INVERSE) @@ -180,7 +180,7 @@ def get_slices_from_polygon(self, poly_to_contain): except ValueError as err: raise InvalidArea(str(err)) from shapely.geometry import Polygon - poly_to_crop = Polygon(zip(*self.area_to_crop.get_edge_bbox_in_projection_coordinates(frequency=10))) + poly_to_crop = Polygon(zip(*self.area_to_crop.get_edge_bbox_in_projection_coordinates(vertices_per_side=10))) if not poly_to_crop.intersects(buffered_poly): raise IncompatibleAreas("Areas not overlapping.") bounds = self._sanitize_polygon_bounds(bounds) diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index 795250415..a2785db46 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -1895,24 +1895,24 @@ def test_geostationary_projection(self, create_test_area): # Check default boundary shape default_n_vertices = 50 - boundary = areadef.boundary(frequency=None) + boundary = areadef.boundary(vertices_per_side=None) assert boundary.vertices.shape == (default_n_vertices, 2) # Check minimum boundary vertices n_vertices = 3 minimum_n_vertices = 4 - boundary = areadef.boundary(frequency=n_vertices) + boundary = areadef.boundary(vertices_per_side=n_vertices) assert boundary.vertices.shape == (minimum_n_vertices, 2) - # Check odd frequency number + # Check odd number of vertices per side # - Rounded to the sequent even number (to construct the sides) n_odd_vertices = 5 - boundary = areadef.boundary(frequency=n_odd_vertices) + boundary = areadef.boundary(vertices_per_side=n_odd_vertices) assert boundary.vertices.shape == (n_odd_vertices + 1, 2) # Check boundary vertices n_vertices = 10 - boundary = areadef.boundary(frequency=n_vertices, force_clockwise=False) + boundary = areadef.boundary(vertices_per_side=n_vertices, force_clockwise=False) # Check boundary vertices is in correct order expected_vertices = np.array([[-7.54251621e+01, 3.53432890e+01], diff --git a/pyresample/test/test_geometry/test_swath.py b/pyresample/test/test_geometry/test_swath.py index 47a4a81ed..68ca936b7 100644 --- a/pyresample/test/test_geometry/test_swath.py +++ b/pyresample/test/test_geometry/test_swath.py @@ -152,7 +152,7 @@ def test_swath_def_bbox( def test_swath_def_bbox_decimated(self, create_test_swath): swath_def = _gen_swath_def_numpy(create_test_swath) - bbox_lons, bbox_lats = swath_def.get_bbox_lonlats(frequency=None) + bbox_lons, bbox_lats = swath_def.get_bbox_lonlats(vertices_per_side=None) assert len(bbox_lons) == len(bbox_lats) assert len(bbox_lons) == 4 assert len(bbox_lons[0]) == 10 @@ -160,7 +160,7 @@ def test_swath_def_bbox_decimated(self, create_test_swath): assert len(bbox_lons[2]) == 10 assert len(bbox_lons[3]) == 50 - bbox_lons, bbox_lats = swath_def.get_bbox_lonlats(frequency=5) + bbox_lons, bbox_lats = swath_def.get_bbox_lonlats(vertices_per_side=5) assert len(bbox_lons) == len(bbox_lats) assert len(bbox_lons) == 4 assert len(bbox_lons[0]) == 5 From 8eff9b4bd359e6d65e99661a723e152d3e09927d Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 22 Nov 2023 14:42:32 +0100 Subject: [PATCH 11/39] Address refactor boundary sides --- pyresample/geometry.py | 96 +++++++++++++++++++++++++++++++----------- 1 file changed, 71 insertions(+), 25 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index bc69abae5..5402017f2 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -273,6 +273,10 @@ def get_lonlats_dask(self, chunks=None): chunks = CHUNK_SIZE # FUTURE: Use a global config object instead return self.get_lonlats(chunks=chunks) + def get_proj_coords(self, data_slice=None, chunks=None, **kwargs): + class_name = self.__class__.__name__ + raise NotImplementedError(f"get_proj_coords is not available for geometry {class_name}.") + def get_boundary_lonlats(self): """Return Boundary objects.""" s1_lon, s1_lat = self.get_lonlats(data_slice=(0, slice(None))) @@ -327,10 +331,8 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - if self.is_geostationary: - lon_sides, lat_sides = self._get_geo_boundary_sides(vertices_per_side=vertices_per_side) - else: - lon_sides, lat_sides = self._get_boundary_sides(self.get_lonlats, vertices_per_side) + lon_sides, lat_sides = self._get_boundary_sides(coordinates="geographic", + vertices_per_side=vertices_per_side) if force_clockwise and not self._corner_is_clockwise( lon_sides[0][-2], lat_sides[0][-2], lon_sides[0][-1], lat_sides[0][-1], @@ -339,7 +341,20 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw lon_sides, lat_sides = self._reverse_boundaries(lon_sides, lat_sides) return lon_sides, lat_sides - def _get_geo_boundary_sides(self, vertices_per_side=None): + def _get_geostationary_fd_coordinate_sides(self, arr, step): + """Retrieve a 'dummy' boundary side list for a geostationary area with boundaries out of the Earth disk. + + The second and fourth sides are always of length 2. + """ + sides = [ + arr[slice(0, step + 1)], + arr[slice(step, step + 2)], + arr[slice(step + 1, step * 2 + 2)], + np.append(arr[step * 2 + 1], arr[0]) + ] + return sides + + def _get_geostationary_boundary_sides(self, vertices_per_side=None): """Retrieve the boundary sides list for geostationary projections.""" # Define default frequency if vertices_per_side is None: @@ -353,24 +368,46 @@ def _get_geo_boundary_sides(self, vertices_per_side=None): lons, lats = _get_geostationary_bounding_box_in_lonlats(self, nb_points=vertices_per_side) # Retrieve dummy sides for GEO (side1 and side3 always of length 2) - # - TODO: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! - # side02_step = int(vertices_per_side / 2) - 1 # old code - side02_step = int(lons.shape[0] / 2) - 1 - lon_sides = [ - lons[slice(0, side02_step + 1)], - lons[slice(side02_step, side02_step + 1 + 1)], - lons[slice(side02_step + 1, side02_step * 2 + 1 + 1)], - np.append(lons[side02_step * 2 + 1], lons[0]) - ] - lat_sides = [ - lats[slice(0, side02_step + 1)], - lats[slice(side02_step, side02_step + 1 + 1)], - lats[slice(side02_step + 1, side02_step * 2 + 1 + 1)], - np.append(lats[side02_step * 2 + 1], lats[0]) - ] + # - BUG: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! + # step = int(vertices_per_side / 2) - 1 # old code + step = int(lons.shape[0] / 2) - 1 + lon_sides = self._get_geostationary_fd_coordinate_sides(lons, step=step) + lat_sides = self._get_geostationary_fd_coordinate_sides(lats, step=step) return lon_sides, lat_sides - def _get_boundary_sides(self, coord_fun, vertices_per_side: Optional[int] = None) -> tuple: + def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optional[int] = None) -> tuple: + """Return the boundary sides of the current area. + + Args: + coordinates: + The type of boundary coordinates to retrieve. + Either "geographic" or "projection". + Projection coordinates are available only for AreaDefinition objects. + vertices_per_side: + The number of points to provide for each side. By default (None) + the full width and height will be provided, except for geostationary + projections where by default only 50 points are selected. + + Returns: + The output structure is a tuple of two lists of four elements each. + The first list contains the longitude or the projection x coordinates. + The second list contains the latitude or the projection y coordinates. + Each list element is a numpy array representing a specific side of the geometry. + The order of the sides are [top", "right", "bottom", "left"] + """ + if coordinates not in ("geographic", "projection"): + raise ValueError(f"coordinates must be either 'geographic' or 'projection', got {coordinates}") + if self.is_geostationary: + if coordinates == "geographic": + return self._get_geostationary_boundary_sides(vertices_per_side) + # ELSE: + # NOT IMPLEMENTED --> Would change behaviour of get_edge_bbox_in_projection_coordinates + # Currently return the x,y coordinates of the full image border + if coordinates == "geographic": + coord_fun = self.get_lonlats + else: + coord_fun = self.get_proj_coords # AreaDefinition + s1_slice, s2_slice, s3_slice, s4_slice = self._get_bbox_slices(vertices_per_side) s1_dim1, s1_dim2 = coord_fun(data_slice=s1_slice) s2_dim1, s2_dim2 = coord_fun(data_slice=s2_slice) @@ -382,8 +419,8 @@ def _get_boundary_sides(self, coord_fun, vertices_per_side: Optional[int] = None (s4_dim1.squeeze(), s4_dim2.squeeze())]) if hasattr(dim1[0], 'compute') and da is not None: dim1, dim2 = da.compute(dim1, dim2) - clean_dim1, clean_dim2 = self._filter_sides_nans(dim1, dim2) - return clean_dim1, clean_dim2 + lon_sides, lat_sides = self._filter_sides_nans(dim1, dim2) + return lon_sides, lat_sides def _filter_sides_nans( self, @@ -401,6 +438,15 @@ def _filter_sides_nans( return new_dim1_sides, new_dim2_sides def _get_bbox_slices(self, vertices_per_side): + """Return the slices for the bounding box of the current area. + + Args: + vertices_per_side: + The number of points to provide for each side. + Results: + The output structure is a tuple of four slices, one for each side. + The order of the sides are [top", "right", "bottom", "left"] + """ height, width = self.shape if vertices_per_side is None: row_num = height @@ -1613,8 +1659,8 @@ def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[in warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - x, y = self._get_boundary_sides(self.get_proj_coords, vertices_per_side) - return np.hstack(x), np.hstack(y) + x_sides, y_sides = self._get_boundary_sides(coordinates="projection", vertices_per_side=vertices_per_side) + return np.hstack(x_sides), np.hstack(y_sides) @property def area_extent(self): From 36795dfd83fa37bba122722b377d685c6fd79201 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 22 Nov 2023 15:18:45 +0100 Subject: [PATCH 12/39] Fix tests related to gradient resampling --- pyresample/geometry.py | 3 ++- pyresample/gradient/__init__.py | 15 +++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 5402017f2..8d48b80d2 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -1659,7 +1659,8 @@ def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[in warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - x_sides, y_sides = self._get_boundary_sides(coordinates="projection", vertices_per_side=vertices_per_side) + x_sides, y_sides = self._get_boundary_sides(coordinates="projection", + vertices_per_side=vertices_per_side) return np.hstack(x_sides), np.hstack(y_sides) @property diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 048d29a9f..949913eac 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -107,7 +107,7 @@ def _get_projection_coordinates(self, datachunks): chunks=datachunks) src_crs = self.source_geo_def.crs self.use_input_coords = True - except AttributeError: + except NotImplementedError: self.src_x, self.src_y = self.source_geo_def.get_lonlats( chunks=datachunks) src_crs = pyproj.CRS.from_string("+proj=longlat") @@ -116,7 +116,7 @@ def _get_projection_coordinates(self, datachunks): self.dst_x, self.dst_y = self.target_geo_def.get_proj_coords( chunks=CHUNK_SIZE) dst_crs = self.target_geo_def.crs - except AttributeError as err: + except NotImplementedError as err: if self.use_input_coords is False: raise NotImplementedError('Cannot resample lon/lat to lon/lat with gradient search.') from err self.dst_x, self.dst_y = self.target_geo_def.get_lonlats( @@ -137,12 +137,11 @@ def _get_src_poly(self, src_y_start, src_y_end, src_x_start, src_x_end): """Get bounding polygon for source chunk.""" geo_def = self.source_geo_def[src_y_start:src_y_end, src_x_start:src_x_end] - try: - src_poly = get_polygon(self.prj, geo_def) - except AttributeError: - # Can't create polygons for SwathDefinition - src_poly = False - + if isinstance(geo_def, SwathDefinition): + return False + + # NOTE: th code below could be used to return a polygon also for SwathDefinition object + src_poly = get_polygon(self.prj, geo_def) return src_poly def _get_dst_poly(self, idx, dst_x_start, dst_x_end, From 1dca71233d94474b2be9914c0872715e3fb6f2c2 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 22 Nov 2023 16:06:10 +0100 Subject: [PATCH 13/39] Deprecate get_edge_lonlat and remove duplicated coordinates --- pyresample/geometry.py | 19 +++++----- pyresample/gradient/__init__.py | 8 ++--- pyresample/slicer.py | 2 +- pyresample/test/test_geometry/test_swath.py | 40 ++++++++++++++------- 4 files changed, 43 insertions(+), 26 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 8d48b80d2..5266374e1 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -274,6 +274,7 @@ def get_lonlats_dask(self, chunks=None): return self.get_lonlats(chunks=chunks) def get_proj_coords(self, data_slice=None, chunks=None, **kwargs): + """Return AreaDefinition projection coordinates.""" class_name = self.__class__.__name__ raise NotImplementedError(f"get_proj_coords is not available for geometry {class_name}.") @@ -406,7 +407,7 @@ def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optio if coordinates == "geographic": coord_fun = self.get_lonlats else: - coord_fun = self.get_proj_coords # AreaDefinition + coord_fun = self.get_proj_coords # AreaDefinition s1_slice, s2_slice, s3_slice, s4_slice = self._get_bbox_slices(vertices_per_side) s1_dim1, s1_dim2 = coord_fun(data_slice=s1_slice) @@ -499,13 +500,14 @@ def _corner_is_clockwise(lon1, lat1, corner_lon, corner_lat, lon2, lat2): def get_edge_lonlats(self, vertices_per_side=None, frequency=None): """Get the concatenated boundary of the current swath.""" if frequency is not None: - warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", + warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead.", PendingDeprecationWarning, stacklevel=2) + msg = "`get_edge_lonlats` is pending deprecation" + msg += "Use `area.boundary(vertices_per_side=vertices_per_side).contour()` instead." + warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lons, lats = self.get_bbox_lonlats(vertices_per_side=vertices_per_side, force_clockwise=False) - blons = np.ma.concatenate(lons) - blats = np.ma.concatenate(lats) - return blons, blats + lons, lats = self.boundary(vertices_per_side=vertices_per_side).contour() + return lons, lats def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None): """Retrieve the AreaBoundary object. @@ -1072,7 +1074,7 @@ def compute_optimal_bb_area(self, proj_dict=None, resolution=None): proj_dict = self.compute_bb_proj_params(proj_dict) area = DynamicAreaDefinition(area_id, description, proj_dict) - lons, lats = self.get_edge_lonlats() + lons, lats = self.boundary(vertices_per_side=None).contour() return area.freeze((lons, lats), shape=(height, width)) @@ -1659,8 +1661,7 @@ def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[in warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - x_sides, y_sides = self._get_boundary_sides(coordinates="projection", - vertices_per_side=vertices_per_side) + x_sides, y_sides = self._get_boundary_sides(coordinates="projection", vertices_per_side=vertices_per_side) return np.hstack(x_sides), np.hstack(y_sides) @property diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 949913eac..21dc5f6db 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -137,10 +137,10 @@ def _get_src_poly(self, src_y_start, src_y_end, src_x_start, src_x_end): """Get bounding polygon for source chunk.""" geo_def = self.source_geo_def[src_y_start:src_y_end, src_x_start:src_x_end] - if isinstance(geo_def, SwathDefinition): - return False - - # NOTE: th code below could be used to return a polygon also for SwathDefinition object + if isinstance(geo_def, SwathDefinition): + return False + + # NOTE: the code below could be used to return a polygon also for SwathDefinition object src_poly = get_polygon(self.prj, geo_def) return src_poly diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 0a7010c6c..833517d3e 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -133,7 +133,7 @@ def _get_chunk_polygons_for_swath_to_crop(swath_to_crop): line_slice = expand_slice(line_slice) col_slice = expand_slice(col_slice) smaller_swath = swath_to_crop[line_slice, col_slice] - lons, lats = smaller_swath.get_edge_lonlats(10) + lons, lats = smaller_swath.boundary(vertices_per_side=10).contour() lons = np.hstack(lons) lats = np.hstack(lats) smaller_poly = Polygon(zip(lons, lats)) diff --git a/pyresample/test/test_geometry/test_swath.py b/pyresample/test/test_geometry/test_swath.py index 68ca936b7..b9aeee5f0 100644 --- a/pyresample/test/test_geometry/test_swath.py +++ b/pyresample/test/test_geometry/test_swath.py @@ -403,14 +403,20 @@ def test_get_edge_lonlats(self, create_test_swath): [81.26400756835938, 29.672000885009766, 10.260000228881836]]).T area = create_test_swath(lons, lats) lons, lats = area.get_edge_lonlats() - np.testing.assert_allclose(lons, [-90.67900085, 79.11000061, 81.26400757, - 81.26400757, 29.67200089, 10.26000023, - 10.26000023, -5.10700035, -21.52500153, - -21.52500153, -21.56500053, -90.67900085]) - np.testing.assert_allclose(lats, [85.23900604, 80.84000397, 67.07600403, - 67.07600403, 54.14700317, 30.54700089, - 30.54700089, 34.0850029, 35.58000183, - 35.58000183, 62.25600433, 85.23900604]) + expected_lons = [ + -90.67900085, 79.11000061, # 81.26400757, + 81.26400757, 29.67200089, # 10.26000023, + 10.26000023, -5.10700035, # -21.52500153, + -21.52500153, -21.56500053, # -90.67900085, + ] + expected_lats = [ + 85.23900604, 80.84000397, # 67.07600403, + 67.07600403, 54.14700317, # 30.54700089, + 30.54700089, 34.0850029, # 35.58000183, + 35.58000183, 62.25600433, # 85.23900604, + ] + np.testing.assert_allclose(lons, expected_lons) + np.testing.assert_allclose(lats, expected_lats) lats = np.array([[80., 80., 80.], [80., 90., 80], @@ -420,10 +426,20 @@ def test_get_edge_lonlats(self, create_test_swath): [-135., -180., 135.]]).T area = create_test_swath(lons, lats) lons, lats = area.get_edge_lonlats() - np.testing.assert_allclose(lons, [-45., -90., -135., -135., -180., 135., - 135., 90., 45., 45., 0., -45.]) - np.testing.assert_allclose(lats, [80., 80., 80., 80., 80., 80., 80., - 80., 80., 80., 80., 80.]) + expected_lons = [ + -45., -90., # -135., + -135., -180., # 135., + 135., 90., # 45., + 45., 0., # -45. + ] + expected_lats = [ + 80., 80., # 80., + 80., 80., # 80., + 80., 80., # 80., + 80., 80., # 80. + ] + np.testing.assert_allclose(lons, expected_lons) + np.testing.assert_allclose(lats, expected_lats) def test_compute_optimal_bb(self, create_test_swath): """Test computing the bb area.""" From 3c60e74b69d810e1b4df4bc190ac9ce2958f0544 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 22 Nov 2023 16:15:27 +0100 Subject: [PATCH 14/39] Ensure shapely polygon is closed ! --- pyresample/slicer.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 833517d3e..787f25600 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -134,8 +134,9 @@ def _get_chunk_polygons_for_swath_to_crop(swath_to_crop): col_slice = expand_slice(col_slice) smaller_swath = swath_to_crop[line_slice, col_slice] lons, lats = smaller_swath.boundary(vertices_per_side=10).contour() - lons = np.hstack(lons) - lats = np.hstack(lats) + # Include last point twice to close the polygon (shapely requirement) + lons = np.hstack((lons, lons[0])) + lats = np.hstack((lats, lats[0])) smaller_poly = Polygon(zip(lons, lats)) res.append((smaller_poly, (line_slice, col_slice))) return res From bba3bdea761020679474bd9e60a5e99d31d89968 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 22 Nov 2023 22:10:20 +0100 Subject: [PATCH 15/39] Refactor get_polygon and get_border_lonlat in gradient --- pyresample/boundary.py | 26 ++++++++++++++-- pyresample/geometry.py | 6 ++-- pyresample/gradient/__init__.py | 51 ++++++++++++++------------------ pyresample/slicer.py | 7 +---- pyresample/test/test_boundary.py | 13 +++++++- pyresample/test/test_gradient.py | 38 +++++++++++------------- 6 files changed, 80 insertions(+), 61 deletions(-) diff --git a/pyresample/boundary.py b/pyresample/boundary.py index 87f9b2ae1..ff6700a6b 100644 --- a/pyresample/boundary.py +++ b/pyresample/boundary.py @@ -84,13 +84,20 @@ def from_lonlat_sides(cls, lon_sides, lat_sides): boundary = cls(*zip(lon_sides, lat_sides)) return boundary - def contour(self): + def contour(self, closed=False): """Get the (lons, lats) tuple of the boundary object. - It excludes the last element of each side because it's included in the next side. + If excludes the last element of each side because it's included in the next side. + If closed=False (the default), the last vertex is not equal to the first vertex + If closed=True, the last vertex is set to be equal to the first + closed=True is required for shapely Polygon creation. + closed=False is required for pyresample SPolygon creation. """ lons = np.concatenate([lns[:-1] for lns in self.sides_lons]) lats = np.concatenate([lts[:-1] for lts in self.sides_lats]) + if closed: + lons = np.hstack((lons, lons[0])) + lats = np.hstack((lats, lats[0])) return lons, lats @property @@ -115,6 +122,21 @@ def decimate(self, ratio): self.sides_lons[i] = self.sides_lons[i][points] self.sides_lats[i] = self.sides_lats[i][points] + def _to_shapely_polygon(self): + from shapely.geometry import Polygon + lons, lats = self.contour(closed=True) + return Polygon(zip(lons, lats)) + + def _to_spherical_polygon(self): + raise NotImplementedError("This will return a SPolygon in pyresample 2.0") + + def polygon(self, shapely=False): + """Return the boundary polygon.""" + if shapely: + return self._to_shapely_polygon() + else: + return self._to_spherical_polygon() + class AreaDefBoundary(AreaBoundary): """Boundaries for area definitions (pyresample).""" diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 5266374e1..ec43c07cd 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -367,7 +367,9 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None): if (vertices_per_side % 2) != 0: vertices_per_side = vertices_per_side + 1 lons, lats = _get_geostationary_bounding_box_in_lonlats(self, nb_points=vertices_per_side) - + # Ensure that a portion of the area is within the Earth disk. + if lons.shape[0] < 2: + raise ValueError("The geostationary projection area is entirely out of the Earth disk.") # Retrieve dummy sides for GEO (side1 and side3 always of length 2) # - BUG: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! # step = int(vertices_per_side / 2) - 1 # old code @@ -2751,7 +2753,7 @@ def get_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): try: x, y = intersection.boundary.xy except NotImplementedError: - return [], [] + return np.array([]), np.array([]) return np.asanyarray(x[:-1]), np.asanyarray(y[:-1]) diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 21dc5f6db..1a3316012 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -36,11 +36,7 @@ from shapely.geometry import Polygon from pyresample import CHUNK_SIZE -from pyresample.geometry import ( - AreaDefinition, - SwathDefinition, - _get_geostationary_bounding_box_in_lonlats, -) +from pyresample.geometry import AreaDefinition, SwathDefinition from pyresample.gradient._gradient_search import ( one_step_gradient_indices, one_step_gradient_search, @@ -133,16 +129,22 @@ def _get_projection_coordinates(self, datachunks): src_prj=src_crs, dst_prj=dst_crs) self.prj = pyproj.Proj(self.target_geo_def.crs) + def _get_polygon(self, geo_def): + # - None if out of Earth Disk + # - False is SwathDefinition + if isinstance(geo_def, SwathDefinition): + return False + try: + poly = get_polygon(self.prj, geo_def) + except Exception: + poly = None + return poly + def _get_src_poly(self, src_y_start, src_y_end, src_x_start, src_x_end): """Get bounding polygon for source chunk.""" geo_def = self.source_geo_def[src_y_start:src_y_end, src_x_start:src_x_end] - if isinstance(geo_def, SwathDefinition): - return False - - # NOTE: the code below could be used to return a polygon also for SwathDefinition object - src_poly = get_polygon(self.prj, geo_def) - return src_poly + return self._get_polygon(geo_def) def _get_dst_poly(self, idx, dst_x_start, dst_x_end, dst_y_start, dst_y_end): @@ -151,13 +153,8 @@ def _get_dst_poly(self, idx, dst_x_start, dst_x_end, if dst_poly is None: geo_def = self.target_geo_def[dst_y_start:dst_y_end, dst_x_start:dst_x_end] - try: - dst_poly = get_polygon(self.prj, geo_def) - except AttributeError: - # Can't create polygons for SwathDefinition - dst_poly = False + dst_poly = self._get_polygon(geo_def) self.dst_polys[idx] = dst_poly - return dst_poly def get_chunk_mappings(self): @@ -178,7 +175,6 @@ def get_chunk_mappings(self): # Get source chunk polygon src_poly = self._get_src_poly(src_y_start, src_y_end, src_x_start, src_x_end) - dst_x_start = 0 for x_step_number, dst_x_step in enumerate(dst_x_chunks): dst_x_end = dst_x_start + dst_x_step @@ -189,7 +185,6 @@ def get_chunk_mappings(self): dst_poly = self._get_dst_poly((x_step_number, y_step_number), dst_x_start, dst_x_end, dst_y_start, dst_y_end) - covers = check_overlap(src_poly, dst_poly) coverage_status.append(covers) @@ -299,13 +294,15 @@ def compute(self, data, fill_value=None, **kwargs): def check_overlap(src_poly, dst_poly): """Check if the two polygons overlap.""" + # swath definition case if dst_poly is False or src_poly is False: covers = True + # area / area case elif dst_poly is not None and src_poly is not None: covers = src_poly.intersects(dst_poly) + # out of earth disk case else: covers = False - return covers @@ -372,21 +369,17 @@ def _check_input_coordinates(dst_x, dst_y, raise ValueError("Target arrays should all have the same shape") -def get_border_lonlats(geo_def: AreaDefinition): +def get_border_lonlats(geo_def: AreaDefinition, vertices_per_side=None): """Get the border x- and y-coordinates.""" - # TODO: we could use geo_def.boundary().contour() here if geo_def.is_geostationary: - lon_b, lat_b = _get_geostationary_bounding_box_in_lonlats(geo_def, 3600) - else: - lons, lats = geo_def.get_boundary_lonlats() - lon_b = np.concatenate((lons.side1, lons.side2, lons.side3, lons.side4)) - lat_b = np.concatenate((lats.side1, lats.side2, lats.side3, lats.side4)) + vertices_per_side = 3600 + lon_b, lat_b = geo_def.boundary(vertices_per_side=vertices_per_side).contour(closed=True) return lon_b, lat_b -def get_polygon(prj, geo_def): +def get_polygon(prj, geo_def, vertices_per_side=None): """Get border polygon from area definition in projection *prj*.""" - lon_b, lat_b = get_border_lonlats(geo_def) + lon_b, lat_b = get_border_lonlats(geo_def, vertices_per_side=vertices_per_side) x_borders, y_borders = prj(lon_b, lat_b) boundary = [(x_borders[i], y_borders[i]) for i in range(len(x_borders)) if np.isfinite(x_borders[i]) and np.isfinite(y_borders[i])] diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 787f25600..eaff08f45 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -127,17 +127,12 @@ def _assemble_slices(chunk_slices): def _get_chunk_polygons_for_swath_to_crop(swath_to_crop): """Get the polygons for each chunk of the area_to_crop.""" res = [] - from shapely.geometry import Polygon src_chunks = swath_to_crop.lons.chunks for _position, (line_slice, col_slice) in _enumerate_chunk_slices(src_chunks): line_slice = expand_slice(line_slice) col_slice = expand_slice(col_slice) smaller_swath = swath_to_crop[line_slice, col_slice] - lons, lats = smaller_swath.boundary(vertices_per_side=10).contour() - # Include last point twice to close the polygon (shapely requirement) - lons = np.hstack((lons, lons[0])) - lats = np.hstack((lats, lats[0])) - smaller_poly = Polygon(zip(lons, lats)) + smaller_poly = smaller_swath.boundary(vertices_per_side=10).polygon(shapely=True) res.append((smaller_poly, (line_slice, col_slice))) return res diff --git a/pyresample/test/test_boundary.py b/pyresample/test/test_boundary.py index e90fa9b03..4f7a83df4 100644 --- a/pyresample/test/test_boundary.py +++ b/pyresample/test/test_boundary.py @@ -99,7 +99,7 @@ def test_vertices_property(self): assert np.allclose(boundary.vertices, expected_vertices) def test_contour(self): - """Test that AreaBoundary.contour returns the correct (lon,lat) tuple.""" + """Test that AreaBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), (np.array([2., 3.]), np.array([7., 8.])), (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), @@ -108,3 +108,14 @@ def test_contour(self): lons, lats = boundary.contour() assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) + + def test_contour_closed(self): + """Test that AreaBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" + list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), + (np.array([2., 3.]), np.array([7., 8.])), + (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), + (np.array([4., 1.]), np.array([9., 6.]))] + boundary = AreaBoundary(*list_sides) + lons, lats = boundary.contour(closed=True) + assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4., 1.])) + assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9., 6.])) diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index 0a15ddb8a..e7d8f2b58 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -123,7 +123,7 @@ def test_get_src_poly_area(self): self.resampler._get_projection_coordinates(chunks) self.resampler._get_gradients() poly = self.resampler._get_src_poly(0, 40, 0, 40) - assert np.allclose(poly.area, 12365358458842.43) + assert np.allclose(poly.area, 12364231944935.44) def test_get_src_poly_swath(self): """Test defining source chunk polygon for SwathDefinition.""" @@ -148,11 +148,6 @@ def test_get_dst_poly(self, get_polygon): self.resampler._get_dst_poly('idx1', 0, 10, 0, 10) assert get_polygon.call_count == 1 - # Swath defs raise AttributeError, and False is returned - get_polygon.side_effect = AttributeError - self.resampler._get_dst_poly('idx2', 0, 10, 0, 10) - assert self.resampler.dst_polys['idx2'] is False - def test_filter_data(self): """Test filtering chunks that do not overlap.""" chunks = (10, 10) @@ -604,31 +599,33 @@ def test_check_overlap(): def test_get_border_lonlats_geos(): """Test that correct methods are called in get_border_lonlats() with geos inputs.""" + from pyresample.geometry import _get_geostationary_bounding_box_in_lonlats # noqa from pyresample.gradient import get_border_lonlats + + lons_v = np.array([1, 2, 3, 4]) + lats_v = np.array([1, 2, 3, 4]) geo_def = AreaDefinition("", "", "", - "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) - with mock.patch("pyresample.gradient._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: - get_geostationary_bounding_box.return_value = 1, 2 - res = get_border_lonlats(geo_def) - assert res == (1, 2) - get_geostationary_bounding_box.assert_called_with(geo_def, 3600) + "+proj=geos +h=1234567", 2, 2, + [1, 2, 3, 4]) + with mock.patch("pyresample.geometry._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: + get_geostationary_bounding_box.return_value = lons_v, lats_v + lons, lats = get_border_lonlats(geo_def) + np.testing.assert_allclose(lons, np.array([1, 2, 3, 4, 1])) + np.testing.assert_allclose(lats, np.array([1, 2, 3, 4, 1])) def test_get_border_lonlats(): """Test that correct methods are called in get_border_lonlats().""" - from pyresample.boundary import SimpleBoundary from pyresample.gradient import get_border_lonlats - lon_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), - side3=np.array([3]), side4=np.array([4])) - lat_sides = SimpleBoundary(side1=np.array([1]), side2=np.array([2]), - side3=np.array([3]), side4=np.array([4])) + lon_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] + lat_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) - with mock.patch.object(geo_def, "get_boundary_lonlats") as get_boundary_lonlats: + with mock.patch.object(geo_def, "_get_boundary_sides") as get_boundary_lonlats: get_boundary_lonlats.return_value = lon_sides, lat_sides lon_b, lat_b = get_border_lonlats(geo_def) - assert np.all(lon_b == np.array([1, 2, 3, 4])) - assert np.all(lat_b == np.array([1, 2, 3, 4])) + np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) + np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) @mock.patch('pyresample.gradient.Polygon') @@ -648,7 +645,6 @@ def test_get_polygon(get_border_lonlats, Polygon): poly = mock.MagicMock(area=2.0) Polygon.return_value = poly res = get_polygon(prj, geo_def) - get_border_lonlats.assert_called_with(geo_def) prj.assert_called_with(1, 2) Polygon.assert_called_with(boundary) assert res is poly From 34edc5c347684a7ae91305c7e04c894f66cc6c46 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Wed, 22 Nov 2023 23:39:04 +0100 Subject: [PATCH 16/39] Initial refactor of AreaBoundary --- pyresample/boundary.py | 201 +++++++++++++++++++++++-------- pyresample/geometry.py | 12 +- pyresample/test/test_boundary.py | 61 ++++++---- 3 files changed, 201 insertions(+), 73 deletions(-) diff --git a/pyresample/boundary.py b/pyresample/boundary.py index ff6700a6b..181929295 100644 --- a/pyresample/boundary.py +++ b/pyresample/boundary.py @@ -54,38 +54,124 @@ def draw(self, mapper, options, **more_options): self.contour_poly.draw(mapper, options, **more_options) +def _is_corner_is_clockwise(lon1, lat1, corner_lon, corner_lat, lon2, lat2): + """Determine if coordinates follow a clockwise path. + + This uses :class:`pyresample.spherical.Arc` to determine the angle + between the first line segment (Arc) from (lon1, lat1) to + (corner_lon, corner_lat) and the second line segment from + (corner_lon, corner_lat) to (lon2, lat2). A straight line would + produce an angle of 0, a clockwise path would have a negative angle, + and a counter-clockwise path would have a positive angle. + + """ + import math + + from pyresample.spherical import Arc, SCoordinate + point1 = SCoordinate(math.radians(lon1), math.radians(lat1)) + point2 = SCoordinate(math.radians(corner_lon), math.radians(corner_lat)) + point3 = SCoordinate(math.radians(lon2), math.radians(lat2)) + arc1 = Arc(point1, point2) + arc2 = Arc(point2, point3) + angle = arc1.angle(arc2) + is_clockwise = -np.pi < angle < 0 + return is_clockwise + + +def _is_boundary_clockwise(sides_lons, sides_lats): + """Determine if the boundary sides are clockwise.""" + is_clockwise = _is_corner_is_clockwise( + lon1=sides_lons[0][-2], + lat1=sides_lats[0][-2], + corner_lon=sides_lons[0][-1], + corner_lat=sides_lats[0][-1], + lon2=sides_lons[1][1], + lat2=sides_lats[1][1]) + return is_clockwise + + +def _check_sides_list(sides): + if not isinstance(sides, list): + raise TypeError("Boundary sides must be a list") + if len(sides) != 4: + raise ValueError("Boundary sides list must be a list with 4 elements.") + # TODO: + # - Numpy array elements of at least length 2 + + class AreaBoundary(Boundary): """Area boundary objects. The inputs must be a (lon_coords, lat_coords) tuple for each of the 4 sides. """ - def __init__(self, *sides): - Boundary.__init__(self) - # Check 4 sides are provided - if len(sides) != 4: - raise ValueError("AreaBoundary expects 4 sides.") - # Retrieve sides - self.sides_lons, self.sides_lats = zip(*sides) - self.sides_lons = list(self.sides_lons) - self.sides_lats = list(self.sides_lats) + def __init__(self, lon_sides, lat_sides, wished_order=None): + _check_sides_list(lon_sides) + _check_sides_list(lat_sides) - @classmethod - def from_lonlat_sides(cls, lon_sides, lat_sides): - """Define AreaBoundary from list of lon_sides and lat_sides. + # Old interface + self._contour_poly = None + self.sides_lons = lon_sides + self.sides_lats = lat_sides - For an area of shape (m, n), the sides must adhere the format: + # New interface + # TODO: self.sides (BoundarySide(s)) - sides = [np.array([v00, v01, ..., v0n]), - np.array([v0n, v1n, ..., vmn]), - np.array([vmn, ..., vm1, vm0]), - np.array([vm0, ... ,v10, v00])] - """ - boundary = cls(*zip(lon_sides, lat_sides)) - return boundary + # Check if it is clockwise/counterclockwise + self.is_clockwise = _is_boundary_clockwise(sides_lons=lon_sides, + sides_lats=lat_sides) + self.is_counterclockwise = not self.is_clockwise + + # Define wished order + if self.is_clockwise: + self._actual_order = "clockwise" + else: + self._actual_order = "counterclockwise" + + if wished_order is None: + self._wished_order = self._actual_order + else: + if wished_order not in ["clockwise", "counterclockwise"]: + raise ValueError("Valid order is 'clockwise' or 'counterclockwise'") + self._wished_order = wished_order + + def set_clockwise(self): + """Set clockwise order for vertices retrieval.""" + self._wished_order = "clockwise" + return self + + def set_counterclockwise(self): + """Set counterclockwise order for vertices retrieval.""" + self._wished_order = "counterclockwise" + return self + + @property + def lons(self): + """Retrieve boundary longitude vertices.""" + lons = np.concatenate([lns[:-1] for lns in self.sides_lons]) + if self._wished_order == self._actual_order: + return lons + else: + return lons[::-1] + + @property + def lats(self): + """Retrieve boundary latitude vertices.""" + lats = np.concatenate([lts[:-1] for lts in self.sides_lats]) + if self._wished_order == self._actual_order: + return lats + else: + return lats[::-1] + + @property + def vertices(self): + """Return boundary vertices 2D array [lon, lat].""" + vertices = np.vstack((self.lons, self.lats)).T + vertices = vertices.astype(np.float64, copy=False) # Important for spherical ops. + return vertices def contour(self, closed=False): - """Get the (lons, lats) tuple of the boundary object. + """Return the (lons, lats) tuple of the boundary object. If excludes the last element of each side because it's included in the next side. If closed=False (the default), the last vertex is not equal to the first vertex @@ -93,23 +179,50 @@ def contour(self, closed=False): closed=True is required for shapely Polygon creation. closed=False is required for pyresample SPolygon creation. """ - lons = np.concatenate([lns[:-1] for lns in self.sides_lons]) - lats = np.concatenate([lts[:-1] for lts in self.sides_lats]) + lons = self.lons + lats = self.lats if closed: lons = np.hstack((lons, lons[0])) lats = np.hstack((lats, lats[0])) return lons, lats - @property - def vertices(self): - """Return boundary polygon vertices.""" - lons, lats = self.contour() - vertices = np.vstack((lons, lats)).T - vertices = vertices.astype(np.float64, copy=False) # Important for spherical ops. - return vertices + def _to_shapely_polygon(self): + from shapely.geometry import Polygon + self = self.set_counterclockwise() # TODO: add exception for pole wrapping polygons + lons, lats = self.contour(closed=True) + return Polygon(zip(lons, lats)) + + def _to_spherical_polygon(self): + self = self.set_clockwise() # TODO: add exception for pole wrapping polygons + raise NotImplementedError("This will return a SPolygon in pyresample 2.0") + + def polygon(self, shapely=False): + """Return the boundary polygon.""" + if shapely: + return self._to_shapely_polygon() + else: + return self._to_spherical_polygon() + + # For backward compatibility ! + @classmethod + def from_lonlat_sides(cls, lon_sides, lat_sides): + """Define AreaBoundary from list of lon_sides and lat_sides. + + For an area of shape (m, n), the sides must adhere the format: + + sides = [np.array([v00, v01, ..., v0n]), + np.array([v0n, v1n, ..., vmn]), + np.array([vmn, ..., vm1, vm0]), + np.array([vm0, ... ,v10, v00])] + """ + warnings.warn("Use `AreaBoundary(lon_sides, lat_sides)` instead of `from_lonlat_sides`", + PendingDeprecationWarning, stacklevel=2) + boundary = cls(lon_sides=lon_sides, lat_sides=lat_sides) + return boundary def decimate(self, ratio): """Remove some points in the boundaries, but never the corners.""" + # TODO: to update --> used by AreaDefBoundary for i in range(len(self.sides_lons)): length = len(self.sides_lons[i]) start = int((length % ratio) / 2) @@ -122,33 +235,27 @@ def decimate(self, ratio): self.sides_lons[i] = self.sides_lons[i][points] self.sides_lats[i] = self.sides_lats[i][points] - def _to_shapely_polygon(self): - from shapely.geometry import Polygon - lons, lats = self.contour(closed=True) - return Polygon(zip(lons, lats)) - - def _to_spherical_polygon(self): - raise NotImplementedError("This will return a SPolygon in pyresample 2.0") + @property + def contour_poly(self): + """Return the pyresample SphPolygon.""" + if self._contour_poly is None: + self._contour_poly = SphPolygon(np.deg2rad(self.vertices)) + return self._contour_poly - def polygon(self, shapely=False): - """Return the boundary polygon.""" - if shapely: - return self._to_shapely_polygon() - else: - return self._to_spherical_polygon() + def draw(self, mapper, options, **more_options): + """Draw the current boundary on the *mapper*.""" + self.contour_poly.draw(mapper, options, **more_options) class AreaDefBoundary(AreaBoundary): """Boundaries for area definitions (pyresample).""" def __init__(self, area, frequency=1): - lons, lats = area.get_bbox_lonlats() + lon_sides, lat_sides = area.get_bbox_lonlats() warnings.warn("'AreaDefBoundary' will be removed in the future. " + "Use the Swath/AreaDefinition 'boundary' method instead!.", PendingDeprecationWarning, stacklevel=2) - AreaBoundary.__init__(self, - *zip(lons, lats)) - + AreaBoundary.__init__(self, lon_sides=lon_sides, lat_sides=lat_sides) if frequency != 1: self.decimate(frequency) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index ec43c07cd..3916b5cc7 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -534,9 +534,15 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lon_sides, lat_sides = self.get_bbox_lonlats(vertices_per_side=vertices_per_side, - force_clockwise=force_clockwise) - return AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) + lon_sides, lat_sides = self._get_boundary_sides(coordinates="geographic", + vertices_per_side=vertices_per_side) + # TODO: this could be changed but it would breaks backward compatibility + # TODO: Implement code to return projection boundary ! + if force_clockwise: + wished_order = "clockwise" + else: + wished_order = None + return AreaBoundary(lon_sides, lat_sides, wished_order=wished_order) def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): """Retrieve cartesian coordinates of geometry definition. diff --git a/pyresample/test/test_boundary.py b/pyresample/test/test_boundary.py index 4f7a83df4..6ebe6af8e 100644 --- a/pyresample/test/test_boundary.py +++ b/pyresample/test/test_boundary.py @@ -38,6 +38,7 @@ def test_creation_from_lonlat_sides(self): np.array([7.0, 8.0]), np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] + # Define AreaBoundary boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) @@ -50,15 +51,17 @@ def test_creation_from_lonlat_sides(self): def test_creation(self): """Test AreaBoundary creation.""" - list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), - (np.array([2., 3.]), np.array([7., 8.])), - (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), - (np.array([4., 1.]), np.array([9., 6.]))] - lon_sides = [side[0]for side in list_sides] - lat_sides = [side[1]for side in list_sides] + lon_sides = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + lat_sides = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] # Define AreaBoundary - boundary = AreaBoundary(*list_sides) + boundary = AreaBoundary(lon_sides, lat_sides) # Assert sides coincides for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): @@ -69,12 +72,14 @@ def test_creation(self): def test_number_sides_required(self): """Test AreaBoundary requires 4 sides .""" - list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), - (np.array([2., 3.]), np.array([7., 8.])), - (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), - (np.array([4., 1.]), np.array([9., 6.]))] + lon_sides = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([4.0, 1.0])] + lat_sides = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([9.0, 6.0])] with pytest.raises(ValueError): - AreaBoundary(*list_sides[0:3]) + AreaBoundary(lon_sides, lat_sides) def test_vertices_property(self): """Test AreaBoundary vertices property.""" @@ -87,7 +92,7 @@ def test_vertices_property(self): np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] # Define AreaBoundary - boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) + boundary = AreaBoundary(lon_sides, lat_sides) # Assert vertices expected_vertices = np.array([[1., 6.], @@ -100,22 +105,32 @@ def test_vertices_property(self): def test_contour(self): """Test that AreaBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" - list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), - (np.array([2., 3.]), np.array([7., 8.])), - (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), - (np.array([4., 1.]), np.array([9., 6.]))] - boundary = AreaBoundary(*list_sides) + lon_sides = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + lat_sides = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] + # Define AreaBoundary + boundary = AreaBoundary(lon_sides, lat_sides) lons, lats = boundary.contour() assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) def test_contour_closed(self): """Test that AreaBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" - list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), - (np.array([2., 3.]), np.array([7., 8.])), - (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), - (np.array([4., 1.]), np.array([9., 6.]))] - boundary = AreaBoundary(*list_sides) + lon_sides = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + lat_sides = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] + # Define AreaBoundary + boundary = AreaBoundary(lon_sides, lat_sides) lons, lats = boundary.contour(closed=True) assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4., 1.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9., 6.])) From 8fbf02f64263bd8cf15ce7bb43277d51b9b85959 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 08:07:59 +0100 Subject: [PATCH 17/39] Deprecate get_bbox_lonlats --- docs/source/howtos/spherical_geometry.rst | 3 +-- pyresample/boundary.py | 7 ++++++- pyresample/geometry.py | 3 +++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/source/howtos/spherical_geometry.rst b/docs/source/howtos/spherical_geometry.rst index d2c0056ce..5dd0e8b4f 100644 --- a/docs/source/howtos/spherical_geometry.rst +++ b/docs/source/howtos/spherical_geometry.rst @@ -72,9 +72,8 @@ satellite passes. See trollschedule_ how to generate a list of satellite overpas `area_def` is an :class:`~pyresample.geometry.AreaDefinition` object. >>> from pyresample.spherical_utils import GetNonOverlapUnions - >>> from pyresample.boundary import AreaDefBoundary - >>> area_boundary = AreaDefBoundary(area_def, frequency=100) # doctest: +SKIP + >>> area_boundary = area_def.boundary(vertices_per_side=100) # doctest: +SKIP >>> area_boundary = area_boundary.contour_poly # doctest: +SKIP >>> list_of_polygons = [] diff --git a/pyresample/boundary.py b/pyresample/boundary.py index 181929295..a14d36c48 100644 --- a/pyresample/boundary.py +++ b/pyresample/boundary.py @@ -145,6 +145,11 @@ def set_counterclockwise(self): self._wished_order = "counterclockwise" return self + @property + def sides(self): + """Return the boundary sides as a tuple of (lon_sides, lat_sides) arrays.""" + return self.sides_lons, self.sides_lats + @property def lons(self): """Retrieve boundary longitude vertices.""" @@ -251,7 +256,7 @@ class AreaDefBoundary(AreaBoundary): """Boundaries for area definitions (pyresample).""" def __init__(self, area, frequency=1): - lon_sides, lat_sides = area.get_bbox_lonlats() + lon_sides, lat_sides = area.boundary().sides warnings.warn("'AreaDefBoundary' will be removed in the future. " + "Use the Swath/AreaDefinition 'boundary' method instead!.", PendingDeprecationWarning, stacklevel=2) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 3916b5cc7..cf96df8b3 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -331,9 +331,12 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw if frequency is not None: warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) + vertices_per_side = vertices_per_side or frequency lon_sides, lat_sides = self._get_boundary_sides(coordinates="geographic", vertices_per_side=vertices_per_side) + warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.boundary().sides` instead", + PendingDeprecationWarning, stacklevel=2) if force_clockwise and not self._corner_is_clockwise( lon_sides[0][-2], lat_sides[0][-2], lon_sides[0][-1], lat_sides[0][-1], From d9f41658e5c064cc88435ad89d554e596668f3a7 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 08:20:46 +0100 Subject: [PATCH 18/39] Private get_polygon and get_border_lonlat in gradient.__init__ --- pyresample/gradient/__init__.py | 14 +++++------ pyresample/test/test_gradient.py | 42 ++++++++++++++++---------------- 2 files changed, 28 insertions(+), 28 deletions(-) diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 1a3316012..58ea83be2 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -129,13 +129,13 @@ def _get_projection_coordinates(self, datachunks): src_prj=src_crs, dst_prj=dst_crs) self.prj = pyproj.Proj(self.target_geo_def.crs) - def _get_polygon(self, geo_def): + def _get_prj_poly(self, geo_def): # - None if out of Earth Disk # - False is SwathDefinition if isinstance(geo_def, SwathDefinition): return False try: - poly = get_polygon(self.prj, geo_def) + poly = _get_polygon(self.prj, geo_def) except Exception: poly = None return poly @@ -144,7 +144,7 @@ def _get_src_poly(self, src_y_start, src_y_end, src_x_start, src_x_end): """Get bounding polygon for source chunk.""" geo_def = self.source_geo_def[src_y_start:src_y_end, src_x_start:src_x_end] - return self._get_polygon(geo_def) + return self._get_prj_poly(geo_def) def _get_dst_poly(self, idx, dst_x_start, dst_x_end, dst_y_start, dst_y_end): @@ -153,7 +153,7 @@ def _get_dst_poly(self, idx, dst_x_start, dst_x_end, if dst_poly is None: geo_def = self.target_geo_def[dst_y_start:dst_y_end, dst_x_start:dst_x_end] - dst_poly = self._get_polygon(geo_def) + dst_poly = self._get_prj_poly(geo_def) self.dst_polys[idx] = dst_poly return dst_poly @@ -369,7 +369,7 @@ def _check_input_coordinates(dst_x, dst_y, raise ValueError("Target arrays should all have the same shape") -def get_border_lonlats(geo_def: AreaDefinition, vertices_per_side=None): +def _get_border_lonlats(geo_def: AreaDefinition, vertices_per_side=None): """Get the border x- and y-coordinates.""" if geo_def.is_geostationary: vertices_per_side = 3600 @@ -377,9 +377,9 @@ def get_border_lonlats(geo_def: AreaDefinition, vertices_per_side=None): return lon_b, lat_b -def get_polygon(prj, geo_def, vertices_per_side=None): +def _get_polygon(prj, geo_def, vertices_per_side=None): """Get border polygon from area definition in projection *prj*.""" - lon_b, lat_b = get_border_lonlats(geo_def, vertices_per_side=vertices_per_side) + lon_b, lat_b = _get_border_lonlats(geo_def, vertices_per_side=vertices_per_side) x_borders, y_borders = prj(lon_b, lat_b) boundary = [(x_borders[i], y_borders[i]) for i in range(len(x_borders)) if np.isfinite(x_borders[i]) and np.isfinite(y_borders[i])] diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index e7d8f2b58..bdc53c1a3 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -134,19 +134,19 @@ def test_get_src_poly_swath(self): poly = self.swath_resampler._get_src_poly(0, 40, 0, 40) assert poly is False - @mock.patch('pyresample.gradient.get_polygon') - def test_get_dst_poly(self, get_polygon): + @mock.patch('pyresample.gradient._get_polygon') + def test_get_dst_poly(self, _get_polygon): """Test defining destination chunk polygon.""" chunks = (10, 10) self.resampler._get_projection_coordinates(chunks) self.resampler._get_gradients() - # First call should make a call to get_polygon() + # First call should make a call to _get_polygon() self.resampler._get_dst_poly('idx1', 0, 10, 0, 10) - assert get_polygon.call_count == 1 + assert _get_polygon.call_count == 1 assert 'idx1' in self.resampler.dst_polys # The second call to the same index should come from cache self.resampler._get_dst_poly('idx1', 0, 10, 0, 10) - assert get_polygon.call_count == 1 + assert _get_polygon.call_count == 1 def test_filter_data(self): """Test filtering chunks that do not overlap.""" @@ -597,10 +597,10 @@ def test_check_overlap(): assert check_overlap(poly1, poly2) is False -def test_get_border_lonlats_geos(): - """Test that correct methods are called in get_border_lonlats() with geos inputs.""" +def test__get_border_lonlats_geos(): + """Test that correct methods are called in _get_border_lonlats() with geos inputs.""" from pyresample.geometry import _get_geostationary_bounding_box_in_lonlats # noqa - from pyresample.gradient import get_border_lonlats + from pyresample.gradient import _get_border_lonlats lons_v = np.array([1, 2, 3, 4]) lats_v = np.array([1, 2, 3, 4]) @@ -609,33 +609,33 @@ def test_get_border_lonlats_geos(): [1, 2, 3, 4]) with mock.patch("pyresample.geometry._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: get_geostationary_bounding_box.return_value = lons_v, lats_v - lons, lats = get_border_lonlats(geo_def) + lons, lats = _get_border_lonlats(geo_def) np.testing.assert_allclose(lons, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lats, np.array([1, 2, 3, 4, 1])) -def test_get_border_lonlats(): - """Test that correct methods are called in get_border_lonlats().""" - from pyresample.gradient import get_border_lonlats +def test__get_border_lonlats(): + """Test that correct methods are called in _get_border_lonlats().""" + from pyresample.gradient import _get_border_lonlats lon_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] lat_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) with mock.patch.object(geo_def, "_get_boundary_sides") as get_boundary_lonlats: get_boundary_lonlats.return_value = lon_sides, lat_sides - lon_b, lat_b = get_border_lonlats(geo_def) + lon_b, lat_b = _get_border_lonlats(geo_def) np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) @mock.patch('pyresample.gradient.Polygon') -@mock.patch('pyresample.gradient.get_border_lonlats') -def test_get_polygon(get_border_lonlats, Polygon): +@mock.patch('pyresample.gradient._get_border_lonlats') +def test__get_polygon(_get_border_lonlats, Polygon): """Test polygon creation.""" - from pyresample.gradient import get_polygon + from pyresample.gradient import _get_polygon # Valid polygon - get_border_lonlats.return_value = (1, 2) + _get_border_lonlats.return_value = (1, 2) geo_def = mock.MagicMock() prj = mock.MagicMock() x_borders = [0, 0, 1, 1] @@ -644,7 +644,7 @@ def test_get_polygon(get_border_lonlats, Polygon): prj.return_value = (x_borders, y_borders) poly = mock.MagicMock(area=2.0) Polygon.return_value = poly - res = get_polygon(prj, geo_def) + res = _get_polygon(prj, geo_def) prj.assert_called_with(1, 2) Polygon.assert_called_with(boundary) assert res is poly @@ -654,18 +654,18 @@ def test_get_polygon(get_border_lonlats, Polygon): y_borders = [-1, 0, np.nan, 1, 1, np.nan, -1] boundary = [(0, 0), (0, 1), (1, 1), (2, -1)] prj.return_value = (x_borders, y_borders) - res = get_polygon(prj, geo_def) + res = _get_polygon(prj, geo_def) Polygon.assert_called_with(boundary) assert res is poly # Polygon area is NaN poly.area = np.nan - res = get_polygon(prj, geo_def) + res = _get_polygon(prj, geo_def) assert res is None # Polygon area is 0.0 poly.area = 0.0 - res = get_polygon(prj, geo_def) + res = _get_polygon(prj, geo_def) assert res is None From 83ec6dfe1ee713b4ff21e424caefa41ad58ae711 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 09:44:39 +0100 Subject: [PATCH 19/39] Add test area fixtures --- pyresample/test/test_geometry/test_area.py | 279 ++++++++++++++++----- 1 file changed, 219 insertions(+), 60 deletions(-) diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index 021d7866f..8f6500602 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -58,6 +58,126 @@ def stere_area(create_test_area): ) +@pytest.fixture +def global_lonlat_antimeridian_centered_area(create_test_area): + """Create global lonlat projection area centered on the -180 antimeridian.""" + x_size = 4 + y_size = 4 + area_extent = (0, -90.0, 360, 90.0) + proj_dict = '+proj=longlat +pm=180' + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + +@pytest.fixture +def global_platee_caree_area(create_test_area): + """Create global platee projection area.""" + x_size = 4 + y_size = 4 + area_extent = (-180.0, -90.0, 180.0, 90.0) + proj_dict = 'EPSG:4326' + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + +@pytest.fixture +def global_platee_caree_minimum_area(create_test_area): + """Create minimum size global platee projection area.""" + x_size = 2 + y_size = 2 + area_extent = (-180.0, -90.0, 180.0, 90.0) + proj_dict = 'EPSG:4326' + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + +@pytest.fixture +def local_platee_caree_area(create_test_area): + """Create local platee projection area.""" + x_size = 4 + y_size = 4 + area_extent = (100, 20, 120, 40) + proj_dict = 'EPSG:4326' + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + +@pytest.fixture +def local_lonlat_antimeridian_centered_area(create_test_area): + """Create local lonlat projection area centered on the -180 antimeridian.""" + x_size = 4 + y_size = 4 + area_extent = (100, 20, 120, 40) + proj_dict = '+proj=longlat +pm=180' + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + +@pytest.fixture +def local_meter_area(create_test_area): + """Create local meter projection area.""" + x_size = 2 + y_size = 2 + area_extent = (2_600_000.0, 1_050_000, 2_800_000.0, 1_170_000) + proj_dict = 'EPSG:2056' + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + +@pytest.fixture +def south_pole_area(create_test_area): + """Create projection area centered on south pole.""" + x_size = 2 + y_size = 2 + area_extent = (-5326849.0625, -5326849.0625, 5326849.0625, 5326849.0625) + proj_dict = {'proj': 'laea', 'lat_0': -90, 'lon_0': 0, 'a': 6371228.0, 'units': 'm'} + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + +@pytest.fixture +def north_pole_area(create_test_area): + """Create projection area centered on north pole.""" + x_size = 2 + y_size = 2 + area_extent = (-5326849.0625, -5326849.0625, 5326849.0625, 5326849.0625) + proj_dict = {'proj': 'laea', 'lat_0': 90, 'lon_0': 0, 'a': 6371228.0, 'units': 'm'} + return create_test_area( + proj_dict, + x_size, + y_size, + area_extent, + ) + + @pytest.fixture def geos_src_area(create_test_area): """Create basic geostationary area definition.""" @@ -74,6 +194,92 @@ def geos_src_area(create_test_area): ) +@pytest.fixture +def geos_fd_area(create_test_area): + """Create full disc geostationary area definition.""" + shape = (100, 100) + area_extent = (-5500000., -5500000., 5500000., 5500000.) + proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, + 'lon_0': 0, 'proj': 'geos', 'units': 'm'} + return create_test_area( + proj_dict, + shape[0], + shape[1], + area_extent, + ) + + +@pytest.fixture +def geos_out_disk_area(create_test_area): + """Create out of Earth diskc geostationary area definition.""" + shape = (10, 10) + area_extent = (-5500000., -5500000., -5300000., -5300000.) + proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, + 'lon_0': 0, 'proj': 'geos', 'units': 'm'} + return create_test_area( + proj_dict, + shape[0], + shape[1], + area_extent, + ) + + +@pytest.fixture +def geos_conus_area(create_test_area): + """Create CONUS geostationary area definition.""" + shape = (30, 50) # (3000, 5000) for GOES-R CONUS/PACUS + proj_dict = {'h': 35786023, 'sweep': 'x', 'x_0': 0, 'y_0': 0, + 'ellps': 'GRS80', 'no_defs': None, 'type': 'crs', + 'lon_0': -75, 'proj': 'geos', 'units': 'm'} + area_extent = (-3627271.29128, 1583173.65752, 1382771.92872, 4589199.58952) + return create_test_area( + proj_dict, + shape[0], + shape[1], + area_extent, + ) + + +@pytest.fixture +def geos_mesoscale_area(create_test_area): + """Create CONUS geostationary area definition.""" + shape = (10, 10) # (1000, 1000) for GOES-R mesoscale + proj_dict = {'h': 35786023, 'sweep': 'x', 'x_0': 0, 'y_0': 0, + 'ellps': 'GRS80', 'no_defs': None, 'type': 'crs', + 'lon_0': -75, 'proj': 'geos', 'units': 'm'} + area_extent = (-501004.322, 3286588.35232, 501004.322, 4288596.99632) + return create_test_area( + proj_dict, + shape[0], + shape[1], + area_extent, + ) + + +@pytest.fixture +def truncated_geos_area(create_test_area): + """Create a truncated geostationary area.""" + projection = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', + 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} + area_extent = (5567248.0742, 5570248.4773, -5570248.4773, 1393687.2705) + width = 3712 + height = 1392 + geos_area = create_test_area(projection, width, height, area_extent) + return geos_area + + +@pytest.fixture +def truncated_geos_area_in_space(create_test_area): + """Create a truncated geostationary area.""" + projection = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', + 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} + area_extent = (5575000, 5575000, 5570000, 5570000) + width = 10 + height = 10 + geos_area = create_test_area(projection, width, height, area_extent) + return geos_area + + @pytest.fixture def laea_area(create_test_area): """Create basic LAEA area definition.""" @@ -1423,30 +1629,6 @@ def test_create_area_def_nonpole_center(self): assert area_def.shape == (101, 90) -@pytest.fixture -def truncated_geos_area(create_test_area): - """Create a truncated geostationary area.""" - projection = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', - 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} - area_extent = (5567248.0742, 5570248.4773, -5570248.4773, 1393687.2705) - width = 3712 - height = 1392 - geos_area = create_test_area(projection, width, height, area_extent) - return geos_area - - -@pytest.fixture -def truncated_geos_area_in_space(create_test_area): - """Create a truncated geostationary area.""" - projection = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', - 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} - area_extent = (5575000, 5575000, 5570000, 5570000) - width = 10 - height = 10 - geos_area = create_test_area(projection, width, height, area_extent) - return geos_area - - class TestGeostationaryTools: """Test the geostationary bbox tools.""" @@ -1880,13 +2062,9 @@ def test_unsupported_slice_inputs(self, create_test_area, create_test_swath, swa class TestBoundary: """Test 'boundary' method for AreaDefinition classes.""" - def test_polar_south_pole_projection(self, create_test_area): + def test_polar_south_pole_projection(self, south_pole_area): """Test boundary for polar projection around the South Pole.""" - areadef = create_test_area( - {'proj': 'laea', 'lat_0': -90, 'lon_0': 0, 'a': 6371228.0, 'units': 'm'}, - 2, 2, - (-5326849.0625, -5326849.0625, 5326849.0625, 5326849.0625), - ) + areadef = south_pole_area boundary = areadef.boundary(force_clockwise=False) # Check boundary shape @@ -1901,13 +2079,10 @@ def test_polar_south_pole_projection(self, create_test_area): [-135., -55.61313895]]) np.testing.assert_allclose(expected_vertices, boundary.vertices) - def test_nort_pole_projection(self, create_test_area): + def test_north_pole_projection(self, north_pole_area): """Test boundary for polar projection around the North Pole.""" - areadef = create_test_area( - {'proj': 'laea', 'lat_0': 90, 'lon_0': 0, 'a': 6371228.0, 'units': 'm'}, - 2, 2, - (-5326849.0625, -5326849.0625, 5326849.0625, 5326849.0625), - ) + areadef = north_pole_area + boundary = areadef.boundary(force_clockwise=False) # Check boundary shape @@ -1922,13 +2097,9 @@ def test_nort_pole_projection(self, create_test_area): [-45., 55.61313895]]) np.testing.assert_allclose(expected_vertices, boundary.vertices) - def test_geostationary_projection(self, create_test_area): + def test_full_disc_geostationary_projection(self, geos_fd_area): """Test boundary for geostationary projection.""" - areadef = create_test_area( - {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.00, 'lon_0': 0, 'proj': 'geos'}, - 100, 100, - (-5500000., -5500000., 5500000., 5500000.), - ) + areadef = geos_fd_area # Check default boundary shape default_n_vertices = 50 @@ -1964,13 +2135,9 @@ def test_geostationary_projection(self, create_test_area): [-7.92337283e+01, 6.94302533e-15]]) np.testing.assert_allclose(expected_vertices, boundary.vertices) - def test_global_platee_caree_projection(self, create_test_area): + def test_global_platee_caree_projection(self, global_platee_caree_area): """Test boundary for global platee caree projection.""" - areadef = create_test_area( - 'EPSG:4326', - 4, 4, - (-180.0, -90.0, 180.0, 90.0), - ) + areadef = global_platee_caree_area boundary = areadef.boundary(force_clockwise=False) # Check boundary shape @@ -1993,13 +2160,9 @@ def test_global_platee_caree_projection(self, create_test_area): [-135., 22.5]]) np.testing.assert_allclose(expected_vertices, boundary.vertices) - def test_minimal_global_platee_caree_projection(self, create_test_area): + def test_minimal_global_platee_caree_projection(self, global_platee_caree_minimum_area): """Test boundary for global platee caree projection.""" - areadef = create_test_area( - 'EPSG:4326', - 2, 2, - (-180.0, -90.0, 180.0, 90.0), - ) + areadef = global_platee_caree_minimum_area boundary = areadef.boundary(force_clockwise=False) # Check boundary shape @@ -2014,13 +2177,9 @@ def test_minimal_global_platee_caree_projection(self, create_test_area): [-90., -45.]]) np.testing.assert_allclose(expected_vertices, boundary.vertices) - def test_local_area_projection(self, create_test_area): + def test_local_area_projection(self, local_meter_area): """Test local area projection in meter.""" - areadef = create_test_area( - 'EPSG:2056', - 2, 2, - (2_600_000.0, 1_050_000, 2_800_000.0, 1_170_000), - ) + areadef = local_meter_area boundary = areadef.boundary(force_clockwise=False) # Check boundary shape From 9de0a44a0ded6e6e816f531d2b1679d358e776f7 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 15:19:29 +0100 Subject: [PATCH 20/39] Treat geo area inside earth disk as classical AreaDef --- pyresample/boundary.py | 12 + pyresample/geometry.py | 91 +++++-- pyresample/test/test_geometry/test_area.py | 276 ++++++++++++-------- pyresample/test/test_geometry/test_dummy.py | 163 ++++++++++++ pyresample/test/test_gradient.py | 19 +- pyresample/visualization/__init__.py | 8 + pyresample/visualization/geometries.py | 71 +++++ 7 files changed, 493 insertions(+), 147 deletions(-) create mode 100644 pyresample/test/test_geometry/test_dummy.py create mode 100644 pyresample/visualization/__init__.py create mode 100644 pyresample/visualization/geometries.py diff --git a/pyresample/boundary.py b/pyresample/boundary.py index a14d36c48..569d0df00 100644 --- a/pyresample/boundary.py +++ b/pyresample/boundary.py @@ -208,6 +208,18 @@ def polygon(self, shapely=False): else: return self._to_spherical_polygon() + def plot(self, ax=None, subplot_kw=None, **kwargs): + """Plot the the boundary.""" + import cartopy.crs as ccrs + + from pyresample.visualization.geometries import plot_geometries + + geom = self.polygon(shapely=True) + crs = ccrs.Geodetic() + p = plot_geometries(geometries=[geom], crs=crs, + ax=ax, subplot_kw=subplot_kw, **kwargs) + return p + # For backward compatibility ! @classmethod def from_lonlat_sides(cls, lon_sides, lat_sides): diff --git a/pyresample/geometry.py b/pyresample/geometry.py index cf96df8b3..b16bda258 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -358,7 +358,7 @@ def _get_geostationary_fd_coordinate_sides(self, arr, step): ] return sides - def _get_geostationary_boundary_sides(self, vertices_per_side=None): + def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates="geographic"): """Retrieve the boundary sides list for geostationary projections.""" # Define default frequency if vertices_per_side is None: @@ -369,17 +369,19 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None): # Ensure an even number of vertices for side creation if (vertices_per_side % 2) != 0: vertices_per_side = vertices_per_side + 1 - lons, lats = _get_geostationary_bounding_box_in_lonlats(self, nb_points=vertices_per_side) + # Retrieve coordinates (x,y) or (lon, lat) + x, y = _get_geostationary_bounding_box(self, coordinates=coordinates, nb_points=vertices_per_side) # Ensure that a portion of the area is within the Earth disk. - if lons.shape[0] < 2: + if x.shape[0] < 2: raise ValueError("The geostationary projection area is entirely out of the Earth disk.") # Retrieve dummy sides for GEO (side1 and side3 always of length 2) # - BUG: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! + # --> BUG is in get_geostationary_bounding_box_in_proj_coords # step = int(vertices_per_side / 2) - 1 # old code - step = int(lons.shape[0] / 2) - 1 - lon_sides = self._get_geostationary_fd_coordinate_sides(lons, step=step) - lat_sides = self._get_geostationary_fd_coordinate_sides(lats, step=step) - return lon_sides, lat_sides + step = int(x.shape[0] / 2) - 1 # patch + x_sides = self._get_geostationary_fd_coordinate_sides(x, step=step) + y_sides = self._get_geostationary_fd_coordinate_sides(y, step=step) + return x_sides, y_sides def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optional[int] = None) -> tuple: """Return the boundary sides of the current area. @@ -391,8 +393,10 @@ def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optio Projection coordinates are available only for AreaDefinition objects. vertices_per_side: The number of points to provide for each side. By default (None) - the full width and height will be provided, except for geostationary - projections where by default only 50 points are selected. + the full width and height will be provided. + If any of the area corners is out of the Earth disk (i.e. full + disc geostationary area and hemispheric polar projections) + by default only 50 points are selected. Returns: The output structure is a tuple of two lists of four elements each. @@ -403,12 +407,22 @@ def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optio """ if coordinates not in ("geographic", "projection"): raise ValueError(f"coordinates must be either 'geographic' or 'projection', got {coordinates}") - if self.is_geostationary: - if coordinates == "geographic": - return self._get_geostationary_boundary_sides(vertices_per_side) - # ELSE: - # NOT IMPLEMENTED --> Would change behaviour of get_edge_bbox_in_projection_coordinates - # Currently return the x,y coordinates of the full image border + is_swath = self.__class__.__name__ == "SwathDefinition" + if is_swath: + if coordinates not in ["geographic"]: + raise ValueError("'coordinates' must be 'geographic' for SwathDefinition") + + if not is_swath and _is_any_corner_out_of_earth_disk(self): + if self.is_geostationary: + return self._get_geostationary_boundary_sides(vertices_per_side=vertices_per_side, + coordinates=coordinates) + # ELSE: + # NOT IMPLEMENTED --> Would change behaviour of get_edge_bbox_in_projection_coordinates + # Currently return the x,y coordinates of the full image border + + # if self.is_polar_projection + # self.is_robinson + # raise NotImplementedError("Likely a polar projection.") if coordinates == "geographic": coord_fun = self.get_lonlats else: @@ -514,7 +528,8 @@ def get_edge_lonlats(self, vertices_per_side=None, frequency=None): lons, lats = self.boundary(vertices_per_side=vertices_per_side).contour() return lons, lats - def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None): + def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None, + coordinates="geographic"): """Retrieve the AreaBoundary object. Parameters @@ -537,7 +552,7 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lon_sides, lat_sides = self._get_boundary_sides(coordinates="geographic", + lon_sides, lat_sides = self._get_boundary_sides(coordinates=coordinates, vertices_per_side=vertices_per_side) # TODO: this could be changed but it would breaks backward compatibility # TODO: Implement code to return projection boundary ! @@ -602,7 +617,7 @@ def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): @property def corners(self): - """Return the corners of the current area.""" + """Return the corners (pixel centroids) of the current area.""" from pyresample.spherical_geometry import Coordinate return [Coordinate(*self.get_lonlat(0, 0)), Coordinate(*self.get_lonlat(0, -1)), @@ -2786,6 +2801,24 @@ def get_full_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): return x, y +def _get_geostationary_bounding_box(geos_area, coordinates="geographic", nb_points=50): + """Get the bounding box coordinates of the valid pixels inside `geos_area`. + + If coordinates='geographic', it returns the lat/lon coordinates. + If coordinates='projection', it returns the projection coordinates. + + Args: + geos_area: Geostationary area definition to get the bounding box for. + coordinates: Whether to retrieve geographic or projection coordinates. + nb_points: Number of points on the polygon + """ + x, y = get_geostationary_bounding_box_in_proj_coords(geos_area, nb_points) + if coordinates == "geographic": + lons, lats = Proj(geos_area.crs)(x, y, inverse=True) + return lons, lats + return x, y + + def _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): """Get the bbox in lon/lats of the valid pixels inside `geos_area`. @@ -2793,9 +2826,12 @@ def _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): geos_area: Geostationary area definition to get the bounding box for. nb_points: Number of points on the polygon """ - x, y = get_geostationary_bounding_box_in_proj_coords(geos_area, nb_points) - lons, lats = Proj(geos_area.crs)(x, y, inverse=True) - return lons, lats + warnings.warn("'_get_geostationary_bounding_box_in_lonlats' is deprecated. Please call " + "'_get_geostationary_bounding_box' instead.", + DeprecationWarning, stacklevel=2) + return _get_geostationary_bounding_box(geos_area, + coordinates="geographic", + nb_points=nb_points) def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): @@ -2808,7 +2844,9 @@ def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): warnings.warn("'get_geostationary_bounding_box_in_lonlats' is deprecated. Please call " "'area.boundary().contour()' instead.", DeprecationWarning, stacklevel=2) - return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) + return _get_geostationary_bounding_box(geos_area, + coordinates="geographic", + nb_points=nb_points) def get_geostationary_bounding_box(geos_area, nb_points=50): @@ -2825,6 +2863,15 @@ def get_geostationary_bounding_box(geos_area, nb_points=50): return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) +def _is_any_corner_out_of_earth_disk(area_def): + """Check if the area corners are out of the Earth disk.""" + try: + _ = area_def.corners + return False + except Exception: + return True + + def combine_area_extents_vertical(area1, area2): """Combine the area extents of areas 1 and 2.""" if (area1.area_extent[0] == area2.area_extent[0] and area1.area_extent[2] == area2.area_extent[2]): diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index 8f6500602..f5fab504c 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -25,6 +25,7 @@ from pyproj import CRS, Proj import pyresample +import pyresample.geometry from pyresample import geo_filter, parse_area_file from pyresample.future.geometry import AreaDefinition, SwathDefinition from pyresample.future.geometry.area import ( @@ -39,209 +40,213 @@ from pyresample.test.utils import assert_future_geometry +# BUG in 'area_class' fixture +# --> Overwrite create_test_area here +def create_test_area(crs, shape, area_extent, **kwargs): + """Create an AreaDefinition object for testing.""" + area = AreaDefinition(crs=crs, shape=shape, area_extent=area_extent, **kwargs) + return area + + @pytest.fixture -def stere_area(create_test_area): +def stere_area(): """Create basic polar-stereographic area definition.""" + proj_dict = { + 'a': '6378144.0', + 'b': '6356759.0', + 'lat_0': '50.00', + 'lat_ts': '50.00', + 'lon_0': '8.00', + 'proj': 'stere' + } + shape = (800, 800) + area_extent = (-1370912.72, -909968.64000000001, 1029087.28, 1490031.3600000001) return create_test_area( - { - 'a': '6378144.0', - 'b': '6356759.0', - 'lat_0': '50.00', - 'lat_ts': '50.00', - 'lon_0': '8.00', - 'proj': 'stere' - }, - 800, - 800, - (-1370912.72, -909968.64000000001, 1029087.28, 1490031.3600000001), + proj_dict, + shape, + area_extent, attrs={"name": 'areaD'}, ) @pytest.fixture -def global_lonlat_antimeridian_centered_area(create_test_area): +def global_lonlat_antimeridian_centered_area(): """Create global lonlat projection area centered on the -180 antimeridian.""" - x_size = 4 - y_size = 4 + shape = (4, 4) area_extent = (0, -90.0, 360, 90.0) proj_dict = '+proj=longlat +pm=180' return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def global_platee_caree_area(create_test_area): +def global_platee_caree_area(): """Create global platee projection area.""" - x_size = 4 - y_size = 4 + shape = (4, 4) area_extent = (-180.0, -90.0, 180.0, 90.0) proj_dict = 'EPSG:4326' return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def global_platee_caree_minimum_area(create_test_area): +def global_platee_caree_minimum_area(): """Create minimum size global platee projection area.""" - x_size = 2 - y_size = 2 + """Create global platee projection area.""" + shape = (2, 2) area_extent = (-180.0, -90.0, 180.0, 90.0) proj_dict = 'EPSG:4326' return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def local_platee_caree_area(create_test_area): +def local_platee_caree_area(): """Create local platee projection area.""" - x_size = 4 - y_size = 4 + shape = (4, 4) area_extent = (100, 20, 120, 40) proj_dict = 'EPSG:4326' return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def local_lonlat_antimeridian_centered_area(create_test_area): +def local_lonlat_antimeridian_centered_area(): """Create local lonlat projection area centered on the -180 antimeridian.""" - x_size = 4 - y_size = 4 + shape = (4, 4) area_extent = (100, 20, 120, 40) proj_dict = '+proj=longlat +pm=180' return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def local_meter_area(create_test_area): +def local_meter_area(): """Create local meter projection area.""" - x_size = 2 - y_size = 2 + shape = (2, 2) area_extent = (2_600_000.0, 1_050_000, 2_800_000.0, 1_170_000) proj_dict = 'EPSG:2056' return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def south_pole_area(create_test_area): +def south_pole_area(): """Create projection area centered on south pole.""" - x_size = 2 - y_size = 2 + shape = (2, 2) area_extent = (-5326849.0625, -5326849.0625, 5326849.0625, 5326849.0625) proj_dict = {'proj': 'laea', 'lat_0': -90, 'lon_0': 0, 'a': 6371228.0, 'units': 'm'} return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def north_pole_area(create_test_area): +def north_pole_area(): """Create projection area centered on north pole.""" - x_size = 2 - y_size = 2 + shape = (2, 2) area_extent = (-5326849.0625, -5326849.0625, 5326849.0625, 5326849.0625) proj_dict = {'proj': 'laea', 'lat_0': 90, 'lon_0': 0, 'a': 6371228.0, 'units': 'm'} return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def geos_src_area(create_test_area): +def geos_src_area(): """Create basic geostationary area definition.""" - x_size = 3712 - y_size = 3712 + shape = (3712, 3712) area_extent = (-5570248.477339745, -5561247.267842293, 5567248.074173927, 5570248.477339745) proj_dict = {'a': 6378169.0, 'b': 6356583.8, 'h': 35785831.0, 'lon_0': 0.0, 'proj': 'geos', 'units': 'm'} return create_test_area( - proj_dict, - x_size, - y_size, - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def geos_fd_area(create_test_area): +def geos_fd_area(): """Create full disc geostationary area definition.""" shape = (100, 100) area_extent = (-5500000., -5500000., 5500000., 5500000.) proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, 'lon_0': 0, 'proj': 'geos', 'units': 'm'} return create_test_area( - proj_dict, - shape[0], - shape[1], - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def geos_out_disk_area(create_test_area): +def geos_out_disk_area(): """Create out of Earth diskc geostationary area definition.""" shape = (10, 10) area_extent = (-5500000., -5500000., -5300000., -5300000.) proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, 'lon_0': 0, 'proj': 'geos', 'units': 'm'} return create_test_area( - proj_dict, - shape[0], - shape[1], - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def geos_conus_area(create_test_area): - """Create CONUS geostationary area definition.""" +def geos_half_out_disk_area(): + """Create geostationary area definition with portion of boundary out of earth_disk.""" + shape = (100, 100) + area_extent = (-5500000., -10000., 0, 10000.) + proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, + 'lon_0': 0, 'proj': 'geos', 'units': 'm'} + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) + + +@pytest.fixture +def geos_conus_area(): + """Create CONUS geostationary area definition (portion is out-of-Earth disk).""" shape = (30, 50) # (3000, 5000) for GOES-R CONUS/PACUS proj_dict = {'h': 35786023, 'sweep': 'x', 'x_0': 0, 'y_0': 0, 'ellps': 'GRS80', 'no_defs': None, 'type': 'crs', 'lon_0': -75, 'proj': 'geos', 'units': 'm'} area_extent = (-3627271.29128, 1583173.65752, 1382771.92872, 4589199.58952) return create_test_area( - proj_dict, - shape[0], - shape[1], - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def geos_mesoscale_area(create_test_area): +def geos_mesoscale_area(): """Create CONUS geostationary area definition.""" shape = (10, 10) # (1000, 1000) for GOES-R mesoscale proj_dict = {'h': 35786023, 'sweep': 'x', 'x_0': 0, 'y_0': 0, @@ -249,45 +254,51 @@ def geos_mesoscale_area(create_test_area): 'lon_0': -75, 'proj': 'geos', 'units': 'm'} area_extent = (-501004.322, 3286588.35232, 501004.322, 4288596.99632) return create_test_area( - proj_dict, - shape[0], - shape[1], - area_extent, + crs=proj_dict, + shape=shape, + area_extent=area_extent, ) @pytest.fixture -def truncated_geos_area(create_test_area): +def truncated_geos_area(): """Create a truncated geostationary area.""" - projection = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', - 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} + proj_dict = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', + 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} area_extent = (5567248.0742, 5570248.4773, -5570248.4773, 1393687.2705) - width = 3712 - height = 1392 - geos_area = create_test_area(projection, width, height, area_extent) - return geos_area + shape = (1392, 3712) + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) @pytest.fixture -def truncated_geos_area_in_space(create_test_area): +def truncated_geos_area_in_space(): """Create a truncated geostationary area.""" - projection = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', - 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} + proj_dict = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', + 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} area_extent = (5575000, 5575000, 5570000, 5570000) - width = 10 - height = 10 - geos_area = create_test_area(projection, width, height, area_extent) - return geos_area + shape = (10, 10) + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) @pytest.fixture -def laea_area(create_test_area): +def laea_area(): """Create basic LAEA area definition.""" - x_size = 10 - y_size = 10 + shape = (10, 10) area_extent = [1000000, 0, 1050000, 50000] proj_dict = {"proj": 'laea', 'lat_0': '60', 'lon_0': '0', 'a': '6371228.0', 'units': 'm'} - return create_test_area(proj_dict, x_size, y_size, area_extent) + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) class TestAreaHashability: @@ -1777,6 +1788,23 @@ def test_get_geostationary_angle_extent(self): np.testing.assert_allclose(expected, get_geostationary_angle_extent(geos_area)) + @pytest.mark.parametrize('area_def_name,corner_out_of_disk', [ + ("geos_fd_area", True), + ("geos_out_disk_area", True), + ("geos_half_out_disk_area", True), + ("geos_conus_area", True), + ("geos_mesoscale_area", False), + ]) + def test_is_any_corner_out_of_earth_disk(self, request, area_def_name, corner_out_of_disk): + """Test if corner area is out of Earth disk.""" + from pyresample.geometry import _is_any_corner_out_of_earth_disk + + area_def = request.getfixturevalue(area_def_name) + if corner_out_of_disk: + assert _is_any_corner_out_of_earth_disk(area_def) + else: + assert not _is_any_corner_out_of_earth_disk(area_def) + class TestCrop: """Test the area helpers.""" @@ -2062,6 +2090,26 @@ def test_unsupported_slice_inputs(self, create_test_area, create_test_swath, swa class TestBoundary: """Test 'boundary' method for AreaDefinition classes.""" + @pytest.mark.parametrize('area_def_name,assert_is_called', [ + ("geos_fd_area", True), + ("geos_out_disk_area", True), + ("geos_half_out_disk_area", True), + ("geos_conus_area", True), + ("geos_mesoscale_area", False), + ]) + def test_get_boundary_sides_call_geostationary_utility(self, request, area_def_name, assert_is_called): + area_def = request.getfixturevalue(area_def_name) + + with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: + + # Call the method that could trigger the geostationary _get_geostationary_boundary_sides + _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) + # Assert _get_geostationary_boundary_sides was not called + if assert_is_called: + mock_get_geo.assert_called_once() + else: + mock_get_geo.assert_not_called() + def test_polar_south_pole_projection(self, south_pole_area): """Test boundary for polar projection around the South Pole.""" areadef = south_pole_area diff --git a/pyresample/test/test_geometry/test_dummy.py b/pyresample/test/test_geometry/test_dummy.py new file mode 100644 index 000000000..d36eec604 --- /dev/null +++ b/pyresample/test/test_geometry/test_dummy.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Nov 23 12:26:45 2023 + +@author: ghiggi +""" + +# Copyright (C) 2010-2022 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Test AreaDefinition objects.""" +import io +import sys +from glob import glob +from unittest.mock import MagicMock, patch + +import dask.array as da +import numpy as np +import pytest +import xarray as xr +from pyproj import CRS, Proj + +import pyresample +import pyresample.geometry +from pyresample import geo_filter, parse_area_file +from pyresample.future.geometry import AreaDefinition, SwathDefinition +from pyresample.future.geometry.area import ( + _get_geostationary_bounding_box_in_lonlats, + get_full_geostationary_bounding_box_in_proj_coords, + get_geostationary_angle_extent, + get_geostationary_bounding_box_in_proj_coords, + ignore_pyproj_proj_warnings, +) +from pyresample.future.geometry.base import get_array_hashable +from pyresample.geometry import AreaDefinition as LegacyAreaDefinition +from pyresample.test.utils import assert_future_geometry + + +def create_test_area(crs, shape, area_extent): + """Create an AreaDefinition object for testing.""" + area = AreaDefinition(crs=crs, shape=shape, area_extent=area_extent) + return area + + +@pytest.fixture +def geos_fd_area(): + """Create full disc geostationary area definition.""" + shape = (100, 100) + area_extent = (-5500000., -5500000., 5500000., 5500000.) + proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, + 'lon_0': 0, 'proj': 'geos', 'units': 'm'} + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) + + +@pytest.fixture +def geos_out_disk_area(): + """Create out of Earth diskc geostationary area definition.""" + shape = (10, 10) + area_extent = (-5500000., -5500000., -5300000., -5300000.) + proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, + 'lon_0': 0, 'proj': 'geos', 'units': 'm'} + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) + +@pytest.fixture +def geos_half_out_disk_area(): + """Create geostationary area definition with portion of boundary out of earth_disk.""" + shape = (100, 100) + area_extent = (-5500000., -10000., 0, 10000.) + proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, + 'lon_0': 0, 'proj': 'geos', 'units': 'm'} + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) + + +@pytest.fixture +def geos_conus_area(): + """Create CONUS geostationary area definition (portion is out-of-Earth disk).""" + shape = (30, 50) # (3000, 5000) for GOES-R CONUS/PACUS + proj_dict = {'h': 35786023, 'sweep': 'x', 'x_0': 0, 'y_0': 0, + 'ellps': 'GRS80', 'no_defs': None, 'type': 'crs', + 'lon_0': -75, 'proj': 'geos', 'units': 'm'} + area_extent = (-3627271.29128, 1583173.65752, 1382771.92872, 4589199.58952) + return create_test_area( + crs=proj_dict, + shape=shape, + area_extent=area_extent, + ) + + +class TestBoundary: + """Test 'boundary' method for AreaDefinition classes.""" + + def test_get_boundary_sides_call_geostationary_utility1(self, geos_fd_area): + """Test that the geostationary boundary sides are retrieved correctly.""" + area_def = geos_fd_area + + with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: + + # Call the method that could trigger the geostationary _get_geostationary_boundary_sides + _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) + # Assert _get_geostationary_boundary_sides was not called + mock_get_geo.assert_called_once() + + @pytest.mark.parametrize("area_def_name", ["geos_fd_area", "geos_conus_area", "geos_half_out_disk_area"]) + def test_get_boundary_sides_call_geostationary_utility2(self, request, area_def_name): + """Test that the geostationary boundary sides are retrieved correctly.""" + area_def = request.getfixturevalue(area_def_name) + + with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: + + # Call the method that could trigger the geostationary _get_geostationary_boundary_sides + _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) + # Assert _get_geostationary_boundary_sides was not called + mock_get_geo.assert_called_once() + + + + @pytest.mark.parametrize('area_def_name,assert_is_called', [ + ("geos_fd_area", True), + ("geos_out_disk_area", True), + ("geos_half_out_disk_area", True), + ("geos_conus_area", True), + ]) + def test_get_boundary_sides_call_geostationary_utility(self, request, area_def_name, assert_is_called): + area_def = request.getfixturevalue(area_def_name) + + with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: + + # Call the method that could trigger the geostationary _get_geostationary_boundary_sides + _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) + # Assert _get_geostationary_boundary_sides was not called + if assert_is_called: + mock_get_geo.assert_called_once() + else: + mock_get_geo.assert_not_called() + + + + + \ No newline at end of file diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index bdc53c1a3..da186d8db 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -599,19 +599,16 @@ def test_check_overlap(): def test__get_border_lonlats_geos(): """Test that correct methods are called in _get_border_lonlats() with geos inputs.""" - from pyresample.geometry import _get_geostationary_bounding_box_in_lonlats # noqa from pyresample.gradient import _get_border_lonlats - - lons_v = np.array([1, 2, 3, 4]) - lats_v = np.array([1, 2, 3, 4]) + lon_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] + lat_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", - "+proj=geos +h=1234567", 2, 2, - [1, 2, 3, 4]) - with mock.patch("pyresample.geometry._get_geostationary_bounding_box_in_lonlats") as get_geostationary_bounding_box: - get_geostationary_bounding_box.return_value = lons_v, lats_v - lons, lats = _get_border_lonlats(geo_def) - np.testing.assert_allclose(lons, np.array([1, 2, 3, 4, 1])) - np.testing.assert_allclose(lats, np.array([1, 2, 3, 4, 1])) + "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) + with mock.patch.object(geo_def, "_get_boundary_sides") as get_boundary_lonlats: + get_boundary_lonlats.return_value = lon_sides, lat_sides + lon_b, lat_b = _get_border_lonlats(geo_def) + np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) + np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) def test__get_border_lonlats(): diff --git a/pyresample/visualization/__init__.py b/pyresample/visualization/__init__.py new file mode 100644 index 000000000..22c4a7d1b --- /dev/null +++ b/pyresample/visualization/__init__.py @@ -0,0 +1,8 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Nov 23 14:00:00 2023 + +@author: ghiggi +""" + diff --git a/pyresample/visualization/geometries.py b/pyresample/visualization/geometries.py new file mode 100644 index 000000000..7b64624ac --- /dev/null +++ b/pyresample/visualization/geometries.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (C) 2010-2020 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Define how to plot a shapely geometry.""" + + +def _add_map_background(ax): + """Add cartopy map background.""" + ax.stock_img() + ax.coastlines() + gl = ax.gridlines(draw_labels=True, linestyle="--") + gl.top_labels = False + gl.right_labels = False + return ax + + +def _check_subplot_kw(subplot_kw): + """Check subplot_kw arguments.""" + import cartopy.crs as ccrs + + if subplot_kw is None: + subplot_kw = dict(projection=ccrs.PlateCarree()) + if not isinstance(subplot_kw, dict): + raise TypeError("'subplot_kw' must be a dictionary.'") + if "projection" not in subplot_kw: + raise ValueError("Specify a cartopy 'projection' in subplot_kw.") + return subplot_kw + + +def _initialize_plot(ax=None, subplot_kw=None): + """Initialize plot.""" + import matplotlib.pyplot as plt + + if ax is None: + subplot_kw = _check_subplot_kw(subplot_kw) + fig, ax = plt.subplots(subplot_kw=subplot_kw) + return fig, ax, True + else: + return None, ax, False + + +def plot_geometries(geometries, crs, ax=None, subplot_kw=None, **kwargs): + """Plot geometries in cartopy.""" + # Create figure if ax not provided + fig, ax, initialized_here = _initialize_plot(ax=ax, subplot_kw=subplot_kw) + # Add map background if ax not provided as input + if initialized_here: + ax = _add_map_background(ax) + # Add geometries + ax.add_geometries(geometries, crs=crs, **kwargs) + # Return Figure / Axis + if initialized_here: + return fig + else: + return ax + + From efd077369cd3d03f5d4a645665149bdc1b5cdc1f Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 17:42:28 +0100 Subject: [PATCH 21/39] Deprecate get_edge_bbox_in_projection_coordinates --- pyresample/boundary.py | 208 +++++++++++++++++--- pyresample/geometry.py | 35 ++-- pyresample/slicer.py | 17 +- pyresample/test/test_boundary.py | 40 ++-- pyresample/test/test_geometry/test_dummy.py | 163 --------------- pyresample/test/test_geometry/test_swath.py | 2 +- pyresample/visualization/__init__.py | 24 ++- pyresample/visualization/geometries.py | 22 +-- 8 files changed, 254 insertions(+), 257 deletions(-) delete mode 100644 pyresample/test/test_geometry/test_dummy.py diff --git a/pyresample/boundary.py b/pyresample/boundary.py index 569d0df00..f6867ed8e 100644 --- a/pyresample/boundary.py +++ b/pyresample/boundary.py @@ -27,33 +27,6 @@ logger = logging.getLogger(__name__) -class Boundary(object): - """Boundary objects.""" - - def __init__(self, lons=None, lats=None, frequency=1): - self._contour_poly = None - if lons is not None: - self.lons = lons[::frequency] - if lats is not None: - self.lats = lats[::frequency] - - def contour(self): - """Get lon/lats of the contour.""" - return self.lons, self.lats - - @property - def contour_poly(self): - """Get the Spherical polygon corresponding to the Boundary.""" - if self._contour_poly is None: - self._contour_poly = SphPolygon( - np.deg2rad(np.vstack(self.contour()).T)) - return self._contour_poly - - def draw(self, mapper, options, **more_options): - """Draw the current boundary on the *mapper*.""" - self.contour_poly.draw(mapper, options, **more_options) - - def _is_corner_is_clockwise(lon1, lat1, corner_lon, corner_lat, lon2, lat2): """Determine if coordinates follow a clockwise path. @@ -99,7 +72,26 @@ def _check_sides_list(sides): # - Numpy array elements of at least length 2 -class AreaBoundary(Boundary): +# Potentially shared method +# --> _x, _y ? +# --> _sides_* +# --> sides_*, # BoundarySide + +# sides_* = boundary.sides_* (as property) ? Depending on _side_* +# sides_lon, sides_lat = boundary.sides +# --> sides_lon, sides_lat of BoundarySide? +# --> iter to behave as list ? + +# (x/lons), (y/lats), +# --> contour, +# --> vertices, + +# set_clockwise +# set_counter_clockwise, +# _to_shapely_polygon + + +class GeographicBoundary(): """Area boundary objects. The inputs must be a (lon_coords, lat_coords) tuple for each of the 4 sides. @@ -109,7 +101,7 @@ def __init__(self, lon_sides, lat_sides, wished_order=None): _check_sides_list(lon_sides) _check_sides_list(lat_sides) - # Old interface + # Old interface for compatibility to AreaBoundary self._contour_poly = None self.sides_lons = lon_sides self.sides_lats = lat_sides @@ -264,7 +256,161 @@ def draw(self, mapper, options, **more_options): self.contour_poly.draw(mapper, options, **more_options) -class AreaDefBoundary(AreaBoundary): +class ProjectionBoundary(): + """Projection Boundary object. + + The inputs must be the x and y sides of the projection. + It expects the projection coordinates to be planar (i.e. metric, radians). + """ + + def __init__(self, sides_x, sides_y, wished_order=None, crs=None): + + self.crs = crs # TODO needed to plot + + # New interface + self.sides_x = sides_x + self.sides_y = sides_y + # TODO: self.sides (BoundarySide(s)) + + # Check if it is clockwise/counterclockwise + self.is_clockwise = self._is_projection_boundary_clockwise() + self.is_counterclockwise = not self.is_clockwise + + # Define wished order + if self.is_clockwise: + self._actual_order = "clockwise" + else: + self._actual_order = "counterclockwise" + + if wished_order is None: + self._wished_order = self._actual_order + else: + if wished_order not in ["clockwise", "counterclockwise"]: + raise ValueError("Valid order is 'clockwise' or 'counterclockwise'") + self._wished_order = wished_order + + def _is_projection_boundary_clockwise(self): + """Determine if the boundary is clockwise-defined in projection coordinates.""" + from shapely.geometry import Polygon + + x = np.concatenate([xs[:-1] for xs in self.sides_x]) + y = np.concatenate([ys[:-1] for ys in self.sides_y]) + x = np.hstack((x, x[0])) + y = np.hstack((y, y[0])) + polygon = Polygon(zip(x, y)) + return not polygon.exterior.is_ccw + + def set_clockwise(self): + """Set clockwise order for vertices retrieval.""" + self._wished_order = "clockwise" + return self + + def set_counterclockwise(self): + """Set counterclockwise order for vertices retrieval.""" + self._wished_order = "counterclockwise" + return self + + @property + def sides(self): + """Return the boundary sides as a tuple of (sides_x, sides_y) arrays.""" + return self.sides_x, self.sides_y + + @property + def x(self): + """Retrieve boundary x vertices.""" + xs = np.concatenate([xs[:-1] for xs in self.sides_x]) + if self._wished_order == self._actual_order: + return xs + else: + return xs[::-1] + + @property + def y(self): + """Retrieve boundary y vertices.""" + ys = np.concatenate([ys[:-1] for ys in self.sides_y]) + if self._wished_order == self._actual_order: + return ys + else: + return ys[::-1] + + @property + def vertices(self): + """Return boundary vertices 2D array [x, y].""" + vertices = np.vstack((self.x, self.y)).T + vertices = vertices.astype(np.float64, copy=False) + return vertices + + def contour(self, closed=False): + """Return the (x, y) tuple of the boundary object. + + If excludes the last element of each side because it's included in the next side. + If closed=False (the default), the last vertex is not equal to the first vertex + If closed=True, the last vertex is set to be equal to the first + closed=True is required for shapely Polygon creation. + """ + x = self.x + y = self.y + if closed: + x = np.hstack((x, x[0])) + y = np.hstack((y, y[0])) + return x, y + + def _to_shapely_polygon(self): + from shapely.geometry import Polygon + self = self.set_counterclockwise() + x, y = self.contour(closed=True) + return Polygon(zip(x, y)) + + def polygon(self, shapely=True): + """Return the boundary polygon.""" + if shapely: + return self._to_shapely_polygon() + else: + raise NotImplementedError("Only shapely polygon available.") + + def plot(self, ax=None, subplot_kw=None, crs=None, **kwargs): + """Plot the the boundary.""" + from pyresample.visualization.geometries import plot_geometries + + if self.crs is None and crs is None: + raise ValueError("Projection 'crs' is required to display projection boundary.") + if crs is None: + crs = self.crs + + geom = self.polygon(shapely=True) + p = plot_geometries(geometries=[geom], crs=crs, + ax=ax, subplot_kw=subplot_kw, **kwargs) + return p + + +class Boundary(object): + """Boundary objects.""" + + def __init__(self, lons=None, lats=None, frequency=1): + self._contour_poly = None + if lons is not None: + self.lons = lons[::frequency] + if lats is not None: + self.lats = lats[::frequency] + + def contour(self): + """Get lon/lats of the contour.""" + return self.lons, self.lats + + @property + def contour_poly(self): + """Get the Spherical polygon corresponding to the Boundary.""" + if self._contour_poly is None: + self._contour_poly = SphPolygon( + np.deg2rad(np.vstack(self.contour()).T)) + return self._contour_poly + + def draw(self, mapper, options, **more_options): + """Draw the current boundary on the *mapper*.""" + self.contour_poly.draw(mapper, options, **more_options) + + +class AreaDefBoundary(GeographicBoundary): """Boundaries for area definitions (pyresample).""" def __init__(self, area, frequency=1): @@ -272,7 +418,7 @@ def __init__(self, area, frequency=1): warnings.warn("'AreaDefBoundary' will be removed in the future. " + "Use the Swath/AreaDefinition 'boundary' method instead!.", PendingDeprecationWarning, stacklevel=2) - AreaBoundary.__init__(self, lon_sides=lon_sides, lat_sides=lat_sides) + GeographicBoundary.__init__(self, lon_sides=lon_sides, lat_sides=lat_sides) if frequency != 1: self.decimate(frequency) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index b16bda258..948b1090e 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -416,11 +416,7 @@ def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optio if self.is_geostationary: return self._get_geostationary_boundary_sides(vertices_per_side=vertices_per_side, coordinates=coordinates) - # ELSE: - # NOT IMPLEMENTED --> Would change behaviour of get_edge_bbox_in_projection_coordinates - # Currently return the x,y coordinates of the full image border - - # if self.is_polar_projection + # if self.is_polar_projection # BUG # self.is_robinson # raise NotImplementedError("Likely a polar projection.") if coordinates == "geographic": @@ -547,20 +543,30 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N operations assume that coordinates are clockwise. Default is False. """ - from pyresample.boundary import AreaBoundary + from pyresample.boundary import GeographicBoundary, ProjectionBoundary + if frequency is not None: warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lon_sides, lat_sides = self._get_boundary_sides(coordinates=coordinates, - vertices_per_side=vertices_per_side) - # TODO: this could be changed but it would breaks backward compatibility - # TODO: Implement code to return projection boundary ! + x_sides, y_sides = self._get_boundary_sides(coordinates=coordinates, + vertices_per_side=vertices_per_side) + + # TODO: I would suggest to deprecate force_clockwise + # --> And use order/wished_order argument (None take as it is) if force_clockwise: wished_order = "clockwise" else: wished_order = None - return AreaBoundary(lon_sides, lat_sides, wished_order=wished_order) + + if coordinates == "geographic" or self.crs.is_geographic: + return GeographicBoundary(lon_sides=x_sides, + lat_sides=y_sides, + wished_order=wished_order) + else: + return ProjectionBoundary(sides_x=x_sides, + sides_y=y_sides, + wished_order=wished_order) def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): """Retrieve cartesian coordinates of geometry definition. @@ -1686,9 +1692,12 @@ def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[in if frequency is not None: warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) + warnings.warn("The `get_edge_bbox_in_projection_coordinates` method is pending deprecation." + "Use `area.boundary(coordinates='projection').contour()` instead.", + PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - x_sides, y_sides = self._get_boundary_sides(coordinates="projection", vertices_per_side=vertices_per_side) - return np.hstack(x_sides), np.hstack(y_sides) + x, y = self.boundary(coordinates="projection", vertices_per_side=vertices_per_side).contour(closed=True) + return x, y @property def area_extent(self): diff --git a/pyresample/slicer.py b/pyresample/slicer.py index eaff08f45..5649cc4a0 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -27,11 +27,7 @@ from pyproj.enums import TransformDirection from pyresample import AreaDefinition, SwathDefinition -from pyresample.geometry import ( - IncompatibleAreas, - InvalidArea, - get_geostationary_bounding_box_in_proj_coords, -) +from pyresample.geometry import IncompatibleAreas, InvalidArea try: import dask.array as da @@ -99,7 +95,7 @@ class SwathSlicer(Slicer): def get_polygon_to_contain(self): """Get the shapely Polygon corresponding to *area_to_contain* in lon/lat coordinates.""" from shapely.geometry import Polygon - x, y = self.area_to_contain.get_edge_bbox_in_projection_coordinates(10) + x, y = self.area_to_contain.boundary(coordinates="projection", vertices_per_side=10).contour(closed=True) poly = Polygon(zip(*self._transformer.transform(x, y))) return poly @@ -148,9 +144,10 @@ class AreaSlicer(Slicer): def get_polygon_to_contain(self): """Get the shapely Polygon corresponding to *area_to_contain* in projection coordinates of *area_to_crop*.""" from shapely.geometry import Polygon - x, y = self.area_to_contain.get_edge_bbox_in_projection_coordinates(vertices_per_side=10) + x, y = self.area_to_contain.boundary(coordinates="projection", vertices_per_side=10).contour(closed=True) if self.area_to_crop.is_geostationary: - x_geos, y_geos = get_geostationary_bounding_box_in_proj_coords(self.area_to_crop, 360) + geo_boundary = self.area_to_crop.boundary(coordinates="projection", vertices_per_side=360) + x_geos, y_geos = geo_boundary.contour(closed=True) x_geos, y_geos = self._transformer.transform(x_geos, y_geos, direction=TransformDirection.INVERSE) geos_poly = Polygon(zip(x_geos, y_geos)) poly = Polygon(zip(x, y)) @@ -175,8 +172,8 @@ def get_slices_from_polygon(self, poly_to_contain): bounds = buffered_poly.bounds except ValueError as err: raise InvalidArea("Invalid area") from err - from shapely.geometry import Polygon - poly_to_crop = Polygon(zip(*self.area_to_crop.get_edge_bbox_in_projection_coordinates(vertices_per_side=10))) + + poly_to_crop = self.area_to_crop.boundary(coordinates="projection", vertices_per_side=10).polygon(shapely=True) if not poly_to_crop.intersects(buffered_poly): raise IncompatibleAreas("Areas not overlapping.") bounds = self._sanitize_polygon_bounds(bounds) diff --git a/pyresample/test/test_boundary.py b/pyresample/test/test_boundary.py index 6ebe6af8e..8ee21d8f5 100644 --- a/pyresample/test/test_boundary.py +++ b/pyresample/test/test_boundary.py @@ -22,14 +22,14 @@ import numpy as np import pytest -from pyresample.boundary import AreaBoundary +from pyresample.boundary import GeographicBoundary -class TestAreaBoundary(unittest.TestCase): - """Test 'AreaBoundary' class.""" +class TestGeographicBoundary(unittest.TestCase): + """Test 'GeographicBoundary' class.""" def test_creation_from_lonlat_sides(self): - """Test AreaBoundary creation from sides.""" + """Test GeographicBoundary creation from sides.""" lon_sides = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), np.array([3.0, 3.5, 4.0]), @@ -39,8 +39,8 @@ def test_creation_from_lonlat_sides(self): np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] - # Define AreaBoundary - boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) + # Define GeographicBoundary + boundary = GeographicBoundary.from_lonlat_sides(lon_sides, lat_sides) # Assert sides coincides for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): @@ -50,7 +50,7 @@ def test_creation_from_lonlat_sides(self): assert np.allclose(b_lat, src_lat) def test_creation(self): - """Test AreaBoundary creation.""" + """Test GeographicBoundary creation.""" lon_sides = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), np.array([3.0, 3.5, 4.0]), @@ -60,8 +60,8 @@ def test_creation(self): np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] - # Define AreaBoundary - boundary = AreaBoundary(lon_sides, lat_sides) + # Define GeographicBoundary + boundary = GeographicBoundary(lon_sides, lat_sides) # Assert sides coincides for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): @@ -71,7 +71,7 @@ def test_creation(self): assert np.allclose(b_lat, src_lat) def test_number_sides_required(self): - """Test AreaBoundary requires 4 sides .""" + """Test GeographicBoundary requires 4 sides .""" lon_sides = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), np.array([4.0, 1.0])] @@ -79,10 +79,10 @@ def test_number_sides_required(self): np.array([7.0, 8.0]), np.array([9.0, 6.0])] with pytest.raises(ValueError): - AreaBoundary(lon_sides, lat_sides) + GeographicBoundary(lon_sides, lat_sides) def test_vertices_property(self): - """Test AreaBoundary vertices property.""" + """Test GeographicBoundary vertices property.""" lon_sides = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), np.array([3.0, 3.5, 4.0]), @@ -91,8 +91,8 @@ def test_vertices_property(self): np.array([7.0, 8.0]), np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] - # Define AreaBoundary - boundary = AreaBoundary(lon_sides, lat_sides) + # Define GeographicBoundary + boundary = GeographicBoundary(lon_sides, lat_sides) # Assert vertices expected_vertices = np.array([[1., 6.], @@ -104,7 +104,7 @@ def test_vertices_property(self): assert np.allclose(boundary.vertices, expected_vertices) def test_contour(self): - """Test that AreaBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" + """Test that GeographicBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" lon_sides = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), np.array([3.0, 3.5, 4.0]), @@ -113,14 +113,14 @@ def test_contour(self): np.array([7.0, 8.0]), np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] - # Define AreaBoundary - boundary = AreaBoundary(lon_sides, lat_sides) + # Define GeographicBoundary + boundary = GeographicBoundary(lon_sides, lat_sides) lons, lats = boundary.contour() assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) def test_contour_closed(self): - """Test that AreaBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" + """Test that GeographicBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" lon_sides = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), np.array([3.0, 3.5, 4.0]), @@ -129,8 +129,8 @@ def test_contour_closed(self): np.array([7.0, 8.0]), np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] - # Define AreaBoundary - boundary = AreaBoundary(lon_sides, lat_sides) + # Define GeographicBoundary + boundary = GeographicBoundary(lon_sides, lat_sides) lons, lats = boundary.contour(closed=True) assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4., 1.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9., 6.])) diff --git a/pyresample/test/test_geometry/test_dummy.py b/pyresample/test/test_geometry/test_dummy.py deleted file mode 100644 index d36eec604..000000000 --- a/pyresample/test/test_geometry/test_dummy.py +++ /dev/null @@ -1,163 +0,0 @@ -#!/usr/bin/env python3 -# -*- coding: utf-8 -*- -""" -Created on Thu Nov 23 12:26:45 2023 - -@author: ghiggi -""" - -# Copyright (C) 2010-2022 Pyresample developers -# -# This program is free software: you can redistribute it and/or modify it under -# the terms of the GNU Lesser General Public License as published by the Free -# Software Foundation, either version 3 of the License, or (at your option) any -# later version. -# -# This program is distributed in the hope that it will be useful, but WITHOUT -# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more -# details. -# -# You should have received a copy of the GNU Lesser General Public License -# along with this program. If not, see . -"""Test AreaDefinition objects.""" -import io -import sys -from glob import glob -from unittest.mock import MagicMock, patch - -import dask.array as da -import numpy as np -import pytest -import xarray as xr -from pyproj import CRS, Proj - -import pyresample -import pyresample.geometry -from pyresample import geo_filter, parse_area_file -from pyresample.future.geometry import AreaDefinition, SwathDefinition -from pyresample.future.geometry.area import ( - _get_geostationary_bounding_box_in_lonlats, - get_full_geostationary_bounding_box_in_proj_coords, - get_geostationary_angle_extent, - get_geostationary_bounding_box_in_proj_coords, - ignore_pyproj_proj_warnings, -) -from pyresample.future.geometry.base import get_array_hashable -from pyresample.geometry import AreaDefinition as LegacyAreaDefinition -from pyresample.test.utils import assert_future_geometry - - -def create_test_area(crs, shape, area_extent): - """Create an AreaDefinition object for testing.""" - area = AreaDefinition(crs=crs, shape=shape, area_extent=area_extent) - return area - - -@pytest.fixture -def geos_fd_area(): - """Create full disc geostationary area definition.""" - shape = (100, 100) - area_extent = (-5500000., -5500000., 5500000., 5500000.) - proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, - 'lon_0': 0, 'proj': 'geos', 'units': 'm'} - return create_test_area( - crs=proj_dict, - shape=shape, - area_extent=area_extent, - ) - - -@pytest.fixture -def geos_out_disk_area(): - """Create out of Earth diskc geostationary area definition.""" - shape = (10, 10) - area_extent = (-5500000., -5500000., -5300000., -5300000.) - proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, - 'lon_0': 0, 'proj': 'geos', 'units': 'm'} - return create_test_area( - crs=proj_dict, - shape=shape, - area_extent=area_extent, - ) - -@pytest.fixture -def geos_half_out_disk_area(): - """Create geostationary area definition with portion of boundary out of earth_disk.""" - shape = (100, 100) - area_extent = (-5500000., -10000., 0, 10000.) - proj_dict = {'a': 6378169.00, 'b': 6356583.80, 'h': 35785831.0, - 'lon_0': 0, 'proj': 'geos', 'units': 'm'} - return create_test_area( - crs=proj_dict, - shape=shape, - area_extent=area_extent, - ) - - -@pytest.fixture -def geos_conus_area(): - """Create CONUS geostationary area definition (portion is out-of-Earth disk).""" - shape = (30, 50) # (3000, 5000) for GOES-R CONUS/PACUS - proj_dict = {'h': 35786023, 'sweep': 'x', 'x_0': 0, 'y_0': 0, - 'ellps': 'GRS80', 'no_defs': None, 'type': 'crs', - 'lon_0': -75, 'proj': 'geos', 'units': 'm'} - area_extent = (-3627271.29128, 1583173.65752, 1382771.92872, 4589199.58952) - return create_test_area( - crs=proj_dict, - shape=shape, - area_extent=area_extent, - ) - - -class TestBoundary: - """Test 'boundary' method for AreaDefinition classes.""" - - def test_get_boundary_sides_call_geostationary_utility1(self, geos_fd_area): - """Test that the geostationary boundary sides are retrieved correctly.""" - area_def = geos_fd_area - - with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: - - # Call the method that could trigger the geostationary _get_geostationary_boundary_sides - _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) - # Assert _get_geostationary_boundary_sides was not called - mock_get_geo.assert_called_once() - - @pytest.mark.parametrize("area_def_name", ["geos_fd_area", "geos_conus_area", "geos_half_out_disk_area"]) - def test_get_boundary_sides_call_geostationary_utility2(self, request, area_def_name): - """Test that the geostationary boundary sides are retrieved correctly.""" - area_def = request.getfixturevalue(area_def_name) - - with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: - - # Call the method that could trigger the geostationary _get_geostationary_boundary_sides - _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) - # Assert _get_geostationary_boundary_sides was not called - mock_get_geo.assert_called_once() - - - - @pytest.mark.parametrize('area_def_name,assert_is_called', [ - ("geos_fd_area", True), - ("geos_out_disk_area", True), - ("geos_half_out_disk_area", True), - ("geos_conus_area", True), - ]) - def test_get_boundary_sides_call_geostationary_utility(self, request, area_def_name, assert_is_called): - area_def = request.getfixturevalue(area_def_name) - - with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: - - # Call the method that could trigger the geostationary _get_geostationary_boundary_sides - _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) - # Assert _get_geostationary_boundary_sides was not called - if assert_is_called: - mock_get_geo.assert_called_once() - else: - mock_get_geo.assert_not_called() - - - - - \ No newline at end of file diff --git a/pyresample/test/test_geometry/test_swath.py b/pyresample/test/test_geometry/test_swath.py index b9aeee5f0..4c7b6f1f8 100644 --- a/pyresample/test/test_geometry/test_swath.py +++ b/pyresample/test/test_geometry/test_swath.py @@ -602,7 +602,7 @@ def test_swath_definition(self, create_test_swath): lats = np.array([[65.9, 65.86, 65.82, 65.78], [65.89, 65.86, 65.82, 65.78]]) - # Define SwathDefinition and retrieve AreaBoundary + # Define SwathDefinition and retrieve GeographicBoundary swath_def = create_test_swath(lons, lats) boundary = swath_def.boundary(force_clockwise=False) diff --git a/pyresample/visualization/__init__.py b/pyresample/visualization/__init__.py index 22c4a7d1b..40fd322f2 100644 --- a/pyresample/visualization/__init__.py +++ b/pyresample/visualization/__init__.py @@ -1,8 +1,18 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # -*- coding: utf-8 -*- -""" -Created on Thu Nov 23 14:00:00 2023 - -@author: ghiggi -""" - +# +# Copyright (c) 2014-2021 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""pyresample tools for visualization.""" diff --git a/pyresample/visualization/geometries.py b/pyresample/visualization/geometries.py index 7b64624ac..192e40695 100644 --- a/pyresample/visualization/geometries.py +++ b/pyresample/visualization/geometries.py @@ -18,23 +18,23 @@ """Define how to plot a shapely geometry.""" -def _add_map_background(ax): +def _add_map_background(ax): """Add cartopy map background.""" ax.stock_img() ax.coastlines() gl = ax.gridlines(draw_labels=True, linestyle="--") gl.top_labels = False gl.right_labels = False - return ax + return ax def _check_subplot_kw(subplot_kw): """Check subplot_kw arguments.""" import cartopy.crs as ccrs - + if subplot_kw is None: - subplot_kw = dict(projection=ccrs.PlateCarree()) - if not isinstance(subplot_kw, dict): + subplot_kw = dict(projection=ccrs.PlateCarree()) + if not isinstance(subplot_kw, dict): raise TypeError("'subplot_kw' must be a dictionary.'") if "projection" not in subplot_kw: raise ValueError("Specify a cartopy 'projection' in subplot_kw.") @@ -44,15 +44,15 @@ def _check_subplot_kw(subplot_kw): def _initialize_plot(ax=None, subplot_kw=None): """Initialize plot.""" import matplotlib.pyplot as plt - + if ax is None: subplot_kw = _check_subplot_kw(subplot_kw) fig, ax = plt.subplots(subplot_kw=subplot_kw) return fig, ax, True - else: + else: return None, ax, False - - + + def plot_geometries(geometries, crs, ax=None, subplot_kw=None, **kwargs): """Plot geometries in cartopy.""" # Create figure if ax not provided @@ -64,8 +64,6 @@ def plot_geometries(geometries, crs, ax=None, subplot_kw=None, **kwargs): ax.add_geometries(geometries, crs=crs, **kwargs) # Return Figure / Axis if initialized_here: - return fig + return fig else: return ax - - From 0b669f8899efc317a95661084b7323601a7c9d34 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 23:27:53 +0100 Subject: [PATCH 22/39] Refactor boundary classes and ensure backward compatibilities --- pyresample/boundary/__init__.py | 32 +++ pyresample/boundary/area_boundary.py | 132 ++++++++++ .../geographic_boundary.py} | 231 +----------------- pyresample/boundary/projection_boundary.py | 154 ++++++++++++ pyresample/boundary/sides.py | 86 +++++++ pyresample/boundary/simple_boundary.py | 35 +++ pyresample/future/geometry/_subset.py | 4 +- .../test/test_boundary/test_area_boundary.py | 110 +++++++++ .../test_geographic_boundary.py} | 21 -- 9 files changed, 561 insertions(+), 244 deletions(-) create mode 100644 pyresample/boundary/__init__.py create mode 100644 pyresample/boundary/area_boundary.py rename pyresample/{boundary.py => boundary/geographic_boundary.py} (52%) create mode 100644 pyresample/boundary/projection_boundary.py create mode 100644 pyresample/boundary/sides.py create mode 100644 pyresample/boundary/simple_boundary.py create mode 100644 pyresample/test/test_boundary/test_area_boundary.py rename pyresample/test/{test_boundary.py => test_boundary/test_geographic_boundary.py} (85%) diff --git a/pyresample/boundary/__init__.py b/pyresample/boundary/__init__.py new file mode 100644 index 000000000..9c2b6308a --- /dev/null +++ b/pyresample/boundary/__init__.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2021 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""The Boundary classes.""" + +from pyresample.boundary.simple_boundary import SimpleBoundary +from pyresample.boundary.area_boundary import AreaBoundary, AreaDefBoundary +from pyresample.boundary.geographic_boundary import GeographicBoundary +from pyresample.boundary.projection_boundary import ProjectionBoundary + +__all__ = [ + "GeographicBoundary", + "ProjectionBoundary", + # Deprecated + "SimpleBoundary", + "AreaBoundary", + "AreaDefBoundary", +] \ No newline at end of file diff --git a/pyresample/boundary/area_boundary.py b/pyresample/boundary/area_boundary.py new file mode 100644 index 000000000..45baf74c7 --- /dev/null +++ b/pyresample/boundary/area_boundary.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2023 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Deprecated Boundary, AreaBoundary, AreaDefBoundary class.""" +import logging +import warnings + +import numpy as np + +from pyresample.spherical import SphPolygon + +logger = logging.getLogger(__name__) + + +class Boundary(object): + """Boundary objects.""" + + def __init__(self, lons=None, lats=None, frequency=1): + self._contour_poly = None + if lons is not None: + self.lons = lons[::frequency] + if lats is not None: + self.lats = lats[::frequency] + + def contour(self): + """Get lon/lats of the contour.""" + return self.lons, self.lats + + @property + def contour_poly(self): + """Get the Spherical polygon corresponding to the Boundary.""" + if self._contour_poly is None: + self._contour_poly = SphPolygon( + np.deg2rad(np.vstack(self.contour()).T)) + return self._contour_poly + + def draw(self, mapper, options, **more_options): + """Draw the current boundary on the *mapper*.""" + self.contour_poly.draw(mapper, options, **more_options) + + +class AreaBoundary(Boundary): + """Area boundary objects. + + The inputs must be a (lon_coords, lat_coords) tuple for each of the 4 sides. + """ + + def __init__(self, *sides): + Boundary.__init__(self) + warnings.warn("'AreaBoundary' will be removed in the future. " + + "Use the Swath/AreaDefinition 'boundary' method instead!.", + PendingDeprecationWarning, stacklevel=2) + # Check 4 sides are provided + if len(sides) != 4: + raise ValueError("AreaBoundary expects 4 sides.") + # Retrieve sides + self.sides_lons, self.sides_lats = zip(*sides) + self.sides_lons = list(self.sides_lons) + self.sides_lats = list(self.sides_lats) + + @classmethod + def from_lonlat_sides(cls, lon_sides, lat_sides): + """Define AreaBoundary from list of lon_sides and lat_sides. + + For an area of shape (m, n), the sides must adhere the format: + + sides = [np.array([v00, v01, ..., v0n]), + np.array([v0n, v1n, ..., vmn]), + np.array([vmn, ..., vm1, vm0]), + np.array([vm0, ... ,v10, v00])] + """ + boundary = cls(*zip(lon_sides, lat_sides)) + return boundary + + def contour(self): + """Get the (lons, lats) tuple of the boundary object. + + It excludes the last element of each side because it's included in the next side. + """ + lons = np.concatenate([lns[:-1] for lns in self.sides_lons]) + lats = np.concatenate([lts[:-1] for lts in self.sides_lats]) + return lons, lats + + @property + def vertices(self): + """Return boundary polygon vertices.""" + lons, lats = self.contour() + vertices = np.vstack((lons, lats)).T + vertices = vertices.astype(np.float64, copy=False) # Important for spherical ops. + return vertices + + def decimate(self, ratio): + """Remove some points in the boundaries, but never the corners.""" + for i in range(len(self.sides_lons)): + length = len(self.sides_lons[i]) + start = int((length % ratio) / 2) + points = np.concatenate(([0], np.arange(start, length, ratio), + [length - 1])) + if points[1] == 0: + points = points[1:] + if points[-2] == (length - 1): + points = points[:-1] + self.sides_lons[i] = self.sides_lons[i][points] + self.sides_lats[i] = self.sides_lats[i][points] + + +class AreaDefBoundary(AreaBoundary): + """Boundaries for a pyresample AreaDefinition.""" + + def __init__(self, area, frequency=1): + lon_sides, lat_sides = area.boundary().sides + warnings.warn("'AreaDefBoundary' will be removed in the future. " + + "Use the Swath/AreaDefinition 'boundary' method instead!.", + PendingDeprecationWarning, stacklevel=2) + AreaBoundary.__init__(self, + *zip(lon_sides, lat_sides)) + if frequency != 1: + self.decimate(frequency) diff --git a/pyresample/boundary.py b/pyresample/boundary/geographic_boundary.py similarity index 52% rename from pyresample/boundary.py rename to pyresample/boundary/geographic_boundary.py index f6867ed8e..968f5e60d 100644 --- a/pyresample/boundary.py +++ b/pyresample/boundary/geographic_boundary.py @@ -1,7 +1,7 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- # -# Copyright (c) 2014-2021 Pyresample developers +# Copyright (c) 2014-2023 Pyresample developers # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by @@ -15,7 +15,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""The Boundary classes.""" +"""Define the GeographicBoundary class.""" import logging import warnings @@ -23,6 +23,7 @@ import numpy as np from pyresample.spherical import SphPolygon +from pyresample.boundary.sides import BoundarySides logger = logging.getLogger(__name__) @@ -63,51 +64,19 @@ def _is_boundary_clockwise(sides_lons, sides_lats): return is_clockwise -def _check_sides_list(sides): - if not isinstance(sides, list): - raise TypeError("Boundary sides must be a list") - if len(sides) != 4: - raise ValueError("Boundary sides list must be a list with 4 elements.") - # TODO: - # - Numpy array elements of at least length 2 - - -# Potentially shared method -# --> _x, _y ? -# --> _sides_* -# --> sides_*, # BoundarySide - -# sides_* = boundary.sides_* (as property) ? Depending on _side_* -# sides_lon, sides_lat = boundary.sides -# --> sides_lon, sides_lat of BoundarySide? -# --> iter to behave as list ? - -# (x/lons), (y/lats), -# --> contour, -# --> vertices, - -# set_clockwise -# set_counter_clockwise, -# _to_shapely_polygon - - class GeographicBoundary(): - """Area boundary objects. + """GeographicBoundary object. - The inputs must be a (lon_coords, lat_coords) tuple for each of the 4 sides. + The inputs must be the list of longitude and latitude boundary sides. """ def __init__(self, lon_sides, lat_sides, wished_order=None): - _check_sides_list(lon_sides) - _check_sides_list(lat_sides) - + + self.sides_lons = BoundarySides(lon_sides) + self.sides_lats = BoundarySides(lat_sides) + # Old interface for compatibility to AreaBoundary self._contour_poly = None - self.sides_lons = lon_sides - self.sides_lats = lat_sides - - # New interface - # TODO: self.sides (BoundarySide(s)) # Check if it is clockwise/counterclockwise self.is_clockwise = _is_boundary_clockwise(sides_lons=lon_sides, @@ -253,184 +222,4 @@ def contour_poly(self): def draw(self, mapper, options, **more_options): """Draw the current boundary on the *mapper*.""" - self.contour_poly.draw(mapper, options, **more_options) - - -class ProjectionBoundary(): - """Projection Boundary object. - - The inputs must be the x and y sides of the projection. - It expects the projection coordinates to be planar (i.e. metric, radians). - """ - - def __init__(self, sides_x, sides_y, wished_order=None, crs=None): - - self.crs = crs # TODO needed to plot - - # New interface - self.sides_x = sides_x - self.sides_y = sides_y - # TODO: self.sides (BoundarySide(s)) - - # Check if it is clockwise/counterclockwise - self.is_clockwise = self._is_projection_boundary_clockwise() - self.is_counterclockwise = not self.is_clockwise - - # Define wished order - if self.is_clockwise: - self._actual_order = "clockwise" - else: - self._actual_order = "counterclockwise" - - if wished_order is None: - self._wished_order = self._actual_order - else: - if wished_order not in ["clockwise", "counterclockwise"]: - raise ValueError("Valid order is 'clockwise' or 'counterclockwise'") - self._wished_order = wished_order - - def _is_projection_boundary_clockwise(self): - """Determine if the boundary is clockwise-defined in projection coordinates.""" - from shapely.geometry import Polygon - - x = np.concatenate([xs[:-1] for xs in self.sides_x]) - y = np.concatenate([ys[:-1] for ys in self.sides_y]) - x = np.hstack((x, x[0])) - y = np.hstack((y, y[0])) - polygon = Polygon(zip(x, y)) - return not polygon.exterior.is_ccw - - def set_clockwise(self): - """Set clockwise order for vertices retrieval.""" - self._wished_order = "clockwise" - return self - - def set_counterclockwise(self): - """Set counterclockwise order for vertices retrieval.""" - self._wished_order = "counterclockwise" - return self - - @property - def sides(self): - """Return the boundary sides as a tuple of (sides_x, sides_y) arrays.""" - return self.sides_x, self.sides_y - - @property - def x(self): - """Retrieve boundary x vertices.""" - xs = np.concatenate([xs[:-1] for xs in self.sides_x]) - if self._wished_order == self._actual_order: - return xs - else: - return xs[::-1] - - @property - def y(self): - """Retrieve boundary y vertices.""" - ys = np.concatenate([ys[:-1] for ys in self.sides_y]) - if self._wished_order == self._actual_order: - return ys - else: - return ys[::-1] - - @property - def vertices(self): - """Return boundary vertices 2D array [x, y].""" - vertices = np.vstack((self.x, self.y)).T - vertices = vertices.astype(np.float64, copy=False) - return vertices - - def contour(self, closed=False): - """Return the (x, y) tuple of the boundary object. - - If excludes the last element of each side because it's included in the next side. - If closed=False (the default), the last vertex is not equal to the first vertex - If closed=True, the last vertex is set to be equal to the first - closed=True is required for shapely Polygon creation. - """ - x = self.x - y = self.y - if closed: - x = np.hstack((x, x[0])) - y = np.hstack((y, y[0])) - return x, y - - def _to_shapely_polygon(self): - from shapely.geometry import Polygon - self = self.set_counterclockwise() - x, y = self.contour(closed=True) - return Polygon(zip(x, y)) - - def polygon(self, shapely=True): - """Return the boundary polygon.""" - if shapely: - return self._to_shapely_polygon() - else: - raise NotImplementedError("Only shapely polygon available.") - - def plot(self, ax=None, subplot_kw=None, crs=None, **kwargs): - """Plot the the boundary.""" - from pyresample.visualization.geometries import plot_geometries - - if self.crs is None and crs is None: - raise ValueError("Projection 'crs' is required to display projection boundary.") - if crs is None: - crs = self.crs - - geom = self.polygon(shapely=True) - p = plot_geometries(geometries=[geom], crs=crs, - ax=ax, subplot_kw=subplot_kw, **kwargs) - return p - - -class Boundary(object): - """Boundary objects.""" - - def __init__(self, lons=None, lats=None, frequency=1): - self._contour_poly = None - if lons is not None: - self.lons = lons[::frequency] - if lats is not None: - self.lats = lats[::frequency] - - def contour(self): - """Get lon/lats of the contour.""" - return self.lons, self.lats - - @property - def contour_poly(self): - """Get the Spherical polygon corresponding to the Boundary.""" - if self._contour_poly is None: - self._contour_poly = SphPolygon( - np.deg2rad(np.vstack(self.contour()).T)) - return self._contour_poly - - def draw(self, mapper, options, **more_options): - """Draw the current boundary on the *mapper*.""" - self.contour_poly.draw(mapper, options, **more_options) - - -class AreaDefBoundary(GeographicBoundary): - """Boundaries for area definitions (pyresample).""" - - def __init__(self, area, frequency=1): - lon_sides, lat_sides = area.boundary().sides - warnings.warn("'AreaDefBoundary' will be removed in the future. " + - "Use the Swath/AreaDefinition 'boundary' method instead!.", - PendingDeprecationWarning, stacklevel=2) - GeographicBoundary.__init__(self, lon_sides=lon_sides, lat_sides=lat_sides) - if frequency != 1: - self.decimate(frequency) - - -class SimpleBoundary(object): - """Container for geometry boundary. - - Labelling starts in upper left corner and proceeds clockwise - """ - - def __init__(self, side1, side2, side3, side4): - self.side1 = side1 - self.side2 = side2 - self.side3 = side3 - self.side4 = side4 + self.contour_poly.draw(mapper, options, **more_options) \ No newline at end of file diff --git a/pyresample/boundary/projection_boundary.py b/pyresample/boundary/projection_boundary.py new file mode 100644 index 000000000..512b52f8e --- /dev/null +++ b/pyresample/boundary/projection_boundary.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2023 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Define the ProjectionBoundary class.""" + +import logging + +import numpy as np + +from pyresample.boundary.sides import BoundarySides + +logger = logging.getLogger(__name__) + + +class ProjectionBoundary(): + """Projection Boundary object. + + The inputs must be the x and y sides of the projection. + It expects the projection coordinates to be planar (i.e. metric, radians). + """ + + def __init__(self, sides_x, sides_y, wished_order=None, crs=None): + + self.crs = crs # TODO needed to plot + + self.sides_x = BoundarySides(sides_x) + self.sides_y = BoundarySides(sides_y) + + # Check if it is clockwise/counterclockwise + self.is_clockwise = self._is_projection_boundary_clockwise() + self.is_counterclockwise = not self.is_clockwise + + # Define wished order + if self.is_clockwise: + self._actual_order = "clockwise" + else: + self._actual_order = "counterclockwise" + + if wished_order is None: + self._wished_order = self._actual_order + else: + if wished_order not in ["clockwise", "counterclockwise"]: + raise ValueError("Valid order is 'clockwise' or 'counterclockwise'") + self._wished_order = wished_order + + def _is_projection_boundary_clockwise(self): + """Determine if the boundary is clockwise-defined in projection coordinates.""" + from shapely.geometry import Polygon + + x = np.concatenate([xs[:-1] for xs in self.sides_x]) + y = np.concatenate([ys[:-1] for ys in self.sides_y]) + x = np.hstack((x, x[0])) + y = np.hstack((y, y[0])) + polygon = Polygon(zip(x, y)) + return not polygon.exterior.is_ccw + + def set_clockwise(self): + """Set clockwise order for vertices retrieval.""" + self._wished_order = "clockwise" + return self + + def set_counterclockwise(self): + """Set counterclockwise order for vertices retrieval.""" + self._wished_order = "counterclockwise" + return self + + @property + def sides(self): + """Return the boundary sides as a tuple of (sides_x, sides_y) arrays.""" + return self.sides_x, self.sides_y + + @property + def x(self): + """Retrieve boundary x vertices.""" + xs = np.concatenate([xs[:-1] for xs in self.sides_x]) + if self._wished_order == self._actual_order: + return xs + else: + return xs[::-1] + + @property + def y(self): + """Retrieve boundary y vertices.""" + ys = np.concatenate([ys[:-1] for ys in self.sides_y]) + if self._wished_order == self._actual_order: + return ys + else: + return ys[::-1] + + @property + def vertices(self): + """Return boundary vertices 2D array [x, y].""" + vertices = np.vstack((self.x, self.y)).T + vertices = vertices.astype(np.float64, copy=False) + return vertices + + def contour(self, closed=False): + """Return the (x, y) tuple of the boundary object. + + If excludes the last element of each side because it's included in the next side. + If closed=False (the default), the last vertex is not equal to the first vertex + If closed=True, the last vertex is set to be equal to the first + closed=True is required for shapely Polygon creation. + """ + x = self.x + y = self.y + if closed: + x = np.hstack((x, x[0])) + y = np.hstack((y, y[0])) + return x, y + + def _to_shapely_polygon(self): + from shapely.geometry import Polygon + self = self.set_counterclockwise() + x, y = self.contour(closed=True) + return Polygon(zip(x, y)) + + def polygon(self, shapely=True): + """Return the boundary polygon.""" + if shapely: + return self._to_shapely_polygon() + else: + raise NotImplementedError("Only shapely polygon available.") + + def plot(self, ax=None, subplot_kw=None, crs=None, **kwargs): + """Plot the the boundary.""" + from pyresample.visualization.geometries import plot_geometries + + if self.crs is None and crs is None: + raise ValueError("Projection 'crs' is required to display projection boundary.") + if crs is None: + crs = self.crs + + geom = self.polygon(shapely=True) + p = plot_geometries(geometries=[geom], crs=crs, + ax=ax, subplot_kw=subplot_kw, **kwargs) + return p + + + diff --git a/pyresample/boundary/sides.py b/pyresample/boundary/sides.py new file mode 100644 index 000000000..5fd2ea443 --- /dev/null +++ b/pyresample/boundary/sides.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2023 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Define the BoundarySides class.""" + +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + + +class BoundarySides: + """A class to represent the sides of an area boundary. + + The sides are stored as a tuple of 4 numpy arrays, each representing the + coordinate (geographic or projected) of the vertices of the boundary side. + The sides must be stored in the order (top, right, left, bottom), + which refers to the side position with respect to the coordinate array. + The first row of the coordinate array correspond to the top side, the last row to the bottom side, + the first column to the left side and the last column to the right side. + Please note that the last vertex of each side must be equal to the first vertex of the next side. + """ + __slots__ = ['_sides'] + + def __init__(self, sides): + """Initialize the BoundarySides object.""" + if len(sides) != 4 or not all(isinstance(side, np.ndarray) for side in sides): + raise ValueError("Sides must be a list of four numpy arrays.") + + if not all(np.array_equal(sides[i][-1], sides[(i + 1) % 4][0]) for i in range(4)): + raise ValueError("The last element of each side must be equal to the first element of the next side.") + + self._sides = tuple(sides) # Store as a tuple + + @property + def top(self): + """Return the vertices of the top side.""" + return self._sides[0] + + @property + def right(self): + """Return the vertices of the right side.""" + return self._sides[1] + + @property + def bottom(self): + """Return the vertices of the bottom side.""" + return self._sides[2] + + @property + def left(self): + """Return the vertices of the left side.""" + return self._sides[3] + + @property + def vertices(self): + """Return the vertices of the concatenated sides. + + Note that the last element of each side is discarded to avoid duplicates. + """ + return np.concatenate([side[:-1] for side in self._sides]) + + def __iter__(self): + """Return an iterator over the sides.""" + return iter(self._sides) + + def __getitem__(self, index): + """Return the side at the given index.""" + if not isinstance(index, int) or not 0 <= index < 4: + raise IndexError("Index must be an integer from 0 to 3.") + return self._sides[index] \ No newline at end of file diff --git a/pyresample/boundary/simple_boundary.py b/pyresample/boundary/simple_boundary.py new file mode 100644 index 000000000..6b70d95aa --- /dev/null +++ b/pyresample/boundary/simple_boundary.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2023 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""The deprecated SimpleBoundary class.""" + +import logging + +logger = logging.getLogger(__name__) + + +class SimpleBoundary(object): + """Container for geometry boundary. + + Labelling starts in upper left corner and proceeds clockwise + """ + + def __init__(self, side1, side2, side3, side4): + self.side1 = side1 + self.side2 = side2 + self.side3 = side3 + self.side4 = side4 \ No newline at end of file diff --git a/pyresample/future/geometry/_subset.py b/pyresample/future/geometry/_subset.py index 86670eadb..4012a7697 100644 --- a/pyresample/future/geometry/_subset.py +++ b/pyresample/future/geometry/_subset.py @@ -11,7 +11,7 @@ # must be imported inside functions in the geometry modules if needed # to avoid circular dependencies from pyresample._caching import cache_to_json_if -from pyresample.boundary import Boundary +from pyresample.boundary import GeographicBoundary from pyresample.utils import check_slice_orientation if TYPE_CHECKING: @@ -97,7 +97,7 @@ def _get_slice_starts_stops(src_area, area_to_cover): return xstart, xstop, ystart, ystop -def _get_area_boundary(area_to_cover: AreaDefinition) -> Boundary: +def _get_area_boundary(area_to_cover: AreaDefinition) -> GeographicBoundary: try: if area_to_cover.is_geostationary: vertices_per_side = None diff --git a/pyresample/test/test_boundary/test_area_boundary.py b/pyresample/test/test_boundary/test_area_boundary.py new file mode 100644 index 000000000..f59635b93 --- /dev/null +++ b/pyresample/test/test_boundary/test_area_boundary.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pyresample, Resampling of remote sensing image data in python +# +# Copyright (C) 2010-2022 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Test the boundary objects.""" +import unittest + +import numpy as np +import pytest + +from pyresample.boundary import AreaBoundary + + +class TestAreaBoundary(unittest.TestCase): + """Test 'AreaBoundary' class.""" + + def test_creation_from_lonlat_sides(self): + """Test AreaBoundary creation from sides.""" + lon_sides = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + lat_sides = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] + # Define AreaBoundary + boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) + + # Assert sides coincides + for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): + assert np.allclose(b_lon, src_lon) + + for b_lat, src_lat in zip(boundary.sides_lats, lat_sides): + assert np.allclose(b_lat, src_lat) + + def test_creation(self): + """Test AreaBoundary creation.""" + list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), + (np.array([2., 3.]), np.array([7., 8.])), + (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), + (np.array([4., 1.]), np.array([9., 6.]))] + lon_sides = [side[0]for side in list_sides] + lat_sides = [side[1]for side in list_sides] + + # Define AreaBoundary + boundary = AreaBoundary(*list_sides) + + # Assert sides coincides + for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): + assert np.allclose(b_lon, src_lon) + + for b_lat, src_lat in zip(boundary.sides_lats, lat_sides): + assert np.allclose(b_lat, src_lat) + + def test_number_sides_required(self): + """Test AreaBoundary requires 4 sides .""" + list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), + (np.array([2., 3.]), np.array([7., 8.])), + (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), + (np.array([4., 1.]), np.array([9., 6.]))] + with pytest.raises(ValueError): + AreaBoundary(*list_sides[0:3]) + + def test_vertices_property(self): + """Test AreaBoundary vertices property.""" + lon_sides = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + lat_sides = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] + # Define AreaBoundary + boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) + + # Assert vertices + expected_vertices = np.array([[1., 6.], + [1.5, 6.5], + [2., 7.], + [3., 8.], + [3.5, 8.5], + [4., 9.]]) + assert np.allclose(boundary.vertices, expected_vertices) + + def test_contour(self): + """Test that AreaBoundary.contour returns the correct (lon,lat) tuple.""" + list_sides = [(np.array([1., 1.5, 2.]), np.array([6., 6.5, 7.])), + (np.array([2., 3.]), np.array([7., 8.])), + (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), + (np.array([4., 1.]), np.array([9., 6.]))] + boundary = AreaBoundary(*list_sides) + lons, lats = boundary.contour() + assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) + assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) \ No newline at end of file diff --git a/pyresample/test/test_boundary.py b/pyresample/test/test_boundary/test_geographic_boundary.py similarity index 85% rename from pyresample/test/test_boundary.py rename to pyresample/test/test_boundary/test_geographic_boundary.py index 8ee21d8f5..756d72723 100644 --- a/pyresample/test/test_boundary.py +++ b/pyresample/test/test_boundary/test_geographic_boundary.py @@ -28,27 +28,6 @@ class TestGeographicBoundary(unittest.TestCase): """Test 'GeographicBoundary' class.""" - def test_creation_from_lonlat_sides(self): - """Test GeographicBoundary creation from sides.""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] - - # Define GeographicBoundary - boundary = GeographicBoundary.from_lonlat_sides(lon_sides, lat_sides) - - # Assert sides coincides - for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): - assert np.allclose(b_lon, src_lon) - - for b_lat, src_lat in zip(boundary.sides_lats, lat_sides): - assert np.allclose(b_lat, src_lat) - def test_creation(self): """Test GeographicBoundary creation.""" lon_sides = [np.array([1.0, 1.5, 2.0]), From cea363f33c1710a2c7b5d7fb1422676e9ab0dd91 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 23:43:41 +0100 Subject: [PATCH 23/39] Add BoundarySides test units --- pyresample/boundary/__init__.py | 12 +- pyresample/boundary/area_boundary.py | 3 +- pyresample/boundary/geographic_boundary.py | 10 +- pyresample/boundary/projection_boundary.py | 5 +- pyresample/boundary/sides.py | 6 +- pyresample/boundary/simple_boundary.py | 4 +- .../test/test_boundary/test_area_boundary.py | 4 +- .../test_boundary/test_geographic_boundary.py | 2 +- pyresample/test/test_boundary/test_sides.py | 106 ++++++++++++++++++ 9 files changed, 127 insertions(+), 25 deletions(-) create mode 100644 pyresample/test/test_boundary/test_sides.py diff --git a/pyresample/boundary/__init__.py b/pyresample/boundary/__init__.py index 9c2b6308a..4916a44f2 100644 --- a/pyresample/boundary/__init__.py +++ b/pyresample/boundary/__init__.py @@ -17,16 +17,16 @@ # along with this program. If not, see . """The Boundary classes.""" -from pyresample.boundary.simple_boundary import SimpleBoundary -from pyresample.boundary.area_boundary import AreaBoundary, AreaDefBoundary -from pyresample.boundary.geographic_boundary import GeographicBoundary -from pyresample.boundary.projection_boundary import ProjectionBoundary +from pyresample.boundary.area_boundary import AreaBoundary, AreaDefBoundary +from pyresample.boundary.geographic_boundary import GeographicBoundary +from pyresample.boundary.projection_boundary import ProjectionBoundary +from pyresample.boundary.simple_boundary import SimpleBoundary -__all__ = [ +__all__ = [ "GeographicBoundary", "ProjectionBoundary", # Deprecated "SimpleBoundary", "AreaBoundary", "AreaDefBoundary", -] \ No newline at end of file +] diff --git a/pyresample/boundary/area_boundary.py b/pyresample/boundary/area_boundary.py index 45baf74c7..71dd92b97 100644 --- a/pyresample/boundary/area_boundary.py +++ b/pyresample/boundary/area_boundary.py @@ -126,7 +126,6 @@ def __init__(self, area, frequency=1): warnings.warn("'AreaDefBoundary' will be removed in the future. " + "Use the Swath/AreaDefinition 'boundary' method instead!.", PendingDeprecationWarning, stacklevel=2) - AreaBoundary.__init__(self, - *zip(lon_sides, lat_sides)) + AreaBoundary.__init__(self, *zip(lon_sides, lat_sides)) if frequency != 1: self.decimate(frequency) diff --git a/pyresample/boundary/geographic_boundary.py b/pyresample/boundary/geographic_boundary.py index 968f5e60d..53cd8b568 100644 --- a/pyresample/boundary/geographic_boundary.py +++ b/pyresample/boundary/geographic_boundary.py @@ -22,8 +22,8 @@ import numpy as np -from pyresample.spherical import SphPolygon from pyresample.boundary.sides import BoundarySides +from pyresample.spherical import SphPolygon logger = logging.getLogger(__name__) @@ -67,14 +67,14 @@ def _is_boundary_clockwise(sides_lons, sides_lats): class GeographicBoundary(): """GeographicBoundary object. - The inputs must be the list of longitude and latitude boundary sides. + The inputs must be the list of longitude and latitude boundary sides. """ def __init__(self, lon_sides, lat_sides, wished_order=None): - + self.sides_lons = BoundarySides(lon_sides) self.sides_lats = BoundarySides(lat_sides) - + # Old interface for compatibility to AreaBoundary self._contour_poly = None @@ -222,4 +222,4 @@ def contour_poly(self): def draw(self, mapper, options, **more_options): """Draw the current boundary on the *mapper*.""" - self.contour_poly.draw(mapper, options, **more_options) \ No newline at end of file + self.contour_poly.draw(mapper, options, **more_options) diff --git a/pyresample/boundary/projection_boundary.py b/pyresample/boundary/projection_boundary.py index 512b52f8e..6f4512983 100644 --- a/pyresample/boundary/projection_boundary.py +++ b/pyresample/boundary/projection_boundary.py @@ -36,7 +36,7 @@ class ProjectionBoundary(): def __init__(self, sides_x, sides_y, wished_order=None, crs=None): self.crs = crs # TODO needed to plot - + self.sides_x = BoundarySides(sides_x) self.sides_y = BoundarySides(sides_y) @@ -149,6 +149,3 @@ def plot(self, ax=None, subplot_kw=None, crs=None, **kwargs): p = plot_geometries(geometries=[geom], crs=crs, ax=ax, subplot_kw=subplot_kw, **kwargs) return p - - - diff --git a/pyresample/boundary/sides.py b/pyresample/boundary/sides.py index 5fd2ea443..e52499ea6 100644 --- a/pyresample/boundary/sides.py +++ b/pyresample/boundary/sides.py @@ -33,13 +33,13 @@ class BoundarySides: which refers to the side position with respect to the coordinate array. The first row of the coordinate array correspond to the top side, the last row to the bottom side, the first column to the left side and the last column to the right side. - Please note that the last vertex of each side must be equal to the first vertex of the next side. + Please note that the last vertex of each side must be equal to the first vertex of the next side. """ __slots__ = ['_sides'] def __init__(self, sides): """Initialize the BoundarySides object.""" - if len(sides) != 4 or not all(isinstance(side, np.ndarray) for side in sides): + if len(sides) != 4 or not all(isinstance(side, np.ndarray) and side.ndim == 1 for side in sides): raise ValueError("Sides must be a list of four numpy arrays.") if not all(np.array_equal(sides[i][-1], sides[(i + 1) % 4][0]) for i in range(4)): @@ -83,4 +83,4 @@ def __getitem__(self, index): """Return the side at the given index.""" if not isinstance(index, int) or not 0 <= index < 4: raise IndexError("Index must be an integer from 0 to 3.") - return self._sides[index] \ No newline at end of file + return self._sides[index] diff --git a/pyresample/boundary/simple_boundary.py b/pyresample/boundary/simple_boundary.py index 6b70d95aa..ff66302c5 100644 --- a/pyresample/boundary/simple_boundary.py +++ b/pyresample/boundary/simple_boundary.py @@ -17,7 +17,7 @@ # along with this program. If not, see . """The deprecated SimpleBoundary class.""" -import logging +import logging logger = logging.getLogger(__name__) @@ -32,4 +32,4 @@ def __init__(self, side1, side2, side3, side4): self.side1 = side1 self.side2 = side2 self.side3 = side3 - self.side4 = side4 \ No newline at end of file + self.side4 = side4 diff --git a/pyresample/test/test_boundary/test_area_boundary.py b/pyresample/test/test_boundary/test_area_boundary.py index f59635b93..8a31f5223 100644 --- a/pyresample/test/test_boundary/test_area_boundary.py +++ b/pyresample/test/test_boundary/test_area_boundary.py @@ -16,7 +16,7 @@ # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . -"""Test the boundary objects.""" +"""Test the AreaBoundary objects.""" import unittest import numpy as np @@ -107,4 +107,4 @@ def test_contour(self): boundary = AreaBoundary(*list_sides) lons, lats = boundary.contour() assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) - assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) \ No newline at end of file + assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) diff --git a/pyresample/test/test_boundary/test_geographic_boundary.py b/pyresample/test/test_boundary/test_geographic_boundary.py index 756d72723..75cf7e4a1 100644 --- a/pyresample/test/test_boundary/test_geographic_boundary.py +++ b/pyresample/test/test_boundary/test_geographic_boundary.py @@ -16,7 +16,7 @@ # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . -"""Test the boundary objects.""" +"""Test the GeographicBoundary objects.""" import unittest import numpy as np diff --git a/pyresample/test/test_boundary/test_sides.py b/pyresample/test/test_boundary/test_sides.py new file mode 100644 index 000000000..bd2f1e1f0 --- /dev/null +++ b/pyresample/test/test_boundary/test_sides.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pyresample, Resampling of remote sensing image data in python +# +# Copyright (C) 2010-2022 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Test the BoundarySides objects.""" + +import pytest +import numpy as np +from pyresample.boundary.sides import BoundarySides + + +class TestBoundarySides: + """Test suite for the BoundarySides class with 1D numpy arrays for sides.""" + + def test_initialization_valid_input(self): + """Test initialization with valid 1D numpy array inputs.""" + sides = [np.array([1, 2, 3]), # top + np.array([3, 4, 5]), # right + np.array([5, 6, 7]), # bottom + np.array([7, 8, 1])] # left + boundary = BoundarySides(sides) + assert all(np.array_equal(boundary[i], sides[i]) for i in range(4)) + + def test_initialization_invalid_input(self): + """Test initialization with invalid inputs, such as wrong number of sides or non-1D arrays.""" + with pytest.raises(ValueError): + BoundarySides([np.array([1, 2]), # Invalid number of sides + np.array([2, 3])]) + + with pytest.raises(ValueError): + BoundarySides([np.array([1, 2]), # Non-1D arrays + np.array([[2, 3], [4, 5]]), + np.array([5, 6]), + np.array([6, 7])]) + + with pytest.raises(ValueError): + BoundarySides([np.array([1, 2]), # Invalid side connection + np.array([3, 4]), + np.array([4, 6]), + np.array([6, 1])]) + + def test_property_accessors(self): + """Test property accessors with 1D numpy arrays.""" + sides = [np.array([1, 2, 3]), # top + np.array([3, 4, 5]), # right + np.array([5, 6, 7]), # bottom + np.array([7, 8, 1])] # left + boundary = BoundarySides(sides) + assert np.array_equal(boundary.top, sides[0]) + assert np.array_equal(boundary.right, sides[1]) + assert np.array_equal(boundary.bottom, sides[2]) + assert np.array_equal(boundary.left, sides[3]) + + def test_vertices_property(self): + """Test the vertices property with concatenated 1D numpy arrays.""" + sides = [np.array([1, 2, 3]), # top + np.array([3, 4, 5]), # right + np.array([5, 6, 7]), # bottom + np.array([7, 8, 1])] # left + boundary = BoundarySides(sides) + expected_vertices = np.array([1, 2, 3, 4, 5, 6, 7, 8]) + assert np.array_equal(boundary.vertices, expected_vertices) + + def test_iteration(self): + """Test iteration over the 1D numpy array sides.""" + sides = [np.array([1, 2, 3]), # top + np.array([3, 4, 5]), # right + np.array([5, 6, 7]), # bottom + np.array([7, 8, 1])] # left + boundary = BoundarySides(sides) + for i, side in enumerate(boundary): + assert np.array_equal(side, sides[i]) + + def test_indexing_valid(self): + """Test valid indexing with 1D numpy arrays.""" + sides = [np.array([1, 2, 3]), # top + np.array([3, 4, 5]), # right + np.array([5, 6, 7]), # bottom + np.array([7, 8, 1])] # left + boundary = BoundarySides(sides) + for i in range(4): + assert np.array_equal(boundary[i], sides[i]) + + def test_indexing_invalid(self): + """Test indexing with invalid indices.""" + sides = [np.array([1, 2, 3]), # top + np.array([3, 4, 5]), # right + np.array([5, 6, 7]), # bottom + np.array([7, 8, 1])] # left + boundary = BoundarySides(sides) + with pytest.raises(IndexError): + boundary[4] # Invalid index From b9cda35e4c80f3da8667b847925fc3ce081fbd0c Mon Sep 17 00:00:00 2001 From: ghiggi Date: Thu, 23 Nov 2023 23:54:00 +0100 Subject: [PATCH 24/39] Add test units for visualizion utilities --- pyresample/test/test_boundary/test_sides.py | 3 +- .../test_visualization/test_geometries.py | 87 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 pyresample/test/test_visualization/test_geometries.py diff --git a/pyresample/test/test_boundary/test_sides.py b/pyresample/test/test_boundary/test_sides.py index bd2f1e1f0..29de28265 100644 --- a/pyresample/test/test_boundary/test_sides.py +++ b/pyresample/test/test_boundary/test_sides.py @@ -18,8 +18,9 @@ # along with this program. If not, see . """Test the BoundarySides objects.""" -import pytest import numpy as np +import pytest + from pyresample.boundary.sides import BoundarySides diff --git a/pyresample/test/test_visualization/test_geometries.py b/pyresample/test/test_visualization/test_geometries.py new file mode 100644 index 000000000..548587777 --- /dev/null +++ b/pyresample/test/test_visualization/test_geometries.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pyresample, Resampling of remote sensing image data in python +# +# Copyright (C) 2010-2022 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Test cartopy plotting utilities.""" + +import pytest +import matplotlib.pyplot as plt +import cartopy.crs as ccrs +from shapely.geometry import Polygon +from pyresample.visualization.geometries import ( + _add_map_background, + _check_subplot_kw, + _initialize_plot, + plot_geometries, +) + + +class TestPlotFunctions: + """Test suite for the provided plotting functions.""" + + def test_add_map_background(self): + """Test adding a map background to an axis.""" + fig, ax = plt.subplots(subplot_kw={'projection': ccrs.PlateCarree()}) + result_ax = _add_map_background(ax) + assert isinstance(result_ax, plt.Axes) + + def test_check_subplot_kw_valid(self): + """Test _check_subplot_kw with valid input.""" + valid_kw = {'projection': ccrs.PlateCarree()} + assert _check_subplot_kw(valid_kw) == valid_kw + + def test_check_subplot_kw_none(self): + """Test _check_subplot_kw with None input.""" + assert 'projection' in _check_subplot_kw(None) + + def test_check_subplot_kw_invalid(self): + """Test _check_subplot_kw with invalid input.""" + with pytest.raises(TypeError): + _check_subplot_kw("invalid") + + with pytest.raises(TypeError): + _check_subplot_kw(2) + + with pytest.raises(TypeError): + _check_subplot_kw([2]) + + with pytest.raises(ValueError): + _check_subplot_kw({}) + + def test_initialize_plot_with_ax(self): + """Test _initialize_plot with an existing ax.""" + fig, ax = plt.subplots() + _, result_ax, initialized_here = _initialize_plot(ax=ax) + assert result_ax == ax + assert not initialized_here + + @pytest.mark.parametrize("ax_provided", [True, False]) + def test_plot_geometries(self, ax_provided): + """Test plot_geometries function returns the correct type based on ax_provided.""" + vertices1 = [(0,0),(0,1), (1, 0)] + vertices2 = [(0,0),(0,2), (2, 0)] + geometries = [Polygon(vertices1), Polygon(vertices2)] + crs = ccrs.PlateCarree() + ax = plt.axes(projection=crs) if ax_provided else None + result = plot_geometries(geometries, crs, ax=ax) + + if ax_provided: + assert isinstance(result, plt.Axes) + else: + assert isinstance(result, plt.Figure) + + \ No newline at end of file From d41973acbb5e74831d4f04837812fbb96fd56ac8 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 00:13:43 +0100 Subject: [PATCH 25/39] Consistent naming across repo of lon/lat sides with sides_lons and sides_lats --- pyresample/boundary/area_boundary.py | 4 +- pyresample/boundary/geographic_boundary.py | 29 ++---- pyresample/geometry.py | 22 ++--- .../test/test_boundary/test_area_boundary.py | 50 +++++------ .../test_boundary/test_geographic_boundary.py | 90 +++++++++---------- pyresample/test/test_gradient.py | 12 +-- .../test_visualization/test_geometries.py | 29 +++--- 7 files changed, 109 insertions(+), 127 deletions(-) diff --git a/pyresample/boundary/area_boundary.py b/pyresample/boundary/area_boundary.py index 71dd92b97..5c9a8f9f6 100644 --- a/pyresample/boundary/area_boundary.py +++ b/pyresample/boundary/area_boundary.py @@ -122,10 +122,10 @@ class AreaDefBoundary(AreaBoundary): """Boundaries for a pyresample AreaDefinition.""" def __init__(self, area, frequency=1): - lon_sides, lat_sides = area.boundary().sides + sides_lons, sides_lats = area.boundary().sides warnings.warn("'AreaDefBoundary' will be removed in the future. " + "Use the Swath/AreaDefinition 'boundary' method instead!.", PendingDeprecationWarning, stacklevel=2) - AreaBoundary.__init__(self, *zip(lon_sides, lat_sides)) + AreaBoundary.__init__(self, *zip(sides_lons, sides_lats)) if frequency != 1: self.decimate(frequency) diff --git a/pyresample/boundary/geographic_boundary.py b/pyresample/boundary/geographic_boundary.py index 53cd8b568..f1f5e7496 100644 --- a/pyresample/boundary/geographic_boundary.py +++ b/pyresample/boundary/geographic_boundary.py @@ -18,7 +18,6 @@ """Define the GeographicBoundary class.""" import logging -import warnings import numpy as np @@ -70,17 +69,17 @@ class GeographicBoundary(): The inputs must be the list of longitude and latitude boundary sides. """ - def __init__(self, lon_sides, lat_sides, wished_order=None): + def __init__(self, sides_lons, sides_lats, wished_order=None): - self.sides_lons = BoundarySides(lon_sides) - self.sides_lats = BoundarySides(lat_sides) + self.sides_lons = BoundarySides(sides_lons) + self.sides_lats = BoundarySides(sides_lats) # Old interface for compatibility to AreaBoundary self._contour_poly = None # Check if it is clockwise/counterclockwise - self.is_clockwise = _is_boundary_clockwise(sides_lons=lon_sides, - sides_lats=lat_sides) + self.is_clockwise = _is_boundary_clockwise(sides_lons=sides_lons, + sides_lats=sides_lats) self.is_counterclockwise = not self.is_clockwise # Define wished order @@ -108,7 +107,7 @@ def set_counterclockwise(self): @property def sides(self): - """Return the boundary sides as a tuple of (lon_sides, lat_sides) arrays.""" + """Return the boundary sides as a tuple of (sides_lons, sides_lats) arrays.""" return self.sides_lons, self.sides_lats @property @@ -182,22 +181,6 @@ def plot(self, ax=None, subplot_kw=None, **kwargs): return p # For backward compatibility ! - @classmethod - def from_lonlat_sides(cls, lon_sides, lat_sides): - """Define AreaBoundary from list of lon_sides and lat_sides. - - For an area of shape (m, n), the sides must adhere the format: - - sides = [np.array([v00, v01, ..., v0n]), - np.array([v0n, v1n, ..., vmn]), - np.array([vmn, ..., vm1, vm0]), - np.array([vm0, ... ,v10, v00])] - """ - warnings.warn("Use `AreaBoundary(lon_sides, lat_sides)` instead of `from_lonlat_sides`", - PendingDeprecationWarning, stacklevel=2) - boundary = cls(lon_sides=lon_sides, lat_sides=lat_sides) - return boundary - def decimate(self, ratio): """Remove some points in the boundaries, but never the corners.""" # TODO: to update --> used by AreaDefBoundary diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 948b1090e..e8c1ed505 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -333,17 +333,17 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lon_sides, lat_sides = self._get_boundary_sides(coordinates="geographic", - vertices_per_side=vertices_per_side) + sides_lons, sides_lats = self._get_boundary_sides(coordinates="geographic", + vertices_per_side=vertices_per_side) warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.boundary().sides` instead", PendingDeprecationWarning, stacklevel=2) if force_clockwise and not self._corner_is_clockwise( - lon_sides[0][-2], lat_sides[0][-2], - lon_sides[0][-1], lat_sides[0][-1], - lon_sides[1][1], lat_sides[1][1]): + sides_lons[0][-2], sides_lats[0][-2], + sides_lons[0][-1], sides_lats[0][-1], + sides_lons[1][1], sides_lats[1][1]): # going counter-clockwise - lon_sides, lat_sides = self._reverse_boundaries(lon_sides, lat_sides) - return lon_sides, lat_sides + sides_lons, sides_lats = self._reverse_boundaries(sides_lons, sides_lats) + return sides_lons, sides_lats def _get_geostationary_fd_coordinate_sides(self, arr, step): """Retrieve a 'dummy' boundary side list for a geostationary area with boundaries out of the Earth disk. @@ -435,8 +435,8 @@ def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optio (s4_dim1.squeeze(), s4_dim2.squeeze())]) if hasattr(dim1[0], 'compute') and da is not None: dim1, dim2 = da.compute(dim1, dim2) - lon_sides, lat_sides = self._filter_sides_nans(dim1, dim2) - return lon_sides, lat_sides + sides_lons, sides_lats = self._filter_sides_nans(dim1, dim2) + return sides_lons, sides_lats def _filter_sides_nans( self, @@ -560,8 +560,8 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N wished_order = None if coordinates == "geographic" or self.crs.is_geographic: - return GeographicBoundary(lon_sides=x_sides, - lat_sides=y_sides, + return GeographicBoundary(sides_lons=x_sides, + sides_lats=y_sides, wished_order=wished_order) else: return ProjectionBoundary(sides_x=x_sides, diff --git a/pyresample/test/test_boundary/test_area_boundary.py b/pyresample/test/test_boundary/test_area_boundary.py index 8a31f5223..9638e4343 100644 --- a/pyresample/test/test_boundary/test_area_boundary.py +++ b/pyresample/test/test_boundary/test_area_boundary.py @@ -28,24 +28,24 @@ class TestAreaBoundary(unittest.TestCase): """Test 'AreaBoundary' class.""" - def test_creation_from_lonlat_sides(self): + def test_creation_from_lonsides_lats(self): """Test AreaBoundary creation from sides.""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] + sides_lons = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + sides_lats = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] # Define AreaBoundary - boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) + boundary = AreaBoundary.from_lonsides_lats(sides_lons, sides_lats) # Assert sides coincides - for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): + for b_lon, src_lon in zip(boundary.sides_lons, sides_lons): assert np.allclose(b_lon, src_lon) - for b_lat, src_lat in zip(boundary.sides_lats, lat_sides): + for b_lat, src_lat in zip(boundary.sides_lats, sides_lats): assert np.allclose(b_lat, src_lat) def test_creation(self): @@ -54,17 +54,17 @@ def test_creation(self): (np.array([2., 3.]), np.array([7., 8.])), (np.array([3., 3.5, 4.]), np.array([8., 8.5, 9.])), (np.array([4., 1.]), np.array([9., 6.]))] - lon_sides = [side[0]for side in list_sides] - lat_sides = [side[1]for side in list_sides] + sides_lons = [side[0]for side in list_sides] + sides_lats = [side[1]for side in list_sides] # Define AreaBoundary boundary = AreaBoundary(*list_sides) # Assert sides coincides - for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): + for b_lon, src_lon in zip(boundary.sides_lons, sides_lons): assert np.allclose(b_lon, src_lon) - for b_lat, src_lat in zip(boundary.sides_lats, lat_sides): + for b_lat, src_lat in zip(boundary.sides_lats, sides_lats): assert np.allclose(b_lat, src_lat) def test_number_sides_required(self): @@ -78,16 +78,16 @@ def test_number_sides_required(self): def test_vertices_property(self): """Test AreaBoundary vertices property.""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] + sides_lons = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + sides_lats = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] # Define AreaBoundary - boundary = AreaBoundary.from_lonlat_sides(lon_sides, lat_sides) + boundary = AreaBoundary.from_lonsides_lats(sides_lons, sides_lats) # Assert vertices expected_vertices = np.array([[1., 6.], diff --git a/pyresample/test/test_boundary/test_geographic_boundary.py b/pyresample/test/test_boundary/test_geographic_boundary.py index 75cf7e4a1..0af54eac7 100644 --- a/pyresample/test/test_boundary/test_geographic_boundary.py +++ b/pyresample/test/test_boundary/test_geographic_boundary.py @@ -30,48 +30,48 @@ class TestGeographicBoundary(unittest.TestCase): def test_creation(self): """Test GeographicBoundary creation.""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] + sides_lons = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + sides_lats = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] # Define GeographicBoundary - boundary = GeographicBoundary(lon_sides, lat_sides) + boundary = GeographicBoundary(sides_lons, sides_lats) # Assert sides coincides - for b_lon, src_lon in zip(boundary.sides_lons, lon_sides): + for b_lon, src_lon in zip(boundary.sides_lons, sides_lons): assert np.allclose(b_lon, src_lon) - for b_lat, src_lat in zip(boundary.sides_lats, lat_sides): + for b_lat, src_lat in zip(boundary.sides_lats, sides_lats): assert np.allclose(b_lat, src_lat) def test_number_sides_required(self): """Test GeographicBoundary requires 4 sides .""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([9.0, 6.0])] + sides_lons = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([4.0, 1.0])] + sides_lats = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([9.0, 6.0])] with pytest.raises(ValueError): - GeographicBoundary(lon_sides, lat_sides) + GeographicBoundary(sides_lons, sides_lats) def test_vertices_property(self): """Test GeographicBoundary vertices property.""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] + sides_lons = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + sides_lats = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] # Define GeographicBoundary - boundary = GeographicBoundary(lon_sides, lat_sides) + boundary = GeographicBoundary(sides_lons, sides_lats) # Assert vertices expected_vertices = np.array([[1., 6.], @@ -84,32 +84,32 @@ def test_vertices_property(self): def test_contour(self): """Test that GeographicBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] + sides_lons = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + sides_lats = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] # Define GeographicBoundary - boundary = GeographicBoundary(lon_sides, lat_sides) + boundary = GeographicBoundary(sides_lons, sides_lats) lons, lats = boundary.contour() assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) def test_contour_closed(self): """Test that GeographicBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" - lon_sides = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - lat_sides = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] + sides_lons = [np.array([1.0, 1.5, 2.0]), + np.array([2.0, 3.0]), + np.array([3.0, 3.5, 4.0]), + np.array([4.0, 1.0])] + sides_lats = [np.array([6.0, 6.5, 7.0]), + np.array([7.0, 8.0]), + np.array([8.0, 8.5, 9.0]), + np.array([9.0, 6.0])] # Define GeographicBoundary - boundary = GeographicBoundary(lon_sides, lat_sides) + boundary = GeographicBoundary(sides_lons, sides_lats) lons, lats = boundary.contour(closed=True) assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4., 1.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9., 6.])) diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index da186d8db..514409407 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -600,12 +600,12 @@ def test_check_overlap(): def test__get_border_lonlats_geos(): """Test that correct methods are called in _get_border_lonlats() with geos inputs.""" from pyresample.gradient import _get_border_lonlats - lon_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] - lat_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] + sides_lons = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] + sides_lats = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) with mock.patch.object(geo_def, "_get_boundary_sides") as get_boundary_lonlats: - get_boundary_lonlats.return_value = lon_sides, lat_sides + get_boundary_lonlats.return_value = sides_lons, sides_lats lon_b, lat_b = _get_border_lonlats(geo_def) np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) @@ -614,12 +614,12 @@ def test__get_border_lonlats_geos(): def test__get_border_lonlats(): """Test that correct methods are called in _get_border_lonlats().""" from pyresample.gradient import _get_border_lonlats - lon_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] - lat_sides = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] + sides_lons = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] + sides_lats = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) with mock.patch.object(geo_def, "_get_boundary_sides") as get_boundary_lonlats: - get_boundary_lonlats.return_value = lon_sides, lat_sides + get_boundary_lonlats.return_value = sides_lons, sides_lats lon_b, lat_b = _get_border_lonlats(geo_def) np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) diff --git a/pyresample/test/test_visualization/test_geometries.py b/pyresample/test/test_visualization/test_geometries.py index 548587777..025c02d57 100644 --- a/pyresample/test/test_visualization/test_geometries.py +++ b/pyresample/test/test_visualization/test_geometries.py @@ -18,14 +18,15 @@ # along with this program. If not, see . """Test cartopy plotting utilities.""" -import pytest -import matplotlib.pyplot as plt import cartopy.crs as ccrs -from shapely.geometry import Polygon +import matplotlib.pyplot as plt +import pytest +from shapely.geometry import Polygon + from pyresample.visualization.geometries import ( - _add_map_background, - _check_subplot_kw, - _initialize_plot, + _add_map_background, + _check_subplot_kw, + _initialize_plot, plot_geometries, ) @@ -52,13 +53,13 @@ def test_check_subplot_kw_invalid(self): """Test _check_subplot_kw with invalid input.""" with pytest.raises(TypeError): _check_subplot_kw("invalid") - + with pytest.raises(TypeError): _check_subplot_kw(2) - + with pytest.raises(TypeError): _check_subplot_kw([2]) - + with pytest.raises(ValueError): _check_subplot_kw({}) @@ -68,20 +69,18 @@ def test_initialize_plot_with_ax(self): _, result_ax, initialized_here = _initialize_plot(ax=ax) assert result_ax == ax assert not initialized_here - + @pytest.mark.parametrize("ax_provided", [True, False]) def test_plot_geometries(self, ax_provided): """Test plot_geometries function returns the correct type based on ax_provided.""" - vertices1 = [(0,0),(0,1), (1, 0)] - vertices2 = [(0,0),(0,2), (2, 0)] + vertices1 = [(0, 0), (0, 1), (1, 0)] + vertices2 = [(0, 0), (0, 2), (2, 0)] geometries = [Polygon(vertices1), Polygon(vertices2)] crs = ccrs.PlateCarree() ax = plt.axes(projection=crs) if ax_provided else None result = plot_geometries(geometries, crs, ax=ax) - + if ax_provided: assert isinstance(result, plt.Axes) else: assert isinstance(result, plt.Figure) - - \ No newline at end of file From c6a93780809ce406d9285a4d353fe64e4692d7f3 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 00:53:17 +0100 Subject: [PATCH 26/39] Add geographic_boundary and projection_boundary methods --- pyresample/boundary/geographic_boundary.py | 10 +- pyresample/boundary/projection_boundary.py | 10 +- pyresample/geometry.py | 107 ++++++++++++------ pyresample/slicer.py | 8 +- .../test/test_boundary/test_area_boundary.py | 6 +- 5 files changed, 92 insertions(+), 49 deletions(-) diff --git a/pyresample/boundary/geographic_boundary.py b/pyresample/boundary/geographic_boundary.py index f1f5e7496..92f01952d 100644 --- a/pyresample/boundary/geographic_boundary.py +++ b/pyresample/boundary/geographic_boundary.py @@ -69,7 +69,7 @@ class GeographicBoundary(): The inputs must be the list of longitude and latitude boundary sides. """ - def __init__(self, sides_lons, sides_lats, wished_order=None): + def __init__(self, sides_lons, sides_lats, order=None): self.sides_lons = BoundarySides(sides_lons) self.sides_lats = BoundarySides(sides_lats) @@ -88,12 +88,12 @@ def __init__(self, sides_lons, sides_lats, wished_order=None): else: self._actual_order = "counterclockwise" - if wished_order is None: + if order is None: self._wished_order = self._actual_order else: - if wished_order not in ["clockwise", "counterclockwise"]: - raise ValueError("Valid order is 'clockwise' or 'counterclockwise'") - self._wished_order = wished_order + if order not in ["clockwise", "counterclockwise"]: + raise ValueError("Valid 'order' is 'clockwise' or 'counterclockwise'") + self._wished_order = order def set_clockwise(self): """Set clockwise order for vertices retrieval.""" diff --git a/pyresample/boundary/projection_boundary.py b/pyresample/boundary/projection_boundary.py index 6f4512983..7153c6edc 100644 --- a/pyresample/boundary/projection_boundary.py +++ b/pyresample/boundary/projection_boundary.py @@ -33,7 +33,7 @@ class ProjectionBoundary(): It expects the projection coordinates to be planar (i.e. metric, radians). """ - def __init__(self, sides_x, sides_y, wished_order=None, crs=None): + def __init__(self, sides_x, sides_y, order=None, crs=None): self.crs = crs # TODO needed to plot @@ -50,12 +50,12 @@ def __init__(self, sides_x, sides_y, wished_order=None, crs=None): else: self._actual_order = "counterclockwise" - if wished_order is None: + if order is None: self._wished_order = self._actual_order else: - if wished_order not in ["clockwise", "counterclockwise"]: - raise ValueError("Valid order is 'clockwise' or 'counterclockwise'") - self._wished_order = wished_order + if order not in ["clockwise", "counterclockwise"]: + raise ValueError("Valid 'order' is 'clockwise' or 'counterclockwise'") + self._wished_order = order def _is_projection_boundary_clockwise(self): """Determine if the boundary is clockwise-defined in projection coordinates.""" diff --git a/pyresample/geometry.py b/pyresample/geometry.py index e8c1ed505..d61745688 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -379,9 +379,9 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates= # --> BUG is in get_geostationary_bounding_box_in_proj_coords # step = int(vertices_per_side / 2) - 1 # old code step = int(x.shape[0] / 2) - 1 # patch - x_sides = self._get_geostationary_fd_coordinate_sides(x, step=step) - y_sides = self._get_geostationary_fd_coordinate_sides(y, step=step) - return x_sides, y_sides + sides_x = self._get_geostationary_fd_coordinate_sides(x, step=step) + sides_y = self._get_geostationary_fd_coordinate_sides(y, step=step) + return sides_x, sides_y def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optional[int] = None) -> tuple: """Return the boundary sides of the current area. @@ -392,10 +392,10 @@ def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optio Either "geographic" or "projection". Projection coordinates are available only for AreaDefinition objects. vertices_per_side: - The number of points to provide for each side. By default (None) - the full width and height will be provided. - If any of the area corners is out of the Earth disk (i.e. full - disc geostationary area and hemispheric polar projections) + The number of points to provide for each side. + By default (None) the full width and height will be provided. + If the area object is an AreaDefinition with any corner out of the Earth disk + (i.e. full disc geostationary area, Robinson projection, polar projections, ...) by default only 50 points are selected. Returns: @@ -524,16 +524,17 @@ def get_edge_lonlats(self, vertices_per_side=None, frequency=None): lons, lats = self.boundary(vertices_per_side=vertices_per_side).contour() return lons, lats - def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None, - coordinates="geographic"): + def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None): """Retrieve the AreaBoundary object. Parameters ---------- vertices_per_side: - (formerly `frequency`) The number of points to provide for each side. By default (None) - the full width and height will be provided, except for geostationary - projection where by default only 50 points are selected. + (formerly `frequency`) The number of points to provide for each side. + By default (None) the full width and height will be provided. + If the area object is an AreaDefinition with any corner out of the Earth disk + (i.e. full disc geostationary area, Robinson projection, polar projections, ...) + by default only 50 points are selected. force_clockwise: Perform minimal checks and reordering of coordinates to ensure that the returned coordinates follow a clockwise direction. @@ -543,30 +544,44 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N operations assume that coordinates are clockwise. Default is False. """ - from pyresample.boundary import GeographicBoundary, ProjectionBoundary - if frequency is not None: warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) + warnings.warn("The `boundary` method is pending deprecation. Use `geographic_boundary` instead", + PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - x_sides, y_sides = self._get_boundary_sides(coordinates=coordinates, - vertices_per_side=vertices_per_side) - - # TODO: I would suggest to deprecate force_clockwise - # --> And use order/wished_order argument (None take as it is) if force_clockwise: - wished_order = "clockwise" + order = "clockwise" else: - wished_order = None + order = None + return self.geographic_boundary(vertices_per_side=vertices_per_side, order=order) - if coordinates == "geographic" or self.crs.is_geographic: - return GeographicBoundary(sides_lons=x_sides, - sides_lats=y_sides, - wished_order=wished_order) - else: - return ProjectionBoundary(sides_x=x_sides, - sides_y=y_sides, - wished_order=wished_order) + def geographic_boundary(self, vertices_per_side=None, order=None): + """Retrieve the GeographicBoundary object. + + Parameters + ---------- + vertices_per_side: + The number of points to provide for each side. + By default (None) the full width and height will be provided. + If the area object is an AreaDefinition with any corner out of the Earth disk + (i.e. full disc geostationary area, Robinson projection, polar projections, ...) + by default only 50 points are selected. + order: + Specify the desired order of the boundary polygon vertices in GeographicBoundary. + If order=None, the sides order is used. + If order="clockwise", the boundary polygon vertices are returned by + GeographicBoundary in clockwise order. + If order="counterclockwise", the boundary polygon vertices are returned by + GeographicBoundary in counterclockwise order. + """ + from pyresample.boundary import GeographicBoundary + + sides_x, sides_y = self._get_boundary_sides(coordinates="geographic", + vertices_per_side=vertices_per_side) + return GeographicBoundary(sides_lons=sides_x, + sides_lats=sides_y, + order=order) def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): """Retrieve cartesian coordinates of geometry definition. @@ -1686,6 +1701,34 @@ def is_geostationary(self): return False return 'geostationary' in coord_operation.method_name.lower() + def projection_boundary(self, vertices_per_side=None, order=None): + """Retrieve the ProjectionBoundary object. + + Parameters + ---------- + vertices_per_side: + The number of points to provide for each side. + By default (None) the full width and height will be provided. + If the area object is an AreaDefinition with any corner out of the Earth disk + (i.e. full disc geostationary area, Robinson projection, polar projections, ...) + by default only 50 points are selected. + order: + Specify the desired order of the boundary polygon vertices in GeographicBoundary. + If order=None, the sides order is used. + If order="clockwise", the boundary polygon vertices are returned by + GeographicBoundary in clockwise order. + If order="counterclockwise", the boundary polygon vertices are returned by + GeographicBoundary in counterclockwise order. + """ + from pyresample.boundary import ProjectionBoundary + if self.crs.is_geographic: + return self.geographic_boundary(vertices_per_side=vertices_per_side, order=order) + sides_x, sides_y = self._get_boundary_sides(coordinates="projection", + vertices_per_side=vertices_per_side) + return ProjectionBoundary(sides_x=sides_x, + sides_y=sides_y, + order=order) + def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[int] = None, frequency: Optional[int] = None): """Return the bounding box in projection coordinates.""" @@ -1693,10 +1736,10 @@ def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[in warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) warnings.warn("The `get_edge_bbox_in_projection_coordinates` method is pending deprecation." - "Use `area.boundary(coordinates='projection').contour()` instead.", + "Use `area.projection_boundary().contour()` instead.", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - x, y = self.boundary(coordinates="projection", vertices_per_side=vertices_per_side).contour(closed=True) + x, y = self.projection_boundary(vertices_per_side=vertices_per_side).contour(closed=True) return x, y @property @@ -2851,7 +2894,7 @@ def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): nb_points: Number of points on the polygon """ warnings.warn("'get_geostationary_bounding_box_in_lonlats' is deprecated. Please call " - "'area.boundary().contour()' instead.", + "'area.geographic_boundary().contour()' instead.", DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box(geos_area, coordinates="geographic", diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 5649cc4a0..74e5a3f1d 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -95,7 +95,7 @@ class SwathSlicer(Slicer): def get_polygon_to_contain(self): """Get the shapely Polygon corresponding to *area_to_contain* in lon/lat coordinates.""" from shapely.geometry import Polygon - x, y = self.area_to_contain.boundary(coordinates="projection", vertices_per_side=10).contour(closed=True) + x, y = self.area_to_contain.projection_boundary(vertices_per_side=10).contour(closed=True) poly = Polygon(zip(*self._transformer.transform(x, y))) return poly @@ -144,9 +144,9 @@ class AreaSlicer(Slicer): def get_polygon_to_contain(self): """Get the shapely Polygon corresponding to *area_to_contain* in projection coordinates of *area_to_crop*.""" from shapely.geometry import Polygon - x, y = self.area_to_contain.boundary(coordinates="projection", vertices_per_side=10).contour(closed=True) + x, y = self.area_to_contain.projection_boundary(vertices_per_side=10).contour(closed=True) if self.area_to_crop.is_geostationary: - geo_boundary = self.area_to_crop.boundary(coordinates="projection", vertices_per_side=360) + geo_boundary = self.area_to_crop.projection_boundary(vertices_per_side=360) x_geos, y_geos = geo_boundary.contour(closed=True) x_geos, y_geos = self._transformer.transform(x_geos, y_geos, direction=TransformDirection.INVERSE) geos_poly = Polygon(zip(x_geos, y_geos)) @@ -173,7 +173,7 @@ def get_slices_from_polygon(self, poly_to_contain): except ValueError as err: raise InvalidArea("Invalid area") from err - poly_to_crop = self.area_to_crop.boundary(coordinates="projection", vertices_per_side=10).polygon(shapely=True) + poly_to_crop = self.area_to_crop.projection_boundary(vertices_per_side=10).polygon(shapely=True) if not poly_to_crop.intersects(buffered_poly): raise IncompatibleAreas("Areas not overlapping.") bounds = self._sanitize_polygon_bounds(bounds) diff --git a/pyresample/test/test_boundary/test_area_boundary.py b/pyresample/test/test_boundary/test_area_boundary.py index 9638e4343..77fe66f74 100644 --- a/pyresample/test/test_boundary/test_area_boundary.py +++ b/pyresample/test/test_boundary/test_area_boundary.py @@ -28,7 +28,7 @@ class TestAreaBoundary(unittest.TestCase): """Test 'AreaBoundary' class.""" - def test_creation_from_lonsides_lats(self): + def test_creation_from_lonlat_sides(self): """Test AreaBoundary creation from sides.""" sides_lons = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), @@ -39,7 +39,7 @@ def test_creation_from_lonsides_lats(self): np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] # Define AreaBoundary - boundary = AreaBoundary.from_lonsides_lats(sides_lons, sides_lats) + boundary = AreaBoundary.from_lonlat_sides(sides_lons, sides_lats) # Assert sides coincides for b_lon, src_lon in zip(boundary.sides_lons, sides_lons): @@ -87,7 +87,7 @@ def test_vertices_property(self): np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] # Define AreaBoundary - boundary = AreaBoundary.from_lonsides_lats(sides_lons, sides_lats) + boundary = AreaBoundary.from_lonlat_sides(sides_lons, sides_lats) # Assert vertices expected_vertices = np.array([[1., 6.], From 63238146ad8580cd9e4e0325070186aaa4857840 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 01:13:53 +0100 Subject: [PATCH 27/39] Deprecate boundary() for geographic_boundary() --- docs/source/howtos/spherical_geometry.rst | 2 +- pyresample/future/geometry/_subset.py | 2 +- pyresample/geometry.py | 13 +++++++------ pyresample/gradient/__init__.py | 2 +- pyresample/slicer.py | 2 +- pyresample/test/test_geometry/test_area.py | 18 +++++++++--------- pyresample/test/test_geometry/test_swath.py | 2 +- 7 files changed, 21 insertions(+), 20 deletions(-) diff --git a/docs/source/howtos/spherical_geometry.rst b/docs/source/howtos/spherical_geometry.rst index 5dd0e8b4f..0bf413470 100644 --- a/docs/source/howtos/spherical_geometry.rst +++ b/docs/source/howtos/spherical_geometry.rst @@ -73,7 +73,7 @@ satellite passes. See trollschedule_ how to generate a list of satellite overpas >>> from pyresample.spherical_utils import GetNonOverlapUnions - >>> area_boundary = area_def.boundary(vertices_per_side=100) # doctest: +SKIP + >>> area_boundary = area_def.geographic_boundary(vertices_per_side=100) # doctest: +SKIP >>> area_boundary = area_boundary.contour_poly # doctest: +SKIP >>> list_of_polygons = [] diff --git a/pyresample/future/geometry/_subset.py b/pyresample/future/geometry/_subset.py index 4012a7697..c401f54e5 100644 --- a/pyresample/future/geometry/_subset.py +++ b/pyresample/future/geometry/_subset.py @@ -103,7 +103,7 @@ def _get_area_boundary(area_to_cover: AreaDefinition) -> GeographicBoundary: vertices_per_side = None else: vertices_per_side = max(max(*area_to_cover.shape) // 100 + 1, 3) - return area_to_cover.boundary(vertices_per_side=vertices_per_side, force_clockwise=True) + return area_to_cover.geographic_boundary(vertices_per_side=vertices_per_side, order="clockwise") except ValueError as err: raise NotImplementedError("Can't determine boundary of area to cover") from err diff --git a/pyresample/geometry.py b/pyresample/geometry.py index d61745688..2247eb1dd 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -292,7 +292,8 @@ def is_geostationary(self): """Whether this geometry is in a geostationary satellite projection or not.""" return False - def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockwise: bool = True, + def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, + force_clockwise: bool = True, frequency: Optional[int] = None) -> tuple: """Return the bounding box lons and lats sides. @@ -335,7 +336,7 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, force_clockw vertices_per_side = vertices_per_side or frequency sides_lons, sides_lats = self._get_boundary_sides(coordinates="geographic", vertices_per_side=vertices_per_side) - warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.boundary().sides` instead", + warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.geographic_boundary().sides` instead", PendingDeprecationWarning, stacklevel=2) if force_clockwise and not self._corner_is_clockwise( sides_lons[0][-2], sides_lats[0][-2], @@ -518,10 +519,10 @@ def get_edge_lonlats(self, vertices_per_side=None, frequency=None): warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead.", PendingDeprecationWarning, stacklevel=2) msg = "`get_edge_lonlats` is pending deprecation" - msg += "Use `area.boundary(vertices_per_side=vertices_per_side).contour()` instead." + msg += "Use `area.geographic_boundary(vertices_per_side=vertices_per_side).contour()` instead." warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lons, lats = self.boundary(vertices_per_side=vertices_per_side).contour() + lons, lats = self.geographic_boundary(vertices_per_side=vertices_per_side).contour() return lons, lats def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None): @@ -1121,7 +1122,7 @@ def compute_optimal_bb_area(self, proj_dict=None, resolution=None): proj_dict = self.compute_bb_proj_params(proj_dict) area = DynamicAreaDefinition(area_id, description, proj_dict) - lons, lats = self.boundary(vertices_per_side=None).contour() + lons, lats = self.geographic_boundary(vertices_per_side=None).contour() return area.freeze((lons, lats), shape=(height, width)) @@ -2910,7 +2911,7 @@ def get_geostationary_bounding_box(geos_area, nb_points=50): """ warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " - "'area.boundary().contour()' instead.", + "'area.geographic_boundary().contour()' instead.", DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 58ea83be2..74c3838c1 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -373,7 +373,7 @@ def _get_border_lonlats(geo_def: AreaDefinition, vertices_per_side=None): """Get the border x- and y-coordinates.""" if geo_def.is_geostationary: vertices_per_side = 3600 - lon_b, lat_b = geo_def.boundary(vertices_per_side=vertices_per_side).contour(closed=True) + lon_b, lat_b = geo_def.geographic_boundary(vertices_per_side=vertices_per_side).contour(closed=True) return lon_b, lat_b diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 74e5a3f1d..60d887c0d 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -128,7 +128,7 @@ def _get_chunk_polygons_for_swath_to_crop(swath_to_crop): line_slice = expand_slice(line_slice) col_slice = expand_slice(col_slice) smaller_swath = swath_to_crop[line_slice, col_slice] - smaller_poly = smaller_swath.boundary(vertices_per_side=10).polygon(shapely=True) + smaller_poly = smaller_swath.geographic_boundary(vertices_per_side=10).polygon(shapely=True) res.append((smaller_poly, (line_slice, col_slice))) return res diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index f5fab504c..53693d397 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -2113,7 +2113,7 @@ def test_get_boundary_sides_call_geostationary_utility(self, request, area_def_n def test_polar_south_pole_projection(self, south_pole_area): """Test boundary for polar projection around the South Pole.""" areadef = south_pole_area - boundary = areadef.boundary(force_clockwise=False) + boundary = areadef.geographic_boundary(order=None) # Check boundary shape height, width = areadef.shape @@ -2131,7 +2131,7 @@ def test_north_pole_projection(self, north_pole_area): """Test boundary for polar projection around the North Pole.""" areadef = north_pole_area - boundary = areadef.boundary(force_clockwise=False) + boundary = areadef.geographic_boundary(order=None) # Check boundary shape height, width = areadef.shape @@ -2151,24 +2151,24 @@ def test_full_disc_geostationary_projection(self, geos_fd_area): # Check default boundary shape default_n_vertices = 50 - boundary = areadef.boundary(vertices_per_side=None) + boundary = areadef.geographic_boundary(vertices_per_side=None, order=None) assert boundary.vertices.shape == (default_n_vertices, 2) # Check minimum boundary vertices n_vertices = 3 minimum_n_vertices = 4 - boundary = areadef.boundary(vertices_per_side=n_vertices) + boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, order=None) assert boundary.vertices.shape == (minimum_n_vertices, 2) # Check odd number of vertices per side # - Rounded to the sequent even number (to construct the sides) n_odd_vertices = 5 - boundary = areadef.boundary(vertices_per_side=n_odd_vertices) + boundary = areadef.geographic_boundary(vertices_per_side=n_odd_vertices) assert boundary.vertices.shape == (n_odd_vertices + 1, 2) # Check boundary vertices n_vertices = 10 - boundary = areadef.boundary(vertices_per_side=n_vertices, force_clockwise=False) + boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, order=None) # Check boundary vertices is in correct order expected_vertices = np.array([[-7.54251621e+01, 3.53432890e+01], @@ -2186,7 +2186,7 @@ def test_full_disc_geostationary_projection(self, geos_fd_area): def test_global_platee_caree_projection(self, global_platee_caree_area): """Test boundary for global platee caree projection.""" areadef = global_platee_caree_area - boundary = areadef.boundary(force_clockwise=False) + boundary = areadef.geographic_boundary(order=None) # Check boundary shape height, width = areadef.shape @@ -2211,7 +2211,7 @@ def test_global_platee_caree_projection(self, global_platee_caree_area): def test_minimal_global_platee_caree_projection(self, global_platee_caree_minimum_area): """Test boundary for global platee caree projection.""" areadef = global_platee_caree_minimum_area - boundary = areadef.boundary(force_clockwise=False) + boundary = areadef.geographic_boundary(order=None) # Check boundary shape height, width = areadef.shape @@ -2228,7 +2228,7 @@ def test_minimal_global_platee_caree_projection(self, global_platee_caree_minimu def test_local_area_projection(self, local_meter_area): """Test local area projection in meter.""" areadef = local_meter_area - boundary = areadef.boundary(force_clockwise=False) + boundary = areadef.geographic_boundary(order=None) # Check boundary shape height, width = areadef.shape diff --git a/pyresample/test/test_geometry/test_swath.py b/pyresample/test/test_geometry/test_swath.py index 4c7b6f1f8..a16d3905f 100644 --- a/pyresample/test/test_geometry/test_swath.py +++ b/pyresample/test/test_geometry/test_swath.py @@ -604,7 +604,7 @@ def test_swath_definition(self, create_test_swath): # Define SwathDefinition and retrieve GeographicBoundary swath_def = create_test_swath(lons, lats) - boundary = swath_def.boundary(force_clockwise=False) + boundary = swath_def.geographic_boundary(order=None) # Check boundary shape height, width = swath_def.shape From 6210beaee32b1e8e313b85f9d0658aade5081b62 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 01:23:54 +0100 Subject: [PATCH 28/39] Pass AreaDef crs to ProjectionBoundary crs --- pyresample/boundary/projection_boundary.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyresample/boundary/projection_boundary.py b/pyresample/boundary/projection_boundary.py index 7153c6edc..7c19a2c3a 100644 --- a/pyresample/boundary/projection_boundary.py +++ b/pyresample/boundary/projection_boundary.py @@ -34,8 +34,8 @@ class ProjectionBoundary(): """ def __init__(self, sides_x, sides_y, order=None, crs=None): - - self.crs = crs # TODO needed to plot + """Initialize the ProjectionBoundary object.""" + self.crs = crs # required for .plot() method self.sides_x = BoundarySides(sides_x) self.sides_y = BoundarySides(sides_y) From 28b55276f782ca4a1442e94c3881a7bfab27c7b6 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 11:48:59 +0100 Subject: [PATCH 29/39] Solve wasted polygon computations in gradient for GEO FD --- pyresample/geometry.py | 3 ++- pyresample/gradient/__init__.py | 17 ++++++++++++++--- pyresample/slicer.py | 2 ++ 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 2247eb1dd..b6373ce73 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -1728,7 +1728,8 @@ def projection_boundary(self, vertices_per_side=None, order=None): vertices_per_side=vertices_per_side) return ProjectionBoundary(sides_x=sides_x, sides_y=sides_y, - order=order) + order=order, + crs=self.crs) def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[int] = None, frequency: Optional[int] = None): diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 74c3838c1..73f7b8223 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -162,6 +162,8 @@ def get_chunk_mappings(self): src_y_chunks, src_x_chunks = self.src_x.chunks dst_y_chunks, dst_x_chunks = self.dst_x.chunks + dst_is_swath = isinstance(self.target_geo_def, SwathDefinition) + coverage_status = [] src_slices, dst_slices = [], [] dst_mosaic_locations = [] @@ -182,9 +184,18 @@ def get_chunk_mappings(self): for y_step_number, dst_y_step in enumerate(dst_y_chunks): dst_y_end = dst_y_start + dst_y_step # Get destination chunk polygon - dst_poly = self._get_dst_poly((x_step_number, y_step_number), - dst_x_start, dst_x_end, - dst_y_start, dst_y_end) + # - Retrieve it only if source chunk poly is inside Earth Disk + # - Skips lot polygon creations when source is GEO FD + # - Retrieve if dst area is swath - + # - Currently dst_poly will be False + # - check_overlap will return True + if src_poly is not None or dst_is_swath: + dst_poly = self._get_dst_poly((x_step_number, y_step_number), + dst_x_start, dst_x_end, + dst_y_start, dst_y_end) + else: + dst_poly = None + covers = check_overlap(src_poly, dst_poly) coverage_status.append(covers) diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 60d887c0d..fcc6f8099 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -148,6 +148,8 @@ def get_polygon_to_contain(self): if self.area_to_crop.is_geostationary: geo_boundary = self.area_to_crop.projection_boundary(vertices_per_side=360) x_geos, y_geos = geo_boundary.contour(closed=True) + # POSSIBLE BUG: Here I expect that some coordinates could be NaN ! + # - if points of the geostationary disk are out of the CRS bounds of the area_to_contain x_geos, y_geos = self._transformer.transform(x_geos, y_geos, direction=TransformDirection.INVERSE) geos_poly = Polygon(zip(x_geos, y_geos)) poly = Polygon(zip(x, y)) From 0eb7a6b7aedad8da8845444bb2644b6061a92a17 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 17:37:50 +0100 Subject: [PATCH 30/39] Deprecate get_boundary_lonlats and SimpleBoundary --- pyresample/__init__.py | 9 +++++++-- pyresample/bilinear/_base.py | 5 ++--- pyresample/data_reduce.py | 28 ++++++++++++++++------------ pyresample/geometry.py | 4 +++- pyresample/gradient/__init__.py | 5 +++-- pyresample/kd_tree.py | 12 ++++++------ pyresample/test/test_data_reduce.py | 6 +++--- pyresample/test/test_gradient.py | 8 ++++---- pyresample/test/test_image.py | 2 +- pyresample/test/test_plot.py | 16 ++++++++++------ 10 files changed, 55 insertions(+), 40 deletions(-) diff --git a/pyresample/__init__.py b/pyresample/__init__.py index b229e0951..20aa92a79 100644 --- a/pyresample/__init__.py +++ b/pyresample/__init__.py @@ -56,8 +56,13 @@ from .version import get_versions # noqa -__all__ = ['grid', 'image', 'kd_tree', 'utils', 'plot', 'geo_filter', 'geometry', 'CHUNK_SIZE', - 'load_area', 'create_area_def', 'get_area_def', 'parse_area_file', 'convert_def_to_yaml'] +_root_path = os.path.dirname(os.path.realpath(__file__)) + +__all__ = [ + 'grid', 'image', 'kd_tree', 'utils', 'plot', 'geo_filter', 'geometry', 'CHUNK_SIZE', + 'load_area', 'create_area_def', 'get_area_def', 'parse_area_file', 'convert_def_to_yaml', + "_root_path", +] __version__ = get_versions()['version'] del get_versions diff --git a/pyresample/bilinear/_base.py b/pyresample/bilinear/_base.py index 447428b1b..46f0cca96 100644 --- a/pyresample/bilinear/_base.py +++ b/pyresample/bilinear/_base.py @@ -599,12 +599,11 @@ def get_valid_indices_from_lonlat_boundaries( target_geo_def, source_lons, source_lats, radius_of_influence): """Get valid indices from lonlat boundaries.""" # Resampling from swath to grid or from grid to grid - lonlat_boundary = target_geo_def.get_boundary_lonlats() + sides_lons, sides_lats = target_geo_def.geographic_boundary().sides # Combine reduced and legal values return data_reduce.get_valid_index_from_lonlat_boundaries( - lonlat_boundary[0], - lonlat_boundary[1], + sides_lons, sides_lats, source_lons, source_lats, radius_of_influence) diff --git a/pyresample/data_reduce.py b/pyresample/data_reduce.py index 5e4b0518d..154f0e2dc 100644 --- a/pyresample/data_reduce.py +++ b/pyresample/data_reduce.py @@ -63,7 +63,7 @@ def get_valid_index_from_cartesian_grid(cart_grid, lons, lats, Parameters ---------- - chart_grid : numpy array + cart_grid : numpy array Grid of area cartesian coordinates lons : numpy array Swath lons @@ -103,7 +103,8 @@ def _get_lats(z): return valid_index -def swath_from_lonlat_grid(grid_lons, grid_lats, lons, lats, data, +def swath_from_lonlat_grid(grid_lons, grid_lats, + lons, lats, data, radius_of_influence): """Make coarse data reduction of swath data by comparison with lon lat grid. @@ -137,20 +138,21 @@ def swath_from_lonlat_grid(grid_lons, grid_lats, lons, lats, data, return lons, lats, data -def swath_from_lonlat_boundaries(boundary_lons, boundary_lats, lons, lats, data, +def swath_from_lonlat_boundaries(boundary_lons, boundary_lats, + lons, lats, data, radius_of_influence): """Make coarse data reduction of swath data by comparison with lon lat boundary. Parameters ---------- boundary_lons : numpy array - Grid of area lons + Longitude BoundarySide object boundary_lats : numpy array - Grid of area lats + Latitude BoundarySide object lons : numpy array - Swath lons + Swath longitude lats : numpy array - Swath lats + Swath latitude data : numpy array Swath data radius_of_influence : float @@ -162,7 +164,9 @@ def swath_from_lonlat_boundaries(boundary_lons, boundary_lats, lons, lats, data, Reduced swath data and coordinate set """ valid_index = get_valid_index_from_lonlat_boundaries(boundary_lons, - boundary_lats, lons, lats, radius_of_influence) + boundary_lats, + lons, lats, + radius_of_influence) lons = lons[valid_index] lats = lats[valid_index] @@ -212,10 +216,10 @@ def get_valid_index_from_lonlat_grid(grid_lons, grid_lats, lons, lats, radius_of def get_valid_index_from_lonlat_boundaries(boundary_lons, boundary_lats, lons, lats, radius_of_influence): """Find relevant indices from grid boundaries using the winding number theorem.""" - valid_index = _get_valid_index(boundary_lons.side1, boundary_lons.side2, - boundary_lons.side3, boundary_lons.side4, - boundary_lats.side1, boundary_lats.side2, - boundary_lats.side3, boundary_lats.side4, + valid_index = _get_valid_index(boundary_lons.top, boundary_lons.right, + boundary_lons.bottom, boundary_lons.left, + boundary_lats.top, boundary_lats.right, + boundary_lats.bottom, boundary_lats.left, lons, lats, radius_of_influence) return valid_index diff --git a/pyresample/geometry.py b/pyresample/geometry.py index b6373ce73..9f2678d47 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -280,6 +280,8 @@ def get_proj_coords(self, data_slice=None, chunks=None, **kwargs): def get_boundary_lonlats(self): """Return Boundary objects.""" + warnings.warn("'get_boundary_lonlats' is deprecated. Please use " + "'area.geographic_boundary().sides'.", DeprecationWarning, stacklevel=2) s1_lon, s1_lat = self.get_lonlats(data_slice=(0, slice(None))) s2_lon, s2_lat = self.get_lonlats(data_slice=(slice(None), -1)) s3_lon, s3_lat = self.get_lonlats(data_slice=(-1, slice(None, None, -1))) @@ -375,7 +377,7 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates= # Ensure that a portion of the area is within the Earth disk. if x.shape[0] < 2: raise ValueError("The geostationary projection area is entirely out of the Earth disk.") - # Retrieve dummy sides for GEO (side1 and side3 always of length 2) + # Retrieve dummy sides for GEO (side right (1) and side left (3) are set to length 2) # - BUG: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! # --> BUG is in get_geostationary_bounding_box_in_proj_coords # step = int(vertices_per_side / 2) - 1 # old code diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 73f7b8223..2151705b5 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -184,9 +184,10 @@ def get_chunk_mappings(self): for y_step_number, dst_y_step in enumerate(dst_y_chunks): dst_y_end = dst_y_start + dst_y_step # Get destination chunk polygon - # - Retrieve it only if source chunk poly is inside Earth Disk + # - Retrieve if source chunk poly is inside Earth Disk + # - src_poly = None # - Skips lot polygon creations when source is GEO FD - # - Retrieve if dst area is swath - + # - Retrieve if dst area is swath # - Currently dst_poly will be False # - check_overlap will return True if src_poly is not None or dst_is_swath: diff --git a/pyresample/kd_tree.py b/pyresample/kd_tree.py index fa43001bd..d7e486ac0 100644 --- a/pyresample/kd_tree.py +++ b/pyresample/kd_tree.py @@ -413,13 +413,13 @@ def _get_valid_input_index(source_geo_def, source_is_coord = isinstance(source_geo_def, geometry.CoordinateDefinition) if (source_is_coord or source_is_griddish) and target_is_griddish: # Resampling from swath to grid or from grid to grid - lonlat_boundary = target_geo_def.get_boundary_lonlats() + sides_lons, sides_lats = target_geo_def.geographic_boundary().sides # Combine reduced and legal values valid_input_index &= \ data_reduce.get_valid_index_from_lonlat_boundaries( - lonlat_boundary[0], - lonlat_boundary[1], + sides_lons, + sides_lats, source_lons, source_lats, radius_of_influence) @@ -440,11 +440,11 @@ def _get_valid_output_index(source_geo_def, target_geo_def, target_lons, geometry.AreaDefinition)) and \ isinstance(target_geo_def, geometry.CoordinateDefinition): # Resampling from grid to swath - lonlat_boundary = source_geo_def.get_boundary_lonlats() + sides_lons, sides_lats = source_geo_def.geographic_boundary().sides valid_output_index = \ data_reduce.get_valid_index_from_lonlat_boundaries( - lonlat_boundary[0], - lonlat_boundary[1], + sides_lons, + sides_lats, target_lons, target_lats, radius_of_influence) diff --git a/pyresample/test/test_data_reduce.py b/pyresample/test/test_data_reduce.py index 478a7ad51..cde7fa800 100644 --- a/pyresample/test/test_data_reduce.py +++ b/pyresample/test/test_data_reduce.py @@ -73,9 +73,9 @@ def test_reduce_boundary(self): lambda y, x: -180 + (360.0 / 1000) * x, (1000, 1000)) lats = np.fromfunction( lambda y, x: -90 + (180.0 / 1000) * y, (1000, 1000)) - boundary_lonlats = self.area_def.get_boundary_lonlats() - lons, lats, data = swath_from_lonlat_boundaries(boundary_lonlats[0], - boundary_lonlats[1], + sides_lons, sides_lats = self.area_def.geographic_boundary().sides + lons, lats, data = swath_from_lonlat_boundaries(sides_lons, + sides_lats, lons, lats, data, 7000) cross_sum = data.sum() expected = 20685125.0 diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index 514409407..8d47d61ef 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -604,8 +604,8 @@ def test__get_border_lonlats_geos(): sides_lats = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) - with mock.patch.object(geo_def, "_get_boundary_sides") as get_boundary_lonlats: - get_boundary_lonlats.return_value = sides_lons, sides_lats + with mock.patch.object(geo_def, "_get_boundary_sides") as mock_get_boundary_sides: + mock_get_boundary_sides.return_value = sides_lons, sides_lats lon_b, lat_b = _get_border_lonlats(geo_def) np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) @@ -618,8 +618,8 @@ def test__get_border_lonlats(): sides_lats = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) - with mock.patch.object(geo_def, "_get_boundary_sides") as get_boundary_lonlats: - get_boundary_lonlats.return_value = sides_lons, sides_lats + with mock.patch.object(geo_def, "_get_boundary_sides") as mock_get_boundary_sides: + mock_get_boundary_sides.return_value = sides_lons, sides_lats lon_b, lat_b = _get_border_lonlats(geo_def) np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) diff --git a/pyresample/test/test_image.py b/pyresample/test/test_image.py index dbfb06da4..ce8cfe3d7 100644 --- a/pyresample/test/test_image.py +++ b/pyresample/test/test_image.py @@ -146,7 +146,7 @@ def test_nearest_resize(self): area_con = msg_con.resample(self.msg_area_resize) res = area_con.image_data cross_sum = res.sum() - expected = 2212023.0175830 + expected = 2211981.7030199994 self.assertAlmostEqual(cross_sum, expected) def test_nearest_neighbour_multi(self): diff --git a/pyresample/test/test_plot.py b/pyresample/test/test_plot.py index 3220f88c2..b7b1ef362 100644 --- a/pyresample/test/test_plot.py +++ b/pyresample/test/test_plot.py @@ -24,6 +24,8 @@ import numpy as np +from pyresample import _root_path + try: import matplotlib matplotlib.use('Agg') @@ -36,6 +38,8 @@ Basemap = None +TEST_FILES_PATH = os.path.join(_root_path, "test", 'test_files') + MERIDIANS1 = np.array([-180, -170, -160, -150, -140, -130, -120, -110, -100, -90, -80, -70, -60, -50, -40, -30, -20, -10, 0, 10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, @@ -48,8 +52,7 @@ class Test(unittest.TestCase): """Test the plot utilities.""" - filename = os.path.abspath(os.path.join(os.path.dirname(__file__), - 'test_files', 'ssmis_swath.npz')) + filename = os.path.join(TEST_FILES_PATH, 'ssmis_swath.npz') data = np.load(filename)['data'] lons = data[:, 0].astype(np.float64) lats = data[:, 1].astype(np.float64) @@ -79,7 +82,7 @@ def test_ellps2axis(self): def test_area_def2basemap(self): """Test the area to Basemap object conversion function.""" from pyresample import parse_area_file, plot - area_def = parse_area_file(os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml'), 'ease_sh')[0] + area_def = parse_area_file(os.path.join(TEST_FILES_PATH, 'areas.yaml'), 'ease_sh')[0] bmap = plot.area_def2basemap(area_def) self.assertTrue(bmap.rmajor == bmap.rminor and bmap.rmajor == 6371228.0, 'Failed to create Basemap object') @@ -158,7 +161,8 @@ def test_translate_coast_res(self): def test_plate_carreeplot(self): """Test the Plate Caree plotting functionality.""" from pyresample import geometry, kd_tree, parse_area_file, plot - area_def = parse_area_file(os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml'), 'pc_world')[0] + + area_def = parse_area_file(os.path.join(TEST_FILES_PATH, 'areas.yaml'), 'pc_world')[0] swath_def = geometry.SwathDefinition(self.lons, self.lats) result = kd_tree.resample_nearest(swath_def, self.tb37v, area_def, radius_of_influence=20000, @@ -171,7 +175,7 @@ def test_plate_carreeplot(self): def test_easeplot(self): """Test the plotting on the ease grid area.""" from pyresample import geometry, kd_tree, parse_area_file, plot - area_def = parse_area_file(os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml'), 'ease_sh')[0] + area_def = parse_area_file(os.path.join(TEST_FILES_PATH, 'areas.yaml'), 'ease_sh')[0] swath_def = geometry.SwathDefinition(self.lons, self.lats) result = kd_tree.resample_nearest(swath_def, self.tb37v, area_def, radius_of_influence=20000, @@ -181,7 +185,7 @@ def test_easeplot(self): def test_orthoplot(self): """Test the ortho plotting.""" from pyresample import geometry, kd_tree, parse_area_file, plot - area_def = parse_area_file(os.path.join(os.path.dirname(__file__), 'test_files', 'areas.cfg'), 'ortho')[0] + area_def = parse_area_file(os.path.join(TEST_FILES_PATH, 'areas.cfg'), 'ortho')[0] swath_def = geometry.SwathDefinition(self.lons, self.lats) result = kd_tree.resample_nearest(swath_def, self.tb37v, area_def, radius_of_influence=20000, From 0f1b578a943b6e3ac964af67fccdaab31a145a21 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 17:56:45 +0100 Subject: [PATCH 31/39] Remove use of __file__ and relative paths in test units --- pyresample/test/test_area_config.py | 20 +++++++++++-------- pyresample/test/test_image.py | 11 +++++----- pyresample/test/test_kd_tree.py | 31 +++++++++++------------------ pyresample/test/test_swath.py | 7 ++++--- pyresample/test/test_utils.py | 9 ++++++--- 5 files changed, 39 insertions(+), 39 deletions(-) diff --git a/pyresample/test/test_area_config.py b/pyresample/test/test_area_config.py index 70f615f81..8f17bdb0b 100644 --- a/pyresample/test/test_area_config.py +++ b/pyresample/test/test_area_config.py @@ -24,6 +24,10 @@ import numpy as np +from pyresample import _root_path + +TEST_FILES_PATH = os.path.join(_root_path, "test", 'test_files') + class TestLegacyAreaParser(unittest.TestCase): """Test legacy .cfg parsing.""" @@ -31,7 +35,7 @@ class TestLegacyAreaParser(unittest.TestCase): def test_area_parser_legacy(self): """Test legacy area parser.""" from pyresample import parse_area_file - ease_nh, ease_sh = parse_area_file(os.path.join(os.path.dirname(__file__), 'test_files', 'areas.cfg'), + ease_nh, ease_sh = parse_area_file(os.path.join(TEST_FILES_PATH, 'areas.cfg'), 'ease_nh', 'ease_sh') # pyproj 2.0+ adds some extra parameters @@ -63,7 +67,7 @@ def test_area_parser_legacy(self): def test_load_area(self): from pyresample import load_area - ease_nh = load_area(os.path.join(os.path.dirname(__file__), 'test_files', 'areas.cfg'), 'ease_nh') + ease_nh = load_area(os.path.join(TEST_FILES_PATH, 'areas.cfg'), 'ease_nh') # pyproj 2.0+ adds some extra parameters projection = ("{'R': '6371228', 'lat_0': '90', 'lon_0': '0', " "'no_defs': 'None', 'proj': 'laea', 'type': 'crs', " @@ -87,11 +91,11 @@ def test_area_file_not_found_exception(self): def test_not_found_exception(self): from pyresample.area_config import AreaNotFound, parse_area_file self.assertRaises(AreaNotFound, parse_area_file, - os.path.join(os.path.dirname(__file__), 'test_files', 'areas.cfg'), 'no_area') + os.path.join(TEST_FILES_PATH, 'areas.cfg'), 'no_area') def test_commented(self): from pyresample import parse_area_file - areas = parse_area_file(os.path.join(os.path.dirname(__file__), 'test_files', 'areas.cfg')) + areas = parse_area_file(os.path.join(TEST_FILES_PATH, 'areas.cfg')) self.assertNotIn('commented', [area.name for area in areas]) @@ -101,7 +105,7 @@ class TestYAMLAreaParser(unittest.TestCase): def test_area_parser_yaml(self): """Test YAML area parser.""" from pyresample import parse_area_file - test_area_file = os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml') + test_area_file = os.path.join(TEST_FILES_PATH, 'areas.yaml') test_areas = parse_area_file(test_area_file, 'ease_nh', 'ease_sh', 'test_meters', 'test_degrees', 'test_latlong') ease_nh, ease_sh, test_m, test_deg, test_latlong = test_areas @@ -158,7 +162,7 @@ def test_dynamic_area_parser_yaml(self): """Test YAML area parser on dynamic areas.""" from pyresample import parse_area_file from pyresample.geometry import DynamicAreaDefinition - test_area_file = os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml') + test_area_file = os.path.join(TEST_FILES_PATH, 'areas.yaml') test_area = parse_area_file(test_area_file, 'test_dynamic_resolution')[0] self.assertIsInstance(test_area, DynamicAreaDefinition) @@ -168,7 +172,7 @@ def test_dynamic_area_parser_yaml(self): # lat/lon from pyresample import parse_area_file from pyresample.geometry import DynamicAreaDefinition - test_area_file = os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml') + test_area_file = os.path.join(TEST_FILES_PATH, 'areas.yaml') test_area = parse_area_file(test_area_file, 'test_dynamic_resolution_ll')[0] self.assertIsInstance(test_area, DynamicAreaDefinition) @@ -178,7 +182,7 @@ def test_dynamic_area_parser_yaml(self): def test_dynamic_area_parser_passes_resolution(self): """Test that the resolution from the file is passed to a dynamic area.""" from pyresample import parse_area_file - test_area_file = os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml') + test_area_file = os.path.join(TEST_FILES_PATH, 'areas.yaml') test_area = parse_area_file(test_area_file, 'omerc_bb_1000')[0] assert test_area.resolution == (1000, 1000) diff --git a/pyresample/test/test_image.py b/pyresample/test/test_image.py index ce8cfe3d7..5c0273e39 100644 --- a/pyresample/test/test_image.py +++ b/pyresample/test/test_image.py @@ -22,7 +22,9 @@ import numpy -from pyresample import geometry, image, utils +from pyresample import _root_path, geometry, image, utils + +TEST_FILES_PATH = os.path.join(_root_path, "test", 'test_files') class Test(unittest.TestCase): @@ -108,8 +110,7 @@ def test_masked_image(self): area_con = msg_con.resample(self.area_def) res = area_con.image_data resampled_mask = res.mask.astype('int') - expected = numpy.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', 'mask_grid.dat'), + expected = numpy.fromfile(os.path.join(TEST_FILES_PATH, 'mask_grid.dat'), sep=' ').reshape((800, 800)) self.assertTrue(numpy.array_equal(resampled_mask, expected)) @@ -123,9 +124,7 @@ def test_masked_image_fill(self): area_con = msg_con.resample(self.area_def) res = area_con.image_data resampled_mask = res.mask.astype('int') - expected = numpy.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', - 'mask_grid.dat'), + expected = numpy.fromfile(os.path.join(TEST_FILES_PATH, 'mask_grid.dat'), sep=' ').reshape((800, 800)) self.assertTrue(numpy.array_equal(resampled_mask, expected)) diff --git a/pyresample/test/test_kd_tree.py b/pyresample/test/test_kd_tree.py index e26663241..45c84f1b6 100644 --- a/pyresample/test/test_kd_tree.py +++ b/pyresample/test/test_kd_tree.py @@ -23,9 +23,11 @@ import numpy as np import pytest -from pyresample import geometry, kd_tree, utils +from pyresample import _root_path, geometry, kd_tree, utils from pyresample.test.utils import catch_warnings +TEST_FILES_PATH = os.path.join(_root_path, "test", 'test_files') + class Test(unittest.TestCase): """Test nearest neighbor resampling on numpy arrays.""" @@ -496,12 +498,10 @@ def test_masked_nearest(self): masked_data = np.ma.array(data, mask=mask) res = kd_tree.resample_nearest(swath_def, masked_data.ravel(), self.area_def, 50000, segments=1) - expected_mask = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_mask = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_nearest_mask.dat'), sep=' ').reshape((800, 800)) - expected_data = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_data = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_nearest_data.dat'), sep=' ').reshape((800, 800)) self.assertTrue(np.array_equal(expected_mask, res.mask)) @@ -531,12 +531,10 @@ def test_masked_gauss(self): masked_data = np.ma.array(data, mask=mask) res = kd_tree.resample_gauss(swath_def, masked_data.ravel(), self.area_def, 50000, 25000, segments=1) - expected_mask = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_mask = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_mask.dat'), sep=' ').reshape((800, 800)) - expected_data = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_data = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_data.dat'), sep=' ').reshape((800, 800)) expected = expected_data.sum() @@ -552,8 +550,7 @@ def test_masked_fill_float(self): swath_def = geometry.SwathDefinition(lons=lons, lats=lats) res = kd_tree.resample_nearest(swath_def, data.ravel(), self.area_def, 50000, fill_value=None, segments=1) - expected_fill_mask = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_fill_mask = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_fill_value.dat'), sep=' ').reshape((800, 800)) fill_mask = res.mask @@ -566,8 +563,7 @@ def test_masked_fill_int(self): swath_def = geometry.SwathDefinition(lons=lons, lats=lats) res = kd_tree.resample_nearest(swath_def, data.ravel(), self.area_def, 50000, fill_value=None, segments=1) - expected_fill_mask = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_fill_mask = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_fill_value.dat'), sep=' ').reshape((800, 800)) fill_mask = res.mask @@ -586,8 +582,7 @@ def test_masked_full(self): masked_data.ravel( ), self.area_def, 50000, fill_value=None, segments=1) - expected_fill_mask = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_fill_mask = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_full_fill.dat'), sep=' ').reshape((800, 800)) fill_mask = res.mask @@ -614,8 +609,7 @@ def test_masked_full_multi(self): res = kd_tree.resample_nearest(swath_def, masked_data, self.area_def, 50000, fill_value=None, segments=1) - expected_fill_mask = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_fill_mask = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_full_fill_multi.dat'), sep=' ').reshape((800, 800, 3)) fill_mask = res.mask @@ -750,8 +744,7 @@ def test_masked_multi_from_sample(self): valid_input_index, valid_output_index, index_array, fill_value=None) - expected_fill_mask = np.fromfile(os.path.join(os.path.dirname(__file__), - 'test_files', + expected_fill_mask = np.fromfile(os.path.join(TEST_FILES_PATH, 'mask_test_full_fill_multi.dat'), sep=' ').reshape((800, 800, 3)) fill_mask = res.mask diff --git a/pyresample/test/test_swath.py b/pyresample/test/test_swath.py index 23c1d6de6..7db5e5a7a 100644 --- a/pyresample/test/test_swath.py +++ b/pyresample/test/test_swath.py @@ -23,17 +23,18 @@ import numpy as np -from pyresample import geometry, kd_tree +from pyresample import _root_path, geometry, kd_tree from pyresample.test.utils import catch_warnings +TEST_FILES_PATH = os.path.join(_root_path, "test", 'test_files') + warnings.simplefilter("always") class Test(unittest.TestCase): """Tests for swath definitions.""" - filename = os.path.abspath(os.path.join(os.path.dirname(__file__), - 'test_files', 'ssmis_swath.npz')) + filename = os.path.join(TEST_FILES_PATH, 'ssmis_swath.npz') data = np.load(filename)['data'] lons = data[:, 0].astype(np.float64) lats = data[:, 1].astype(np.float64) diff --git a/pyresample/test/test_utils.py b/pyresample/test/test_utils.py index 34b4948f8..fd6719164 100644 --- a/pyresample/test/test_utils.py +++ b/pyresample/test/test_utils.py @@ -29,6 +29,7 @@ from pyproj import CRS import pyresample +from pyresample import _root_path from pyresample.test.utils import ( assert_future_geometry, create_test_latitude, @@ -37,6 +38,8 @@ from pyresample.utils import load_cf_area from pyresample.utils.row_appendable_array import RowAppendableArray +TEST_FILES_PATH = os.path.join(_root_path, "test", 'test_files') + def tmptiff(width=100, height=100, transform=None, crs=None, dtype=np.uint8): """Create a temporary in-memory TIFF file of all ones.""" @@ -238,7 +241,7 @@ def test_def2yaml_converter(self): import tempfile from pyresample import convert_def_to_yaml, parse_area_file - def_file = os.path.join(os.path.dirname(__file__), 'test_files', 'areas.cfg') + def_file = os.path.join(TEST_FILES_PATH, 'areas.cfg') filehandle, yaml_file = tempfile.mkstemp() os.close(filehandle) try: @@ -460,12 +463,12 @@ class TestLoadCFAreaPublic: """Test public API load_cf_area() for loading an AreaDefinition from netCDF/CF files.""" def test_load_cf_no_exist(self): - cf_file = os.path.join(os.path.dirname(__file__), 'test_files', 'does_not_exist.nc') + cf_file = os.path.join(TEST_FILES_PATH, 'does_not_exist.nc') with pytest.raises(FileNotFoundError): load_cf_area(cf_file) def test_load_cf_from_not_nc(self): - cf_file = os.path.join(os.path.dirname(__file__), 'test_files', 'areas.yaml') + cf_file = os.path.join(TEST_FILES_PATH, 'areas.yaml') with pytest.raises((ValueError, OSError)): load_cf_area(cf_file) From 059d4963c0a8b224a02f5cf7ac9f36f3124c02de Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 18:19:54 +0100 Subject: [PATCH 32/39] Improve clarity of sides extraction for geographic and projection coordinates --- pyresample/geometry.py | 108 ++++++++++++--------- pyresample/test/test_geometry/test_area.py | 4 +- pyresample/test/test_gradient.py | 8 +- 3 files changed, 67 insertions(+), 53 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 9f2678d47..3b70daae5 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -336,8 +336,7 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - sides_lons, sides_lats = self._get_boundary_sides(coordinates="geographic", - vertices_per_side=vertices_per_side) + sides_lons, sides_lats = self._get_geographic_sides(vertices_per_side=vertices_per_side) warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.geographic_boundary().sides` instead", PendingDeprecationWarning, stacklevel=2) if force_clockwise and not self._corner_is_clockwise( @@ -348,7 +347,7 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, sides_lons, sides_lats = self._reverse_boundaries(sides_lons, sides_lats) return sides_lons, sides_lats - def _get_geostationary_fd_coordinate_sides(self, arr, step): + def _get_geostationary_dummy_sides(self, arr, step): """Retrieve a 'dummy' boundary side list for a geostationary area with boundaries out of the Earth disk. The second and fourth sides are always of length 2. @@ -382,18 +381,14 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates= # --> BUG is in get_geostationary_bounding_box_in_proj_coords # step = int(vertices_per_side / 2) - 1 # old code step = int(x.shape[0] / 2) - 1 # patch - sides_x = self._get_geostationary_fd_coordinate_sides(x, step=step) - sides_y = self._get_geostationary_fd_coordinate_sides(y, step=step) + sides_x = self._get_geostationary_dummy_sides(x, step=step) + sides_y = self._get_geostationary_dummy_sides(y, step=step) return sides_x, sides_y - def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optional[int] = None) -> tuple: - """Return the boundary sides of the current area. + def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tuple: + """Return the geographic boundary sides of the current area. Args: - coordinates: - The type of boundary coordinates to retrieve. - Either "geographic" or "projection". - Projection coordinates are available only for AreaDefinition objects. vertices_per_side: The number of points to provide for each side. By default (None) the full width and height will be provided. @@ -403,49 +398,45 @@ def _get_boundary_sides(self, coordinates="geographic", vertices_per_side: Optio Returns: The output structure is a tuple of two lists of four elements each. - The first list contains the longitude or the projection x coordinates. - The second list contains the latitude or the projection y coordinates. + The first list contains the longitude coordinates. + The second list contains the latitude coordinates. Each list element is a numpy array representing a specific side of the geometry. The order of the sides are [top", "right", "bottom", "left"] """ - if coordinates not in ("geographic", "projection"): - raise ValueError(f"coordinates must be either 'geographic' or 'projection', got {coordinates}") is_swath = self.__class__.__name__ == "SwathDefinition" - if is_swath: - if coordinates not in ["geographic"]: - raise ValueError("'coordinates' must be 'geographic' for SwathDefinition") - if not is_swath and _is_any_corner_out_of_earth_disk(self): if self.is_geostationary: return self._get_geostationary_boundary_sides(vertices_per_side=vertices_per_side, - coordinates=coordinates) + coordinates="geographic") + # TODO ! # if self.is_polar_projection # BUG # self.is_robinson # raise NotImplementedError("Likely a polar projection.") - if coordinates == "geographic": - coord_fun = self.get_lonlats - else: - coord_fun = self.get_proj_coords # AreaDefinition - - s1_slice, s2_slice, s3_slice, s4_slice = self._get_bbox_slices(vertices_per_side) - s1_dim1, s1_dim2 = coord_fun(data_slice=s1_slice) - s2_dim1, s2_dim2 = coord_fun(data_slice=s2_slice) - s3_dim1, s3_dim2 = coord_fun(data_slice=s3_slice) - s4_dim1, s4_dim2 = coord_fun(data_slice=s4_slice) - dim1, dim2 = zip(*[(s1_dim1.squeeze(), s1_dim2.squeeze()), - (s2_dim1.squeeze(), s2_dim2.squeeze()), - (s3_dim1.squeeze(), s3_dim2.squeeze()), - (s4_dim1.squeeze(), s4_dim2.squeeze())]) + sides_lons, sides_lats = self._get_sides(coord_fun=self.get_lonlats, vertices_per_side=vertices_per_side) + return sides_lons, sides_lats + + def _get_sides(self, coord_fun, vertices_per_side): + """Return the boundary sides.""" + top_slice, right_slice, bottom_slice, left_slice = self._get_bbox_slices(vertices_per_side) + top_dim1, top_dim2 = coord_fun(data_slice=top_slice) + right_dim1, right_dim2 = coord_fun(data_slice=right_slice) + bottom_dim1, bottom_dim2 = coord_fun(data_slice=bottom_slice) + left_dim1, left_dim2 = coord_fun(data_slice=left_slice) + dim1, dim2 = zip(*[(top_dim1.squeeze(), top_dim2.squeeze()), + (right_dim1.squeeze(), right_dim2.squeeze()), + (bottom_dim1.squeeze(), bottom_dim2.squeeze()), + (left_dim1.squeeze(), left_dim2.squeeze())]) if hasattr(dim1[0], 'compute') and da is not None: dim1, dim2 = da.compute(dim1, dim2) - sides_lons, sides_lats = self._filter_sides_nans(dim1, dim2) - return sides_lons, sides_lats + sides_dim1, sides_dim2 = self._filter_sides_nans(dim1, dim2) + return sides_dim1, sides_dim2 def _filter_sides_nans( self, dim1_sides: list[np.ndarray], dim2_sides: list[np.ndarray], ) -> tuple[list[np.ndarray], list[np.ndarray]]: + """Remove nan values present in each side.""" new_dim1_sides = [] new_dim2_sides = [] for dim1_side, dim2_side in zip(dim1_sides, dim2_sides): @@ -473,11 +464,11 @@ def _get_bbox_slices(self, vertices_per_side): else: row_num = vertices_per_side col_num = vertices_per_side - s1_slice = (0, np.linspace(0, width - 1, col_num, dtype=int)) - s2_slice = (np.linspace(0, height - 1, row_num, dtype=int), -1) - s3_slice = (-1, np.linspace(width - 1, 0, col_num, dtype=int)) - s4_slice = (np.linspace(height - 1, 0, row_num, dtype=int), 0) - return s1_slice, s2_slice, s3_slice, s4_slice + top_slice = (0, np.linspace(0, width - 1, col_num, dtype=int)) + right_slice = (np.linspace(0, height - 1, row_num, dtype=int), -1) + bottom_slice = (-1, np.linspace(width - 1, 0, col_num, dtype=int)) + left_slice = (np.linspace(height - 1, 0, row_num, dtype=int), 0) + return top_slice, right_slice, bottom_slice, left_slice @staticmethod def _reverse_boundaries(sides_lons: list, sides_lats: list) -> tuple: @@ -580,10 +571,9 @@ def geographic_boundary(self, vertices_per_side=None, order=None): """ from pyresample.boundary import GeographicBoundary - sides_x, sides_y = self._get_boundary_sides(coordinates="geographic", - vertices_per_side=vertices_per_side) - return GeographicBoundary(sides_lons=sides_x, - sides_lats=sides_y, + sides_lons, sides_lats = self._get_geographic_sides(vertices_per_side=vertices_per_side) + return GeographicBoundary(sides_lons=sides_lons, + sides_lats=sides_lats, order=order) def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): @@ -1704,6 +1694,31 @@ def is_geostationary(self): return False return 'geostationary' in coord_operation.method_name.lower() + def _get_projection_sides(self, vertices_per_side: Optional[int] = None) -> tuple: + """Return the projection boundary sides of the current area. + + Args: + vertices_per_side: + The number of points to provide for each side. + By default (None) the full width and height will be provided. + If the area object is an AreaDefinition with any corner out of the Earth disk + (i.e. full disc geostationary area, Robinson projection, polar projections, ...) + by default only 50 points are selected. + + Returns: + The output structure is a tuple of two lists of four elements each. + The first list contains the projection x coordinates. + The second list contains the projection y coordinates. + Each list element is a numpy array representing a specific side of the geometry. + The order of the sides are [top", "right", "bottom", "left"] + """ + if _is_any_corner_out_of_earth_disk(self): + if self.is_geostationary: + return self._get_geostationary_boundary_sides(vertices_per_side=vertices_per_side, + coordinates="projection") + sides_lons, sides_lats = self._get_sides(coord_fun=self.get_proj_coords, vertices_per_side=vertices_per_side) + return sides_lons, sides_lats + def projection_boundary(self, vertices_per_side=None, order=None): """Retrieve the ProjectionBoundary object. @@ -1726,8 +1741,7 @@ def projection_boundary(self, vertices_per_side=None, order=None): from pyresample.boundary import ProjectionBoundary if self.crs.is_geographic: return self.geographic_boundary(vertices_per_side=vertices_per_side, order=order) - sides_x, sides_y = self._get_boundary_sides(coordinates="projection", - vertices_per_side=vertices_per_side) + sides_x, sides_y = self._get_projection_sides(vertices_per_side=vertices_per_side) return ProjectionBoundary(sides_x=sides_x, sides_y=sides_y, order=order, diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index 53693d397..a6cb41f74 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -2097,13 +2097,13 @@ class TestBoundary: ("geos_conus_area", True), ("geos_mesoscale_area", False), ]) - def test_get_boundary_sides_call_geostationary_utility(self, request, area_def_name, assert_is_called): + def test_get_geographic_sides_call_geostationary_utility(self, request, area_def_name, assert_is_called): area_def = request.getfixturevalue(area_def_name) with patch.object(area_def, '_get_geostationary_boundary_sides') as mock_get_geo: # Call the method that could trigger the geostationary _get_geostationary_boundary_sides - _ = area_def._get_boundary_sides(coordinates="geographic", vertices_per_side=None) + _ = area_def._get_geographic_sides(vertices_per_side=None) # Assert _get_geostationary_boundary_sides was not called if assert_is_called: mock_get_geo.assert_called_once() diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index 8d47d61ef..ad17b8b9f 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -604,8 +604,8 @@ def test__get_border_lonlats_geos(): sides_lats = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=geos +h=1234567", 2, 2, [1, 2, 3, 4]) - with mock.patch.object(geo_def, "_get_boundary_sides") as mock_get_boundary_sides: - mock_get_boundary_sides.return_value = sides_lons, sides_lats + with mock.patch.object(geo_def, "_get_geographic_sides") as mock_get_geographic_sides: + mock_get_geographic_sides.return_value = sides_lons, sides_lats lon_b, lat_b = _get_border_lonlats(geo_def) np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) @@ -618,8 +618,8 @@ def test__get_border_lonlats(): sides_lats = [np.array([1, 2]), np.array([2, 3]), np.array([3, 4]), np.array([4, 1])] geo_def = AreaDefinition("", "", "", "+proj=lcc +lat_1=25 +lat_2=25", 2, 2, [1, 2, 3, 4]) - with mock.patch.object(geo_def, "_get_boundary_sides") as mock_get_boundary_sides: - mock_get_boundary_sides.return_value = sides_lons, sides_lats + with mock.patch.object(geo_def, "_get_geographic_sides") as mock_get_geographic_sides: + mock_get_geographic_sides.return_value = sides_lons, sides_lats lon_b, lat_b = _get_border_lonlats(geo_def) np.testing.assert_allclose(lon_b, np.array([1, 2, 3, 4, 1])) np.testing.assert_allclose(lat_b, np.array([1, 2, 3, 4, 1])) From d35f1b599f3f6ec6d6b07840c0e5e1f09cf1b906 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Fri, 24 Nov 2023 21:00:00 +0100 Subject: [PATCH 33/39] Refactor Geographic and Projection Boundary using composition and dependency injections --- pyresample/boundary/base_boundary.py | 119 ++++++++++++++++ pyresample/boundary/geographic_boundary.py | 129 +++--------------- pyresample/boundary/projection_boundary.py | 120 ++++------------ pyresample/geometry.py | 11 +- .../test_visualization/test_geometries.py | 7 +- pyresample/visualization/geometries.py | 10 +- 6 files changed, 184 insertions(+), 212 deletions(-) create mode 100644 pyresample/boundary/base_boundary.py diff --git a/pyresample/boundary/base_boundary.py b/pyresample/boundary/base_boundary.py new file mode 100644 index 000000000..3f92850a6 --- /dev/null +++ b/pyresample/boundary/base_boundary.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2023 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Define the BaseBoundary class.""" + +import logging + +import numpy as np + +from pyresample.boundary.sides import BoundarySides + + +logger = logging.getLogger(__name__) + + +class BaseBoundary: + __slots__ = ["_sides_x", "_sides_y"] + + def __init__(self, sides_x, sides_y, order=None): + self._sides_x = BoundarySides(sides_x) + self._sides_y = BoundarySides(sides_y) + + self.is_clockwise = self._check_is_boundary_clockwise(sides_x, sides_y) + self.is_counterclockwise = not self.is_clockwise + self._set_order(order) + + def _check_is_boundary_clockwise(self, sides_x, sides_y): + raise NotImplementedError() + + def _set_order(self, order): + if self.is_clockwise: + self._actual_order = "clockwise" + else: + self._actual_order = "counterclockwise" + + if order is None: + self._wished_order = self._actual_order + else: + if order not in ["clockwise", "counterclockwise"]: + raise ValueError("Valid 'order' is 'clockwise' or 'counterclockwise'") + self._wished_order = order + + def set_clockwise(self): + """Set clockwise order for vertices retrieval.""" + self._wished_order = "clockwise" + return self + + def set_counterclockwise(self): + """Set counterclockwise order for vertices retrieval.""" + self._wished_order = "counterclockwise" + return self + + @property + def sides(self): + """Return the boundary sides as a tuple of (sides_x, sides_y) arrays.""" + return self._sides_x, self._sides_y + + @property + def _x(self): + """Retrieve boundary x vertices.""" + xs = self._sides_x.vertices + if self._wished_order == self._actual_order: + return xs + else: + return xs[::-1] + + @property + def _y(self): + """Retrieve boundary y vertices.""" + ys = self._sides_y.vertices + if self._wished_order == self._actual_order: + return ys + else: + return ys[::-1] + + @property + def vertices(self): + """Return boundary vertices 2D array [x, y].""" + vertices = np.vstack((self._x, self._y)).T + vertices = vertices.astype(np.float64, copy=False) # Important for spherical ops. + return vertices + + def contour(self, closed=False): + """Return the (x, y) tuple of the boundary object. + + If excludes the last element of each side because it's included in the next side. + If closed=False (the default), the last vertex is not equal to the first vertex + If closed=True, the last vertex is set to be equal to the first + closed=True is required for shapely Polygon creation. + closed=False is required for pyresample SPolygon creation. + """ + x = self._x + y = self._y + if closed: + x = np.hstack((x, x[0])) + y = np.hstack((y, y[0])) + return x, y + + def _to_shapely_polygon(self): + """Define a Shapely Polygon.""" + from shapely.geometry import Polygon + self = self.set_counterclockwise() # FIXME: add exception for pole wrapping polygons + x, y = self.contour(closed=True) + return Polygon(zip(x, y)) + diff --git a/pyresample/boundary/geographic_boundary.py b/pyresample/boundary/geographic_boundary.py index 92f01952d..abe6ebb88 100644 --- a/pyresample/boundary/geographic_boundary.py +++ b/pyresample/boundary/geographic_boundary.py @@ -20,9 +20,10 @@ import logging import numpy as np +from pyproj import CRS -from pyresample.boundary.sides import BoundarySides -from pyresample.spherical import SphPolygon +from pyresample.boundary.area_boundary import Boundary as OldBoundary +from pyresample.boundary.base_boundary import BaseBoundary logger = logging.getLogger(__name__) @@ -63,99 +64,37 @@ def _is_boundary_clockwise(sides_lons, sides_lats): return is_clockwise -class GeographicBoundary(): +class GeographicBoundary(BaseBoundary, OldBoundary): """GeographicBoundary object. The inputs must be the list of longitude and latitude boundary sides. """ + # NOTES: + # - Boundary provide the ancient method contour_poly and draw + # from the old interface for compatibility to AreaBoundary - def __init__(self, sides_lons, sides_lats, order=None): + @classmethod + def _check_is_boundary_clockwise(cls, sides_x, sides_y): + """GeographicBoundary specific implementation.""" + return _is_boundary_clockwise(sides_lons=sides_x, sides_lats=sides_y) - self.sides_lons = BoundarySides(sides_lons) - self.sides_lats = BoundarySides(sides_lats) + def __init__(self, sides_lons, sides_lats, order=None, crs=None): + super().__init__(sides_x=sides_lons, sides_y=sides_lats, order=order) - # Old interface for compatibility to AreaBoundary - self._contour_poly = None - - # Check if it is clockwise/counterclockwise - self.is_clockwise = _is_boundary_clockwise(sides_lons=sides_lons, - sides_lats=sides_lats) - self.is_counterclockwise = not self.is_clockwise - - # Define wished order - if self.is_clockwise: - self._actual_order = "clockwise" - else: - self._actual_order = "counterclockwise" - - if order is None: - self._wished_order = self._actual_order - else: - if order not in ["clockwise", "counterclockwise"]: - raise ValueError("Valid 'order' is 'clockwise' or 'counterclockwise'") - self._wished_order = order - - def set_clockwise(self): - """Set clockwise order for vertices retrieval.""" - self._wished_order = "clockwise" - return self - - def set_counterclockwise(self): - """Set counterclockwise order for vertices retrieval.""" - self._wished_order = "counterclockwise" - return self - - @property - def sides(self): - """Return the boundary sides as a tuple of (sides_lons, sides_lats) arrays.""" - return self.sides_lons, self.sides_lats + self.sides_lons = self._sides_x + self.sides_lats = self._sides_y + self.crs = crs or CRS(proj="longlat", ellps="WGS84") + self._contour_poly = None # Backcompatibility with old AreaBoundary @property def lons(self): """Retrieve boundary longitude vertices.""" - lons = np.concatenate([lns[:-1] for lns in self.sides_lons]) - if self._wished_order == self._actual_order: - return lons - else: - return lons[::-1] + return self._x @property def lats(self): """Retrieve boundary latitude vertices.""" - lats = np.concatenate([lts[:-1] for lts in self.sides_lats]) - if self._wished_order == self._actual_order: - return lats - else: - return lats[::-1] - - @property - def vertices(self): - """Return boundary vertices 2D array [lon, lat].""" - vertices = np.vstack((self.lons, self.lats)).T - vertices = vertices.astype(np.float64, copy=False) # Important for spherical ops. - return vertices - - def contour(self, closed=False): - """Return the (lons, lats) tuple of the boundary object. - - If excludes the last element of each side because it's included in the next side. - If closed=False (the default), the last vertex is not equal to the first vertex - If closed=True, the last vertex is set to be equal to the first - closed=True is required for shapely Polygon creation. - closed=False is required for pyresample SPolygon creation. - """ - lons = self.lons - lats = self.lats - if closed: - lons = np.hstack((lons, lons[0])) - lats = np.hstack((lats, lats[0])) - return lons, lats - - def _to_shapely_polygon(self): - from shapely.geometry import Polygon - self = self.set_counterclockwise() # TODO: add exception for pole wrapping polygons - lons, lats = self.contour(closed=True) - return Polygon(zip(lons, lats)) + return self._y def _to_spherical_polygon(self): self = self.set_clockwise() # TODO: add exception for pole wrapping polygons @@ -163,6 +102,9 @@ def _to_spherical_polygon(self): def polygon(self, shapely=False): """Return the boundary polygon.""" + # ALTERNATIVE: + # - shapely: to_shapely_polygon(), to_shapely_line(), + # - pyresample spherical: to_polygon(), to_line(), polygon, line if shapely: return self._to_shapely_polygon() else: @@ -179,30 +121,3 @@ def plot(self, ax=None, subplot_kw=None, **kwargs): p = plot_geometries(geometries=[geom], crs=crs, ax=ax, subplot_kw=subplot_kw, **kwargs) return p - - # For backward compatibility ! - def decimate(self, ratio): - """Remove some points in the boundaries, but never the corners.""" - # TODO: to update --> used by AreaDefBoundary - for i in range(len(self.sides_lons)): - length = len(self.sides_lons[i]) - start = int((length % ratio) / 2) - points = np.concatenate(([0], np.arange(start, length, ratio), - [length - 1])) - if points[1] == 0: - points = points[1:] - if points[-2] == (length - 1): - points = points[:-1] - self.sides_lons[i] = self.sides_lons[i][points] - self.sides_lats[i] = self.sides_lats[i][points] - - @property - def contour_poly(self): - """Return the pyresample SphPolygon.""" - if self._contour_poly is None: - self._contour_poly = SphPolygon(np.deg2rad(self.vertices)) - return self._contour_poly - - def draw(self, mapper, options, **more_options): - """Draw the current boundary on the *mapper*.""" - self.contour_poly.draw(mapper, options, **more_options) diff --git a/pyresample/boundary/projection_boundary.py b/pyresample/boundary/projection_boundary.py index 7c19a2c3a..7620f50ff 100644 --- a/pyresample/boundary/projection_boundary.py +++ b/pyresample/boundary/projection_boundary.py @@ -21,113 +21,51 @@ import numpy as np -from pyresample.boundary.sides import BoundarySides +from pyresample.boundary.base_boundary import BaseBoundary logger = logging.getLogger(__name__) -class ProjectionBoundary(): +def _is_projection_boundary_clockwise(sides_x, sides_y): + """Determine if the boundary is clockwise-defined in planar coordinates.""" + from shapely.geometry import Polygon + x = np.concatenate([xs[:-1] for xs in sides_x]) + y = np.concatenate([ys[:-1] for ys in sides_y]) + x = np.hstack((x, x[0])) + y = np.hstack((y, y[0])) + polygon = Polygon(zip(x, y)) + return not polygon.exterior.is_ccw + + +class ProjectionBoundary(BaseBoundary): """Projection Boundary object. The inputs must be the x and y sides of the projection. It expects the projection coordinates to be planar (i.e. metric, radians). """ - def __init__(self, sides_x, sides_y, order=None, crs=None): - """Initialize the ProjectionBoundary object.""" - self.crs = crs # required for .plot() method - - self.sides_x = BoundarySides(sides_x) - self.sides_y = BoundarySides(sides_y) - - # Check if it is clockwise/counterclockwise - self.is_clockwise = self._is_projection_boundary_clockwise() - self.is_counterclockwise = not self.is_clockwise - - # Define wished order - if self.is_clockwise: - self._actual_order = "clockwise" - else: - self._actual_order = "counterclockwise" + @classmethod + def _check_is_boundary_clockwise(cls, sides_x, sides_y): + """GeographicBoundary specific implementation.""" + return _is_projection_boundary_clockwise(sides_x=sides_x, sides_y=sides_y) - if order is None: - self._wished_order = self._actual_order - else: - if order not in ["clockwise", "counterclockwise"]: - raise ValueError("Valid 'order' is 'clockwise' or 'counterclockwise'") - self._wished_order = order - - def _is_projection_boundary_clockwise(self): - """Determine if the boundary is clockwise-defined in projection coordinates.""" - from shapely.geometry import Polygon - - x = np.concatenate([xs[:-1] for xs in self.sides_x]) - y = np.concatenate([ys[:-1] for ys in self.sides_y]) - x = np.hstack((x, x[0])) - y = np.hstack((y, y[0])) - polygon = Polygon(zip(x, y)) - return not polygon.exterior.is_ccw - - def set_clockwise(self): - """Set clockwise order for vertices retrieval.""" - self._wished_order = "clockwise" - return self - - def set_counterclockwise(self): - """Set counterclockwise order for vertices retrieval.""" - self._wished_order = "counterclockwise" - return self + def __init__(self, sides_x, sides_y, crs, order=None, cartopy_crs=None): + super().__init__(sides_x=sides_x, sides_y=sides_y, order=order) - @property - def sides(self): - """Return the boundary sides as a tuple of (sides_x, sides_y) arrays.""" - return self.sides_x, self.sides_y + self.sides_x = self._sides_x + self.sides_y = self._sides_y + self.crs = crs + self.cartopy_crs = cartopy_crs @property def x(self): """Retrieve boundary x vertices.""" - xs = np.concatenate([xs[:-1] for xs in self.sides_x]) - if self._wished_order == self._actual_order: - return xs - else: - return xs[::-1] + return self._x @property def y(self): """Retrieve boundary y vertices.""" - ys = np.concatenate([ys[:-1] for ys in self.sides_y]) - if self._wished_order == self._actual_order: - return ys - else: - return ys[::-1] - - @property - def vertices(self): - """Return boundary vertices 2D array [x, y].""" - vertices = np.vstack((self.x, self.y)).T - vertices = vertices.astype(np.float64, copy=False) - return vertices - - def contour(self, closed=False): - """Return the (x, y) tuple of the boundary object. - - If excludes the last element of each side because it's included in the next side. - If closed=False (the default), the last vertex is not equal to the first vertex - If closed=True, the last vertex is set to be equal to the first - closed=True is required for shapely Polygon creation. - """ - x = self.x - y = self.y - if closed: - x = np.hstack((x, x[0])) - y = np.hstack((y, y[0])) - return x, y - - def _to_shapely_polygon(self): - from shapely.geometry import Polygon - self = self.set_counterclockwise() - x, y = self.contour(closed=True) - return Polygon(zip(x, y)) + return self._y def polygon(self, shapely=True): """Return the boundary polygon.""" @@ -137,13 +75,13 @@ def polygon(self, shapely=True): raise NotImplementedError("Only shapely polygon available.") def plot(self, ax=None, subplot_kw=None, crs=None, **kwargs): - """Plot the the boundary.""" + """Plot the the boundary. crs must be a Cartopy CRS !""" from pyresample.visualization.geometries import plot_geometries - if self.crs is None and crs is None: - raise ValueError("Projection 'crs' is required to display projection boundary.") + if self.cartopy_crs is None and crs is None: + raise ValueError("Projection Cartopy 'crs' is required to display projection boundary.") if crs is None: - crs = self.crs + crs = self.cartopy_crs geom = self.polygon(shapely=True) p = plot_geometries(geometries=[geom], crs=crs, diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 3b70daae5..e65a994af 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -572,9 +572,14 @@ def geographic_boundary(self, vertices_per_side=None, order=None): from pyresample.boundary import GeographicBoundary sides_lons, sides_lats = self._get_geographic_sides(vertices_per_side=vertices_per_side) + if self.__class__.__name__ == "SwathDefinition": + crs = self.crs + else: + crs = None # default to WGS84 for AreaDefinition return GeographicBoundary(sides_lons=sides_lons, sides_lats=sides_lats, - order=order) + order=order, + crs=crs) def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): """Retrieve cartesian coordinates of geometry definition. @@ -1744,8 +1749,10 @@ def projection_boundary(self, vertices_per_side=None, order=None): sides_x, sides_y = self._get_projection_sides(vertices_per_side=vertices_per_side) return ProjectionBoundary(sides_x=sides_x, sides_y=sides_y, + crs=self.crs, order=order, - crs=self.crs) + cartopy_crs=self.to_cartopy_crs() + ) def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[int] = None, frequency: Optional[int] = None): diff --git a/pyresample/test/test_visualization/test_geometries.py b/pyresample/test/test_visualization/test_geometries.py index 025c02d57..13f0c777a 100644 --- a/pyresample/test/test_visualization/test_geometries.py +++ b/pyresample/test/test_visualization/test_geometries.py @@ -73,14 +73,11 @@ def test_initialize_plot_with_ax(self): @pytest.mark.parametrize("ax_provided", [True, False]) def test_plot_geometries(self, ax_provided): """Test plot_geometries function returns the correct type based on ax_provided.""" + import cartopy vertices1 = [(0, 0), (0, 1), (1, 0)] vertices2 = [(0, 0), (0, 2), (2, 0)] geometries = [Polygon(vertices1), Polygon(vertices2)] crs = ccrs.PlateCarree() ax = plt.axes(projection=crs) if ax_provided else None result = plot_geometries(geometries, crs, ax=ax) - - if ax_provided: - assert isinstance(result, plt.Axes) - else: - assert isinstance(result, plt.Figure) + assert isinstance(result, cartopy.mpl.feature_artist.FeatureArtist) diff --git a/pyresample/visualization/geometries.py b/pyresample/visualization/geometries.py index 192e40695..cd027ceb9 100644 --- a/pyresample/visualization/geometries.py +++ b/pyresample/visualization/geometries.py @@ -60,10 +60,6 @@ def plot_geometries(geometries, crs, ax=None, subplot_kw=None, **kwargs): # Add map background if ax not provided as input if initialized_here: ax = _add_map_background(ax) - # Add geometries - ax.add_geometries(geometries, crs=crs, **kwargs) - # Return Figure / Axis - if initialized_here: - return fig - else: - return ax + # Add geometries + p = ax.add_geometries(geometries, crs=crs, **kwargs) + return p From b3273b88a6eb1f1bbeb6ba886fac6638e823237f Mon Sep 17 00:00:00 2001 From: ghiggi Date: Sat, 25 Nov 2023 02:12:30 +0100 Subject: [PATCH 34/39] Deal with AreaDefinition with unvalid sides: polar projections, global planar projections, ... --- pyresample/bilinear/_base.py | 15 ++-- pyresample/boundary/base_boundary.py | 23 +++--- pyresample/geometry.py | 103 +++++++++++++++++++-------- pyresample/kd_tree.py | 43 ++++++----- pyresample/test/test_gradient.py | 2 +- 5 files changed, 118 insertions(+), 68 deletions(-) diff --git a/pyresample/bilinear/_base.py b/pyresample/bilinear/_base.py index 46f0cca96..da42073e0 100644 --- a/pyresample/bilinear/_base.py +++ b/pyresample/bilinear/_base.py @@ -599,13 +599,14 @@ def get_valid_indices_from_lonlat_boundaries( target_geo_def, source_lons, source_lats, radius_of_influence): """Get valid indices from lonlat boundaries.""" # Resampling from swath to grid or from grid to grid - sides_lons, sides_lats = target_geo_def.geographic_boundary().sides - - # Combine reduced and legal values - return data_reduce.get_valid_index_from_lonlat_boundaries( - sides_lons, sides_lats, - source_lons, source_lats, - radius_of_influence) + try: + sides_lons, sides_lats = target_geo_def.geographic_boundary().sides + valid_indices = data_reduce.get_valid_index_from_lonlat_boundaries(sides_lons, sides_lats, + source_lons, source_lats, + radius_of_influence) + except Exception: + valid_indices = np.ones(source_lons.size, dtype=bool) + return valid_indices def get_slicer(data): diff --git a/pyresample/boundary/base_boundary.py b/pyresample/boundary/base_boundary.py index 3f92850a6..ffeb8f61e 100644 --- a/pyresample/boundary/base_boundary.py +++ b/pyresample/boundary/base_boundary.py @@ -23,25 +23,27 @@ from pyresample.boundary.sides import BoundarySides - logger = logging.getLogger(__name__) class BaseBoundary: + """Base class for boundary objects.""" __slots__ = ["_sides_x", "_sides_y"] - + def __init__(self, sides_x, sides_y, order=None): self._sides_x = BoundarySides(sides_x) self._sides_y = BoundarySides(sides_y) - + self.is_clockwise = self._check_is_boundary_clockwise(sides_x, sides_y) self.is_counterclockwise = not self.is_clockwise self._set_order(order) - - def _check_is_boundary_clockwise(self, sides_x, sides_y): + + def _check_is_boundary_clockwise(self, sides_x, sides_y): + """Check if the boundary is clockwise or counterclockwise.""" raise NotImplementedError() - + def _set_order(self, order): + """Set the order of the boundary vertices.""" if self.is_clockwise: self._actual_order = "clockwise" else: @@ -63,12 +65,12 @@ def set_counterclockwise(self): """Set counterclockwise order for vertices retrieval.""" self._wished_order = "counterclockwise" return self - + @property def sides(self): """Return the boundary sides as a tuple of (sides_x, sides_y) arrays.""" return self._sides_x, self._sides_y - + @property def _x(self): """Retrieve boundary x vertices.""" @@ -93,7 +95,7 @@ def vertices(self): vertices = np.vstack((self._x, self._y)).T vertices = vertices.astype(np.float64, copy=False) # Important for spherical ops. return vertices - + def contour(self, closed=False): """Return the (x, y) tuple of the boundary object. @@ -113,7 +115,6 @@ def contour(self, closed=False): def _to_shapely_polygon(self): """Define a Shapely Polygon.""" from shapely.geometry import Polygon - self = self.set_counterclockwise() # FIXME: add exception for pole wrapping polygons + self = self.set_counterclockwise() # FIXME: add exception for pole wrapping polygons x, y = self.contour(closed=True) return Polygon(zip(x, y)) - diff --git a/pyresample/geometry.py b/pyresample/geometry.py index e65a994af..33fecc2eb 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -347,21 +347,47 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, sides_lons, sides_lats = self._reverse_boundaries(sides_lons, sides_lats) return sides_lons, sides_lats - def _get_geostationary_dummy_sides(self, arr, step): + def _get_geostationary_dummy_sides(self, arr, vertices_per_side): """Retrieve a 'dummy' boundary side list for a geostationary area with boundaries out of the Earth disk. The second and fourth sides are always of length 2. """ - sides = [ - arr[slice(0, step + 1)], - arr[slice(step, step + 2)], - arr[slice(step + 1, step * 2 + 2)], - np.append(arr[step * 2 + 1], arr[0]) - ] + N = len(arr) + if vertices_per_side is None: + vertices_per_side = min(vertices_per_side, int(N / 2)) + top_side = arr[slice(0, vertices_per_side)] + bottom_side = arr[slice(vertices_per_side, N)] + else: + # Sample vertices + # - Split the array into two halves + first_half = arr[:N // 2] + second_half = arr[N // 2:] + # - Adjust the number of vertices if necessary + vertices_per_side = min(vertices_per_side, len(first_half), len(second_half)) + # - Sample points from each half + top_side_indices = np.round(np.linspace(0, len(first_half) - 1, vertices_per_side)).astype(int) + bottom_side_indices = np.round(np.linspace(0, len(second_half) - 1, vertices_per_side)).astype(int) + top_side = first_half[top_side_indices] + bottom_side = second_half[bottom_side_indices] + # - Create side object + sides = [top_side, np.array([top_side[-1], bottom_side[0]]), + bottom_side, np.array([bottom_side[-1], top_side[0]])] return sides def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates="geographic"): - """Retrieve the boundary sides list for geostationary projections.""" + """Retrieve the boundary sides list for geostationary projections with out-of-Earth disk coordinates. + + If vertices_per_side is too small, there is the risk to loose boundary side points + at the intersection corners between the CRS bounds polygon and the area + extent polygon (which could exclude relevant regions of the geos area). + + The boundary sides right (1) and side left (3) are set to length 2. + """ + # TODO: + # - Evaluate nb_points required for FULL DISC and CONUS area ! + # - Maybe just use vertices_per_side for _get_geostationary_bounding_box and + # then return all the returned vertices --> _get_geostationary_dummy_sides(x, None) + # Define default frequency if vertices_per_side is None: vertices_per_side = 50 @@ -374,15 +400,12 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates= # Retrieve coordinates (x,y) or (lon, lat) x, y = _get_geostationary_bounding_box(self, coordinates=coordinates, nb_points=vertices_per_side) # Ensure that a portion of the area is within the Earth disk. - if x.shape[0] < 2: + if x.shape[0] < 4: raise ValueError("The geostationary projection area is entirely out of the Earth disk.") - # Retrieve dummy sides for GEO (side right (1) and side left (3) are set to length 2) - # - BUG: _get_geostationary_bounding_box_in_lonlats now does not return nb_points ! - # --> BUG is in get_geostationary_bounding_box_in_proj_coords - # step = int(vertices_per_side / 2) - 1 # old code - step = int(x.shape[0] / 2) - 1 # patch - sides_x = self._get_geostationary_dummy_sides(x, step=step) - sides_y = self._get_geostationary_dummy_sides(y, step=step) + # Retrieve dummy sides for GEO + # - _get_geostationary_bounding_box_in_lonlats does not guarantee to return nb_points and even points! + sides_x = self._get_geostationary_dummy_sides(x, vertices_per_side=vertices_per_side) + sides_y = self._get_geostationary_dummy_sides(y, vertices_per_side=vertices_per_side) return sides_x, sides_y def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tuple: @@ -408,7 +431,7 @@ def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tupl if self.is_geostationary: return self._get_geostationary_boundary_sides(vertices_per_side=vertices_per_side, coordinates="geographic") - # TODO ! + # Polar Projections, Global Planar Projections (Mollweide, Robinson) # if self.is_polar_projection # BUG # self.is_robinson # raise NotImplementedError("Likely a polar projection.") @@ -422,13 +445,13 @@ def _get_sides(self, coord_fun, vertices_per_side): right_dim1, right_dim2 = coord_fun(data_slice=right_slice) bottom_dim1, bottom_dim2 = coord_fun(data_slice=bottom_slice) left_dim1, left_dim2 = coord_fun(data_slice=left_slice) - dim1, dim2 = zip(*[(top_dim1.squeeze(), top_dim2.squeeze()), - (right_dim1.squeeze(), right_dim2.squeeze()), - (bottom_dim1.squeeze(), bottom_dim2.squeeze()), - (left_dim1.squeeze(), left_dim2.squeeze())]) - if hasattr(dim1[0], 'compute') and da is not None: - dim1, dim2 = da.compute(dim1, dim2) - sides_dim1, sides_dim2 = self._filter_sides_nans(dim1, dim2) + sides_dim1, sides_dim2 = zip(*[(top_dim1.squeeze(), top_dim2.squeeze()), + (right_dim1.squeeze(), right_dim2.squeeze()), + (bottom_dim1.squeeze(), bottom_dim2.squeeze()), + (left_dim1.squeeze(), left_dim2.squeeze())]) + if hasattr(sides_dim1[0], 'compute') and da is not None: + sides_dim1, sides_dim2 = da.compute(sides_dim1, sides_dim2) + sides_dim1, sides_dim2 = self._filter_sides_nans(sides_dim1, sides_dim2) return sides_dim1, sides_dim2 def _filter_sides_nans( @@ -436,13 +459,13 @@ def _filter_sides_nans( dim1_sides: list[np.ndarray], dim2_sides: list[np.ndarray], ) -> tuple[list[np.ndarray], list[np.ndarray]]: - """Remove nan values present in each side.""" + """Remove nan and inf values present in each side.""" new_dim1_sides = [] new_dim2_sides = [] for dim1_side, dim2_side in zip(dim1_sides, dim2_sides): - is_valid_mask = ~(np.isnan(dim1_side) | np.isnan(dim2_side)) + is_valid_mask = ~(~np.isfinite(dim1_side) | ~np.isfinite(dim1_side)) if not is_valid_mask.any(): - raise ValueError("Can't compute swath bounding coordinates. At least one side is completely invalid.") + raise ValueError("Can't compute boundary coordinates. At least one side is completely invalid.") new_dim1_sides.append(dim1_side[is_valid_mask]) new_dim2_sides.append(dim2_side[is_valid_mask]) return new_dim1_sides, new_dim2_sides @@ -2839,17 +2862,20 @@ def get_geostationary_angle_extent(geos_area): def get_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): """Get the bbox in geos projection coordinates of the valid pixels inside `geos_area`. + NOTE: The area_bbox must be defined with many vertices to avoid loosing + vertices at the corners later on in _get_geostationary_boundary_sides. + Args: geos_area: Geostationary area definition to get the bounding box for. nb_points: Number of points on the polygon. """ + from shapely.geometry import Polygon + x, y = get_full_geostationary_bounding_box_in_proj_coords(geos_area, nb_points) - ll_x, ll_y, ur_x, ur_y = geos_area.area_extent - from shapely.geometry import Polygon geo_bbox = Polygon(np.vstack((x, y)).T) - area_bbox = Polygon(((ll_x, ll_y), (ll_x, ur_y), (ur_x, ur_y), (ur_x, ll_y))) + area_bbox = _create_area_extent_polygon(geos_area.area_extent, nb_points=nb_points) intersection = area_bbox.intersection(geo_bbox) try: x, y = intersection.boundary.xy @@ -2858,8 +2884,23 @@ def get_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): return np.asanyarray(x[:-1]), np.asanyarray(y[:-1]) +def _create_area_extent_polygon(area_extent, nb_points=50): + """Create the area_extent polygon with nb_points vertices per side.""" + from shapely.geometry import Polygon + + # Generate points for each edge + ll_x, ll_y, ur_x, ur_y = area_extent + bottom = [(x, ll_y) for x in np.linspace(ll_x, ur_x, nb_points + 2)] + right = [(ur_x, y) for y in np.linspace(ll_y, ur_y, nb_points + 2)][1:] + top = [(x, ur_y) for x in np.linspace(ur_x, ll_x, nb_points + 2)][1:] + left = [(ll_x, y) for y in np.linspace(ur_y, ll_y, nb_points + 2)][1:-1] + + # Combine points to form the area_extent polygon + return Polygon(bottom + right + top + left) + + def get_full_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): - """Get the bbox in geos projection coordinates of the full disk in `geos_area` projection. + """Get the valid boundary geos projection coordinates of the full disk. Args: geos_area: Geostationary area definition to get the bounding box for. diff --git a/pyresample/kd_tree.py b/pyresample/kd_tree.py index d7e486ac0..57aebbf26 100644 --- a/pyresample/kd_tree.py +++ b/pyresample/kd_tree.py @@ -413,15 +413,18 @@ def _get_valid_input_index(source_geo_def, source_is_coord = isinstance(source_geo_def, geometry.CoordinateDefinition) if (source_is_coord or source_is_griddish) and target_is_griddish: # Resampling from swath to grid or from grid to grid - sides_lons, sides_lats = target_geo_def.geographic_boundary().sides - - # Combine reduced and legal values - valid_input_index &= \ - data_reduce.get_valid_index_from_lonlat_boundaries( - sides_lons, - sides_lats, - source_lons, source_lats, - radius_of_influence) + # - If invalid sides, return np.ones + try: + sides_lons, sides_lats = target_geo_def.geographic_boundary().sides + # Combine reduced and legal values + valid_input_index &= \ + data_reduce.get_valid_index_from_lonlat_boundaries( + sides_lons, + sides_lats, + source_lons, source_lats, + radius_of_influence) + except Exception: + valid_input_index = np.ones(source_lons.size, dtype=bool) if isinstance(valid_input_index, np.ma.core.MaskedArray): # Make sure valid_input_index is not a masked array @@ -440,15 +443,19 @@ def _get_valid_output_index(source_geo_def, target_geo_def, target_lons, geometry.AreaDefinition)) and \ isinstance(target_geo_def, geometry.CoordinateDefinition): # Resampling from grid to swath - sides_lons, sides_lats = source_geo_def.geographic_boundary().sides - valid_output_index = \ - data_reduce.get_valid_index_from_lonlat_boundaries( - sides_lons, - sides_lats, - target_lons, - target_lats, - radius_of_influence) - valid_output_index = valid_output_index.astype(bool) + # - If invalid sides, return np.ones + try: + sides_lons, sides_lats = source_geo_def.geographic_boundary().sides + valid_output_index = \ + data_reduce.get_valid_index_from_lonlat_boundaries( + sides_lons, + sides_lats, + target_lons, + target_lats, + radius_of_influence) + valid_output_index = valid_output_index.astype(bool) + except Exception: + valid_output_index = np.ones(target_lons.size, dtype=bool) # Remove illegal values valid_out = ((target_lons >= -180) & (target_lons <= 180) & (target_lats <= 90) & (target_lats >= -90)) diff --git a/pyresample/test/test_gradient.py b/pyresample/test/test_gradient.py index ad17b8b9f..addc3be1c 100644 --- a/pyresample/test/test_gradient.py +++ b/pyresample/test/test_gradient.py @@ -123,7 +123,7 @@ def test_get_src_poly_area(self): self.resampler._get_projection_coordinates(chunks) self.resampler._get_gradients() poly = self.resampler._get_src_poly(0, 40, 0, 40) - assert np.allclose(poly.area, 12364231944935.44) + assert np.allclose(poly.area, 12365352661227.719) def test_get_src_poly_swath(self): """Test defining source chunk polygon for SwathDefinition.""" From e3f4b2479eb5d8173379629333e8151bd5886462 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Sat, 25 Nov 2023 03:27:51 +0100 Subject: [PATCH 35/39] Add code structure for boundary extraction for polar and global planar projections --- pyresample/geometry.py | 88 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 77 insertions(+), 11 deletions(-) diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 33fecc2eb..1ada96580 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -108,6 +108,7 @@ def __init__(self, lons=None, lats=None, nprocs=1): self.ndim = None self.cartesian_coords = None self.hash = None + self._boundary_mask = None def __getitem__(self, key): """Slice a 2D geographic definition.""" @@ -347,14 +348,14 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, sides_lons, sides_lats = self._reverse_boundaries(sides_lons, sides_lats) return sides_lons, sides_lats - def _get_geostationary_dummy_sides(self, arr, vertices_per_side): - """Retrieve a 'dummy' boundary side list for a geostationary area with boundaries out of the Earth disk. + def _get_dummy_sides(self, arr, vertices_per_side): + """Retrieve a 'dummy' boundary side list for AreaDefinition with boundaries out of the Earth disk. The second and fourth sides are always of length 2. """ N = len(arr) if vertices_per_side is None: - vertices_per_side = min(vertices_per_side, int(N / 2)) + vertices_per_side = int(N / 2) top_side = arr[slice(0, vertices_per_side)] bottom_side = arr[slice(vertices_per_side, N)] else: @@ -404,10 +405,17 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates= raise ValueError("The geostationary projection area is entirely out of the Earth disk.") # Retrieve dummy sides for GEO # - _get_geostationary_bounding_box_in_lonlats does not guarantee to return nb_points and even points! - sides_x = self._get_geostationary_dummy_sides(x, vertices_per_side=vertices_per_side) - sides_y = self._get_geostationary_dummy_sides(y, vertices_per_side=vertices_per_side) + sides_x = self._get_dummy_sides(x, vertices_per_side=vertices_per_side) + sides_y = self._get_dummy_sides(y, vertices_per_side=vertices_per_side) return sides_x, sides_y + def _compute_boundary_mask(self): + """Compute valid boundary mask for AreaDefinition(s) with sides out of the Earth disk.""" + if self._boundary_mask is None: + lons, lats = self.get_lonlats() # all in memory ! + self._boundary_mask = find_boundary_mask(lons, lats) + return self._boundary_mask + def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tuple: """Return the geographic boundary sides of the current area. @@ -428,14 +436,20 @@ def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tupl """ is_swath = self.__class__.__name__ == "SwathDefinition" if not is_swath and _is_any_corner_out_of_earth_disk(self): + # Geostationary if self.is_geostationary: return self._get_geostationary_boundary_sides(vertices_per_side=vertices_per_side, coordinates="geographic") # Polar Projections, Global Planar Projections (Mollweide, Robinson) - # if self.is_polar_projection # BUG - # self.is_robinson - # raise NotImplementedError("Likely a polar projection.") - sides_lons, sides_lats = self._get_sides(coord_fun=self.get_lonlats, vertices_per_side=vertices_per_side) + # - Retrieve dummy right and left sides + boundary_mask = self._compute_boundary_mask() + lons, lats = self.get_lonlats() + lons = lons[boundary_mask] + lats = lats[boundary_mask] + sides_lons = self._get_dummy_sides(lons, vertices_per_side=vertices_per_side) + sides_lats = self._get_dummy_sides(lats, vertices_per_side=vertices_per_side) + else: + sides_lons, sides_lats = self._get_sides(coord_fun=self.get_lonlats, vertices_per_side=vertices_per_side) return sides_lons, sides_lats def _get_sides(self, coord_fun, vertices_per_side): @@ -1741,11 +1755,22 @@ def _get_projection_sides(self, vertices_per_side: Optional[int] = None) -> tupl The order of the sides are [top", "right", "bottom", "left"] """ if _is_any_corner_out_of_earth_disk(self): + # Geostationary if self.is_geostationary: return self._get_geostationary_boundary_sides(vertices_per_side=vertices_per_side, coordinates="projection") - sides_lons, sides_lats = self._get_sides(coord_fun=self.get_proj_coords, vertices_per_side=vertices_per_side) - return sides_lons, sides_lats + # Polar Projections, Global Planar Projections (Mollweide, Robinson) + # - Retrieve dummy right and left sides + boundary_mask = self._compute_boundary_mask() + x, y = self.get_proj_coords() + x = x[boundary_mask] + y = y[boundary_mask] + sides_x = self._get_dummy_sides(x, vertices_per_side=vertices_per_side) + sides_y = self._get_dummy_sides(y, vertices_per_side=vertices_per_side) + else: + sides_x, sides_y = self._get_sides(coord_fun=self.get_proj_coords, + vertices_per_side=vertices_per_side) + return sides_x, sides_y def projection_boundary(self, vertices_per_side=None, order=None): """Retrieve the ProjectionBoundary object. @@ -2990,6 +3015,47 @@ def _is_any_corner_out_of_earth_disk(area_def): return True +def _find_boundary_indices(mask): + """Assume mask does not have any row/column with only False values.""" + # For rows + first_valid_row = np.argmax(mask, axis=0) + last_valid_row = mask.shape[0] - 1 - np.argmax(mask[::-1], axis=0) + # For columns + first_valid_col = np.argmax(mask, axis=1) + last_valid_col = mask.shape[1] - 1 - np.argmax(mask[:, ::-1], axis=1) + + return first_valid_row, last_valid_row, first_valid_col, last_valid_col + + +def find_boundary_mask(lons, lats): + """Find the boundary mask.""" + valid_mask = np.isfinite(lons) & np.isfinite(lats) + + # Filter out rows and columns without valid values + valid_rows = np.any(valid_mask, axis=1) + valid_cols = np.any(valid_mask, axis=0) + filtered_mask = valid_mask[valid_rows][:, valid_cols] + + # Find boundary indices + fvr, lvr, fvc, lvc = _find_boundary_indices(filtered_mask) + + # Prepare row and column indices for gathering coordinates + col_indices = np.arange(filtered_mask.shape[1]) + row_indices = np.arange(filtered_mask.shape[0]) + + # Gather coordinates using advanced indexing + filtered_boundary_mask = np.zeros_like(filtered_mask, dtype=bool) + filtered_boundary_mask[fvr, col_indices] = True + filtered_boundary_mask[lvr, col_indices] = True + filtered_boundary_mask[row_indices, fvc] = True + filtered_boundary_mask[row_indices, lvc] = True + + # Reinsert the boundary mask into the original mask's shape + boundary_mask = np.zeros_like(valid_mask, dtype=bool) + boundary_mask[np.ix_(valid_rows, valid_cols)] = filtered_boundary_mask + return boundary_mask + + def combine_area_extents_vertical(area1, area2): """Combine the area extents of areas 1 and 2.""" if (area1.area_extent[0] == area2.area_extent[0] and area1.area_extent[2] == area2.area_extent[2]): From 915e34f2ac851bee93eda66c19c0b73e00562981 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Mon, 27 Nov 2023 22:56:07 +0100 Subject: [PATCH 36/39] Fix boundary ordering logic for swath and projections ! --- docs/source/howtos/spherical_geometry.rst | 4 +- pyresample/_config.py | 1 + pyresample/boundary/area_boundary.py | 3 + pyresample/boundary/base_boundary.py | 19 ++- pyresample/boundary/geographic_boundary.py | 150 ++++++++++++------ pyresample/boundary/projection_boundary.py | 35 ++-- pyresample/boundary/utils.py | 84 ++++++++++ pyresample/future/geometry/_subset.py | 6 +- pyresample/future/geometry/area.py | 1 + pyresample/geometry.py | 148 +++++------------ pyresample/slicer.py | 30 ++-- .../test_boundary/test_geographic_boundary.py | 73 ++++----- pyresample/test/test_boundary/test_order.py | 133 ++++++++++++++++ pyresample/test/test_boundary/test_utils.py | 145 +++++++++++++++++ pyresample/test/test_geometry/test_area.py | 65 ++++---- pyresample/test/test_geometry/test_swath.py | 2 +- pyresample/test/test_slicer.py | 11 +- 17 files changed, 636 insertions(+), 274 deletions(-) create mode 100644 pyresample/boundary/utils.py create mode 100644 pyresample/test/test_boundary/test_order.py create mode 100644 pyresample/test/test_boundary/test_utils.py diff --git a/docs/source/howtos/spherical_geometry.rst b/docs/source/howtos/spherical_geometry.rst index 0bf413470..6ca535671 100644 --- a/docs/source/howtos/spherical_geometry.rst +++ b/docs/source/howtos/spherical_geometry.rst @@ -74,11 +74,11 @@ satellite passes. See trollschedule_ how to generate a list of satellite overpas >>> from pyresample.spherical_utils import GetNonOverlapUnions >>> area_boundary = area_def.geographic_boundary(vertices_per_side=100) # doctest: +SKIP - >>> area_boundary = area_boundary.contour_poly # doctest: +SKIP + >>> area_boundary = area_boundary.polygon # doctest: +SKIP >>> list_of_polygons = [] >>> for mypass in passes: # doctest: +SKIP - >>> list_of_polygons.append(mypass.boundary.contour_poly) # doctest: +SKIP + >>> list_of_polygons.append(mypass.geographic_boundary().polygon) # doctest: +SKIP >>> non_overlaps = GetNonOverlapUnions(list_of_polygons) # doctest: +SKIP >>> non_overlaps.merge() # doctest: +SKIP diff --git a/pyresample/_config.py b/pyresample/_config.py index 9a4a2b1ae..4d92c927b 100644 --- a/pyresample/_config.py +++ b/pyresample/_config.py @@ -40,6 +40,7 @@ "features": { "future_geometries": False, }, + "force_boundary_computations": False, }], paths=_CONFIG_PATHS, ) diff --git a/pyresample/boundary/area_boundary.py b/pyresample/boundary/area_boundary.py index 5c9a8f9f6..b3087b829 100644 --- a/pyresample/boundary/area_boundary.py +++ b/pyresample/boundary/area_boundary.py @@ -43,6 +43,9 @@ def contour(self): @property def contour_poly(self): """Get the Spherical polygon corresponding to the Boundary.""" + warnings.warn("'contour_poly' is deprecated." + + "Use the 'boundary().polygon' property instead!.", + PendingDeprecationWarning, stacklevel=2) if self._contour_poly is None: self._contour_poly = SphPolygon( np.deg2rad(np.vstack(self.contour()).T)) diff --git a/pyresample/boundary/base_boundary.py b/pyresample/boundary/base_boundary.py index ffeb8f61e..7306237fc 100644 --- a/pyresample/boundary/base_boundary.py +++ b/pyresample/boundary/base_boundary.py @@ -30,18 +30,25 @@ class BaseBoundary: """Base class for boundary objects.""" __slots__ = ["_sides_x", "_sides_y"] - def __init__(self, sides_x, sides_y, order=None): + def __init__(self, area, vertices_per_side=None): + + sides_x, sides_y = self._compute_boundary_sides(area, vertices_per_side) self._sides_x = BoundarySides(sides_x) self._sides_y = BoundarySides(sides_y) + self._area = area - self.is_clockwise = self._check_is_boundary_clockwise(sides_x, sides_y) + self.is_clockwise = self._check_is_boundary_clockwise(sides_x, sides_y, area) self.is_counterclockwise = not self.is_clockwise - self._set_order(order) + self._set_order(order=None) # FIX ! - def _check_is_boundary_clockwise(self, sides_x, sides_y): + def _check_is_boundary_clockwise(self, sides_x, sides_y, area): """Check if the boundary is clockwise or counterclockwise.""" raise NotImplementedError() + def _compute_boundary_sides(self, area, vertices_per_side): + """Compute boundary sides.""" + raise NotImplementedError() + def _set_order(self, order): """Set the order of the boundary vertices.""" if self.is_clockwise: @@ -112,9 +119,9 @@ def contour(self, closed=False): y = np.hstack((y, y[0])) return x, y - def _to_shapely_polygon(self): + def to_shapely_polygon(self): """Define a Shapely Polygon.""" from shapely.geometry import Polygon - self = self.set_counterclockwise() # FIXME: add exception for pole wrapping polygons + self = self.set_counterclockwise() x, y = self.contour(closed=True) return Polygon(zip(x, y)) diff --git a/pyresample/boundary/geographic_boundary.py b/pyresample/boundary/geographic_boundary.py index abe6ebb88..3adc13510 100644 --- a/pyresample/boundary/geographic_boundary.py +++ b/pyresample/boundary/geographic_boundary.py @@ -24,44 +24,86 @@ from pyresample.boundary.area_boundary import Boundary as OldBoundary from pyresample.boundary.base_boundary import BaseBoundary +from pyresample.spherical import SphPolygon logger = logging.getLogger(__name__) -def _is_corner_is_clockwise(lon1, lat1, corner_lon, corner_lat, lon2, lat2): - """Determine if coordinates follow a clockwise path. +def _get_swath_local_inside_point_from_sides(sides_x, sides_y, start_idx=0): + """Retrieve point inside boundary close to the point used to determine order. + + This is required for global areas (spanning more than 180 degrees) and swaths. + The start_idx indicates the opposites sides corners (start_idx, start_idx+2) from + which to try identify a point inside the polygon. + """ + # NOTE: the name of the object here refers to start_idx=0 + from pyresample.spherical import Arc, SCoordinate + idx1 = start_idx + idx2 = (start_idx + 2) % 4 + + top_corner = sides_x[idx1][0], sides_y[idx1][0] + top_right_point = sides_x[idx1][1], sides_y[idx1][1] + bottom_corner = sides_x[idx2][-1], sides_y[idx2][-1] + bottom_right_point = sides_x[idx2][-2], sides_y[idx2][-2] + point_top_corner = SCoordinate(*np.deg2rad(top_corner)) + point_top_right_point = SCoordinate(*np.deg2rad(top_right_point)) + point_bottom_corner = SCoordinate(*np.deg2rad(bottom_corner)) + point_bottom_right_point = SCoordinate(*np.deg2rad(bottom_right_point)) + arc1 = Arc(point_top_corner, point_bottom_right_point) + arc2 = Arc(point_top_right_point, point_bottom_corner) + point_inside = arc1.intersection(arc2) + if point_inside is not None: + point_inside = point_inside.vertices_in_degrees[0] + return point_inside + + +def _try_get_local_inside_point(sides_x, sides_y): + """Try to get a local inside point from one of the 4 boundary sides corners.""" + for start_idx in range(0, 4): + point_inside = _get_swath_local_inside_point_from_sides(sides_x, sides_y, start_idx=start_idx) + if point_inside is not None: + return point_inside, start_idx + else: + return None, None + + +def _is_clockwise_order(first_point, second_point, point_inside): + """Determine if polygon coordinates follow a clockwise path. This uses :class:`pyresample.spherical.Arc` to determine the angle - between the first line segment (Arc) from (lon1, lat1) to - (corner_lon, corner_lat) and the second line segment from - (corner_lon, corner_lat) to (lon2, lat2). A straight line would - produce an angle of 0, a clockwise path would have a negative angle, - and a counter-clockwise path would have a positive angle. + between a polygon arc segment and a point known to be inside the polygon. + Note: pyresample.spherical assumes angles are positive if counterclockwise. + Note: if the longitude distance between the first_point/second_point and point_inside is + larger than 180°, the function likely return a wrong unexpected result ! """ - import math - from pyresample.spherical import Arc, SCoordinate - point1 = SCoordinate(math.radians(lon1), math.radians(lat1)) - point2 = SCoordinate(math.radians(corner_lon), math.radians(corner_lat)) - point3 = SCoordinate(math.radians(lon2), math.radians(lat2)) - arc1 = Arc(point1, point2) - arc2 = Arc(point2, point3) - angle = arc1.angle(arc2) + point1 = SCoordinate(*np.deg2rad(first_point)) + point2 = SCoordinate(*np.deg2rad(second_point)) + point3 = SCoordinate(*np.deg2rad(point_inside)) + arc12 = Arc(point1, point2) + arc23 = Arc(point2, point3) + angle = arc12.angle(arc23) is_clockwise = -np.pi < angle < 0 return is_clockwise -def _is_boundary_clockwise(sides_lons, sides_lats): - """Determine if the boundary sides are clockwise.""" - is_clockwise = _is_corner_is_clockwise( - lon1=sides_lons[0][-2], - lat1=sides_lats[0][-2], - corner_lon=sides_lons[0][-1], - corner_lat=sides_lats[0][-1], - lon2=sides_lons[1][1], - lat2=sides_lats[1][1]) - return is_clockwise +def _check_is_clockwise(area, sides_x, sides_y): + from pyresample import SwathDefinition + + if isinstance(area, SwathDefinition): + point_inside, start_idx = _try_get_local_inside_point(sides_x, sides_y) + first_point = sides_x[start_idx][0], sides_y[start_idx][0] + second_point = sides_x[start_idx][1], sides_y[start_idx][1] + return _is_clockwise_order(first_point, second_point, point_inside) + else: + if area.is_geostationary: + point_inside = area.get_lonlat(row=int(area.shape[0] / 2), col=int(area.shape[1] / 2)) + first_point = sides_x[0][0], sides_y[0][0] + second_point = sides_x[0][1], sides_y[0][1] + return _is_clockwise_order(first_point, second_point, point_inside) + else: + return True class GeographicBoundary(BaseBoundary, OldBoundary): @@ -74,17 +116,35 @@ class GeographicBoundary(BaseBoundary, OldBoundary): # from the old interface for compatibility to AreaBoundary @classmethod - def _check_is_boundary_clockwise(cls, sides_x, sides_y): + def _check_is_boundary_clockwise(cls, sides_x, sides_y, area): """GeographicBoundary specific implementation.""" - return _is_boundary_clockwise(sides_lons=sides_x, sides_lats=sides_y) + return _check_is_clockwise(area, sides_x, sides_y) + + @classmethod + def _compute_boundary_sides(cls, area, vertices_per_side): + sides_lons, sides_lats = area._get_geographic_sides(vertices_per_side=vertices_per_side) + return sides_lons, sides_lats - def __init__(self, sides_lons, sides_lats, order=None, crs=None): - super().__init__(sides_x=sides_lons, sides_y=sides_lats, order=order) + def __init__(self, area, vertices_per_side=None): + super().__init__(area=area, vertices_per_side=vertices_per_side) self.sides_lons = self._sides_x self.sides_lats = self._sides_y - self.crs = crs or CRS(proj="longlat", ellps="WGS84") - self._contour_poly = None # Backcompatibility with old AreaBoundary + + # Define CRS + if self.is_swath: + crs = self._area.crs + else: + crs = CRS(proj="longlat", ellps="WGS84") # FIXME: AreaDefinition.get_lonlat for geographic projections? + self.crs = crs + + # Backcompatibility with old AreaBoundary + self._contour_poly = None + + @property + def is_swath(self): + """Determine if is the boundary of a swath.""" + return self._area.__class__.__name__ == "SwathDefinition" @property def lons(self): @@ -96,28 +156,22 @@ def lats(self): """Retrieve boundary latitude vertices.""" return self._y - def _to_spherical_polygon(self): - self = self.set_clockwise() # TODO: add exception for pole wrapping polygons - raise NotImplementedError("This will return a SPolygon in pyresample 2.0") - - def polygon(self, shapely=False): - """Return the boundary polygon.""" - # ALTERNATIVE: - # - shapely: to_shapely_polygon(), to_shapely_line(), - # - pyresample spherical: to_polygon(), to_line(), polygon, line - if shapely: - return self._to_shapely_polygon() - else: - return self._to_spherical_polygon() - - def plot(self, ax=None, subplot_kw=None, **kwargs): + @property + def polygon(self): + """Return the boundary spherical polygon.""" + self = self.set_clockwise() + if self._contour_poly is None: + self._contour_poly = SphPolygon(np.deg2rad(self.vertices)) + return self._contour_poly + + def plot(self, ax=None, subplot_kw=None, alpha=0.6, **kwargs): """Plot the the boundary.""" import cartopy.crs as ccrs from pyresample.visualization.geometries import plot_geometries - geom = self.polygon(shapely=True) + geom = self.to_shapely_polygon() crs = ccrs.Geodetic() p = plot_geometries(geometries=[geom], crs=crs, - ax=ax, subplot_kw=subplot_kw, **kwargs) + ax=ax, subplot_kw=subplot_kw, alpha=alpha, **kwargs) return p diff --git a/pyresample/boundary/projection_boundary.py b/pyresample/boundary/projection_boundary.py index 7620f50ff..c1fd07055 100644 --- a/pyresample/boundary/projection_boundary.py +++ b/pyresample/boundary/projection_boundary.py @@ -45,17 +45,27 @@ class ProjectionBoundary(BaseBoundary): """ @classmethod - def _check_is_boundary_clockwise(cls, sides_x, sides_y): + def _check_is_boundary_clockwise(cls, sides_x, sides_y, area=None): """GeographicBoundary specific implementation.""" return _is_projection_boundary_clockwise(sides_x=sides_x, sides_y=sides_y) - def __init__(self, sides_x, sides_y, crs, order=None, cartopy_crs=None): - super().__init__(sides_x=sides_x, sides_y=sides_y, order=order) + @classmethod + def _compute_boundary_sides(cls, area, vertices_per_side): + sides_x, sides_y = area._get_projection_sides(vertices_per_side=vertices_per_side) + return sides_x, sides_y + + def __init__(self, area, vertices_per_side=None): + super().__init__(area=area, vertices_per_side=vertices_per_side) self.sides_x = self._sides_x self.sides_y = self._sides_y - self.crs = crs - self.cartopy_crs = cartopy_crs + self.crs = self._area.crs + self.cartopy_crs = self._area.to_cartopy_crs() + + @property + def _point_inside(self): + x, y = self._area.get_proj_coords(data_slice=(int(self.shape[0] / 2), int(self.shape[1] / 2))) + return x.squeeze(), y.squeeze() @property def x(self): @@ -67,14 +77,7 @@ def y(self): """Retrieve boundary y vertices.""" return self._y - def polygon(self, shapely=True): - """Return the boundary polygon.""" - if shapely: - return self._to_shapely_polygon() - else: - raise NotImplementedError("Only shapely polygon available.") - - def plot(self, ax=None, subplot_kw=None, crs=None, **kwargs): + def plot(self, ax=None, subplot_kw=None, crs=None, alpha=0.6, **kwargs): """Plot the the boundary. crs must be a Cartopy CRS !""" from pyresample.visualization.geometries import plot_geometries @@ -82,8 +85,10 @@ def plot(self, ax=None, subplot_kw=None, crs=None, **kwargs): raise ValueError("Projection Cartopy 'crs' is required to display projection boundary.") if crs is None: crs = self.cartopy_crs + if subplot_kw is None: + subplot_kw = {"projection": crs} - geom = self.polygon(shapely=True) + geom = self.to_shapely_polygon() p = plot_geometries(geometries=[geom], crs=crs, - ax=ax, subplot_kw=subplot_kw, **kwargs) + ax=ax, subplot_kw=subplot_kw, alpha=alpha, **kwargs) return p diff --git a/pyresample/boundary/utils.py b/pyresample/boundary/utils.py new file mode 100644 index 000000000..f6e425e2f --- /dev/null +++ b/pyresample/boundary/utils.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2014-2023 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +"""Utility to extract boundary mask and indices.""" +import numpy as np + + +def _find_boundary_mask(mask): + """Find boundary of a binary mask.""" + mask = mask.astype(int) + # Pad with zeros (enable to detect Trues at mask boundaries) + padded_mask = np.pad(mask, ((1, 1), (1, 1)), mode='constant', constant_values=0) + + # Shift the image in four directions and compare with the original + shift_up = np.roll(padded_mask, -1, axis=0) + shift_down = np.roll(padded_mask, 1, axis=0) + shift_left = np.roll(padded_mask, -1, axis=1) + shift_right = np.roll(padded_mask, 1, axis=1) + + # Find the boundary points + padded_boundary_mask = ((padded_mask != shift_up) | (padded_mask != shift_down) | + (padded_mask != shift_left) | (padded_mask != shift_right)) & padded_mask + + boundary_mask = padded_boundary_mask[1:-1,1:-1] + return boundary_mask + + +def find_boundary_mask(lons, lats): + """Find the boundary mask.""" + valid_mask = np.isfinite(lons) & np.isfinite(lats) + return _find_boundary_mask(valid_mask) + + +def get_ordered_contour(contour_mask): + """Return the ordered indices of a contour mask.""" + # Count number of rows and columns + rows, cols = contour_mask.shape + # Function to find the next contour point + def next_point(current, last, visited): + for dx, dy in [(-1, 0), (0, 1), (1, 0), (0, -1), (1, -1), (-1, -1), (1, 1), (-1, 1)]: + next_pt = (current[0] + dx, current[1] + dy) + if next_pt != last and next_pt not in visited and 0 <= next_pt[0] < rows and 0 <= next_pt[1] < cols and contour_mask[next_pt]: + return next_pt + return None + # Initialize + contour = [] + visited = set() # Keep track of visited points + # Find the starting point + start = tuple(np.argwhere(contour_mask)[0]) + contour.append(start) + visited.add(start) + # Initialize last and current points + last = start + current = next_point(last, None, visited) + while current and current != start: + contour.append(current) + visited.add(current) + next_pt = next_point(current, last, visited) + if not next_pt: # Break if no next point found + break + last, current = current, next_pt + return np.array(contour) + + +def find_boundary_contour_indices(lons, lats): + """Find the boundary contour ordered indices.""" + boundary_mask = find_boundary_mask(lons, lats) + boundary_contour_idx = get_ordered_contour(boundary_mask) + return boundary_contour_idx + \ No newline at end of file diff --git a/pyresample/future/geometry/_subset.py b/pyresample/future/geometry/_subset.py index c401f54e5..c46374489 100644 --- a/pyresample/future/geometry/_subset.py +++ b/pyresample/future/geometry/_subset.py @@ -49,8 +49,8 @@ def get_area_slices( data_boundary = _get_area_boundary(src_area) area_boundary = _get_area_boundary(area_to_cover) - intersection = data_boundary.contour_poly.intersection( - area_boundary.contour_poly) + intersection = data_boundary.polygon.intersection( + area_boundary.polygon) if intersection is None: logger.debug('Cannot determine appropriate slicing. ' "Data and projection area do not overlap.") @@ -103,7 +103,7 @@ def _get_area_boundary(area_to_cover: AreaDefinition) -> GeographicBoundary: vertices_per_side = None else: vertices_per_side = max(max(*area_to_cover.shape) // 100 + 1, 3) - return area_to_cover.geographic_boundary(vertices_per_side=vertices_per_side, order="clockwise") + return area_to_cover.geographic_boundary(vertices_per_side=vertices_per_side) except ValueError as err: raise NotImplementedError("Can't determine boundary of area to cover") from err diff --git a/pyresample/future/geometry/area.py b/pyresample/future/geometry/area.py index 6042debae..caab271b1 100644 --- a/pyresample/future/geometry/area.py +++ b/pyresample/future/geometry/area.py @@ -26,6 +26,7 @@ from pyresample.geometry import AreaDefinition as LegacyAreaDefinition # noqa from pyresample.geometry import ( # noqa DynamicAreaDefinition, + _get_geostationary_bounding_box, _get_geostationary_bounding_box_in_lonlats, get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, diff --git a/pyresample/geometry.py b/pyresample/geometry.py index 1ada96580..bf9489f1c 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -34,7 +34,7 @@ from pyproj import Geod, Proj from pyproj.aoi import AreaOfUse -from pyresample import CHUNK_SIZE +from pyresample import CHUNK_SIZE, config from pyresample._spatial_mp import Cartesian, Cartesian_MP, Proj_MP from pyresample.area_config import create_area_def from pyresample.boundary import SimpleBoundary @@ -108,7 +108,7 @@ def __init__(self, lons=None, lats=None, nprocs=1): self.ndim = None self.cartesian_coords = None self.hash = None - self._boundary_mask = None + self._boundary_contour_idx = None def __getitem__(self, key): """Slice a 2D geographic definition.""" @@ -251,7 +251,8 @@ def get_lonlats(self, data_slice=None, chunks=None, **kwargs): # lons/lats are xarray DataArray objects, use numpy/dask array underneath lons = lons.data lats = lats.data - + # TODO + # --> if data_slice and chunks provided, why here first rechunk all array and then subset? if chunks is not None: import dask.array as da if isinstance(lons, da.Array): @@ -411,10 +412,11 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates= def _compute_boundary_mask(self): """Compute valid boundary mask for AreaDefinition(s) with sides out of the Earth disk.""" - if self._boundary_mask is None: + from pyresample.boundary.utils import find_boundary_contour_indices + if self._boundary_contour_idx is None: lons, lats = self.get_lonlats() # all in memory ! - self._boundary_mask = find_boundary_mask(lons, lats) - return self._boundary_mask + self._boundary_contour_idx = find_boundary_contour_indices(lons, lats) + return self._boundary_contour_idx def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tuple: """Return the geographic boundary sides of the current area. @@ -434,6 +436,8 @@ def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tupl Each list element is a numpy array representing a specific side of the geometry. The order of the sides are [top", "right", "bottom", "left"] """ + if len(self.lons.shape) == 1: + raise ValueError("The area must have 2 dimensions to retrieve the boundary sides.") is_swath = self.__class__.__name__ == "SwathDefinition" if not is_swath and _is_any_corner_out_of_earth_disk(self): # Geostationary @@ -442,14 +446,16 @@ def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tupl coordinates="geographic") # Polar Projections, Global Planar Projections (Mollweide, Robinson) # - Retrieve dummy right and left sides - boundary_mask = self._compute_boundary_mask() - lons, lats = self.get_lonlats() - lons = lons[boundary_mask] - lats = lats[boundary_mask] - sides_lons = self._get_dummy_sides(lons, vertices_per_side=vertices_per_side) - sides_lats = self._get_dummy_sides(lats, vertices_per_side=vertices_per_side) - else: - sides_lons, sides_lats = self._get_sides(coord_fun=self.get_lonlats, vertices_per_side=vertices_per_side) + if config.get("force_boundary_computations", False): + boundary_contour_idx = self._compute_boundary_mask() + lons, lats = self.get_lonlats() + lons = lons[boundary_contour_idx[:, 0], boundary_contour_idx[:, 1]] + lats = lats[boundary_contour_idx[:, 0], boundary_contour_idx[:, 1]] + sides_lons = self._get_dummy_sides(lons, vertices_per_side=vertices_per_side) + sides_lats = self._get_dummy_sides(lats, vertices_per_side=vertices_per_side) + return sides_lons, sides_lats + + sides_lons, sides_lats = self._get_sides(coord_fun=self.get_lonlats, vertices_per_side=vertices_per_side) return sides_lons, sides_lats def _get_sides(self, coord_fun, vertices_per_side): @@ -567,7 +573,8 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N (i.e. full disc geostationary area, Robinson projection, polar projections, ...) by default only 50 points are selected. force_clockwise: - Perform minimal checks and reordering of coordinates to ensure + DEPRECATED. + Performed minimal checks and reordering of coordinates to ensure that the returned coordinates follow a clockwise direction. This is important for compatibility with :class:`pyresample.spherical.SphPolygon` where operations depend @@ -581,13 +588,9 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N warnings.warn("The `boundary` method is pending deprecation. Use `geographic_boundary` instead", PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - if force_clockwise: - order = "clockwise" - else: - order = None - return self.geographic_boundary(vertices_per_side=vertices_per_side, order=order) + return self.geographic_boundary(vertices_per_side=vertices_per_side) - def geographic_boundary(self, vertices_per_side=None, order=None): + def geographic_boundary(self, vertices_per_side=None): """Retrieve the GeographicBoundary object. Parameters @@ -598,25 +601,9 @@ def geographic_boundary(self, vertices_per_side=None, order=None): If the area object is an AreaDefinition with any corner out of the Earth disk (i.e. full disc geostationary area, Robinson projection, polar projections, ...) by default only 50 points are selected. - order: - Specify the desired order of the boundary polygon vertices in GeographicBoundary. - If order=None, the sides order is used. - If order="clockwise", the boundary polygon vertices are returned by - GeographicBoundary in clockwise order. - If order="counterclockwise", the boundary polygon vertices are returned by - GeographicBoundary in counterclockwise order. """ from pyresample.boundary import GeographicBoundary - - sides_lons, sides_lats = self._get_geographic_sides(vertices_per_side=vertices_per_side) - if self.__class__.__name__ == "SwathDefinition": - crs = self.crs - else: - crs = None # default to WGS84 for AreaDefinition - return GeographicBoundary(sides_lons=sides_lons, - sides_lats=sides_lats, - order=order, - crs=crs) + return GeographicBoundary(area=self, vertices_per_side=vertices_per_side) def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): """Retrieve cartesian coordinates of geometry definition. @@ -1761,18 +1748,20 @@ def _get_projection_sides(self, vertices_per_side: Optional[int] = None) -> tupl coordinates="projection") # Polar Projections, Global Planar Projections (Mollweide, Robinson) # - Retrieve dummy right and left sides - boundary_mask = self._compute_boundary_mask() - x, y = self.get_proj_coords() - x = x[boundary_mask] - y = y[boundary_mask] - sides_x = self._get_dummy_sides(x, vertices_per_side=vertices_per_side) - sides_y = self._get_dummy_sides(y, vertices_per_side=vertices_per_side) - else: - sides_x, sides_y = self._get_sides(coord_fun=self.get_proj_coords, - vertices_per_side=vertices_per_side) + if config.get("force_boundary_computations", False): + boundary_contour_idx = self._compute_boundary_mask() + x, y = self.get_proj_coords() + x = x[boundary_contour_idx[:, 0], boundary_contour_idx[:, 1]] + y = y[boundary_contour_idx[:, 0], boundary_contour_idx[:, 1]] + sides_x = self._get_dummy_sides(x, vertices_per_side=vertices_per_side) + sides_y = self._get_dummy_sides(y, vertices_per_side=vertices_per_side) + return sides_x, sides_y + + sides_x, sides_y = self._get_sides(coord_fun=self.get_proj_coords, + vertices_per_side=vertices_per_side) return sides_x, sides_y - def projection_boundary(self, vertices_per_side=None, order=None): + def projection_boundary(self, vertices_per_side=None): """Retrieve the ProjectionBoundary object. Parameters @@ -1782,25 +1771,13 @@ def projection_boundary(self, vertices_per_side=None, order=None): By default (None) the full width and height will be provided. If the area object is an AreaDefinition with any corner out of the Earth disk (i.e. full disc geostationary area, Robinson projection, polar projections, ...) - by default only 50 points are selected. - order: - Specify the desired order of the boundary polygon vertices in GeographicBoundary. - If order=None, the sides order is used. - If order="clockwise", the boundary polygon vertices are returned by - GeographicBoundary in clockwise order. - If order="counterclockwise", the boundary polygon vertices are returned by - GeographicBoundary in counterclockwise order. + by default only 50 points are selected.. """ from pyresample.boundary import ProjectionBoundary if self.crs.is_geographic: - return self.geographic_boundary(vertices_per_side=vertices_per_side, order=order) - sides_x, sides_y = self._get_projection_sides(vertices_per_side=vertices_per_side) - return ProjectionBoundary(sides_x=sides_x, - sides_y=sides_y, - crs=self.crs, - order=order, - cartopy_crs=self.to_cartopy_crs() - ) + return self.geographic_boundary(vertices_per_side=vertices_per_side) + return ProjectionBoundary(area=self, + vertices_per_side=vertices_per_side) def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[int] = None, frequency: Optional[int] = None): @@ -2905,6 +2882,8 @@ def get_geostationary_bounding_box_in_proj_coords(geos_area, nb_points=50): try: x, y = intersection.boundary.xy except NotImplementedError: + # geos_area is fully out of Earth disk + # --> FIXME: Why we do not raise an error here ? return np.array([]), np.array([]) return np.asanyarray(x[:-1]), np.asanyarray(y[:-1]) @@ -3015,47 +2994,6 @@ def _is_any_corner_out_of_earth_disk(area_def): return True -def _find_boundary_indices(mask): - """Assume mask does not have any row/column with only False values.""" - # For rows - first_valid_row = np.argmax(mask, axis=0) - last_valid_row = mask.shape[0] - 1 - np.argmax(mask[::-1], axis=0) - # For columns - first_valid_col = np.argmax(mask, axis=1) - last_valid_col = mask.shape[1] - 1 - np.argmax(mask[:, ::-1], axis=1) - - return first_valid_row, last_valid_row, first_valid_col, last_valid_col - - -def find_boundary_mask(lons, lats): - """Find the boundary mask.""" - valid_mask = np.isfinite(lons) & np.isfinite(lats) - - # Filter out rows and columns without valid values - valid_rows = np.any(valid_mask, axis=1) - valid_cols = np.any(valid_mask, axis=0) - filtered_mask = valid_mask[valid_rows][:, valid_cols] - - # Find boundary indices - fvr, lvr, fvc, lvc = _find_boundary_indices(filtered_mask) - - # Prepare row and column indices for gathering coordinates - col_indices = np.arange(filtered_mask.shape[1]) - row_indices = np.arange(filtered_mask.shape[0]) - - # Gather coordinates using advanced indexing - filtered_boundary_mask = np.zeros_like(filtered_mask, dtype=bool) - filtered_boundary_mask[fvr, col_indices] = True - filtered_boundary_mask[lvr, col_indices] = True - filtered_boundary_mask[row_indices, fvc] = True - filtered_boundary_mask[row_indices, lvc] = True - - # Reinsert the boundary mask into the original mask's shape - boundary_mask = np.zeros_like(valid_mask, dtype=bool) - boundary_mask[np.ix_(valid_rows, valid_cols)] = filtered_boundary_mask - return boundary_mask - - def combine_area_extents_vertical(area1, area2): """Combine the area extents of areas 1 and 2.""" if (area1.area_extent[0] == area2.area_extent[0] and area1.area_extent[2] == area2.area_extent[2]): diff --git a/pyresample/slicer.py b/pyresample/slicer.py index fcc6f8099..09f7c7ab4 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -128,7 +128,7 @@ def _get_chunk_polygons_for_swath_to_crop(swath_to_crop): line_slice = expand_slice(line_slice) col_slice = expand_slice(col_slice) smaller_swath = swath_to_crop[line_slice, col_slice] - smaller_poly = smaller_swath.geographic_boundary(vertices_per_side=10).polygon(shapely=True) + smaller_poly = smaller_swath.geographic_boundary(vertices_per_side=10).to_shapely_polygon() res.append((smaller_poly, (line_slice, col_slice))) return res @@ -148,8 +148,9 @@ def get_polygon_to_contain(self): if self.area_to_crop.is_geostationary: geo_boundary = self.area_to_crop.projection_boundary(vertices_per_side=360) x_geos, y_geos = geo_boundary.contour(closed=True) - # POSSIBLE BUG: Here I expect that some coordinates could be NaN ! + # POSSIBLE BUG: some coordinates could be NaN ! # - if points of the geostationary disk are out of the CRS bounds of the area_to_contain + # - the order could not be counterclockwise (as expected by shapely) x_geos, y_geos = self._transformer.transform(x_geos, y_geos, direction=TransformDirection.INVERSE) geos_poly = Polygon(zip(x_geos, y_geos)) poly = Polygon(zip(x, y)) @@ -158,10 +159,14 @@ def get_polygon_to_contain(self): raise IncompatibleAreas('No slice on area.') x, y = zip(*poly.exterior.coords) + # Return poly_to_contain (in area_to_crop CRS) return Polygon(zip(*self._transformer.transform(x, y))) def get_slices_from_polygon(self, poly_to_contain): - """Get the slices based on the polygon.""" + """Get the slices based on the polygon boundary of area_to_contain. + + poly_to_contain is expected to have been projected in the CRS of area_to_crop + """ if not poly_to_contain.is_valid: raise IncompatibleAreas("Area outside of domain.") try: @@ -170,22 +175,22 @@ def get_slices_from_polygon(self, poly_to_contain): buffer_size = np.max(self.area_to_contain.resolution) else: buffer_size = 0 - buffered_poly = poly_to_contain.buffer(buffer_size) - bounds = buffered_poly.bounds + buffered_poly_to_contain = poly_to_contain.buffer(buffer_size) + bounds_poly_to_contain = buffered_poly_to_contain.bounds except ValueError as err: raise InvalidArea("Invalid area") from err - poly_to_crop = self.area_to_crop.projection_boundary(vertices_per_side=10).polygon(shapely=True) - if not poly_to_crop.intersects(buffered_poly): + poly_to_crop = self.area_to_crop.projection_boundary(vertices_per_side=10).to_shapely_polygon() + if not poly_to_crop.intersects(buffered_poly_to_contain): raise IncompatibleAreas("Areas not overlapping.") - bounds = self._sanitize_polygon_bounds(bounds) - slice_x, slice_y = self._create_slices_from_bounds(bounds) + x_bounds, y_bounds = self._sanitize_polygon_bounds(bounds_poly_to_contain) + slice_x, slice_y = self._create_slices_from_bounds(x_bounds, y_bounds) return slice_x, slice_y - def _sanitize_polygon_bounds(self, bounds): + def _sanitize_polygon_bounds(self, bounds_poly_to_contain): """Reset the bounds within the shape of the area.""" try: - (minx, miny, maxx, maxy) = bounds + (minx, miny, maxx, maxy) = bounds_poly_to_contain except ValueError as err: raise IncompatibleAreas('No slice on area.') from err x_bounds, y_bounds = self.area_to_crop.get_array_coordinates_from_projection_coordinates(np.array([minx, maxx]), @@ -196,9 +201,8 @@ def _sanitize_polygon_bounds(self, bounds): return x_bounds, y_bounds @staticmethod - def _create_slices_from_bounds(bounds): + def _create_slices_from_bounds(x_bounds, y_bounds): """Create slices from bounds.""" - x_bounds, y_bounds = bounds try: slice_x = slice(int(np.floor(max(np.min(x_bounds), 0))), int(np.ceil(np.max(x_bounds)))) diff --git a/pyresample/test/test_boundary/test_geographic_boundary.py b/pyresample/test/test_boundary/test_geographic_boundary.py index 0af54eac7..b61be7a22 100644 --- a/pyresample/test/test_boundary/test_geographic_boundary.py +++ b/pyresample/test/test_boundary/test_geographic_boundary.py @@ -17,19 +17,18 @@ # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . """Test the GeographicBoundary objects.""" -import unittest import numpy as np import pytest +from pyresample import SwathDefinition from pyresample.boundary import GeographicBoundary -class TestGeographicBoundary(unittest.TestCase): - """Test 'GeographicBoundary' class.""" +class TestSwathGeographicBoundary(): + """Test 'GeographicBoundary' class for SwathDefinition.""" - def test_creation(self): - """Test GeographicBoundary creation.""" + def setup_method(self): sides_lons = [np.array([1.0, 1.5, 2.0]), np.array([2.0, 3.0]), np.array([3.0, 3.5, 4.0]), @@ -38,41 +37,40 @@ def test_creation(self): np.array([7.0, 8.0]), np.array([8.0, 8.5, 9.0]), np.array([9.0, 6.0])] + lons = np.array([[1.0, 1.5, 2.0], + [4.0, 3.5, 3.0]]) + lats = np.array([[6.0, 6.5, 7.0], + [9.0, 8.5, 8.0]]) + + self.lons = lons + self.lats = lats + self.area = SwathDefinition(lons, lats) + self.sides_lons = sides_lons + self.sides_lats = sides_lats + self.point_inside = (2.5, 7.5) + def test_creation(self): + """Test GeographicBoundary creation.""" # Define GeographicBoundary - boundary = GeographicBoundary(sides_lons, sides_lats) + boundary = GeographicBoundary(self.area) # Assert sides coincides - for b_lon, src_lon in zip(boundary.sides_lons, sides_lons): + for b_lon, src_lon in zip(boundary.sides_lons, self.sides_lons): assert np.allclose(b_lon, src_lon) - for b_lat, src_lat in zip(boundary.sides_lats, sides_lats): + for b_lat, src_lat in zip(boundary.sides_lats, self.sides_lats): assert np.allclose(b_lat, src_lat) - def test_number_sides_required(self): - """Test GeographicBoundary requires 4 sides .""" - sides_lons = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([4.0, 1.0])] - sides_lats = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([9.0, 6.0])] + def test_minimum_swath_size(self): + """Test swath has minimum 2 dimensions.""" + area = SwathDefinition(self.lons[0], self.lats[1]) with pytest.raises(ValueError): - GeographicBoundary(sides_lons, sides_lats) + GeographicBoundary(area) def test_vertices_property(self): """Test GeographicBoundary vertices property.""" - sides_lons = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - sides_lats = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] # Define GeographicBoundary - boundary = GeographicBoundary(sides_lons, sides_lats) - + boundary = GeographicBoundary(self.area) # Assert vertices expected_vertices = np.array([[1., 6.], [1.5, 6.5], @@ -84,32 +82,17 @@ def test_vertices_property(self): def test_contour(self): """Test that GeographicBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" - sides_lons = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - sides_lats = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] # Define GeographicBoundary - boundary = GeographicBoundary(sides_lons, sides_lats) + boundary = GeographicBoundary(self.area) + # Assert contour lons, lats = boundary.contour() assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) def test_contour_closed(self): """Test that GeographicBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" - sides_lons = [np.array([1.0, 1.5, 2.0]), - np.array([2.0, 3.0]), - np.array([3.0, 3.5, 4.0]), - np.array([4.0, 1.0])] - sides_lats = [np.array([6.0, 6.5, 7.0]), - np.array([7.0, 8.0]), - np.array([8.0, 8.5, 9.0]), - np.array([9.0, 6.0])] # Define GeographicBoundary - boundary = GeographicBoundary(sides_lons, sides_lats) + boundary = GeographicBoundary(self.area) lons, lats = boundary.contour(closed=True) assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4., 1.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9., 6.])) diff --git a/pyresample/test/test_boundary/test_order.py b/pyresample/test/test_boundary/test_order.py new file mode 100644 index 000000000..bc7815758 --- /dev/null +++ b/pyresample/test/test_boundary/test_order.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Mon Nov 27 15:48:41 2023 + +@author: ghiggi +""" +import pytest +from pyresample.boundary.geographic_boundary import _is_clockwise_order + +class Test_Is_Clockwise_Order: + + def test_vertical_edges(self): + """Test with vertical polygon edges.""" + # North Hemisphere + first_point = (0, 10) + second_point = (0, 20) + point_inside = (1, 15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + # South Hemisphere + first_point = (0, -20) + second_point = (0, -10) + point_inside = (1, - 15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + @pytest.mark.parametrize("lon",[-180, -90, 0, 90, 180]) + def test_horizontal_edges(self, lon): + """Test with horizontal polygon edges.""" + # Point in northern hemisphere + first_point = (lon, 0) + second_point = (lon-10, 0) + point_inside = (1, 15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + # Point in northern hemisphere + first_point = (lon, 0) + second_point = (lon+10, 0) + point_inside = (1, -15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + def test_diagonal_edges(self): + """Test with diagonal polygon edges.""" + point_inside = (20, 15) + + # Edge toward right (above point) --> clockwise + first_point = (0, 0) + second_point = (20, 20) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + # Edge toward right (below point) --> not clockwise + first_point = (0, 0) + second_point = (20, 10) + assert not _is_clockwise_order(first_point, second_point, point_inside) + + def test_polygon_edges_on_antimeridian(self): + """Test polygon edges touching the antimeridian edges.""" + ## Right side of antimeridian + # North Hemisphere + first_point = (-180, 10) + second_point = (-180, 20) + point_inside = (-179, 15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + # South Hemisphere + first_point = (-180, -20) + second_point = (-180, -10) + point_inside = (-179, - 15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + ## Left side of antimeridian + # North Hemisphere + first_point = (-180, 20) + second_point = (-180, 10) + point_inside = (179, 15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + # South Hemisphere + first_point = (-180, -10) + second_point = (-180,-20) + point_inside = (179, - 15) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + @pytest.mark.parametrize("lon",[179, 180, -180, -179]) + def test_polygon_around_antimeridian(self, lon): + """Test polygon edges crossing antimeridian.""" + # North Hemisphere + first_point = (170, 10) + second_point = (-170, 10) + point_inside = (lon, 5) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + # South Hemisphere + first_point = (-170, -10) + second_point = (170, -10) + point_inside = (lon, - 5) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + @pytest.mark.parametrize("lon_pole",[-180, 90, 45, 0, 45, 90, 180]) + @pytest.mark.parametrize("lat",[85, 0, -85]) + def test_polygon_around_north_pole(self, lon_pole, lat): + """Test polygon edges around north pole (right to left).""" + point_inside = (lon_pole, 90) + first_point = (0, lat) + second_point = (-10, lat) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + @pytest.mark.parametrize("lon_pole",[-180, 90, 45, 0, 45, 90, 180]) + @pytest.mark.parametrize("lat",[85, 0, -85]) + def test_polygon_around_south_pole(self, lon_pole, lat): + """Test polygon edges around south pole (left to right).""" + point_inside = (lon_pole, -90) + first_point = (0, lat) + second_point = (10, lat) + assert _is_clockwise_order(first_point, second_point, point_inside) + assert not _is_clockwise_order(second_point, first_point , point_inside) + + + + + \ No newline at end of file diff --git a/pyresample/test/test_boundary/test_utils.py b/pyresample/test/test_boundary/test_utils.py new file mode 100644 index 000000000..024516e79 --- /dev/null +++ b/pyresample/test/test_boundary/test_utils.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# pyresample, Resampling of remote sensing image data in python +# +# Copyright (C) 2010-2022 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Test the boundary utility.""" +import numpy as np +import pytest +from pyresample.boundary.utils import ( + find_boundary_mask, + find_boundary_contour_indices, + get_ordered_contour, +) + + +@pytest.mark.parametrize("lonlat, expected", [ + # Case: All True values + ((np.array([[1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4]]), + np.array([[1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4]])), + np.array([[True, True, True, True], + [True, False, False, True], + [True, False, False, True], + [True, True, True, True]])), + + # Case: Multiple True values in the center + ((np.array([[np.inf, np.inf, np.inf, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, np.inf, np.inf, np.inf]]), + np.array([[np.inf, np.inf, np.inf, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, np.inf, np.inf, np.inf]])), + np.array([[False, False, False, False], + [False, True, True, False], + [False, True, True, False], + [False, False, False, False]])), +]) +def test_find_boundary_mask(lonlat, expected): + """Test boundary mask for lon lat array with non finite values.""" + lons, lats = lonlat + result = find_boundary_mask(lons, lats) + np.testing.assert_array_equal(result, expected, err_msg=f"Expected {expected}, but got {result}") + + +@pytest.mark.parametrize("boundary_mask, expected", [ + # Case: All True values + (np.array([[True, True, True, True], + [True, False, False, True], + [True, False, False, True], + [True, True, True, True]]), + np.array([[0, 0], [0, 1], [0, 2], [0, 3], + [1, 3], [2, 3], [3, 3], + [3, 2], [3, 1], [3, 0], + [2, 0], [1, 0]]) + ), + # Case: Multiple True values in the center + (np.array([[False, False, False, False], + [False, True, True, False], + [False, True, True, False], + [False, False, False, False]]), + np.array([[1, 1], [1, 2], [2, 2], [2, 1]]) + ), + # Case: Square on angle + (np.array([[True, True, True, False], + [True, False, True, False], + [True, True, True, False], + [False, False, False, False]]), + np.array([[0, 0], [0, 1], [0, 2], [1, 2], [2, 2], [2, 1], [2, 0], [1, 0]]) + ), + # Case: Cross Pattern + (np.array([[False, True, True, False], + [True, False, False, True], + [True, False, False, True], + [False, True, True, False]]), + np.array([[0, 1], [0, 2], [1, 3], [2, 3], [3, 2], [3, 1], [2, 0], [1, 0]]) + ), + # Case: Possibile infinit loop if not checking visited + (np.array([[1, 1, 1, 1, 0], + [1, 0, 1, 0, 0], + [1, 0, 1, 0, 0], + [1, 1, 0, 0, 0], + [1, 0, 0, 0, 0], + [1, 0, 0, 0, 0], + [0, 0, 0, 0, 0]]), + np.array([[0, 0], [0, 1], [0, 2], [0, 3], [1, 2], [2, 2], [3, 1], [3, 0], [2, 0], [1, 0]]) + ), +]) +def test_get_ordered_contour(boundary_mask, expected): + """Test order of the boundary contour indices (clockwise).""" + result = get_ordered_contour(boundary_mask) + np.testing.assert_array_equal(result, expected, err_msg=f"Expected {expected}, but got {result}") + + +@pytest.mark.parametrize("lonlat, expected", [ + # Case: All True values + ((np.array([[1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4]]), + np.array([[1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4], + [1, 2, 3, 4]])), + np.array([[0, 0], [0, 1], [0, 2], [0, 3], + [1, 3], [2, 3], [3, 3], + [3, 2], [3, 1], [3, 0], + [2, 0], [1, 0]]) + ), + # Case: Multiple True values in the center + ((np.array([[np.inf, np.inf, np.inf, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, np.inf, np.inf, np.inf]]), + np.array([[np.inf, np.inf, np.inf, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, np.inf, np.inf, np.inf]])), + np.array([[1, 1], [1, 2], [2, 2], [2, 1]]) + ), +]) +def test_find_boundary_contour_indices(lonlat, expected): + """Test order of the boundary contour indices (clockwise).""" + lons, lats = lonlat + result = find_boundary_contour_indices(lons, lats) + np.testing.assert_array_equal(result, expected, err_msg=f"Expected {expected}, but got {result}") \ No newline at end of file diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index a6cb41f74..3e2ae11ac 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -29,10 +29,9 @@ from pyresample import geo_filter, parse_area_file from pyresample.future.geometry import AreaDefinition, SwathDefinition from pyresample.future.geometry.area import ( - _get_geostationary_bounding_box_in_lonlats, + _get_geostationary_bounding_box, get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, - get_geostationary_bounding_box_in_proj_coords, ignore_pyproj_proj_warnings, ) from pyresample.future.geometry.base import get_array_hashable @@ -262,7 +261,7 @@ def geos_mesoscale_area(): @pytest.fixture def truncated_geos_area(): - """Create a truncated geostationary area.""" + """Create a truncated geostationary area (SEVIRI above 30° lat).""" proj_dict = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} area_extent = (5567248.0742, 5570248.4773, -5570248.4773, 1393687.2705) @@ -276,7 +275,7 @@ def truncated_geos_area(): @pytest.fixture def truncated_geos_area_in_space(): - """Create a truncated geostationary area.""" + """Create a geostationary area entirely out of the Earth disk !.""" proj_dict = {'a': '6378169', 'h': '35785831', 'lon_0': '9.5', 'no_defs': 'None', 'proj': 'geos', 'rf': '295.488065897014', 'type': 'crs', 'units': 'm', 'x_0': '0', 'y_0': '0'} area_extent = (5575000, 5575000, 5570000, 5570000) @@ -1669,43 +1668,41 @@ def test_get_full_geostationary_bbox(self, truncated_geos_area): def test_get_geostationary_bbox_works_with_truncated_area(self, truncated_geos_area): """Ensure the geostationary bbox works when truncated.""" - lon, lat = _get_geostationary_bounding_box_in_lonlats(truncated_geos_area, 20) - expected_lon = np.array( - [-64.24072434653284, -68.69662326361153, -65.92516214783112, -60.726360278290336, - -47.39851775032484, 9.500000000000018, 66.39851775032487, 79.72636027829033, - 84.92516214783113, 87.69662326361151, 83.24072434653286]) - expected_lat = np.array( - [14.554922655532085, 17.768795771961937, 35.34328897185421, 52.597860701318254, 69.00533141646078, - 79.1481121862375, 69.00533141646076, 52.597860701318254, 35.34328897185421, 17.768795771961933, - 14.554922655532085]) + # NOTE: the results change if nb_points is changed + lon, lat = _get_geostationary_bounding_box(truncated_geos_area, coordinates="geographic", nb_points=5) + expected_lon = np.array([48.23903923, 27.09551769, 9.48612352, -8.12549639, + -29.28016639, -40.25837094, -47.39851775, 84.92516215, + 58.87273432]) + expected_lat = np.array([13.3415869, 12.89359038, 12.76978964, 12.89400717, 13.34272893, + 13.67772269, 69.00533142, 35.34328897, 13.66502884]) np.testing.assert_allclose(lon, expected_lon) np.testing.assert_allclose(lat, expected_lat) def test_get_geostationary_bbox_works_with_truncated_area_proj_coords(self, truncated_geos_area): """Ensure the geostationary bbox works when truncated.""" - x, y = get_geostationary_bounding_box_in_proj_coords(truncated_geos_area, 20) + # NOTE: the results change if nb_points is changed + x, y = _get_geostationary_bounding_box(truncated_geos_area, coordinates="projection", nb_points=5) - expected_x = np.array( - [-5209128.302753595, -5164828.965702432, -4393465.934674804, -3192039.8468840676, -1678154.6586309497, - 3.325297262895822e-10, 1678154.6586309501, 3192039.846884068, 4393465.934674805, 5164828.965702432, - 5209128.302753594]) - expected_y = np.array( - [1393687.2705, 1672427.7900638399, 3181146.6955466354, 4378472.798117005, 5147203.47659387, - 5412090.016106332, 5147203.476593869, 4378472.798117005, 3181146.695546635, 1672427.7900638392, - 1393687.2705]) + expected_x = np.array([3.71099865e+06, 1.85474922e+06, -1.50020155e+03, -1.85774963e+06, + -3.71399905e+06, -4.41458214e+06, -1.67815466e+06, 4.39346593e+06, + 4.39346593e+06]) + expected_y = np.array([1393687.2705, 1393687.2705, 1393687.2705, + 1393687.2705, 1393687.2705, 1393687.2705, 5147203.47659387, + 3181146.69554663, 1393687.2705]) np.testing.assert_allclose(x, expected_x) np.testing.assert_allclose(y, expected_y) def test_get_geostationary_bbox_does_not_contain_inf(self, truncated_geos_area): """Ensure the geostationary bbox does not contain np.inf.""" - lon, lat = _get_geostationary_bounding_box_in_lonlats(truncated_geos_area, 20) + lon, lat = _get_geostationary_bounding_box(truncated_geos_area, coordinates="geographic", nb_points=20) assert not any(np.isinf(lon)) assert not any(np.isinf(lat)) def test_get_geostationary_bbox_returns_empty_lonlats_in_space(self, truncated_geos_area_in_space): """Ensure the geostationary bbox is empty when in space.""" - lon, lat = _get_geostationary_bounding_box_in_lonlats(truncated_geos_area_in_space, 20) + lon, lat = _get_geostationary_bounding_box(truncated_geos_area_in_space, coordinates="geographic", + nb_points=20) assert len(lon) == 0 assert len(lat) == 0 @@ -1722,7 +1719,7 @@ def test_get_geostationary_bbox(self): geos_area.crs = CRS(proj_dict) geos_area.area_extent = [-5500000., -5500000., 5500000., 5500000.] - lon, lat = _get_geostationary_bounding_box_in_lonlats(geos_area, 20) + lon, lat = _get_geostationary_bounding_box(geos_area, coordinates="geographic", nb_points=20) expected_lon = np.array([-78.19662326, -75.42516215, -70.22636028, -56.89851775, 0., 56.89851775, 70.22636028, 75.42516215, 78.19662326, 79.23372832, 78.19662326, @@ -1747,7 +1744,7 @@ def test_get_geostationary_bbox(self): geos_area.crs = CRS(proj_dict) geos_area.area_extent = [-5500000., -5500000., 5500000., 5500000.] - lon, lat = _get_geostationary_bounding_box_in_lonlats(geos_area, 20) + lon, lat = _get_geostationary_bounding_box(geos_area, coordinates="geographic", nb_points=20) np.testing.assert_allclose(lon, expected_lon + lon_0) def test_get_geostationary_angle_extent(self): @@ -2113,7 +2110,7 @@ def test_get_geographic_sides_call_geostationary_utility(self, request, area_def def test_polar_south_pole_projection(self, south_pole_area): """Test boundary for polar projection around the South Pole.""" areadef = south_pole_area - boundary = areadef.geographic_boundary(order=None) + boundary = areadef.geographic_boundary() # Check boundary shape height, width = areadef.shape @@ -2131,7 +2128,7 @@ def test_north_pole_projection(self, north_pole_area): """Test boundary for polar projection around the North Pole.""" areadef = north_pole_area - boundary = areadef.geographic_boundary(order=None) + boundary = areadef.geographic_boundary() # Check boundary shape height, width = areadef.shape @@ -2151,13 +2148,13 @@ def test_full_disc_geostationary_projection(self, geos_fd_area): # Check default boundary shape default_n_vertices = 50 - boundary = areadef.geographic_boundary(vertices_per_side=None, order=None) + boundary = areadef.geographic_boundary(vertices_per_side=None, ) assert boundary.vertices.shape == (default_n_vertices, 2) # Check minimum boundary vertices n_vertices = 3 minimum_n_vertices = 4 - boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, order=None) + boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, ) assert boundary.vertices.shape == (minimum_n_vertices, 2) # Check odd number of vertices per side @@ -2168,7 +2165,7 @@ def test_full_disc_geostationary_projection(self, geos_fd_area): # Check boundary vertices n_vertices = 10 - boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, order=None) + boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, ) # Check boundary vertices is in correct order expected_vertices = np.array([[-7.54251621e+01, 3.53432890e+01], @@ -2186,7 +2183,7 @@ def test_full_disc_geostationary_projection(self, geos_fd_area): def test_global_platee_caree_projection(self, global_platee_caree_area): """Test boundary for global platee caree projection.""" areadef = global_platee_caree_area - boundary = areadef.geographic_boundary(order=None) + boundary = areadef.geographic_boundary() # Check boundary shape height, width = areadef.shape @@ -2211,7 +2208,7 @@ def test_global_platee_caree_projection(self, global_platee_caree_area): def test_minimal_global_platee_caree_projection(self, global_platee_caree_minimum_area): """Test boundary for global platee caree projection.""" areadef = global_platee_caree_minimum_area - boundary = areadef.geographic_boundary(order=None) + boundary = areadef.geographic_boundary() # Check boundary shape height, width = areadef.shape @@ -2228,7 +2225,7 @@ def test_minimal_global_platee_caree_projection(self, global_platee_caree_minimu def test_local_area_projection(self, local_meter_area): """Test local area projection in meter.""" areadef = local_meter_area - boundary = areadef.geographic_boundary(order=None) + boundary = areadef.geographic_boundary() # Check boundary shape height, width = areadef.shape diff --git a/pyresample/test/test_geometry/test_swath.py b/pyresample/test/test_geometry/test_swath.py index a16d3905f..36f09578c 100644 --- a/pyresample/test/test_geometry/test_swath.py +++ b/pyresample/test/test_geometry/test_swath.py @@ -604,7 +604,7 @@ def test_swath_definition(self, create_test_swath): # Define SwathDefinition and retrieve GeographicBoundary swath_def = create_test_swath(lons, lats) - boundary = swath_def.geographic_boundary(order=None) + boundary = swath_def.geographic_boundary() # Check boundary shape height, width = swath_def.shape diff --git a/pyresample/test/test_slicer.py b/pyresample/test/test_slicer.py index af082f896..db198296b 100644 --- a/pyresample/test/test_slicer.py +++ b/pyresample/test/test_slicer.py @@ -146,7 +146,7 @@ def test_barely_touching_chunks_intersection(self): assert x_slice.start > 0 and x_slice.stop < 100 assert y_slice.start > 0 and y_slice.stop >= 100 - def test_slicing_an_area_with_infinite_bounds(self): + def test_slicing_with_dst_area_with_infinite_edges(self): """Test slicing an area with infinite bounds.""" src_area = AreaDefinition('dst', 'dst area', None, {'ellps': 'WGS84', 'proj': 'merc'}, @@ -166,7 +166,14 @@ def test_slicing_an_area_with_infinite_bounds(self): slicer = create_slicer(src_area, dst_area) with pytest.raises(IncompatibleAreas): - slicer.get_slices() + slice_x, slice_y = slicer.get_slices() + + # Unreasonable slices if force_boundary_computations=True + # area_to_crop = src_area + # area_to_contain = dst_area + # slice_x, slice_y = slicer.get_slices() + # assert slice_x.start > 0 and slice_x.stop < 100 + # assert slice_y.start > 0 and slice_y.stop >= 100 def test_slicing_works_with_extents_of_different_units(self): """Test a problematic case.""" From 61240eb4a5132bcf035f4e263e8f7491e4df3922 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Mon, 27 Nov 2023 23:04:14 +0100 Subject: [PATCH 37/39] Fix formatting warnings --- pyresample/boundary/utils.py | 19 +-- pyresample/test/test_boundary/test_order.py | 149 +++++++++++--------- pyresample/test/test_boundary/test_utils.py | 89 ++++++------ 3 files changed, 135 insertions(+), 122 deletions(-) diff --git a/pyresample/boundary/utils.py b/pyresample/boundary/utils.py index f6e425e2f..b884b2b18 100644 --- a/pyresample/boundary/utils.py +++ b/pyresample/boundary/utils.py @@ -16,10 +16,10 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . """Utility to extract boundary mask and indices.""" -import numpy as np +import numpy as np -def _find_boundary_mask(mask): +def _find_boundary_mask(mask): """Find boundary of a binary mask.""" mask = mask.astype(int) # Pad with zeros (enable to detect Trues at mask boundaries) @@ -32,10 +32,10 @@ def _find_boundary_mask(mask): shift_right = np.roll(padded_mask, 1, axis=1) # Find the boundary points - padded_boundary_mask = ((padded_mask != shift_up) | (padded_mask != shift_down) | - (padded_mask != shift_left) | (padded_mask != shift_right)) & padded_mask + padded_boundary_mask = ((padded_mask != shift_up) | (padded_mask != shift_down) | + (padded_mask != shift_left) | (padded_mask != shift_right)) & padded_mask - boundary_mask = padded_boundary_mask[1:-1,1:-1] + boundary_mask = padded_boundary_mask[1:-1, 1:-1] return boundary_mask @@ -43,17 +43,19 @@ def find_boundary_mask(lons, lats): """Find the boundary mask.""" valid_mask = np.isfinite(lons) & np.isfinite(lats) return _find_boundary_mask(valid_mask) - + def get_ordered_contour(contour_mask): """Return the ordered indices of a contour mask.""" - # Count number of rows and columns + # Count number of rows and columns rows, cols = contour_mask.shape + # Function to find the next contour point def next_point(current, last, visited): for dx, dy in [(-1, 0), (0, 1), (1, 0), (0, -1), (1, -1), (-1, -1), (1, 1), (-1, 1)]: next_pt = (current[0] + dx, current[1] + dy) - if next_pt != last and next_pt not in visited and 0 <= next_pt[0] < rows and 0 <= next_pt[1] < cols and contour_mask[next_pt]: + if (next_pt != last and next_pt not in visited and + 0 <= next_pt[0] < rows and 0 <= next_pt[1] < cols and contour_mask[next_pt]): return next_pt return None # Initialize @@ -81,4 +83,3 @@ def find_boundary_contour_indices(lons, lats): boundary_mask = find_boundary_mask(lons, lats) boundary_contour_idx = get_ordered_contour(boundary_mask) return boundary_contour_idx - \ No newline at end of file diff --git a/pyresample/test/test_boundary/test_order.py b/pyresample/test/test_boundary/test_order.py index bc7815758..b1e0085ca 100644 --- a/pyresample/test/test_boundary/test_order.py +++ b/pyresample/test/test_boundary/test_order.py @@ -1,15 +1,31 @@ -#!/usr/bin/env python3 +#!/usr/bin/env python # -*- coding: utf-8 -*- -""" -Created on Mon Nov 27 15:48:41 2023 +# pyresample, Resampling of remote sensing image data in python +# +# Copyright (C) 2010-2022 Pyresample developers +# +# This program is free software: you can redistribute it and/or modify it under +# the terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 3 of the License, or (at your option) any +# later version. +# +# This program is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program. If not, see . +"""Test the boundary ordering checks.""" + +import pytest -@author: ghiggi -""" -import pytest from pyresample.boundary.geographic_boundary import _is_clockwise_order + class Test_Is_Clockwise_Order: - + """Test clockwise check.""" + def test_vertical_edges(self): """Test with vertical polygon edges.""" # North Hemisphere @@ -17,117 +33,112 @@ def test_vertical_edges(self): second_point = (0, 20) point_inside = (1, 15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - + assert not _is_clockwise_order(second_point, first_point, point_inside) + # South Hemisphere - first_point = (0, -20) + first_point = (0, -20) second_point = (0, -10) point_inside = (1, - 15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - @pytest.mark.parametrize("lon",[-180, -90, 0, 90, 180]) + assert not _is_clockwise_order(second_point, first_point, point_inside) + + @pytest.mark.parametrize("lon", [-180, -90, 0, 90, 180]) def test_horizontal_edges(self, lon): """Test with horizontal polygon edges.""" - # Point in northern hemisphere + # Point in northern hemisphere first_point = (lon, 0) - second_point = (lon-10, 0) - point_inside = (1, 15) + second_point = (lon - 10, 0) + point_inside = (1, 15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - # Point in northern hemisphere + assert not _is_clockwise_order(second_point, first_point, point_inside) + + # Point in northern hemisphere first_point = (lon, 0) - second_point = (lon+10, 0) - point_inside = (1, -15) + second_point = (lon + 10, 0) + point_inside = (1, -15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - + assert not _is_clockwise_order(second_point, first_point, point_inside) + def test_diagonal_edges(self): """Test with diagonal polygon edges.""" - point_inside = (20, 15) - - # Edge toward right (above point) --> clockwise + point_inside = (20, 15) + + # Edge toward right (above point) --> clockwise first_point = (0, 0) second_point = (20, 20) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - + assert not _is_clockwise_order(second_point, first_point, point_inside) + # Edge toward right (below point) --> not clockwise first_point = (0, 0) second_point = (20, 10) assert not _is_clockwise_order(first_point, second_point, point_inside) - + def test_polygon_edges_on_antimeridian(self): """Test polygon edges touching the antimeridian edges.""" - ## Right side of antimeridian - # North Hemisphere + # Right side of antimeridian + # - North Hemisphere first_point = (-180, 10) second_point = (-180, 20) point_inside = (-179, 15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - # South Hemisphere - first_point = (-180, -20) + assert not _is_clockwise_order(second_point, first_point, point_inside) + + # - South Hemisphere + first_point = (-180, -20) second_point = (-180, -10) point_inside = (-179, - 15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - ## Left side of antimeridian - # North Hemisphere + assert not _is_clockwise_order(second_point, first_point, point_inside) + + # Left side of antimeridian + # - North Hemisphere first_point = (-180, 20) second_point = (-180, 10) point_inside = (179, 15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - # South Hemisphere - first_point = (-180, -10) - second_point = (-180,-20) + assert not _is_clockwise_order(second_point, first_point, point_inside) + + # - South Hemisphere + first_point = (-180, -10) + second_point = (-180, -20) point_inside = (179, - 15) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - @pytest.mark.parametrize("lon",[179, 180, -180, -179]) + assert not _is_clockwise_order(second_point, first_point, point_inside) + + @pytest.mark.parametrize("lon", [179, 180, -180, -179]) def test_polygon_around_antimeridian(self, lon): """Test polygon edges crossing antimeridian.""" # North Hemisphere - first_point = (170, 10) + first_point = (170, 10) second_point = (-170, 10) point_inside = (lon, 5) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - + assert not _is_clockwise_order(second_point, first_point, point_inside) + # South Hemisphere - first_point = (-170, -10) + first_point = (-170, -10) second_point = (170, -10) point_inside = (lon, - 5) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - @pytest.mark.parametrize("lon_pole",[-180, 90, 45, 0, 45, 90, 180]) - @pytest.mark.parametrize("lat",[85, 0, -85]) + assert not _is_clockwise_order(second_point, first_point, point_inside) + + @pytest.mark.parametrize("lon_pole", [-180, 90, 45, 0, 45, 90, 180]) + @pytest.mark.parametrize("lat", [85, 0, -85]) def test_polygon_around_north_pole(self, lon_pole, lat): """Test polygon edges around north pole (right to left).""" point_inside = (lon_pole, 90) - first_point = (0, lat) - second_point = (-10, lat) + first_point = (0, lat) + second_point = (-10, lat) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - @pytest.mark.parametrize("lon_pole",[-180, 90, 45, 0, 45, 90, 180]) - @pytest.mark.parametrize("lat",[85, 0, -85]) + assert not _is_clockwise_order(second_point, first_point, point_inside) + + @pytest.mark.parametrize("lon_pole", [-180, 90, 45, 0, 45, 90, 180]) + @pytest.mark.parametrize("lat", [85, 0, -85]) def test_polygon_around_south_pole(self, lon_pole, lat): """Test polygon edges around south pole (left to right).""" point_inside = (lon_pole, -90) - first_point = (0, lat) - second_point = (10, lat) + first_point = (0, lat) + second_point = (10, lat) assert _is_clockwise_order(first_point, second_point, point_inside) - assert not _is_clockwise_order(second_point, first_point , point_inside) - - - - - \ No newline at end of file + assert not _is_clockwise_order(second_point, first_point, point_inside) diff --git a/pyresample/test/test_boundary/test_utils.py b/pyresample/test/test_boundary/test_utils.py index 024516e79..7e4558ac6 100644 --- a/pyresample/test/test_boundary/test_utils.py +++ b/pyresample/test/test_boundary/test_utils.py @@ -19,40 +19,41 @@ """Test the boundary utility.""" import numpy as np import pytest + from pyresample.boundary.utils import ( + find_boundary_contour_indices, find_boundary_mask, - find_boundary_contour_indices, get_ordered_contour, ) - + @pytest.mark.parametrize("lonlat, expected", [ # Case: All True values - ((np.array([[1, 2, 3, 4], + ((np.array([[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]]), - np.array([[1, 2, 3, 4], + np.array([[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]])), - np.array([[True, True, True, True], - [True, False, False, True], - [True, False, False, True], - [True, True, True, True]])), + np.array([[True, True, True, True], + [True, False, False, True], + [True, False, False, True], + [True, True, True, True]])), # Case: Multiple True values in the center - ((np.array([[np.inf, np.inf, np.inf, np.inf], + ((np.array([[np.inf, np.inf, np.inf, np.inf], [np.inf, 2, 3, np.inf], [np.inf, 2, 3, np.inf], [np.inf, np.inf, np.inf, np.inf]]), - np.array([[np.inf, np.inf, np.inf, np.inf], - [np.inf, 2, 3, np.inf], - [np.inf, 2, 3, np.inf], - [np.inf, np.inf, np.inf, np.inf]])), + np.array([[np.inf, np.inf, np.inf, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, np.inf, np.inf, np.inf]])), np.array([[False, False, False, False], - [False, True, True, False], - [False, True, True, False], + [False, True, True, False], + [False, True, True, False], [False, False, False, False]])), ]) def test_find_boundary_mask(lonlat, expected): @@ -60,41 +61,41 @@ def test_find_boundary_mask(lonlat, expected): lons, lats = lonlat result = find_boundary_mask(lons, lats) np.testing.assert_array_equal(result, expected, err_msg=f"Expected {expected}, but got {result}") - + @pytest.mark.parametrize("boundary_mask, expected", [ # Case: All True values - (np.array([[True, True, True, True], - [True, False, False, True], - [True, False, False, True], - [True, True, True, True]]), + (np.array([[True, True, True, True], + [True, False, False, True], + [True, False, False, True], + [True, True, True, True]]), np.array([[0, 0], [0, 1], [0, 2], [0, 3], - [1, 3], [2, 3], [3, 3], + [1, 3], [2, 3], [3, 3], [3, 2], [3, 1], [3, 0], [2, 0], [1, 0]]) - ), + ), # Case: Multiple True values in the center (np.array([[False, False, False, False], - [False, True, True, False], - [False, True, True, False], + [False, True, True, False], + [False, True, True, False], [False, False, False, False]]), np.array([[1, 1], [1, 2], [2, 2], [2, 1]]) - ), - # Case: Square on angle + ), + # Case: Square on angle (np.array([[True, True, True, False], [True, False, True, False], [True, True, True, False], [False, False, False, False]]), np.array([[0, 0], [0, 1], [0, 2], [1, 2], [2, 2], [2, 1], [2, 0], [1, 0]]) - ), - # Case: Cross Pattern + ), + # Case: Cross Pattern (np.array([[False, True, True, False], [True, False, False, True], [True, False, False, True], [False, True, True, False]]), - np.array([[0, 1], [0, 2], [1, 3], [2, 3], [3, 2], [3, 1], [2, 0], [1, 0]]) - ), - # Case: Possibile infinit loop if not checking visited + np.array([[0, 1], [0, 2], [1, 3], [2, 3], [3, 2], [3, 1], [2, 0], [1, 0]]) + ), + # Case: Possibile infinit loop if not checking visited (np.array([[1, 1, 1, 1, 0], [1, 0, 1, 0, 0], [1, 0, 1, 0, 0], @@ -102,8 +103,8 @@ def test_find_boundary_mask(lonlat, expected): [1, 0, 0, 0, 0], [1, 0, 0, 0, 0], [0, 0, 0, 0, 0]]), - np.array([[0, 0], [0, 1], [0, 2], [0, 3], [1, 2], [2, 2], [3, 1], [3, 0], [2, 0], [1, 0]]) - ), + np.array([[0, 0], [0, 1], [0, 2], [0, 3], [1, 2], [2, 2], [3, 1], [3, 0], [2, 0], [1, 0]]) + ), ]) def test_get_ordered_contour(boundary_mask, expected): """Test order of the boundary contour indices (clockwise).""" @@ -113,33 +114,33 @@ def test_get_ordered_contour(boundary_mask, expected): @pytest.mark.parametrize("lonlat, expected", [ # Case: All True values - ((np.array([[1, 2, 3, 4], + ((np.array([[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]]), - np.array([[1, 2, 3, 4], + np.array([[1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4], [1, 2, 3, 4]])), np.array([[0, 0], [0, 1], [0, 2], [0, 3], - [1, 3], [2, 3], [3, 3], + [1, 3], [2, 3], [3, 3], [3, 2], [3, 1], [3, 0], [2, 0], [1, 0]]) - ), + ), # Case: Multiple True values in the center - ((np.array([[np.inf, np.inf, np.inf, np.inf], + ((np.array([[np.inf, np.inf, np.inf, np.inf], [np.inf, 2, 3, np.inf], [np.inf, 2, 3, np.inf], [np.inf, np.inf, np.inf, np.inf]]), - np.array([[np.inf, np.inf, np.inf, np.inf], - [np.inf, 2, 3, np.inf], - [np.inf, 2, 3, np.inf], - [np.inf, np.inf, np.inf, np.inf]])), + np.array([[np.inf, np.inf, np.inf, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, 2, 3, np.inf], + [np.inf, np.inf, np.inf, np.inf]])), np.array([[1, 1], [1, 2], [2, 2], [2, 1]]) - ), + ), ]) def test_find_boundary_contour_indices(lonlat, expected): """Test order of the boundary contour indices (clockwise).""" lons, lats = lonlat result = find_boundary_contour_indices(lons, lats) - np.testing.assert_array_equal(result, expected, err_msg=f"Expected {expected}, but got {result}") \ No newline at end of file + np.testing.assert_array_equal(result, expected, err_msg=f"Expected {expected}, but got {result}") From 7069197c184e7de5a03de6a536eb416c4fcd7bb3 Mon Sep 17 00:00:00 2001 From: ghiggi Date: Mon, 27 Nov 2023 23:48:56 +0100 Subject: [PATCH 38/39] Renaming to SphericalBoundary and PlanarBoundary --- docs/source/howtos/spherical_geometry.rst | 4 +- pyresample/bilinear/_base.py | 2 +- pyresample/boundary/__init__.py | 8 +-- ...jection_boundary.py => planar_boundary.py} | 6 +- ...phic_boundary.py => spherical_boundary.py} | 8 +-- pyresample/future/geometry/_subset.py | 6 +- pyresample/geometry.py | 60 ++++++++----------- pyresample/gradient/__init__.py | 2 +- pyresample/kd_tree.py | 4 +- pyresample/slicer.py | 2 +- .../test_boundary/test_geographic_boundary.py | 34 +++++------ pyresample/test/test_boundary/test_order.py | 2 +- pyresample/test/test_data_reduce.py | 2 +- pyresample/test/test_geometry/test_area.py | 18 +++--- pyresample/test/test_geometry/test_swath.py | 4 +- 15 files changed, 76 insertions(+), 86 deletions(-) rename pyresample/boundary/{projection_boundary.py => planar_boundary.py} (95%) rename pyresample/boundary/{geographic_boundary.py => spherical_boundary.py} (97%) diff --git a/docs/source/howtos/spherical_geometry.rst b/docs/source/howtos/spherical_geometry.rst index 6ca535671..73fbdacf2 100644 --- a/docs/source/howtos/spherical_geometry.rst +++ b/docs/source/howtos/spherical_geometry.rst @@ -73,12 +73,12 @@ satellite passes. See trollschedule_ how to generate a list of satellite overpas >>> from pyresample.spherical_utils import GetNonOverlapUnions - >>> area_boundary = area_def.geographic_boundary(vertices_per_side=100) # doctest: +SKIP + >>> area_boundary = area_def.boundary(vertices_per_side=100) # doctest: +SKIP >>> area_boundary = area_boundary.polygon # doctest: +SKIP >>> list_of_polygons = [] >>> for mypass in passes: # doctest: +SKIP - >>> list_of_polygons.append(mypass.geographic_boundary().polygon) # doctest: +SKIP + >>> list_of_polygons.append(mypass.boundary().polygon) # doctest: +SKIP >>> non_overlaps = GetNonOverlapUnions(list_of_polygons) # doctest: +SKIP >>> non_overlaps.merge() # doctest: +SKIP diff --git a/pyresample/bilinear/_base.py b/pyresample/bilinear/_base.py index da42073e0..08d0b4718 100644 --- a/pyresample/bilinear/_base.py +++ b/pyresample/bilinear/_base.py @@ -600,7 +600,7 @@ def get_valid_indices_from_lonlat_boundaries( """Get valid indices from lonlat boundaries.""" # Resampling from swath to grid or from grid to grid try: - sides_lons, sides_lats = target_geo_def.geographic_boundary().sides + sides_lons, sides_lats = target_geo_def.boundary().sides valid_indices = data_reduce.get_valid_index_from_lonlat_boundaries(sides_lons, sides_lats, source_lons, source_lats, radius_of_influence) diff --git a/pyresample/boundary/__init__.py b/pyresample/boundary/__init__.py index 4916a44f2..8dec4345b 100644 --- a/pyresample/boundary/__init__.py +++ b/pyresample/boundary/__init__.py @@ -18,13 +18,13 @@ """The Boundary classes.""" from pyresample.boundary.area_boundary import AreaBoundary, AreaDefBoundary -from pyresample.boundary.geographic_boundary import GeographicBoundary -from pyresample.boundary.projection_boundary import ProjectionBoundary +from pyresample.boundary.planar_boundary import PlanarBoundary from pyresample.boundary.simple_boundary import SimpleBoundary +from pyresample.boundary.spherical_boundary import SphericalBoundary __all__ = [ - "GeographicBoundary", - "ProjectionBoundary", + "SphericalBoundary", + "PlanarBoundary", # Deprecated "SimpleBoundary", "AreaBoundary", diff --git a/pyresample/boundary/projection_boundary.py b/pyresample/boundary/planar_boundary.py similarity index 95% rename from pyresample/boundary/projection_boundary.py rename to pyresample/boundary/planar_boundary.py index c1fd07055..7b10074f5 100644 --- a/pyresample/boundary/projection_boundary.py +++ b/pyresample/boundary/planar_boundary.py @@ -15,7 +15,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the ProjectionBoundary class.""" +"""Define the PlanarBoundary class.""" import logging @@ -37,7 +37,7 @@ def _is_projection_boundary_clockwise(sides_x, sides_y): return not polygon.exterior.is_ccw -class ProjectionBoundary(BaseBoundary): +class PlanarBoundary(BaseBoundary): """Projection Boundary object. The inputs must be the x and y sides of the projection. @@ -46,7 +46,7 @@ class ProjectionBoundary(BaseBoundary): @classmethod def _check_is_boundary_clockwise(cls, sides_x, sides_y, area=None): - """GeographicBoundary specific implementation.""" + """SphericalBoundary specific implementation.""" return _is_projection_boundary_clockwise(sides_x=sides_x, sides_y=sides_y) @classmethod diff --git a/pyresample/boundary/geographic_boundary.py b/pyresample/boundary/spherical_boundary.py similarity index 97% rename from pyresample/boundary/geographic_boundary.py rename to pyresample/boundary/spherical_boundary.py index 3adc13510..48a852930 100644 --- a/pyresample/boundary/geographic_boundary.py +++ b/pyresample/boundary/spherical_boundary.py @@ -15,7 +15,7 @@ # # You should have received a copy of the GNU General Public License # along with this program. If not, see . -"""Define the GeographicBoundary class.""" +"""Define the SphericalBoundary class.""" import logging @@ -106,8 +106,8 @@ def _check_is_clockwise(area, sides_x, sides_y): return True -class GeographicBoundary(BaseBoundary, OldBoundary): - """GeographicBoundary object. +class SphericalBoundary(BaseBoundary, OldBoundary): + """SphericalBoundary object. The inputs must be the list of longitude and latitude boundary sides. """ @@ -117,7 +117,7 @@ class GeographicBoundary(BaseBoundary, OldBoundary): @classmethod def _check_is_boundary_clockwise(cls, sides_x, sides_y, area): - """GeographicBoundary specific implementation.""" + """SphericalBoundary specific implementation.""" return _check_is_clockwise(area, sides_x, sides_y) @classmethod diff --git a/pyresample/future/geometry/_subset.py b/pyresample/future/geometry/_subset.py index c46374489..4639f9849 100644 --- a/pyresample/future/geometry/_subset.py +++ b/pyresample/future/geometry/_subset.py @@ -11,7 +11,7 @@ # must be imported inside functions in the geometry modules if needed # to avoid circular dependencies from pyresample._caching import cache_to_json_if -from pyresample.boundary import GeographicBoundary +from pyresample.boundary import SphericalBoundary from pyresample.utils import check_slice_orientation if TYPE_CHECKING: @@ -97,13 +97,13 @@ def _get_slice_starts_stops(src_area, area_to_cover): return xstart, xstop, ystart, ystop -def _get_area_boundary(area_to_cover: AreaDefinition) -> GeographicBoundary: +def _get_area_boundary(area_to_cover: AreaDefinition) -> SphericalBoundary: try: if area_to_cover.is_geostationary: vertices_per_side = None else: vertices_per_side = max(max(*area_to_cover.shape) // 100 + 1, 3) - return area_to_cover.geographic_boundary(vertices_per_side=vertices_per_side) + return area_to_cover.boundary(vertices_per_side=vertices_per_side) except ValueError as err: raise NotImplementedError("Can't determine boundary of area to cover") from err diff --git a/pyresample/geometry.py b/pyresample/geometry.py index bf9489f1c..a0bd54fce 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -283,7 +283,7 @@ def get_proj_coords(self, data_slice=None, chunks=None, **kwargs): def get_boundary_lonlats(self): """Return Boundary objects.""" warnings.warn("'get_boundary_lonlats' is deprecated. Please use " - "'area.geographic_boundary().sides'.", DeprecationWarning, stacklevel=2) + "'area.boundary().sides'.", DeprecationWarning, stacklevel=2) s1_lon, s1_lat = self.get_lonlats(data_slice=(0, slice(None))) s2_lon, s2_lat = self.get_lonlats(data_slice=(slice(None), -1)) s3_lon, s3_lat = self.get_lonlats(data_slice=(-1, slice(None, None, -1))) @@ -339,7 +339,7 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, vertices_per_side = vertices_per_side or frequency sides_lons, sides_lats = self._get_geographic_sides(vertices_per_side=vertices_per_side) - warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.geographic_boundary().sides` instead", + warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.boundary().sides` instead", PendingDeprecationWarning, stacklevel=2) if force_clockwise and not self._corner_is_clockwise( sides_lons[0][-2], sides_lats[0][-2], @@ -436,8 +436,6 @@ def _get_geographic_sides(self, vertices_per_side: Optional[int] = None) -> tupl Each list element is a numpy array representing a specific side of the geometry. The order of the sides are [top", "right", "bottom", "left"] """ - if len(self.lons.shape) == 1: - raise ValueError("The area must have 2 dimensions to retrieve the boundary sides.") is_swath = self.__class__.__name__ == "SwathDefinition" if not is_swath and _is_any_corner_out_of_earth_disk(self): # Geostationary @@ -500,6 +498,8 @@ def _get_bbox_slices(self, vertices_per_side): The output structure is a tuple of four slices, one for each side. The order of the sides are [top", "right", "bottom", "left"] """ + if len(self.shape) == 1: + raise ValueError("The area must have 2 dimensions to retrieve the boundary sides.") height, width = self.shape if vertices_per_side is None: row_num = height @@ -555,13 +555,13 @@ def get_edge_lonlats(self, vertices_per_side=None, frequency=None): warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead.", PendingDeprecationWarning, stacklevel=2) msg = "`get_edge_lonlats` is pending deprecation" - msg += "Use `area.geographic_boundary(vertices_per_side=vertices_per_side).contour()` instead." + msg += "Use `area.boundary(vertices_per_side=vertices_per_side).contour()` instead." warnings.warn(msg, PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - lons, lats = self.geographic_boundary(vertices_per_side=vertices_per_side).contour() + lons, lats = self.boundary(vertices_per_side=vertices_per_side).contour() return lons, lats - def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=None): + def boundary(self, *, vertices_per_side=None, force_clockwise=None, frequency=None): """Retrieve the AreaBoundary object. Parameters @@ -573,7 +573,7 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N (i.e. full disc geostationary area, Robinson projection, polar projections, ...) by default only 50 points are selected. force_clockwise: - DEPRECATED. + DEPRECATED. IS NOT USED ANYMORE ! Performed minimal checks and reordering of coordinates to ensure that the returned coordinates follow a clockwise direction. This is important for compatibility with @@ -582,28 +582,16 @@ def boundary(self, *, vertices_per_side=None, force_clockwise=False, frequency=N operations assume that coordinates are clockwise. Default is False. """ + from pyresample.boundary import SphericalBoundary if frequency is not None: warnings.warn("The `frequency` argument is pending deprecation, use `vertices_per_side` instead", PendingDeprecationWarning, stacklevel=2) - warnings.warn("The `boundary` method is pending deprecation. Use `geographic_boundary` instead", - PendingDeprecationWarning, stacklevel=2) + if force_clockwise is not None: + warnings.warn("The `force_clockwise` argument is not used anymore. " + "Please remove the argument from the boundary() call !!!", + PendingDeprecationWarning, stacklevel=2) vertices_per_side = vertices_per_side or frequency - return self.geographic_boundary(vertices_per_side=vertices_per_side) - - def geographic_boundary(self, vertices_per_side=None): - """Retrieve the GeographicBoundary object. - - Parameters - ---------- - vertices_per_side: - The number of points to provide for each side. - By default (None) the full width and height will be provided. - If the area object is an AreaDefinition with any corner out of the Earth disk - (i.e. full disc geostationary area, Robinson projection, polar projections, ...) - by default only 50 points are selected. - """ - from pyresample.boundary import GeographicBoundary - return GeographicBoundary(area=self, vertices_per_side=vertices_per_side) + return SphericalBoundary(area=self, vertices_per_side=vertices_per_side) def get_cartesian_coords(self, nprocs=None, data_slice=None, cache=False): """Retrieve cartesian coordinates of geometry definition. @@ -1143,7 +1131,7 @@ def compute_optimal_bb_area(self, proj_dict=None, resolution=None): proj_dict = self.compute_bb_proj_params(proj_dict) area = DynamicAreaDefinition(area_id, description, proj_dict) - lons, lats = self.geographic_boundary(vertices_per_side=None).contour() + lons, lats = self.boundary(vertices_per_side=None).contour() return area.freeze((lons, lats), shape=(height, width)) @@ -1157,7 +1145,7 @@ class DynamicAreaDefinition(object): Note that if the provided projection is geographic (lon/lat degrees) and the provided longitude and latitude data crosses the anti-meridian (-180/180), the resulting area will be the smallest possible in order to - contain that data and avoid a large area spanning from -180 to 180 + contain that data and boundaryspanning from -180 to 180 longitude. This means the resulting AreaDefinition will have a right-most X extent greater than 180 degrees. This does not apply to data crossing the north or south pole as there is no "smallest" area in this case. @@ -1762,7 +1750,10 @@ def _get_projection_sides(self, vertices_per_side: Optional[int] = None) -> tupl return sides_x, sides_y def projection_boundary(self, vertices_per_side=None): - """Retrieve the ProjectionBoundary object. + """Retrieve the boundary object in projection coordinates. + + If the CRS of the AreaDefinition is geographic, the returned boundary + object is a SphericalBoundary, otherwise a PlanarBoundary is returned. Parameters ---------- @@ -1773,11 +1764,10 @@ def projection_boundary(self, vertices_per_side=None): (i.e. full disc geostationary area, Robinson projection, polar projections, ...) by default only 50 points are selected.. """ - from pyresample.boundary import ProjectionBoundary + from pyresample.boundary import PlanarBoundary if self.crs.is_geographic: - return self.geographic_boundary(vertices_per_side=vertices_per_side) - return ProjectionBoundary(area=self, - vertices_per_side=vertices_per_side) + return self.boundary(vertices_per_side=vertices_per_side) + return PlanarBoundary(area=self, vertices_per_side=vertices_per_side) def get_edge_bbox_in_projection_coordinates(self, vertices_per_side: Optional[int] = None, frequency: Optional[int] = None): @@ -2964,7 +2954,7 @@ def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): nb_points: Number of points on the polygon """ warnings.warn("'get_geostationary_bounding_box_in_lonlats' is deprecated. Please call " - "'area.geographic_boundary().contour()' instead.", + "'area.boundary().contour()' instead.", DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box(geos_area, coordinates="geographic", @@ -2980,7 +2970,7 @@ def get_geostationary_bounding_box(geos_area, nb_points=50): """ warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " - "'area.geographic_boundary().contour()' instead.", + "'area.boundary().contour()' instead.", DeprecationWarning, stacklevel=2) return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) diff --git a/pyresample/gradient/__init__.py b/pyresample/gradient/__init__.py index 2151705b5..63d3b2ece 100644 --- a/pyresample/gradient/__init__.py +++ b/pyresample/gradient/__init__.py @@ -385,7 +385,7 @@ def _get_border_lonlats(geo_def: AreaDefinition, vertices_per_side=None): """Get the border x- and y-coordinates.""" if geo_def.is_geostationary: vertices_per_side = 3600 - lon_b, lat_b = geo_def.geographic_boundary(vertices_per_side=vertices_per_side).contour(closed=True) + lon_b, lat_b = geo_def.boundary(vertices_per_side=vertices_per_side).contour(closed=True) return lon_b, lat_b diff --git a/pyresample/kd_tree.py b/pyresample/kd_tree.py index 57aebbf26..68f060e21 100644 --- a/pyresample/kd_tree.py +++ b/pyresample/kd_tree.py @@ -415,7 +415,7 @@ def _get_valid_input_index(source_geo_def, # Resampling from swath to grid or from grid to grid # - If invalid sides, return np.ones try: - sides_lons, sides_lats = target_geo_def.geographic_boundary().sides + sides_lons, sides_lats = target_geo_def.boundary().sides # Combine reduced and legal values valid_input_index &= \ data_reduce.get_valid_index_from_lonlat_boundaries( @@ -445,7 +445,7 @@ def _get_valid_output_index(source_geo_def, target_geo_def, target_lons, # Resampling from grid to swath # - If invalid sides, return np.ones try: - sides_lons, sides_lats = source_geo_def.geographic_boundary().sides + sides_lons, sides_lats = source_geo_def.boundary().sides valid_output_index = \ data_reduce.get_valid_index_from_lonlat_boundaries( sides_lons, diff --git a/pyresample/slicer.py b/pyresample/slicer.py index 09f7c7ab4..d4937e58d 100644 --- a/pyresample/slicer.py +++ b/pyresample/slicer.py @@ -128,7 +128,7 @@ def _get_chunk_polygons_for_swath_to_crop(swath_to_crop): line_slice = expand_slice(line_slice) col_slice = expand_slice(col_slice) smaller_swath = swath_to_crop[line_slice, col_slice] - smaller_poly = smaller_swath.geographic_boundary(vertices_per_side=10).to_shapely_polygon() + smaller_poly = smaller_swath.boundary(vertices_per_side=10).to_shapely_polygon() res.append((smaller_poly, (line_slice, col_slice))) return res diff --git a/pyresample/test/test_boundary/test_geographic_boundary.py b/pyresample/test/test_boundary/test_geographic_boundary.py index b61be7a22..3990faada 100644 --- a/pyresample/test/test_boundary/test_geographic_boundary.py +++ b/pyresample/test/test_boundary/test_geographic_boundary.py @@ -16,17 +16,17 @@ # # You should have received a copy of the GNU Lesser General Public License # along with this program. If not, see . -"""Test the GeographicBoundary objects.""" +"""Test the SphericalBoundary objects.""" import numpy as np import pytest from pyresample import SwathDefinition -from pyresample.boundary import GeographicBoundary +from pyresample.boundary import SphericalBoundary -class TestSwathGeographicBoundary(): - """Test 'GeographicBoundary' class for SwathDefinition.""" +class TestSwathSphericalBoundary(): + """Test 'SphericalBoundary' class for SwathDefinition.""" def setup_method(self): sides_lons = [np.array([1.0, 1.5, 2.0]), @@ -50,9 +50,9 @@ def setup_method(self): self.point_inside = (2.5, 7.5) def test_creation(self): - """Test GeographicBoundary creation.""" - # Define GeographicBoundary - boundary = GeographicBoundary(self.area) + """Test SphericalBoundary creation.""" + # Define SphericalBoundary + boundary = SphericalBoundary(self.area) # Assert sides coincides for b_lon, src_lon in zip(boundary.sides_lons, self.sides_lons): @@ -65,12 +65,12 @@ def test_minimum_swath_size(self): """Test swath has minimum 2 dimensions.""" area = SwathDefinition(self.lons[0], self.lats[1]) with pytest.raises(ValueError): - GeographicBoundary(area) + SphericalBoundary(area) def test_vertices_property(self): - """Test GeographicBoundary vertices property.""" - # Define GeographicBoundary - boundary = GeographicBoundary(self.area) + """Test SphericalBoundary vertices property.""" + # Define SphericalBoundary + boundary = SphericalBoundary(self.area) # Assert vertices expected_vertices = np.array([[1., 6.], [1.5, 6.5], @@ -81,18 +81,18 @@ def test_vertices_property(self): assert np.allclose(boundary.vertices, expected_vertices) def test_contour(self): - """Test that GeographicBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" - # Define GeographicBoundary - boundary = GeographicBoundary(self.area) + """Test that SphericalBoundary.contour(closed=False) returns the correct (lon,lat) tuple.""" + # Define SphericalBoundary + boundary = SphericalBoundary(self.area) # Assert contour lons, lats = boundary.contour() assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9.])) def test_contour_closed(self): - """Test that GeographicBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" - # Define GeographicBoundary - boundary = GeographicBoundary(self.area) + """Test that SphericalBoundary.contour(closed=True) returns the correct (lon,lat) tuple.""" + # Define SphericalBoundary + boundary = SphericalBoundary(self.area) lons, lats = boundary.contour(closed=True) assert np.allclose(lons, np.array([1., 1.5, 2., 3., 3.5, 4., 1.])) assert np.allclose(lats, np.array([6., 6.5, 7., 8., 8.5, 9., 6.])) diff --git a/pyresample/test/test_boundary/test_order.py b/pyresample/test/test_boundary/test_order.py index b1e0085ca..2a8fdd863 100644 --- a/pyresample/test/test_boundary/test_order.py +++ b/pyresample/test/test_boundary/test_order.py @@ -20,7 +20,7 @@ import pytest -from pyresample.boundary.geographic_boundary import _is_clockwise_order +from pyresample.boundary.spherical_boundary import _is_clockwise_order class Test_Is_Clockwise_Order: diff --git a/pyresample/test/test_data_reduce.py b/pyresample/test/test_data_reduce.py index cde7fa800..5f3adc10c 100644 --- a/pyresample/test/test_data_reduce.py +++ b/pyresample/test/test_data_reduce.py @@ -73,7 +73,7 @@ def test_reduce_boundary(self): lambda y, x: -180 + (360.0 / 1000) * x, (1000, 1000)) lats = np.fromfunction( lambda y, x: -90 + (180.0 / 1000) * y, (1000, 1000)) - sides_lons, sides_lats = self.area_def.geographic_boundary().sides + sides_lons, sides_lats = self.area_def.boundary().sides lons, lats, data = swath_from_lonlat_boundaries(sides_lons, sides_lats, lons, lats, data, 7000) diff --git a/pyresample/test/test_geometry/test_area.py b/pyresample/test/test_geometry/test_area.py index 3e2ae11ac..fc005748a 100644 --- a/pyresample/test/test_geometry/test_area.py +++ b/pyresample/test/test_geometry/test_area.py @@ -2110,7 +2110,7 @@ def test_get_geographic_sides_call_geostationary_utility(self, request, area_def def test_polar_south_pole_projection(self, south_pole_area): """Test boundary for polar projection around the South Pole.""" areadef = south_pole_area - boundary = areadef.geographic_boundary() + boundary = areadef.boundary() # Check boundary shape height, width = areadef.shape @@ -2128,7 +2128,7 @@ def test_north_pole_projection(self, north_pole_area): """Test boundary for polar projection around the North Pole.""" areadef = north_pole_area - boundary = areadef.geographic_boundary() + boundary = areadef.boundary() # Check boundary shape height, width = areadef.shape @@ -2148,24 +2148,24 @@ def test_full_disc_geostationary_projection(self, geos_fd_area): # Check default boundary shape default_n_vertices = 50 - boundary = areadef.geographic_boundary(vertices_per_side=None, ) + boundary = areadef.boundary(vertices_per_side=None, ) assert boundary.vertices.shape == (default_n_vertices, 2) # Check minimum boundary vertices n_vertices = 3 minimum_n_vertices = 4 - boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, ) + boundary = areadef.boundary(vertices_per_side=n_vertices, ) assert boundary.vertices.shape == (minimum_n_vertices, 2) # Check odd number of vertices per side # - Rounded to the sequent even number (to construct the sides) n_odd_vertices = 5 - boundary = areadef.geographic_boundary(vertices_per_side=n_odd_vertices) + boundary = areadef.boundary(vertices_per_side=n_odd_vertices) assert boundary.vertices.shape == (n_odd_vertices + 1, 2) # Check boundary vertices n_vertices = 10 - boundary = areadef.geographic_boundary(vertices_per_side=n_vertices, ) + boundary = areadef.boundary(vertices_per_side=n_vertices, ) # Check boundary vertices is in correct order expected_vertices = np.array([[-7.54251621e+01, 3.53432890e+01], @@ -2183,7 +2183,7 @@ def test_full_disc_geostationary_projection(self, geos_fd_area): def test_global_platee_caree_projection(self, global_platee_caree_area): """Test boundary for global platee caree projection.""" areadef = global_platee_caree_area - boundary = areadef.geographic_boundary() + boundary = areadef.boundary() # Check boundary shape height, width = areadef.shape @@ -2208,7 +2208,7 @@ def test_global_platee_caree_projection(self, global_platee_caree_area): def test_minimal_global_platee_caree_projection(self, global_platee_caree_minimum_area): """Test boundary for global platee caree projection.""" areadef = global_platee_caree_minimum_area - boundary = areadef.geographic_boundary() + boundary = areadef.boundary() # Check boundary shape height, width = areadef.shape @@ -2225,7 +2225,7 @@ def test_minimal_global_platee_caree_projection(self, global_platee_caree_minimu def test_local_area_projection(self, local_meter_area): """Test local area projection in meter.""" areadef = local_meter_area - boundary = areadef.geographic_boundary() + boundary = areadef.boundary() # Check boundary shape height, width = areadef.shape diff --git a/pyresample/test/test_geometry/test_swath.py b/pyresample/test/test_geometry/test_swath.py index 36f09578c..90d795820 100644 --- a/pyresample/test/test_geometry/test_swath.py +++ b/pyresample/test/test_geometry/test_swath.py @@ -602,9 +602,9 @@ def test_swath_definition(self, create_test_swath): lats = np.array([[65.9, 65.86, 65.82, 65.78], [65.89, 65.86, 65.82, 65.78]]) - # Define SwathDefinition and retrieve GeographicBoundary + # Define SwathDefinition and retrieve SphericalBoundary swath_def = create_test_swath(lons, lats) - boundary = swath_def.geographic_boundary() + boundary = swath_def.boundary() # Check boundary shape height, width = swath_def.shape From e638557384845d7e14f2703bc19bfd372d42414b Mon Sep 17 00:00:00 2001 From: ghiggi Date: Tue, 28 Nov 2023 00:10:57 +0100 Subject: [PATCH 39/39] Some cleanout and notes --- pyresample/future/geometry/area.py | 1 - pyresample/geometry.py | 27 +++++++++------------------ 2 files changed, 9 insertions(+), 19 deletions(-) diff --git a/pyresample/future/geometry/area.py b/pyresample/future/geometry/area.py index caab271b1..cc4e1e42b 100644 --- a/pyresample/future/geometry/area.py +++ b/pyresample/future/geometry/area.py @@ -27,7 +27,6 @@ from pyresample.geometry import ( # noqa DynamicAreaDefinition, _get_geostationary_bounding_box, - _get_geostationary_bounding_box_in_lonlats, get_full_geostationary_bounding_box_in_proj_coords, get_geostationary_angle_extent, get_geostationary_bounding_box_in_proj_coords, diff --git a/pyresample/geometry.py b/pyresample/geometry.py index a0bd54fce..0ea3bfb10 100644 --- a/pyresample/geometry.py +++ b/pyresample/geometry.py @@ -341,6 +341,10 @@ def get_bbox_lonlats(self, vertices_per_side: Optional[int] = None, sides_lons, sides_lats = self._get_geographic_sides(vertices_per_side=vertices_per_side) warnings.warn("`get_bbox_lonlats` is pending deprecation. Use `area.boundary().sides` instead", PendingDeprecationWarning, stacklevel=2) + # NOTE: This check is erroneous: + # - For SwathDefinition must be checked with respect to a local internal point. + # - For AreaDefinition, the order already defines the enclosing area ! + # - For many AreaDefinition, this is failing (infinite edges ...) if force_clockwise and not self._corner_is_clockwise( sides_lons[0][-2], sides_lats[0][-2], sides_lons[0][-1], sides_lats[0][-1], @@ -405,7 +409,7 @@ def _get_geostationary_boundary_sides(self, vertices_per_side=None, coordinates= if x.shape[0] < 4: raise ValueError("The geostationary projection area is entirely out of the Earth disk.") # Retrieve dummy sides for GEO - # - _get_geostationary_bounding_box_in_lonlats does not guarantee to return nb_points and even points! + # - _get_geostationary_bounding_box does not guarantee to return nb_points and even points! sides_x = self._get_dummy_sides(x, vertices_per_side=vertices_per_side) sides_y = self._get_dummy_sides(y, vertices_per_side=vertices_per_side) return sides_x, sides_y @@ -1145,7 +1149,7 @@ class DynamicAreaDefinition(object): Note that if the provided projection is geographic (lon/lat degrees) and the provided longitude and latitude data crosses the anti-meridian (-180/180), the resulting area will be the smallest possible in order to - contain that data and boundaryspanning from -180 to 180 + contain that data and boundary spanning from -180 to 180 longitude. This means the resulting AreaDefinition will have a right-most X extent greater than 180 degrees. This does not apply to data crossing the north or south pole as there is no "smallest" area in this case. @@ -2931,21 +2935,6 @@ def _get_geostationary_bounding_box(geos_area, coordinates="geographic", nb_poin return x, y -def _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): - """Get the bbox in lon/lats of the valid pixels inside `geos_area`. - - Args: - geos_area: Geostationary area definition to get the bounding box for. - nb_points: Number of points on the polygon - """ - warnings.warn("'_get_geostationary_bounding_box_in_lonlats' is deprecated. Please call " - "'_get_geostationary_bounding_box' instead.", - DeprecationWarning, stacklevel=2) - return _get_geostationary_bounding_box(geos_area, - coordinates="geographic", - nb_points=nb_points) - - def get_geostationary_bounding_box_in_lonlats(geos_area, nb_points=50): """Get the bbox in lon/lats of the valid pixels inside `geos_area`. @@ -2972,7 +2961,9 @@ def get_geostationary_bounding_box(geos_area, nb_points=50): warnings.warn("'get_geostationary_bounding_box' is deprecated. Please call " "'area.boundary().contour()' instead.", DeprecationWarning, stacklevel=2) - return _get_geostationary_bounding_box_in_lonlats(geos_area, nb_points) + return get_geostationary_bounding_box(geos_area, + coordinates="geographic", + nb_points=nb_points) def _is_any_corner_out_of_earth_disk(area_def):