From 86345a59b42e02f67290eba3ef0aa51e5337e19c Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Tue, 10 Sep 2024 11:41:43 -0600 Subject: [PATCH 01/24] Typecheck spatial module --- icepyx/core/query.py | 13 +-- icepyx/core/spatial.py | 219 +++++++++++++++++++++++++++++------------ pyproject.toml | 1 - 3 files changed, 161 insertions(+), 72 deletions(-) diff --git a/icepyx/core/query.py b/icepyx/core/query.py index 763ec6c52..f9718487b 100644 --- a/icepyx/core/query.py +++ b/icepyx/core/query.py @@ -127,13 +127,14 @@ class GenQuery: """ _temporal: tp.Temporal + _spatial: spat.Spatial def __init__( self, - spatial_extent=None, - date_range=None, - start_time=None, - end_time=None, + spatial_extent: Union[str, list[float], None] = None, + date_range: Union[list, dict, None] = None, + start_time: Union[str, dt.time, None] = None, + end_time: Union[str, dt.time, None] = None, **kwargs, ): # validate & init spatial extent @@ -187,7 +188,7 @@ def temporal(self) -> Union[tp.Temporal, list[str]]: return ["No temporal parameters set"] @property - def spatial(self): + def spatial(self) -> spat.Spatial: """ Return the spatial object, which provides the underlying functionality for validating and formatting geospatial objects. The spatial object has several properties to enable @@ -214,7 +215,7 @@ def spatial(self): return self._spatial @property - def spatial_extent(self): + def spatial_extent(self) -> tuple[spat.ExtentType, list[float]]: """ Return an array showing the spatial extent of the query object. Spatial extent is returned as an input type (which depends on how diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 0bc066e78..a51a1fa24 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -1,24 +1,34 @@ import os +from typing import Literal, Optional, Union, cast import warnings import geopandas as gpd import numpy as np +from numpy.typing import NDArray from shapely.geometry import Polygon, box from shapely.geometry.polygon import orient # DevGoal: need to update the spatial_extent docstring to describe coordinate order for input -def geodataframe(extent_type, spatial_extent, file=False, xdateline=None): +ExtentType = Literal["bounding_box", "polygon"] + + +def geodataframe( + extent_type: ExtentType, + spatial_extent: Union[str, list[float]], + file: bool = False, + xdateline: Optional[bool] = None, +) -> gpd.GeoDataFrame: """ Return a geodataframe of the spatial extent Parameters ---------- - extent_type : string + extent_type : One of 'bounding_box' or 'polygon', indicating what type of input the spatial extent is - spatial_extent : string or list + spatial_extent : A list containing the spatial extent OR a string containing a filename. If file is False, spatial_extent should be a list of coordinates in decimal degrees of [lower-left-longitude, @@ -28,9 +38,12 @@ def geodataframe(extent_type, spatial_extent, file=False, xdateline=None): If file is True, spatial_extent is a string containing the full file path and filename to the file containing the desired spatial extent. - file : boolean, default False + file : Indication for whether the spatial_extent string is a filename or coordinate list + xdateline : + Whether the given extent crosses the dateline + Returns ------- gdf : GeoDataFrame @@ -47,17 +60,25 @@ def geodataframe(extent_type, spatial_extent, file=False, xdateline=None): >>> reg_a = ipx.Query('ATL06',[-55, 68, -48, 71],['2019-02-20','2019-02-28']) >>> gdf = geodataframe(reg_a.spatial.extent_type, reg_a.spatial.extent) >>> gdf.geometry - 0 POLYGON ((-48 68, -48 71, -55 71, -55 68, -48 ... + 0 POLYGON ((-48 68, -48 71, -55 71, -55 68, -48 ...)) Name: geometry, dtype: geometry """ + # If extent_type is a polygon AND from a file, create a geopandas geodataframe from it + # DevGoal: Currently this branch isn't tested... + if file is True: + if extent_type == "polygon": + return gpd.read_file(spatial_extent) + else: + raise TypeError("When 'file' is True, 'extent_type' must be 'polygon'") + + if isinstance(spatial_extent, str): + raise TypeError(f"Expected list of floats, received {spatial_extent=}") + if xdateline is not None: xdateline = xdateline - elif file: - pass else: xdateline = check_dateline(extent_type, spatial_extent) - # print("this should cross the dateline:" + str(xdateline)) if extent_type == "bounding_box": if xdateline is True: @@ -67,17 +88,29 @@ def geodataframe(extent_type, spatial_extent, file=False, xdateline=None): for pair in zip(cartesian_lons, spatial_extent[1::2]) for item in pair ] - bbox = box(*cartesian_spatial_extent) + bbox = box( + cartesian_spatial_extent[0], + cartesian_spatial_extent[1], + cartesian_spatial_extent[2], + cartesian_spatial_extent[3], + ) else: - bbox = box(*spatial_extent) + bbox = box( + spatial_extent[0], + spatial_extent[1], + spatial_extent[2], + spatial_extent[3], + ) # TODO: test case that ensures gdf is constructed as expected (correct coords, order, etc.) - gdf = gpd.GeoDataFrame(geometry=[bbox], crs="epsg:4326") + # HACK: Disabled Pyright due to issue + # https://github.com/geopandas/geopandas/issues/3115 + return gpd.GeoDataFrame(geometry=[bbox], crs="epsg:4326") # pyright: ignore[reportCallIssue] # DevGoal: Currently this if/else within this elif are not tested... # DevGoal: the crs setting and management needs to be improved - elif extent_type == "polygon" and file is False: + elif extent_type == "polygon": # if spatial_extent is already a Polygon if isinstance(spatial_extent, Polygon): spatial_extent_geom = spatial_extent @@ -101,34 +134,32 @@ def geodataframe(extent_type, spatial_extent, file=False, xdateline=None): zip(spatial_extent[0::2], spatial_extent[1::2]) ) # spatial_extent # TODO: check if the crs param should always just be epsg:4326 for everything OR if it should be a parameter - gdf = gpd.GeoDataFrame( + # HACK: Disabled Pyright due to issue + # https://github.com/geopandas/geopandas/issues/3115 + return gpd.GeoDataFrame( # pyright: ignore[reportCallIssue] index=[0], crs="epsg:4326", geometry=[spatial_extent_geom] ) - # If extent_type is a polygon AND from a file, create a geopandas geodataframe from it - # DevGoal: Currently this elif isn't tested... - elif extent_type == "polygon" and file is True: - gdf = gpd.read_file(spatial_extent) - else: raise TypeError( f"Your spatial extent type ({extent_type}) is not an accepted " "input and a geodataframe cannot be constructed" ) - return gdf - -def check_dateline(extent_type, spatial_extent): +def check_dateline( + extent_type: ExtentType, + spatial_extent: list[float], +) -> bool: """ Check if a bounding box or polygon input cross the dateline. Parameters ---------- - extent_type : string + extent_type : One of 'bounding_box' or 'polygon', indicating what type of input the spatial extent is - spatial_extent : list + spatial_extent : A list containing the spatial extent as coordinates in decimal degrees of [longitude1, latitude1, longitude2, latitude2, ... longitude_n,latitude_n, longitude1,latitude1]. @@ -139,7 +170,6 @@ def check_dateline(extent_type, spatial_extent): boolean indicating whether or not the spatial extent crosses the dateline. """ - if extent_type == "bounding_box": if spatial_extent[0] > spatial_extent[2]: # if lower left lon is larger then upper right lon, verify the values are crossing the dateline @@ -172,7 +202,9 @@ def check_dateline(extent_type, spatial_extent): return False -def validate_bounding_box(spatial_extent): +def validate_bounding_box( + spatial_extent: Union[list[float], NDArray[np.floating]], +) -> tuple[Literal["bounding_box"], list[float], None]: """ Validates the spatial_extent parameter as a bounding box. @@ -181,13 +213,13 @@ def validate_bounding_box(spatial_extent): Parameters ---------- - spatial_extent: list or np.ndarray - A list or np.ndarray of strings, numerics, or tuples - representing bounding box coordinates in decimal degrees. + spatial_extent: + A list or np.ndarray of exactly 4 numerics representing bounding box coordinates + in decimal degrees. - Must be provided in the order: - [lower-left-longitude, lower-left-latitude, - upper-right-longitude, upper-right-latitude]) + Must be provided in the order: + [lower-left-longitude, lower-left-latitude, + upper-right-longitude, upper-right-latitude]) """ # Latitude must be between -90 and 90 (inclusive); check for this here @@ -213,7 +245,9 @@ def validate_bounding_box(spatial_extent): return "bounding_box", spatial_extent, None -def validate_polygon_pairs(spatial_extent): +def validate_polygon_pairs( + spatial_extent: Union[list[tuple[float, float]], NDArray[np.void]], +) -> tuple[Literal["polygon"], list[float], None]: """ Validates the spatial_extent parameter as a polygon from coordinate pairs. @@ -224,14 +258,21 @@ def validate_polygon_pairs(spatial_extent): Parameters ---------- - spatial_extent: list or np.ndarray + spatial_extent: - A list or np.ndarray of tuples representing polygon coordinate pairs in decimal degrees in the order: - [(longitude1, latitude1), (longitude2, latitude2), ... - ... (longitude_n,latitude_n), (longitude1,latitude1)] + A list or np.ndarray of tuples representing polygon coordinate pairs in decimal + degrees in the order: - If the first and last coordinate pairs are NOT equal, - the polygon will be closed automatically (last point will be connected to the first point). + [ + (longitude_1, latitude_1), + ..., + (longitude_n, latitude_n), + (longitude_1,latitude_1), + ] + + If the first and last coordinate pairs are NOT equal, + the polygon will be closed automatically (last point will be connected to the + first point). """ # Check to make sure all elements of spatial_extent are coordinate pairs; if not, raise an error if any(len(i) != 2 for i in spatial_extent): @@ -269,7 +310,12 @@ def validate_polygon_pairs(spatial_extent): return "polygon", polygon, None -def validate_polygon_list(spatial_extent): +def validate_polygon_list( + spatial_extent: Union[ + list[Union[float, str]], + NDArray[np.floating], + ], +) -> tuple[Literal["polygon"], list[float], None]: """ Validates the spatial_extent parameter as a polygon from a list of coordinates. @@ -280,14 +326,14 @@ def validate_polygon_list(spatial_extent): Parameters ---------- - spatial_extent: list or np.ndarray - A list or np.ndarray of strings, numerics, or tuples representing polygon coordinates, - provided as coordinate pairs in decimal degrees in the order: - [longitude1, latitude1, longitude2, latitude2, ... - ... longitude_n,latitude_n, longitude1,latitude1] - - If the first and last coordinate pairs are NOT equal, - the polygon will be closed automatically (last point will be connected to the first point). + spatial_extent: + A list or np.ndarray of strings or numerics representing polygon coordinates, + provided as coordinate pairs in decimal degrees in the order: + [longitude1, latitude1, longitude2, latitude2, ... + ... longitude_n,latitude_n, longitude1,latitude1] + + If the first and last coordinate pairs are NOT equal, + the polygon will be closed automatically (last point will be connected to the first point). """ # user-entered polygon as a single list of lon and lat coordinates @@ -306,12 +352,10 @@ def validate_polygon_list(spatial_extent): # Add starting long/lat to end if isinstance(spatial_extent, list): - # use list.append() method spatial_extent.append(spatial_extent[0]) spatial_extent.append(spatial_extent[1]) elif isinstance(spatial_extent, np.ndarray): - # use np.insert() method spatial_extent = np.insert( spatial_extent, len(spatial_extent), spatial_extent[0] ) @@ -324,7 +368,9 @@ def validate_polygon_list(spatial_extent): return "polygon", polygon, None -def validate_polygon_file(spatial_extent): +def validate_polygon_file( + spatial_extent: str, +) -> tuple[Literal["polygon"], gpd.GeoDataFrame, str]: """ Validates the spatial_extent parameter as a polygon from a file. @@ -364,7 +410,22 @@ def validate_polygon_file(spatial_extent): class Spatial: - def __init__(self, spatial_extent, **kwarg): + _ext_type: ExtentType + _spatial_ext: list[float] + _geom_file: Optional[str] + + def __init__( + self, + spatial_extent: Union[ + str, # Filepath + list[str], # Bounding box or polygon + list[float], # Bounding box or polygon + list[tuple[float, float]], # Polygon + NDArray, # Polygon + None, + ], + **kwarg, + ): """ Validates input from "spatial_extent" argument, then creates a Spatial object with validated inputs as properties of the object. @@ -384,7 +445,7 @@ def __init__(self, spatial_extent, **kwarg): * [(longitude1, latitude1), (longitude2, latitude2), ... ... (longitude_n,latitude_n), (longitude1,latitude1)] * [longitude1, latitude1, longitude2, latitude2, - ... longitude_n,latitude_n, longitude1,latitude1]. + ... longitude_n,latitude_n, longitude1,latitude1]. * NOTE: If the first and last coordinate pairs are NOT equal, the polygon will be closed automatically (last point will be connected to the first point). * string representing a geospatial polygon file (kml, shp, gpkg) @@ -435,34 +496,56 @@ def __init__(self, spatial_extent, **kwarg): if isinstance(spatial_extent, (list, np.ndarray)): # bounding box if len(spatial_extent) == 4 and all( - isinstance(i, scalar_types) for i in spatial_extent + isinstance(i, scalar_types) # pyright: ignore[reportArgumentType] + for i in spatial_extent ): ( self._ext_type, self._spatial_ext, self._geom_file, - ) = validate_bounding_box(spatial_extent) + ) = validate_bounding_box( + # HACK: Unfortunately, the typechecker can't narrow based on the + # above conditional expressions. Tell the typechecker, "trust us"! + cast( + Union[list[float], NDArray[np.floating]], + spatial_extent, + ), + ) # polygon (as list of lon, lat coordinate pairs, in tuples) elif all( type(i) in [list, tuple, np.ndarray] for i in spatial_extent ) and all( - all(isinstance(i[j], scalar_types) for j in range(len(i))) + all(isinstance(i[j], scalar_types) for j in range(len(i))) # pyright: ignore[reportArgumentType,reportIndexIssue] for i in spatial_extent ): ( self._ext_type, self._spatial_ext, self._geom_file, - ) = validate_polygon_pairs(spatial_extent) + ) = validate_polygon_pairs( + # HACK: Unfortunately, the typechecker can't narrow based on the + # above conditional expressions. Tell the typechecker, "trust us"! + cast( + Union[list[tuple[float, float]], NDArray[np.void]], + spatial_extent, + ) + ) # polygon (as list of lon, lat coordinate pairs, single "flat" list) - elif all(isinstance(i, scalar_types) for i in spatial_extent): + elif all(isinstance(i, scalar_types) for i in spatial_extent): # pyright: ignore[reportArgumentType] ( self._ext_type, self._spatial_ext, self._geom_file, - ) = validate_polygon_list(spatial_extent) + ) = validate_polygon_list( + # HACK: Unfortunately, the typechecker can't narrow based on the + # above conditional expressions. Tell the typechecker, "trust us"! + cast( + Union[list[Union[str, float]], NDArray[np.floating]], + spatial_extent, + ) + ) else: # TODO: Change this warning to be like "usage", tell user possible accepted input types raise ValueError( @@ -503,7 +586,7 @@ def __init__(self, spatial_extent, **kwarg): False, ], "Your 'xdateline' value is invalid. It must be boolean." - def __str__(self): + def __str__(self) -> str: if self._geom_file is not None: return "Extent type: {0}\nSource file: {1}\nCoordinates: {2}".format( self._ext_type, self._geom_file, self._spatial_ext @@ -514,7 +597,7 @@ def __str__(self): ) @property - def extent(self): + def extent(self) -> list[float]: """ Return the coordinates of the spatial extent of the Spatial object. @@ -531,7 +614,7 @@ def extent(self): return self._spatial_ext @property - def extent_as_gdf(self): + def extent_as_gdf(self) -> gpd.GeoDataFrame: """ Return the spatial extent of the query object as a GeoPandas GeoDataframe. @@ -557,7 +640,7 @@ def extent_as_gdf(self): return self._gdf_spat @property - def extent_type(self): + def extent_type(self) -> ExtentType: """ Return the extent type of the Spatial object as a string. @@ -575,7 +658,7 @@ def extent_type(self): return self._ext_type @property - def extent_file(self): + def extent_file(self) -> Optional[str]: """ Return the path to the geospatial polygon file containing the Spatial object's spatial extent. If the spatial extent did not come from a file (i.e. user entered list of coordinates), this will return None. @@ -597,7 +680,7 @@ def extent_file(self): # Methods # TODO: can use this docstring as a todo list - def fmt_for_CMR(self): + def fmt_for_CMR(self) -> str: """ Format the spatial extent for NASA's Common Metadata Repository (CMR) API. @@ -646,9 +729,12 @@ def fmt_for_CMR(self): cmr_extent = ",".join(map(str, extent)) + else: + raise RuntimeError("Programmer error!") + return cmr_extent - def fmt_for_EGI(self): + def fmt_for_EGI(self) -> str: """ Format the spatial extent input into a subsetting key value for submission to EGI (the NSIDC DAAC API). @@ -674,4 +760,7 @@ def fmt_for_EGI(self): egi_extent = gpd.GeoSeries(poly).to_json() egi_extent = egi_extent.replace(" ", "") # remove spaces for API call + else: + raise RuntimeError("Programmer error!") + return egi_extent diff --git a/pyproject.toml b/pyproject.toml index fb4907d35..932da57e9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -143,7 +143,6 @@ ignore = [ "icepyx/core/auth.py", "icepyx/core/is2ref.py", "icepyx/core/read.py", - "icepyx/core/spatial.py", "icepyx/core/variables.py", "icepyx/core/visualization.py", ] From bfa95624f06a60434c763f73f688c3ed62842cf0 Mon Sep 17 00:00:00 2001 From: Matt Fisher Date: Tue, 17 Sep 2024 19:11:50 -0600 Subject: [PATCH 02/24] Fix doctest I broke --- icepyx/core/spatial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index a51a1fa24..bb06792dc 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -60,7 +60,7 @@ def geodataframe( >>> reg_a = ipx.Query('ATL06',[-55, 68, -48, 71],['2019-02-20','2019-02-28']) >>> gdf = geodataframe(reg_a.spatial.extent_type, reg_a.spatial.extent) >>> gdf.geometry - 0 POLYGON ((-48 68, -48 71, -55 71, -55 68, -48 ...)) + 0 POLYGON ((-48 68, -48 71, -55 71, -55 68, -48 ... Name: geometry, dtype: geometry """ From 153c307726b6c64a9805717f14b5bdc5ae158bf9 Mon Sep 17 00:00:00 2001 From: Jessica Scheick Date: Thu, 24 Oct 2024 10:45:20 -0400 Subject: [PATCH 03/24] add datetime import to query.py for typechecking --- icepyx/core/query.py | 1 + 1 file changed, 1 insertion(+) diff --git a/icepyx/core/query.py b/icepyx/core/query.py index f9718487b..39053d331 100644 --- a/icepyx/core/query.py +++ b/icepyx/core/query.py @@ -1,3 +1,4 @@ +import datetime as dt import pprint from typing import Optional, Union, cast From e0ef9671cb91ca4197fafdd5e0aa0cddc7dc41d8 Mon Sep 17 00:00:00 2001 From: Jessica Scheick Date: Thu, 24 Oct 2024 10:49:33 -0400 Subject: [PATCH 04/24] make types in abc order --- icepyx/core/query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icepyx/core/query.py b/icepyx/core/query.py index 39053d331..f34acda37 100644 --- a/icepyx/core/query.py +++ b/icepyx/core/query.py @@ -127,8 +127,8 @@ class GenQuery: Quest """ - _temporal: tp.Temporal _spatial: spat.Spatial + _temporal: tp.Temporal def __init__( self, From 635e31c3e55c042cdab7270bc361939ca9dff76e Mon Sep 17 00:00:00 2001 From: Jessica Scheick Date: Tue, 29 Oct 2024 10:46:09 -0400 Subject: [PATCH 05/24] add shapely.Polygon accepted input to docstring --- icepyx/core/spatial.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index bb06792dc..03a857037 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -29,10 +29,10 @@ def geodataframe( One of 'bounding_box' or 'polygon', indicating what type of input the spatial extent is spatial_extent : - A list containing the spatial extent OR a string containing a filename. - If file is False, spatial_extent should be a - list of coordinates in decimal degrees of [lower-left-longitude, - lower-left-latitute, upper-right-longitude, upper-right-latitude] or + A list containing the spatial extent, a shapely.Polygon, OR a string containing a filename. + If file is False, spatial_extent should be a shapely.Polygon, + list of bounding box coordinates in decimal degrees of [lower-left-longitude, + lower-left-latitute, upper-right-longitude, upper-right-latitude] or polygon vertices as [longitude1, latitude1, longitude2, latitude2, ... longitude_n,latitude_n, longitude1,latitude1]. If file is True, spatial_extent is a string containing the full file path and filename to the From 130160b92174e6d6859aeeed4e03c45856fb3ec9 Mon Sep 17 00:00:00 2001 From: Jessica Scheick Date: Tue, 29 Oct 2024 10:56:23 -0400 Subject: [PATCH 06/24] change to ExhaustiveTypeGuardException error type --- icepyx/core/spatial.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 03a857037..3ca9edf45 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -8,6 +8,8 @@ from shapely.geometry import Polygon, box from shapely.geometry.polygon import orient +import icepyx.core.exceptions + # DevGoal: need to update the spatial_extent docstring to describe coordinate order for input @@ -33,10 +35,11 @@ def geodataframe( If file is False, spatial_extent should be a shapely.Polygon, list of bounding box coordinates in decimal degrees of [lower-left-longitude, lower-left-latitute, upper-right-longitude, upper-right-latitude] or polygon vertices as - [longitude1, latitude1, longitude2, latitude2, ... longitude_n,latitude_n, longitude1,latitude1]. + [longitude1, latitude1, longitude2, latitude2, ... + longitude_n,latitude_n, longitude1,latitude1]. - If file is True, spatial_extent is a string containing the full file path and filename to the - file containing the desired spatial extent. + If file is True, spatial_extent is a string containing the full file path and filename + to the file containing the desired spatial extent. file : Indication for whether the spatial_extent string is a filename or coordinate list @@ -730,7 +733,7 @@ def fmt_for_CMR(self) -> str: cmr_extent = ",".join(map(str, extent)) else: - raise RuntimeError("Programmer error!") + raise icepyx.core.exceptions.ExhaustiveTypeGuardException return cmr_extent From 830f471f58de3a70544be92fce10d50881cb7edf Mon Sep 17 00:00:00 2001 From: Jessica Scheick Date: Tue, 29 Oct 2024 11:11:16 -0400 Subject: [PATCH 07/24] change to ExhaustiveTypeGuardException error type --- icepyx/core/spatial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 3ca9edf45..926e29046 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -764,6 +764,6 @@ def fmt_for_EGI(self) -> str: egi_extent = egi_extent.replace(" ", "") # remove spaces for API call else: - raise RuntimeError("Programmer error!") + raise icepyx.core.exceptions.ExhaustiveTypeGuardException return egi_extent From 0ca4485a683cb4a08c122064c4bcd3c541f6a31b Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 14:26:59 -0600 Subject: [PATCH 08/24] Reorder `Spatial` private properties in alpha order --- icepyx/core/spatial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 926e29046..8ef559491 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -414,8 +414,8 @@ def validate_polygon_file( class Spatial: _ext_type: ExtentType - _spatial_ext: list[float] _geom_file: Optional[str] + _spatial_ext: list[float] def __init__( self, From b035695aa27ab6b14c0cb1504984e3720755e8c9 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 15:35:15 -0600 Subject: [PATCH 09/24] Remove typing allowing list of coordinates as strings --- icepyx/core/spatial.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 8ef559491..92e11d011 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -315,7 +315,7 @@ def validate_polygon_pairs( def validate_polygon_list( spatial_extent: Union[ - list[Union[float, str]], + list[float], NDArray[np.floating], ], ) -> tuple[Literal["polygon"], list[float], None]: @@ -330,7 +330,7 @@ def validate_polygon_list( Parameters ---------- spatial_extent: - A list or np.ndarray of strings or numerics representing polygon coordinates, + A list or np.ndarray of numerics representing polygon coordinates, provided as coordinate pairs in decimal degrees in the order: [longitude1, latitude1, longitude2, latitude2, ... ... longitude_n,latitude_n, longitude1,latitude1] @@ -421,7 +421,6 @@ def __init__( self, spatial_extent: Union[ str, # Filepath - list[str], # Bounding box or polygon list[float], # Bounding box or polygon list[tuple[float, float]], # Polygon NDArray, # Polygon @@ -439,7 +438,7 @@ def __init__( ---------- spatial_extent : list or string * list of coordinates - (stored in a list of strings, list of numerics, list of tuples, OR np.ndarray) as one of: + (stored in a list of numerics, list of tuples, OR np.ndarray) as one of: * bounding box * provided in the order: [lower-left-longitude, lower-left-latitude, upper-right-longitude, upper-right-latitude].) @@ -545,7 +544,7 @@ def __init__( # HACK: Unfortunately, the typechecker can't narrow based on the # above conditional expressions. Tell the typechecker, "trust us"! cast( - Union[list[Union[str, float]], NDArray[np.floating]], + Union[list[float], NDArray[np.floating]], spatial_extent, ) ) From 1c1c4903a2ab922254bdf2df95914bcdd6093393 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 15:52:21 -0600 Subject: [PATCH 10/24] Support shapely.Polygon for `spatial.geodataframe` `spatial_extent` --- icepyx/core/spatial.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 92e11d011..c7635db9b 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -18,7 +18,7 @@ def geodataframe( extent_type: ExtentType, - spatial_extent: Union[str, list[float]], + spatial_extent: Union[str, list[float], Polygon], file: bool = False, xdateline: Optional[bool] = None, ) -> gpd.GeoDataFrame: @@ -76,7 +76,12 @@ def geodataframe( raise TypeError("When 'file' is True, 'extent_type' must be 'polygon'") if isinstance(spatial_extent, str): - raise TypeError(f"Expected list of floats, received {spatial_extent=}") + raise TypeError(f"Expected list of floats or Polygon, received {spatial_extent=}") + + if isinstance(spatial_extent, Polygon): + # Convert `spatial_extent` into a list of floats like: + # `[longitude1, latitude1, longitude2, latitude2, ...]` + spatial_extent = [coord for point in spatial_extent.exterior.coords for coord in point] if xdateline is not None: xdateline = xdateline From cbc761cc4759b908367d67fda3edc15787f76586 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Oct 2024 21:53:01 +0000 Subject: [PATCH 11/24] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- icepyx/core/spatial.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index c7635db9b..59594e634 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -76,12 +76,16 @@ def geodataframe( raise TypeError("When 'file' is True, 'extent_type' must be 'polygon'") if isinstance(spatial_extent, str): - raise TypeError(f"Expected list of floats or Polygon, received {spatial_extent=}") + raise TypeError( + f"Expected list of floats or Polygon, received {spatial_extent=}" + ) if isinstance(spatial_extent, Polygon): # Convert `spatial_extent` into a list of floats like: # `[longitude1, latitude1, longitude2, latitude2, ...]` - spatial_extent = [coord for point in spatial_extent.exterior.coords for coord in point] + spatial_extent = [ + coord for point in spatial_extent.exterior.coords for coord in point + ] if xdateline is not None: xdateline = xdateline From 52037e9d6b4592aa77d631deea2f2df5a4d93f74 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 16:21:44 -0600 Subject: [PATCH 12/24] `spatial.geodataframe` supports a list of tuples for `spatial_extent` Also added tests for the Polygon and `list[tuple[float, float]]` case. --- icepyx/core/spatial.py | 17 ++++++++++++++--- icepyx/tests/unit/test_spatial.py | 25 +++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 59594e634..dddcc0c85 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -1,3 +1,4 @@ +from itertools import chain import os from typing import Literal, Optional, Union, cast import warnings @@ -18,7 +19,7 @@ def geodataframe( extent_type: ExtentType, - spatial_extent: Union[str, list[float], Polygon], + spatial_extent: Union[str, list[float], list[tuple[float, float]], Polygon], file: bool = False, xdateline: Optional[bool] = None, ) -> gpd.GeoDataFrame: @@ -31,7 +32,9 @@ def geodataframe( One of 'bounding_box' or 'polygon', indicating what type of input the spatial extent is spatial_extent : - A list containing the spatial extent, a shapely.Polygon, OR a string containing a filename. + A list containing the spatial extent, a shapely.Polygon, a list of + tuples (i.e.,, `[(longitude1, latitude1), (longitude2, latitude2), + ...]`)containing floats, OR a string containing a filename. If file is False, spatial_extent should be a shapely.Polygon, list of bounding box coordinates in decimal degrees of [lower-left-longitude, lower-left-latitute, upper-right-longitude, upper-right-latitude] or polygon vertices as @@ -84,9 +87,17 @@ def geodataframe( # Convert `spatial_extent` into a list of floats like: # `[longitude1, latitude1, longitude2, latitude2, ...]` spatial_extent = [ - coord for point in spatial_extent.exterior.coords for coord in point + float(coord) for point in spatial_extent.exterior.coords for coord in point ] + # We are dealing with a `list[tuple[float, float]]` + if isinstance(spatial_extent, list) and isinstance(spatial_extent[0], tuple): + # Convert the list of tuples into a flat list of floats + spatial_extent = cast(list[tuple[float, float]], spatial_extent) + spatial_extent = list(chain.from_iterable(spatial_extent)) + + spatial_extent = cast(list[float], spatial_extent) + if xdateline is not None: xdateline = xdateline else: diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index 2012699bf..c1dc1d83d 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -406,6 +406,31 @@ def test_gdf_from_multi_bbox(): assert obs.geometry[0].equals(exp.geometry[0]) +def test_gdf_from_polygon(): + polygon = Polygon(list(zip([-55, -55, -48, -48, -55], [68, 71, 71, 68, 68]))) + obs = spat.geodataframe("polygon", polygon) + exp = gpd.GeoDataFrame(geometry=[polygon]) + + # make sure there is only one geometry before comparing them + assert len(obs.geometry) == 1 + assert len(exp.geometry) == 1 + assert obs.geometry[0].equals(exp.geometry[0]) + + +def test_gdf_from_list_tuples(): + polygon_tuples = list( + zip([-55.0, -55.0, -48.0, -48.0, -55.0], [68.0, 71.0, 71.0, 68.0, 68.0]) + ) + obs = spat.geodataframe("polygon", polygon_tuples) + geom = [Polygon(polygon_tuples)] + exp = gpd.GeoDataFrame(geometry=geom) + + # make sure there is only one geometry before comparing them + assert len(obs.geometry) == 1 + assert len(exp.geometry) == 1 + assert obs.geometry[0].equals(exp.geometry[0]) + + # Potential tests to include once multipolygon and complex polygons are handled # def test_gdf_from_strpoly_one_simple(): From 7c6499170766419139d1757380db1fe819e29e9b Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 16:28:20 -0600 Subject: [PATCH 13/24] Test code branch indicated as a "dev goal" in comment --- icepyx/core/spatial.py | 1 - icepyx/tests/unit/test_spatial.py | 5 +++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index dddcc0c85..7806fdb1c 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -71,7 +71,6 @@ def geodataframe( """ # If extent_type is a polygon AND from a file, create a geopandas geodataframe from it - # DevGoal: Currently this branch isn't tested... if file is True: if extent_type == "polygon": return gpd.read_file(spatial_extent) diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index c1dc1d83d..f27f617e6 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -431,6 +431,11 @@ def test_gdf_from_list_tuples(): assert obs.geometry[0].equals(exp.geometry[0]) +def test_gdf_raises_error_bounding_box_file(): + with pytest.raises(TypeError): + spat.geodataframe("bounding_box", "/fake/file/somewhere/polygon.shp") + + # Potential tests to include once multipolygon and complex polygons are handled # def test_gdf_from_strpoly_one_simple(): From 2adc38db13ba6278df00d567b2081d6f34f4d5ab Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 16:31:42 -0600 Subject: [PATCH 14/24] Fixup test to indicate that we want to read a bbox from file --- icepyx/tests/unit/test_spatial.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index f27f617e6..9984251cd 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -433,7 +433,7 @@ def test_gdf_from_list_tuples(): def test_gdf_raises_error_bounding_box_file(): with pytest.raises(TypeError): - spat.geodataframe("bounding_box", "/fake/file/somewhere/polygon.shp") + spat.geodataframe("bounding_box", "/fake/file/somewhere/polygon.shp", file=True) # Potential tests to include once multipolygon and complex polygons are handled From 9df3ec783a632b494c41972a313432ad08dc6e48 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 16:36:07 -0600 Subject: [PATCH 15/24] Add test for unlikely error case Trying to resolve code coverage complaints... --- icepyx/__init__.py | 1 - icepyx/tests/unit/test_spatial.py | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/icepyx/__init__.py b/icepyx/__init__.py index b0cd8095d..08888ffb4 100644 --- a/icepyx/__init__.py +++ b/icepyx/__init__.py @@ -13,7 +13,6 @@ from _icepyx_version import version as __version__ - from icepyx.core.query import GenQuery, Query from icepyx.core.read import Read from icepyx.core.variables import Variables diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index 9984251cd..fc4f74e2a 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -6,6 +6,7 @@ import pytest from shapely.geometry import Polygon +import icepyx import icepyx.core.spatial as spat # ######### "Bounding Box" input tests ################################################################################ @@ -528,6 +529,13 @@ def test_bbox_fmt(): assert obs == exp +def test_fmt_for_cmr_fails_unknown_extent_type(): + bbox = spat.Spatial([-55, 68, -48, 71]) + bbox._ext_type = "Unknown_user_override" + with pytest.raises(icepyx.core.exceptions.ExhaustiveTypeGuardException): + bbox.fmt_for_CMR() + + @pytest.fixture def poly(): coords = [ From 6f771d83fafe1e56b7e57a0421a10e9acd48263a Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 30 Oct 2024 22:37:53 +0000 Subject: [PATCH 16/24] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- icepyx/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/icepyx/__init__.py b/icepyx/__init__.py index 08888ffb4..b0cd8095d 100644 --- a/icepyx/__init__.py +++ b/icepyx/__init__.py @@ -13,6 +13,7 @@ from _icepyx_version import version as __version__ + from icepyx.core.query import GenQuery, Query from icepyx.core.read import Read from icepyx.core.variables import Variables From 3f923a60e48acbc9c1337092b850e8948311dbab Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 16:41:11 -0600 Subject: [PATCH 17/24] Add unit test for unlikely error in `fmt_for_EGI` --- icepyx/tests/unit/test_spatial.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index fc4f74e2a..690503c78 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -536,6 +536,13 @@ def test_fmt_for_cmr_fails_unknown_extent_type(): bbox.fmt_for_CMR() +def test_fmt_for_egi_fails_unknown_extent_type(): + bbox = spat.Spatial([-55, 68, -48, 71]) + bbox._ext_type = "Unknown_user_override" + with pytest.raises(icepyx.core.exceptions.ExhaustiveTypeGuardException): + bbox.fmt_for_EGI() + + @pytest.fixture def poly(): coords = [ From 27de54847c6c5d2514ea35f7970c30c9b14b615c Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 16:43:21 -0600 Subject: [PATCH 18/24] Fixup typeerror message and add test covering check in geodataframe --- icepyx/core/spatial.py | 2 +- icepyx/tests/unit/test_spatial.py | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 7806fdb1c..75bbfb325 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -79,7 +79,7 @@ def geodataframe( if isinstance(spatial_extent, str): raise TypeError( - f"Expected list of floats or Polygon, received {spatial_extent=}" + f"Expected list of floats, list of tuples of floats, or Polygon, received {spatial_extent=}" ) if isinstance(spatial_extent, Polygon): diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index 690503c78..254154a19 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -437,6 +437,13 @@ def test_gdf_raises_error_bounding_box_file(): spat.geodataframe("bounding_box", "/fake/file/somewhere/polygon.shp", file=True) +def test_gdf_raises_error_string_file_false(): + with pytest.raises(TypeError): + spat.geodataframe( + "bounding_box", "/fake/file/somewhere/polygon.shp", file=False + ) + + # Potential tests to include once multipolygon and complex polygons are handled # def test_gdf_from_strpoly_one_simple(): From 191181eb4f416c9936ff13687c399ff0c8c1b6cd Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Wed, 30 Oct 2024 16:59:12 -0600 Subject: [PATCH 19/24] Add test for code branch that performes a dateline-crossing adjustment --- icepyx/tests/unit/test_spatial.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index 254154a19..f7947b0db 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -444,6 +444,34 @@ def test_gdf_raises_error_string_file_false(): ) +def test_gdf_boundingbox_xdateline(): + bbox = [-55.5, 66.2, -64.2, 72.5] + + # construct a geodataframe with the geometry corrected for the xdateline. + bbox_with_fix_for_xdateline = [304.5, 66.2, 295.8, 72.5] + min_x, min_y, max_x, max_y = bbox_with_fix_for_xdateline + exp = gpd.GeoDataFrame( + geometry=[ + Polygon( + [ + (min_x, min_y), + (min_x, max_y), + (max_x, max_y), + (max_x, min_y), + (min_x, min_y), + ] + ) + ] + ) + + obs = spat.geodataframe("bounding_box", bbox) + + # make sure there is only one geometry before comparing them + assert len(obs.geometry) == 1 + assert len(exp.geometry) == 1 + assert obs.geometry[0].equals(exp.geometry[0]) + + # Potential tests to include once multipolygon and complex polygons are handled # def test_gdf_from_strpoly_one_simple(): From 9aa54dcd584e7ebb9c7a599ee1749a8a4440dc07 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Thu, 31 Oct 2024 16:10:04 -0600 Subject: [PATCH 20/24] import icepyx as ipx in test_spatial Co-authored-by: Jessica Scheick --- icepyx/tests/unit/test_spatial.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index f7947b0db..0aaeec499 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -6,7 +6,7 @@ import pytest from shapely.geometry import Polygon -import icepyx +import icepyx as ipx import icepyx.core.spatial as spat # ######### "Bounding Box" input tests ################################################################################ @@ -567,14 +567,14 @@ def test_bbox_fmt(): def test_fmt_for_cmr_fails_unknown_extent_type(): bbox = spat.Spatial([-55, 68, -48, 71]) bbox._ext_type = "Unknown_user_override" - with pytest.raises(icepyx.core.exceptions.ExhaustiveTypeGuardException): + with pytest.raises(ipx.core.exceptions.ExhaustiveTypeGuardException): bbox.fmt_for_CMR() def test_fmt_for_egi_fails_unknown_extent_type(): bbox = spat.Spatial([-55, 68, -48, 71]) bbox._ext_type = "Unknown_user_override" - with pytest.raises(icepyx.core.exceptions.ExhaustiveTypeGuardException): + with pytest.raises(ipx.core.exceptions.ExhaustiveTypeGuardException): bbox.fmt_for_EGI() From c89df2147471fd71dd7c0104b6f4117ef5110a1e Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Mon, 11 Nov 2024 14:00:15 -0700 Subject: [PATCH 21/24] Refactor `geodataframe` to more clearly show where a list of floats is assumed --- icepyx/core/spatial.py | 152 ++++++++++++++++++++++------------------- 1 file changed, 82 insertions(+), 70 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 75bbfb325..17c5fbd72 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -1,4 +1,3 @@ -from itertools import chain import os from typing import Literal, Optional, Union, cast import warnings @@ -17,6 +16,59 @@ ExtentType = Literal["bounding_box", "polygon"] +def _geodataframe_from_bounding_box( + spatial_extent: list[float], + xdateline: bool, +) -> gpd.GeoDataFrame: + if xdateline is True: + cartesian_lons = [i if i > 0 else i + 360 for i in spatial_extent[0:-1:2]] + cartesian_spatial_extent = [ + item for pair in zip(cartesian_lons, spatial_extent[1::2]) for item in pair + ] + bbox = box( + cartesian_spatial_extent[0], + cartesian_spatial_extent[1], + cartesian_spatial_extent[2], + cartesian_spatial_extent[3], + ) + else: + bbox = box( + spatial_extent[0], + spatial_extent[1], + spatial_extent[2], + spatial_extent[3], + ) + + # TODO: test case that ensures gdf is constructed as expected (correct coords, order, etc.) + # HACK: Disabled Pyright due to issue + # https://github.com/geopandas/geopandas/issues/3115 + return gpd.GeoDataFrame(geometry=[bbox], crs="epsg:4326") # pyright: ignore[reportCallIssue] + + +def _geodataframe_from_polygon_list( + spatial_extent: list[float], + xdateline: bool, +) -> gpd.GeoDataFrame: + if xdateline is True: + cartesian_lons = [i if i > 0 else i + 360 for i in spatial_extent[0:-1:2]] + spatial_extent = [ + item for pair in zip(cartesian_lons, spatial_extent[1::2]) for item in pair + ] + + spatial_extent_geom = Polygon( + # syntax of dbl colon is- "start:stop:steps" + # 0::2 = start at 0, grab every other coord after + # 1::2 = start at 1, grab every other coord after + zip(spatial_extent[0::2], spatial_extent[1::2]) + ) # spatial_extent + # TODO: check if the crs param should always just be epsg:4326 for everything OR if it should be a parameter + # HACK: Disabled Pyright due to issue + # https://github.com/geopandas/geopandas/issues/3115 + return gpd.GeoDataFrame( # pyright: ignore[reportCallIssue] + index=[0], crs="epsg:4326", geometry=[spatial_extent_geom] + ) + + def geodataframe( extent_type: ExtentType, spatial_extent: Union[str, list[float], list[tuple[float, float]], Polygon], @@ -69,6 +121,7 @@ def geodataframe( 0 POLYGON ((-48 68, -48 71, -55 71, -55 68, -48 ... Name: geometry, dtype: geometry """ + # DevGoal: the crs setting and management needs to be improved # If extent_type is a polygon AND from a file, create a geopandas geodataframe from it if file is True: @@ -82,84 +135,38 @@ def geodataframe( f"Expected list of floats, list of tuples of floats, or Polygon, received {spatial_extent=}" ) - if isinstance(spatial_extent, Polygon): - # Convert `spatial_extent` into a list of floats like: - # `[longitude1, latitude1, longitude2, latitude2, ...]` - spatial_extent = [ - float(coord) for point in spatial_extent.exterior.coords for coord in point - ] - - # We are dealing with a `list[tuple[float, float]]` - if isinstance(spatial_extent, list) and isinstance(spatial_extent[0], tuple): - # Convert the list of tuples into a flat list of floats - spatial_extent = cast(list[tuple[float, float]], spatial_extent) - spatial_extent = list(chain.from_iterable(spatial_extent)) - - spatial_extent = cast(list[float], spatial_extent) - - if xdateline is not None: - xdateline = xdateline - else: + #### Non-file processing + if xdateline is None: + assert isinstance(spatial_extent, list) + assert isinstance(spatial_extent[0], float) + spatial_extent = cast(list[float], spatial_extent) xdateline = check_dateline(extent_type, spatial_extent) - if extent_type == "bounding_box": - if xdateline is True: - cartesian_lons = [i if i > 0 else i + 360 for i in spatial_extent[0:-1:2]] - cartesian_spatial_extent = [ - item - for pair in zip(cartesian_lons, spatial_extent[1::2]) - for item in pair - ] - bbox = box( - cartesian_spatial_extent[0], - cartesian_spatial_extent[1], - cartesian_spatial_extent[2], - cartesian_spatial_extent[3], - ) - else: - bbox = box( - spatial_extent[0], - spatial_extent[1], - spatial_extent[2], - spatial_extent[3], - ) - - # TODO: test case that ensures gdf is constructed as expected (correct coords, order, etc.) - # HACK: Disabled Pyright due to issue - # https://github.com/geopandas/geopandas/issues/3115 - return gpd.GeoDataFrame(geometry=[bbox], crs="epsg:4326") # pyright: ignore[reportCallIssue] - # DevGoal: Currently this if/else within this elif are not tested... - # DevGoal: the crs setting and management needs to be improved + if extent_type == "bounding_box": + assert isinstance(spatial_extent, list) + assert isinstance(spatial_extent[0], float) + spatial_extent = cast(list[float], spatial_extent) + return _geodataframe_from_bounding_box( + spatial_extent=spatial_extent, + xdateline=xdateline, + ) elif extent_type == "polygon": # if spatial_extent is already a Polygon if isinstance(spatial_extent, Polygon): spatial_extent_geom = spatial_extent + return gpd.GeoDataFrame( # pyright: ignore[reportCallIssue] + index=[0], crs="epsg:4326", geometry=[spatial_extent_geom] + ) - # else, spatial_extent must be a list of floats (or list of tuples of floats) - else: - if xdateline is True: - cartesian_lons = [ - i if i > 0 else i + 360 for i in spatial_extent[0:-1:2] - ] - spatial_extent = [ - item - for pair in zip(cartesian_lons, spatial_extent[1::2]) - for item in pair - ] - - spatial_extent_geom = Polygon( - # syntax of dbl colon is- "start:stop:steps" - # 0::2 = start at 0, grab every other coord after - # 1::2 = start at 1, grab every other coord after - zip(spatial_extent[0::2], spatial_extent[1::2]) - ) # spatial_extent - # TODO: check if the crs param should always just be epsg:4326 for everything OR if it should be a parameter - # HACK: Disabled Pyright due to issue - # https://github.com/geopandas/geopandas/issues/3115 - return gpd.GeoDataFrame( # pyright: ignore[reportCallIssue] - index=[0], crs="epsg:4326", geometry=[spatial_extent_geom] + # The input must be a list of floats. + assert isinstance(spatial_extent, list) + assert isinstance(spatial_extent[0], float) + spatial_extent = cast(list[float], spatial_extent) + return _geodataframe_from_polygon_list( + spatial_extent=spatial_extent, + xdateline=xdateline, ) else: @@ -171,6 +178,8 @@ def geodataframe( def check_dateline( extent_type: ExtentType, + # TODO: I think this is actually wrong. It expects a different type of + # spatial_extent depending on the `extent_type`, showing below. spatial_extent: list[float], ) -> bool: """ @@ -193,6 +202,7 @@ def check_dateline( indicating whether or not the spatial extent crosses the dateline. """ if extent_type == "bounding_box": + # We expect the bounding_box to be a list of floats. if spatial_extent[0] > spatial_extent[2]: # if lower left lon is larger then upper right lon, verify the values are crossing the dateline assert spatial_extent[0] - 360 <= spatial_extent[2] @@ -208,6 +218,8 @@ def check_dateline( # this works properly, but limits the user to at most 270 deg longitude... elif extent_type == "polygon": + # This checks that the first instance of `spatial_extent` NOT a list or + # a tuple. Assumes that this is a list of floats. assert not isinstance( spatial_extent[0], (list, tuple) ), "Your polygon list is the wrong format for this function." From b632617602290f2218425821e724814fcb2d4388 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Mon, 11 Nov 2024 14:29:54 -0700 Subject: [PATCH 22/24] Convert `spatial_extent` to `list[float]` for most functions --- icepyx/core/spatial.py | 52 ++++++++++++++++++++++++++++++++---------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index 17c5fbd72..cd40fa58b 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -1,3 +1,4 @@ +from itertools import chain import os from typing import Literal, Optional, Union, cast import warnings @@ -16,6 +17,33 @@ ExtentType = Literal["bounding_box", "polygon"] +def _convert_spatial_extent_to_list_of_floats( + spatial_extent: Union[list[float], list[tuple[float, float]], Polygon], +) -> list[float]: + # This is already a list of floats + if isinstance(spatial_extent, list) and isinstance(spatial_extent[0], float): + spatial_extent = cast(list[float], spatial_extent) + return spatial_extent + elif isinstance(spatial_extent, Polygon): + # Convert `spatial_extent` into a list of floats like: + # `[longitude1, latitude1, longitude2, latitude2, ...]` + spatial_extent = [ + float(coord) for point in spatial_extent.exterior.coords for coord in point + ] + return spatial_extent + elif isinstance(spatial_extent, list) and isinstance(spatial_extent[0], tuple): + # Convert the list of tuples into a flat list of floats + spatial_extent = cast(list[tuple[float, float]], spatial_extent) + spatial_extent = list(chain.from_iterable(spatial_extent)) + return spatial_extent + else: + raise TypeError( + "Unrecognized spatial_extent that" + " cannot be converted into a list of floats:" + f"{spatial_extent=}" + ) + + def _geodataframe_from_bounding_box( spatial_extent: list[float], xdateline: bool, @@ -136,19 +164,22 @@ def geodataframe( ) #### Non-file processing + # Most functions that this function calls requires the spatial extent as a + # list of floats. This function provides that. + spatial_extent_list = _convert_spatial_extent_to_list_of_floats( + spatial_extent=spatial_extent, + ) + if xdateline is None: - assert isinstance(spatial_extent, list) - assert isinstance(spatial_extent[0], float) - spatial_extent = cast(list[float], spatial_extent) - xdateline = check_dateline(extent_type, spatial_extent) + xdateline = check_dateline( + extent_type, + spatial_extent_list, + ) # DevGoal: Currently this if/else within this elif are not tested... if extent_type == "bounding_box": - assert isinstance(spatial_extent, list) - assert isinstance(spatial_extent[0], float) - spatial_extent = cast(list[float], spatial_extent) return _geodataframe_from_bounding_box( - spatial_extent=spatial_extent, + spatial_extent=spatial_extent_list, xdateline=xdateline, ) @@ -161,11 +192,8 @@ def geodataframe( ) # The input must be a list of floats. - assert isinstance(spatial_extent, list) - assert isinstance(spatial_extent[0], float) - spatial_extent = cast(list[float], spatial_extent) return _geodataframe_from_polygon_list( - spatial_extent=spatial_extent, + spatial_extent=spatial_extent_list, xdateline=xdateline, ) From 7675584163d30eee5b902b642bb763f9d92d6305 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Mon, 11 Nov 2024 14:35:49 -0700 Subject: [PATCH 23/24] Update unit tests: spatial extent must be floats - not ints --- icepyx/tests/unit/test_spatial.py | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/icepyx/tests/unit/test_spatial.py b/icepyx/tests/unit/test_spatial.py index 0aaeec499..c3babe24f 100644 --- a/icepyx/tests/unit/test_spatial.py +++ b/icepyx/tests/unit/test_spatial.py @@ -386,8 +386,14 @@ def test_bad_poly_inputfile_type_throws_error(): def test_gdf_from_one_bbox(): - obs = spat.geodataframe("bounding_box", [-55, 68, -48, 71]) - geom = [Polygon(list(zip([-55, -55, -48, -48, -55], [68, 71, 71, 68, 68])))] + obs = spat.geodataframe("bounding_box", [-55.0, 68.0, -48.0, 71.0]) + geom = [ + Polygon( + list( + zip([-55.0, -55.0, -48.0, -48.0, -55.0], [68.0, 71.0, 71.0, 68.0, 68.0]) + ) + ) + ] exp = gpd.GeoDataFrame(geometry=geom) # make sure there is only one geometry before comparing them @@ -397,8 +403,14 @@ def test_gdf_from_one_bbox(): def test_gdf_from_multi_bbox(): - obs = spat.geodataframe("bounding_box", [-55, 68, -48, 71]) - geom = [Polygon(list(zip([-55, -55, -48, -48, -55], [68, 71, 71, 68, 68])))] + obs = spat.geodataframe("bounding_box", [-55.0, 68.0, -48.0, 71.0]) + geom = [ + Polygon( + list( + zip([-55.0, -55.0, -48.0, -48.0, -55.0], [68.0, 71.0, 71.0, 68.0, 68.0]) + ) + ) + ] exp = gpd.GeoDataFrame(geometry=geom) # make sure there is only one geometry before comparing them @@ -408,7 +420,9 @@ def test_gdf_from_multi_bbox(): def test_gdf_from_polygon(): - polygon = Polygon(list(zip([-55, -55, -48, -48, -55], [68, 71, 71, 68, 68]))) + polygon = Polygon( + list(zip([-55.0, -55.0, -48.0, -48.0, -55.0], [68.0, 71.0, 71.0, 68.0, 68.0])) + ) obs = spat.geodataframe("polygon", polygon) exp = gpd.GeoDataFrame(geometry=[polygon]) @@ -492,7 +506,7 @@ def test_bad_extent_type_input(): r"Your spatial extent type (polybox) is not an accepted input and a geodataframe cannot be constructed" ) with pytest.raises(TypeError, match=ermsg): - spat.geodataframe("polybox", [1, 2, 3, 4]) + spat.geodataframe("polybox", [1.0, 2.0, 3.0, 4.0]) # ###################### END GEOM FILE INPUT TESTS #################################################################### From 7bd668c0093e08eb2b7980beb1bfd3f312ec7181 Mon Sep 17 00:00:00 2001 From: Trey Stafford Date: Thu, 14 Nov 2024 15:44:25 -0700 Subject: [PATCH 24/24] Remove OBE TODO --- icepyx/core/spatial.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/icepyx/core/spatial.py b/icepyx/core/spatial.py index cd40fa58b..533e90bb4 100644 --- a/icepyx/core/spatial.py +++ b/icepyx/core/spatial.py @@ -206,8 +206,6 @@ def geodataframe( def check_dateline( extent_type: ExtentType, - # TODO: I think this is actually wrong. It expects a different type of - # spatial_extent depending on the `extent_type`, showing below. spatial_extent: list[float], ) -> bool: """