diff --git a/climada/hazard/test/test_tc_tracks.py b/climada/hazard/test/test_tc_tracks.py index f1943c7f3..ced0dc1c7 100644 --- a/climada/hazard/test/test_tc_tracks.py +++ b/climada/hazard/test/test_tc_tracks.py @@ -1341,7 +1341,7 @@ def test_track_land_params(self): lon_test = np.array([170, 179.18, 180.05]) lat_test = np.array([-60, -16.56, -16.85]) on_land = np.array([False, True, True]) - lon_shift = np.array([-360, 0, 360]) + lon_shift = np.array([360, 360, 360]) # ensure both points are considered on land as is np.testing.assert_array_equal( u_coord.coord_on_land(lat=lat_test, lon=lon_test), on_land diff --git a/climada/hazard/trop_cyclone/trop_cyclone_windfields.py b/climada/hazard/trop_cyclone/trop_cyclone_windfields.py index f8ac09078..ac77e0f43 100644 --- a/climada/hazard/trop_cyclone/trop_cyclone_windfields.py +++ b/climada/hazard/trop_cyclone/trop_cyclone_windfields.py @@ -902,6 +902,15 @@ def _coriolis_parameter(lat: np.ndarray) -> np.ndarray: cp : np.ndarray of same shape as input Coriolis parameter. """ + if not u_coord.check_if_geo_coords(lat, 0): + raise ValueError( + "Input lat and lon coordinates do not seem to correspond" + " to geographic coordinates in degrees. This can be because" + " total extents are > 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90360 are allowed to cover cases + of objects being defined close to the 180 meridian for which longitude + intervals such as [179, 538] or [-538, -179] might be used. + + Parameters + ---------- + lat, lon : ndarrays of floats, same shape + Latitudes and longitudes of points. + + Returns + ------- + test : bool + True if lat/lon ranges seem to be in the geographic coordinates range, otherwise False. + """ + lat = np.array(lat) + lon = np.array(lon) + + # Check if latitude is within -90 to 90 and longitude is within -540 to 540 + # and extent are smaller than 180 and 360 respectively + test = ( + lat.min() >= -91 and lat.max() <= 91 and lon.min() >= -541 and lon.max() <= 541 + ) and ((lat.max() - lat.min()) <= 181 and (lon.max() - lon.min()) <= 361) + return bool(test) + + +def get_crs_unit(coords): + """ + Retrieve the unit of measurement for the coordinate reference system (CRS). + + Parameters + ---------- + coords : GeoDataFrame + An object with a coordinate reference system (CRS) attribute. + + Returns + ------- + unit : str + The unit of measurement for the coordinate system, as specified in the + CRS axis information. Assumes that both axes have the same unit. + """ + + unit = coords.crs.axis_info[0].unit_name # assume both axes have the same unit + if unit == "degree": + pass + elif unit == "metre": + unit = "m" + elif unit == "kilometre": + unit = "km" + else: + raise ValueError( + f"Unknown unit: {unit}. Please provide a crs that has a unit " + "of 'degree', 'metre' or 'kilometre'." + ) + return unit + + def latlon_to_geosph_vector(lat, lon, rad=False, basis=False): """Convert lat/lon coodinates to radial vectors (on geosphere) @@ -448,13 +507,26 @@ def get_gridcellarea(lat, resolution=0.5, unit="ha"): unit: string, optional unit of the output area (default: ha, alternatives: m2, km2) """ - + # first check that lat is in geographic coordinates + if not check_if_geo_coords(lat, 0): + raise ValueError( + "Input lat and lon coordinates do not seem to correspond" + " to geographic coordinates in degrees. This can be because" + " total extents are > 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90= 0 else 100) return epsg_utm_base + (math.floor((lon + 180) / 6) % 60) @@ -564,6 +645,15 @@ def dist_to_coast(coord_lat, lon=None, highres=False, signed=False): raise ValueError( f"Mismatching input coordinates size: {lat.size} != {lon.size}" ) + if not check_if_geo_coords(lat, lon): + raise ValueError( + "Input lat and lon coordinates do not seem to correspond" + " to geographic coordinates in degrees. This can be because" + " total extents are > 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90 360: + if not check_if_geo_coords(extent[2:], extent[:2]): raise ValueError( - f"longitude extent range is greater than 360: {extent[0]} to {extent[1]}" + "Input lat and lon coordinates do not seem to correspond" + " to geographic coordinates in degrees. This can be because" + " total extents are > 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90 0 and exact_assign_idx.size != coords_view.size: + # convert to proper units before proceeding to nearest neighbor search + if unit == "degree": + # check that coords are indeed geographic before converting + if not ( + check_if_geo_coords(coords[:, 0], coords[:, 1]) + and check_if_geo_coords( + coords_to_assign[:, 0], coords_to_assign[:, 1] + ) + ): + raise ValueError( + "Input lat and lon coordinates do not seem to correspond" + " to geographic coordinates in degrees. This can be because" + " total extents are > 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90 threshold in points' distances, -1 is returned. @@ -1146,15 +1287,25 @@ def match_centroids( try: if not equal_crs(coord_gdf.crs, centroids.crs): raise ValueError("Set hazard and GeoDataFrame to same CRS first!") - except AttributeError: - # If the coord_gdf has no crs defined (or no valid geometry column), - # no error is raised and it is assumed that the user set the crs correctly - pass + if coord_gdf.crs is None or centroids.crs is None: + raise ValueError( + "Please provide coordinate GeoDataFrame and Hazard " + "object with a valid crs attribute." + ) + except AttributeError as exc: + # a crs attribute is needed for unit inference + raise ValueError( + "Please provide coordinate GeoDataFrame and Hazard object with a valid crs attribute." + ) from exc + + # get unit of coordinate systems from axis of crs + unit = get_crs_unit(coord_gdf) assigned = match_coordinates( np.stack([coord_gdf.geometry.y.values, coord_gdf.geometry.x.values], axis=1), centroids.coord, distance=distance, + unit=unit, threshold=threshold, ) return assigned @@ -1170,7 +1321,7 @@ def _dist_sqr_approx(lats1, lons1, cos_lats1, lats2, lons2): def _nearest_neighbor_approx( - centroids, coordinates, threshold, check_antimeridian=True + centroids, coordinates, unit, threshold, check_antimeridian=True ): """Compute the nearest centroid for each coordinate using the euclidean distance d = ((dlon)cos(lat))^2+(dlat)^2. For distant points @@ -1184,6 +1335,8 @@ def _nearest_neighbor_approx( coordinates : 2d array First column contains latitude, second column contains longitude. Each row is a geographic point + unit : str + Unit to use for non-exact matching. Only possible value is "degree" threshold : float distance threshold in km over which no neighbor will be found. Those are assigned with a -1 index @@ -1198,7 +1351,12 @@ def _nearest_neighbor_approx( np.array with as many rows as coordinates containing the centroids indexes """ - + # first check that unit is in degree + if unit != "degree": + raise ValueError( + "Only degree unit is supported for nearest neighbor approximation." + "Please use euclidean distance for non-degree units." + ) # Compute only for the unique coordinates. Copy the results for the # not unique coordinates _, idx, inv = np.unique(coordinates, axis=0, return_index=True, return_inverse=True) @@ -1239,7 +1397,7 @@ def _nearest_neighbor_approx( return assigned -def _nearest_neighbor_haversine(centroids, coordinates, threshold): +def _nearest_neighbor_haversine(centroids, coordinates, unit, threshold): """Compute the neareast centroid for each coordinate using a Ball tree with haversine distance. Parameters @@ -1250,6 +1408,8 @@ def _nearest_neighbor_haversine(centroids, coordinates, threshold): coordinates : 2d array First column contains latitude, second column contains longitude. Each row is a geographic point + unit : str + Unit to use for non-exact matching. Only possible value is "degree" threshold : float distance threshold in km over which no neighbor will be found. Those are assigned with a -1 index @@ -1259,6 +1419,12 @@ def _nearest_neighbor_haversine(centroids, coordinates, threshold): np.array with as many rows as coordinates containing the centroids indexes """ + # first check that unit is in degree + if unit != "degree": + raise ValueError( + "Only degree unit is supported for nearest neighbor approximation." + "Please use euclidean distance for non-degree units." + ) # Construct tree from centroids tree = BallTree(np.radians(centroids), metric="haversine") # Select unique exposures coordinates @@ -1279,21 +1445,22 @@ def _nearest_neighbor_haversine(centroids, coordinates, threshold): # Raise a warning if the minimum distance is greater than the # threshold and set an unvalid index -1 - num_warn = np.sum(dist * EARTH_RADIUS_KM > threshold) + dist = dist * EARTH_RADIUS_KM + num_warn = np.sum(dist > threshold) if num_warn: LOGGER.warning( - "Distance to closest centroid is greater than %s" "km for %s coordinates.", + "Distance to closest centroid is greater than %i km for %i coordinates.", threshold, num_warn, ) - assigned[dist * EARTH_RADIUS_KM > threshold] = -1 + assigned[dist > threshold] = -1 # Copy result to all exposures and return value return assigned[inv] def _nearest_neighbor_euclidean( - centroids, coordinates, threshold, check_antimeridian=True + centroids, coordinates, unit, threshold, check_antimeridian=True ): """Compute the neareast centroid for each coordinate using a k-d tree. @@ -1305,6 +1472,8 @@ def _nearest_neighbor_euclidean( coordinates : 2d array First column contains latitude, second column contains longitude. Each row is a geographic point + unit : str + Unit to use for non-exact matching. Possible values are "degree", "m", "km". threshold : float distance threshold in km over which no neighbor will be found. Those are assigned with a -1 index @@ -1312,6 +1481,7 @@ def _nearest_neighbor_euclidean( If True, the nearest neighbor in a strip with lon size equal to threshold around the antimeridian is recomputed using the Haversine distance. The antimeridian is guessed from both coordinates and centroids, and is assumed equal to 0.5*(lon_max+lon_min) + 180. + Requires the coordinates to be in degrees. Default: True Returns @@ -1319,24 +1489,38 @@ def _nearest_neighbor_euclidean( np.array with as many rows as coordinates containing the centroids indexes """ + if ( + unit == "degree" + ): # if unit is in degree convert to radians for dist calculations + centroids = np.radians(centroids) + coordinates = np.radians(coordinates) # Construct tree from centroids - tree = scipy.spatial.KDTree(np.radians(centroids)) + tree = scipy.spatial.KDTree(centroids) # Select unique exposures coordinates _, idx, inv = np.unique(coordinates, axis=0, return_index=True, return_inverse=True) # query the k closest points of the n_points using dual tree - dist, assigned = tree.query(np.radians(coordinates[idx]), k=1, p=2, workers=-1) + dist, assigned = tree.query(coordinates[idx], k=1, p=2, workers=-1) + + if unit == "degree": + # convert back to degree for check antimeridian and convert distance + centroids = np.rad2deg(centroids) + coordinates = np.rad2deg(coordinates) + dist = dist * EARTH_RADIUS_KM + else: + # if unit is not in degree, check_antimeridian is forced to False + check_antimeridian = False # Raise a warning if the minimum distance is greater than the # threshold and set an unvalid index -1 - num_warn = np.sum(dist * EARTH_RADIUS_KM > threshold) + num_warn = np.sum(dist > threshold) if num_warn: LOGGER.warning( - "Distance to closest centroid is greater than %s" "km for %s coordinates.", + "Distance to closest centroid is greater than %i km for %i coordinates.", threshold, num_warn, ) - assigned[dist * EARTH_RADIUS_KM > threshold] = -1 + assigned[dist > threshold] = -1 if check_antimeridian: assigned = _nearest_neighbor_antimeridian( @@ -1389,7 +1573,7 @@ def _nearest_neighbor_antimeridian(centroids, coordinates, threshold, assigned): if np.any(cent_strip_bool): cent_strip = centroids[cent_strip_bool] strip_assigned = _nearest_neighbor_haversine( - cent_strip, coord_strip, threshold + cent_strip, coord_strip, "degree", threshold ) new_coords = cent_strip_bool.nonzero()[0][strip_assigned] new_coords[strip_assigned == -1] = -1 @@ -1595,6 +1779,16 @@ def get_country_code(lat, lon, gridded=False): if lat.size == 0: return np.empty((0,), dtype=int) LOGGER.info("Setting region_id %s points.", str(lat.size)) + # first check that input lat lon are geographic + if not check_if_geo_coords(lat, lon): + raise ValueError( + "Input lat and lon coordinates do not seem to correspond" + " to geographic coordinates in degrees. This can be because" + " total extents are > 180 for lat or > 360 for lon, lat coordinates" + " are outside of -90