diff --git a/icepyx/core/query.py b/icepyx/core/query.py index 1a07dbd72..1b17bfb58 100644 --- a/icepyx/core/query.py +++ b/icepyx/core/query.py @@ -123,13 +123,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 @@ -183,7 +184,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 @@ -210,7 +211,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 05cafd83d..9e91b9e78 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -145,7 +145,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", ]