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. + + 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 infer_unit_coords(coords): + """ + Infer the unit of measurement for the given coordinate system. + + Parameters + ---------- + coords : GeoDataFrame or similar object + An object with a coordinate reference system (CRS) attribute. + + Returns + ------- + unit : str + The unit of measurement for the coordinate system, either 'degree' for + geodetic systems or 'm' for projected systems. + + Raises + ------ + ValueError + If the coordinate system is neither geodetic nor projected, or if the + unit cannot be inferred. + """ + + if coords.crs.is_geographic: + unit = "degree" + elif coords.crs.is_projected: + unit = "m" + else: + raise ValueError( + "Unable to infer unit for coordinate points. Please specify unit of the coordinates." + ) + return unit + + def latlon_to_geosph_vector(lat, lon, rad=False, basis=False): """Convert lat/lon coodinates to radial vectors (on geosphere) @@ -448,13 +508,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 +646,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. @@ -1113,6 +1258,9 @@ def match_centroids( Distance to use in case of vector centroids. Possible values are "euclidean", "haversine" and "approx". Default: "euclidean" + unit : str, optional + Unit to use for non-exact matching. Possible values are "degree", "m", "km". + Default: None threshold : float, optional If the distance (in km) to the nearest neighbor exceeds `threshold`, the index `-1` is assigned. @@ -1151,10 +1299,15 @@ def match_centroids( # no error is raised and it is assumed that the user set the crs correctly pass + # infer unit + if not unit: + unit = infer_unit_coords(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 +1323,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 +1337,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,14 +1353,22 @@ 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) # Compute cos(lat) for all centroids - centr_cos_lat = np.cos(np.radians(centroids[:, 0])) + centr_cos_lat = np.cos(centroids[:, 0]) assigned = np.zeros(coordinates.shape[0], int) num_warn = 0 + # need to convert back to degrees for the approx distance and nearest neighbor + coordinates = np.rad2deg(coordinates) + centroids = np.rad2deg(centroids) for icoord, iidx in enumerate(idx): dist = _dist_sqr_approx( centroids[:, 0], @@ -1239,7 +1402,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 +1413,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,14 +1424,20 @@ 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") + tree = BallTree(centroids, metric="haversine") # 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]), + coordinates[idx], k=1, return_distance=True, dualtree=True, @@ -1279,21 +1450,20 @@ 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.", - threshold, - num_warn, + f"Distance to closest centroid is greater than {threshold} km for {num_warn} coordinates." ) - 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 +1475,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 +1484,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 @@ -1320,27 +1493,30 @@ def _nearest_neighbor_euclidean( with as many rows as coordinates containing the centroids indexes """ # 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) # 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) + if unit == "degree": + dist = dist * EARTH_RADIUS_KM + else: + # if unit is not in degree, check_antimeridian is forced to False + check_antimeridian = False + num_warn = np.sum(dist > threshold) if num_warn: LOGGER.warning( - "Distance to closest centroid is greater than %s" "km for %s coordinates.", - threshold, - num_warn, + f"Distance to closest centroid is greater than {threshold} km for {num_warn} coordinates." ) - assigned[dist * EARTH_RADIUS_KM > threshold] = -1 + assigned[dist > threshold] = -1 if check_antimeridian: assigned = _nearest_neighbor_antimeridian( - centroids, coordinates[idx], threshold, assigned + np.rad2deg(centroids), np.rad2deg(coordinates[idx]), threshold, assigned ) # Copy result to all exposures and return value @@ -1389,7 +1565,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 + np.deg2rad(cent_strip), np.deg2rad(coord_strip), "degree", threshold ) new_coords = cent_strip_bool.nonzero()[0][strip_assigned] new_coords[strip_assigned == -1] = -1 @@ -1595,6 +1771,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