# --- START OF FILE map_utils.py --- # map_utils.py """ Provides utility functions for map-related calculations, such as determining bounding boxes from geographic centers and sizes, and finding the necessary map tiles to cover an area using the mercantile library. """ # Standard library imports import logging import math from typing import Tuple, Optional, List, Set # <<< AGGIUNTO QUESTO IMPORT # Third-party imports try: import pyproj # For geodetic calculations except ImportError: pyproj = None try: import mercantile # For tile calculations except ImportError: mercantile = None # Local application imports # (No direct imports from other app modules needed here usually) class MapCalculationError(Exception): """Custom exception for errors during map calculations.""" pass def get_bounding_box_from_center_size( center_lat: float, center_lon: float, size_km: float ) -> Optional[ Tuple[float, float, float, float] ]: # <<< 'Optional' e 'Tuple' ora sono definiti """ Calculates the geographic bounding box (WGS84) given a center point and size. Args: center_lat (float): Latitude of the center point (degrees). center_lon (float): Longitude of the center point (degrees). size_km (float): The width and height of the desired square area in kilometers. Returns: Optional[Tuple[float, float, float, float]]: A tuple containing (west_lon, south_lat, east_lon, north_lat) in degrees, or None on error. Raises: ImportError: If pyproj is not installed. """ log_prefix = "[MapUtils BBox]" if pyproj is None: logging.critical( f"{log_prefix} 'pyproj' library is required for bounding box calculation but not found." ) raise ImportError("'pyproj' library not found.") if not (isinstance(size_km, (int, float)) and size_km > 0): logging.error( f"{log_prefix} Invalid size_km provided: {size_km}. Must be a positive number." ) return None logging.debug( f"{log_prefix} Calculating bounding box for center ({center_lat:.6f}, {center_lon:.6f}), size {size_km} km." ) try: # Define the geodetic calculator (WGS84 ellipsoid) geod = pyproj.Geod(ellps="WGS84") # Calculate half-size in meters half_size_meters = (size_km / 2.0) * 1000.0 # Calculate corner points by moving from the center along cardinals + diagonals # Azimuth angles: 0=N, 90=E, 180=S, 270=W # We need the points North, South, East, West from center to define the box easily. lon_n, lat_n, _ = geod.fwd(center_lon, center_lat, 0, half_size_meters) lon_s, lat_s, _ = geod.fwd(center_lon, center_lat, 180, half_size_meters) lon_e, lat_e, _ = geod.fwd(center_lon, center_lat, 90, half_size_meters) lon_w, lat_w, _ = geod.fwd(center_lon, center_lat, 270, half_size_meters) # The bounding box is defined by the min/max latitudes and longitudes calculated north_lat = lat_n south_lat = lat_s east_lon = lon_e west_lon = lon_w # Handle longitude wrapping around the 180 meridian (simple check) if (east_lon - west_lon) > 180: logging.warning( f"{log_prefix} Potential longitude wrap detected (East={east_lon}, West={west_lon}). BBox might be inaccurate near dateline." ) # This scenario is complex. A simple bbox might not work well. # For moderate sizes not crossing the dateline, this shouldn't happen. # If it does, we might need a more sophisticated approach or limit the area size. # For now, we proceed but log the warning. # Handle latitude crossing the pole (less likely for typical SAR sizes) if abs(north_lat) > 90 or abs(south_lat) > 90: logging.warning( f"{log_prefix} Calculated latitude exceeds +/- 90 degrees. Clamping." ) north_lat = min(north_lat, 90.0) south_lat = max(south_lat, -90.0) logging.info( f"{log_prefix} Calculated BBox: W={west_lon:.6f}, S={south_lat:.6f}, E={east_lon:.6f}, N={north_lat:.6f}" ) return (west_lon, south_lat, east_lon, north_lat) except Exception as e: logging.exception(f"{log_prefix} Error calculating bounding box:") return None def get_tile_ranges_for_bbox( bbox: Tuple[float, float, float, float], zoom: int ) -> Optional[ Tuple[Tuple[int, int], Tuple[int, int]] ]: # <<< 'Optional' e 'Tuple' ora sono definiti """ Calculates the required X and Y tile ranges for a given bounding box and zoom level. Args: bbox (Tuple[float, float, float, float]): Bounding box tuple (west_lon, south_lat, east_lon, north_lat) in degrees. zoom (int): The desired map zoom level. Returns: Optional[Tuple[Tuple[int, int], Tuple[int, int]]]: A tuple containing ((min_x, max_x), (min_y, max_y)) tile coordinates, or None on error. Raises: ImportError: If mercantile is not installed. """ log_prefix = "[MapUtils Tiles]" if mercantile is None: logging.critical( f"{log_prefix} 'mercantile' library is required for tile calculation but not found." ) raise ImportError("'mercantile' library not found.") west, south, east, north = bbox logging.debug( f"{log_prefix} Calculating tile ranges for zoom {zoom} and BBox W={west:.4f}, S={south:.4f}, E={east:.4f}, N={north:.4f}" ) try: # Use mercantile.tiles to get all tiles intersecting the bounding box # Note: mercantile expects (west, south, east, north) tiles_generator = mercantile.tiles( west, south, east, north, zooms=zoom ) # Pass zoom as integer # Collect tiles as a list to find min/max easily tiles = list(tiles_generator) if not tiles: logging.warning( f"{log_prefix} No tiles found for the given bounding box and zoom level {zoom}." ) # Attempt to find the single tile containing the center point as a fallback center_lon = (west + east) / 2.0 center_lat = (south + north) / 2.0 center_tile = mercantile.tile(center_lon, center_lat, zoom) logging.warning( f"{log_prefix} Using fallback: single tile at center ({center_lon:.4f},{center_lat:.4f}) -> {center_tile}" ) min_x = max_x = center_tile.x min_y = max_y = center_tile.y tile_count = 1 # return None # Original behavior: return None if mercantile.tiles is empty else: # Extract x and y coordinates and find min/max x_coords = [tile.x for tile in tiles] y_coords = [tile.y for tile in tiles] min_x = min(x_coords) max_x = max(x_coords) min_y = min(y_coords) max_y = max(y_coords) tile_count = len(tiles) logging.info( f"{log_prefix} Calculated tile ranges for zoom {zoom}: X=[{min_x}, {max_x}], Y=[{min_y}, {max_y}] ({tile_count} tiles)" ) return ((min_x, max_x), (min_y, max_y)) except Exception as e: logging.exception(f"{log_prefix} Error calculating tile ranges:") return None # --- END OF FILE map_utils.py ---