950 lines
38 KiB
Python
950 lines
38 KiB
Python
# flightmonitor/map/map_utils.py
|
|
|
|
import logging
|
|
import math
|
|
from typing import Tuple, Optional, List, Set, Dict, Any
|
|
|
|
try:
|
|
import pyproj
|
|
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE = True
|
|
except ImportError:
|
|
pyproj = None
|
|
PYPROJ_MODULE_LOCALLY_AVAILABLE = False
|
|
import logging
|
|
|
|
logging.warning("MapUtils: 'pyproj' not found.")
|
|
|
|
try:
|
|
import mercantile
|
|
|
|
MERCANTILE_MODULE_LOCALLY_AVAILABLE = True
|
|
except ImportError:
|
|
mercantile = None
|
|
MERCANTILE_MODULE_LOCALLY_AVAILABLE = False
|
|
import logging
|
|
|
|
logging.warning("MapUtils: 'mercantile' not found.")
|
|
|
|
try:
|
|
from ..utils.logger import get_logger
|
|
from . import map_constants
|
|
except ImportError:
|
|
logger = logging.getLogger(__name__)
|
|
logger.warning("MapUtils: App logger or map_constants not available.")
|
|
|
|
class MockMapConstants:
|
|
COORDINATE_DECIMAL_PLACES = 4
|
|
DMS_DEGREE_SYMBOL = " deg"
|
|
DMS_MINUTE_SYMBOL = " min"
|
|
DMS_SECOND_SYMBOL = " sec"
|
|
|
|
map_constants = MockMapConstants()
|
|
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
|
|
class MapCalculationError(Exception):
|
|
"""Custom exception for map calculations."""
|
|
|
|
pass
|
|
|
|
|
|
def _is_valid_bbox_dict(bbox_dict: Dict[str, Any]) -> bool:
|
|
"""Validate bounding box dict structure and ranges."""
|
|
if not isinstance(bbox_dict, dict):
|
|
logger.debug("BBox validation failed: not dict.")
|
|
return False
|
|
required_keys = ["lat_min", "lon_min", "lat_max", "lon_max"]
|
|
if not all(key in bbox_dict for key in required_keys):
|
|
logger.debug(f"BBox validation failed: missing keys {required_keys}.")
|
|
return False
|
|
|
|
try:
|
|
lat_min = float(bbox_dict["lat_min"])
|
|
lon_min = float(bbox_dict["lon_min"])
|
|
lat_max = float(bbox_dict["lat_max"])
|
|
lon_max = float(bbox_dict["lon_max"])
|
|
|
|
if not (
|
|
-90.0 <= lat_min <= 90.0
|
|
and -90.0 <= lat_max <= 90.0
|
|
and -180.0 <= lon_min <= 180.0
|
|
and -180.0 <= lon_max <= 180.0
|
|
):
|
|
logger.debug(
|
|
f"BBox validation failed: coordinates out of range {bbox_dict}."
|
|
)
|
|
return False
|
|
if lat_min >= lat_max:
|
|
logger.debug(
|
|
f"BBox validation failed: lat_min ({lat_min}) >= lat_max ({lat_max})."
|
|
)
|
|
return False
|
|
# Note: For services not supporting antimeridian crossing, lon_min must be < lon_max.
|
|
# Current validation keeps this check for OpenSky compatibility.
|
|
if lon_min >= lon_max:
|
|
logger.debug(
|
|
f"BBox validation failed: lon_min ({lon_min}) >= lon_max ({lon_max})."
|
|
)
|
|
return False
|
|
if not all(
|
|
math.isfinite(coord) for coord in [lat_min, lon_min, lat_max, lon_max]
|
|
):
|
|
logger.debug(f"BBox validation failed: non-finite values {bbox_dict}.")
|
|
return False
|
|
|
|
return True
|
|
except (ValueError, TypeError) as e:
|
|
logger.debug(
|
|
f"BBox validation failed: invalid types/values {bbox_dict}. Error: {e}"
|
|
)
|
|
return False
|
|
except Exception as e:
|
|
logger.error(
|
|
f"Unexpected error validating BBox: {bbox_dict}. Error: {e}", exc_info=True
|
|
)
|
|
return False
|
|
|
|
|
|
def get_bounding_box_from_center_size(
|
|
center_latitude_deg: float, center_longitude_deg: float, area_size_km: float
|
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
"""Calc WGS84 BBox from center and square size (km). Requires pyproj."""
|
|
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None:
|
|
logger.error("'pyproj' required for BBox from center/size.")
|
|
return None
|
|
|
|
if not (isinstance(area_size_km, (int, float)) and area_size_km > 0):
|
|
logger.error(f"Invalid area_size_km: {area_size_km}.")
|
|
return None
|
|
if not (
|
|
isinstance(center_latitude_deg, (int, float))
|
|
and -90.0 <= center_latitude_deg <= 90.0
|
|
):
|
|
logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}.")
|
|
return None
|
|
if not (
|
|
isinstance(center_longitude_deg, (int, float))
|
|
and -180.0 <= center_longitude_deg <= 180.0
|
|
):
|
|
logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}.")
|
|
return None
|
|
|
|
try:
|
|
geodetic_calculator = pyproj.Geod(ellps="WGS84")
|
|
half_side_m = (area_size_km / 2.0) * 1000.0
|
|
|
|
_, north_lat, _ = geodetic_calculator.fwd(
|
|
center_longitude_deg, center_latitude_deg, 0.0, half_side_m
|
|
)
|
|
_, south_lat, _ = geodetic_calculator.fwd(
|
|
center_longitude_deg, center_latitude_deg, 180.0, half_side_m
|
|
)
|
|
east_lon, _, _ = geodetic_calculator.fwd(
|
|
center_longitude_deg, center_latitude_deg, 90.0, half_side_m
|
|
)
|
|
west_lon, _, _ = geodetic_calculator.fwd(
|
|
center_longitude_deg, center_latitude_deg, 270.0, half_side_m
|
|
)
|
|
|
|
north_lat = min(90.0, max(-90.0, north_lat))
|
|
south_lat = min(90.0, max(-90.0, south_lat))
|
|
|
|
if not all(
|
|
math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat]
|
|
):
|
|
logger.error("Calculated BBox contains non-finite values.")
|
|
return None
|
|
|
|
return (west_lon, south_lat, east_lon, north_lat)
|
|
|
|
except Exception as e_bbox_calc:
|
|
logger.exception(f"Error calculating BBox from center/size: {e_bbox_calc}")
|
|
return None
|
|
|
|
|
|
def get_tile_ranges_for_bbox(
|
|
bounding_box_deg: Tuple[float, float, float, float],
|
|
zoom_level: int,
|
|
) -> Optional[Tuple[Tuple[int, int], Tuple[int, int]]]:
|
|
"""Calc X/Y tile ranges covering BBox at zoom. Requires mercantile."""
|
|
if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
|
|
logger.error("'mercantile' required for tile ranges.")
|
|
return None
|
|
|
|
west_lon, south_lat, east_lon, north_lat = bounding_box_deg
|
|
|
|
clamped_south_lat = max(-85.05112877, min(85.05112877, south_lat))
|
|
clamped_north_lat = max(-85.05112877, min(85.05112877, north_lat))
|
|
if clamped_south_lat >= clamped_north_lat:
|
|
logger.warning(
|
|
f"Clamped lat range invalid: S={clamped_south_lat:.4f}, N={clamped_north_lat:.4f}. Cannot get tile ranges."
|
|
)
|
|
return None
|
|
|
|
if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25):
|
|
logger.error(f"Invalid zoom level: {zoom_level}.")
|
|
return None
|
|
|
|
try:
|
|
# mercantile.tiles handles longitude wrapping automatically
|
|
tiles_in_bbox_generator = mercantile.tiles(
|
|
west_lon, clamped_south_lat, east_lon, clamped_north_lat, zooms=[zoom_level]
|
|
)
|
|
list_of_tiles = list(tiles_in_bbox_generator)
|
|
|
|
if not list_of_tiles:
|
|
logger.warning(
|
|
f"No tiles found by mercantile for BBox at zoom {zoom_level}. Using fallback: tile at BBox center."
|
|
)
|
|
center_lon = (west_lon + east_lon) / 2.0
|
|
# Handle antimeridian crossing for center longitude
|
|
if west_lon > east_lon:
|
|
center_lon = (west_lon + east_lon + 360) / 2.0
|
|
if center_lon > 180:
|
|
center_lon -= 360
|
|
|
|
center_lat = (south_lat + north_lat) / 2.0
|
|
center_lat = max(-85.05112877, min(85.05112877, center_lat))
|
|
|
|
try:
|
|
center_tile = mercantile.tile(center_lon, center_lat, zoom_level)
|
|
min_x, max_x = center_tile.x, center_tile.x
|
|
min_y, max_y = center_tile.y, center_tile.y
|
|
logger.debug(
|
|
f"Fallback: Using tile at BBox center ({center_lon:.4f}, {center_lat:.4f}): ({min_x}, {min_y}, {zoom_level})."
|
|
)
|
|
except Exception as e_center_tile:
|
|
logger.error(
|
|
f"Fallback failed: Error getting tile for BBox center: {e_center_tile}",
|
|
exc_info=True,
|
|
)
|
|
return None
|
|
else:
|
|
x_coordinates = [tile.x for tile in list_of_tiles]
|
|
y_coordinates = [tile.y for tile in list_of_tiles]
|
|
min_x, max_x = min(x_coordinates), max(x_coordinates)
|
|
min_y, max_y = min(y_coordinates), max(y_coordinates)
|
|
|
|
return ((min_x, max_x), (min_y, max_y))
|
|
|
|
except Exception as e_tile_range_calc:
|
|
logger.exception(f"Error calculating tile ranges: {e_tile_range_calc}")
|
|
return None
|
|
|
|
|
|
def calculate_meters_per_pixel(
|
|
latitude_degrees: float, zoom_level: int, tile_pixel_size: int = 256
|
|
) -> Optional[float]:
|
|
"""Calc approx ground resolution (m/px) at lat/zoom (Web Mercator)."""
|
|
try:
|
|
if not (
|
|
isinstance(latitude_degrees, (int, float))
|
|
and -90.0 <= latitude_degrees <= 90.0
|
|
):
|
|
logger.warning(f"Invalid latitude for m/px calc: {latitude_degrees}")
|
|
return None
|
|
if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25):
|
|
logger.warning(f"Invalid zoom level for m/px calc: {zoom_level}")
|
|
return None
|
|
if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0):
|
|
logger.warning(f"Invalid tile_pixel_size for m/px calc: {tile_pixel_size}")
|
|
return None
|
|
|
|
EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
|
clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees))
|
|
lat_radians = math.radians(clamped_lat)
|
|
|
|
denominator = tile_pixel_size * (2**zoom_level)
|
|
if denominator <= 1e-9:
|
|
logger.warning("Denominator for m/px calc is zero/near-zero.")
|
|
return None
|
|
|
|
resolution_m_px = (
|
|
EARTH_CIRCUMFERENCE_METERS * math.cos(lat_radians)
|
|
) / denominator
|
|
|
|
if not math.isfinite(resolution_m_px) or resolution_m_px <= 0:
|
|
logger.warning(
|
|
f"Calculated non-finite/non-positive m/px ({resolution_m_px}) at Lat {latitude_degrees}."
|
|
)
|
|
return None
|
|
|
|
return resolution_m_px
|
|
|
|
except Exception as e_mpp_calc:
|
|
logger.exception(f"Error calculating meters per pixel: {e_mpp_calc}")
|
|
return None
|
|
|
|
|
|
def calculate_geographic_bbox_size_km(
|
|
bounding_box_deg: Tuple[float, float, float, float],
|
|
) -> Optional[Tuple[float, float]]:
|
|
"""Calc approx W/H (km) of BBox. Requires pyproj."""
|
|
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None:
|
|
logger.error("'pyproj' required for BBox size.")
|
|
return None
|
|
|
|
west_lon, south_lat, east_lon, north_lat = bounding_box_deg
|
|
|
|
if not (
|
|
isinstance(west_lon, (int, float))
|
|
and isinstance(south_lat, (int, float))
|
|
and isinstance(east_lon, (int, float))
|
|
and isinstance(north_lat, (int, float))
|
|
):
|
|
logger.warning(f"Invalid coordinate types for size calc: {bounding_box_deg}.")
|
|
return None
|
|
if not (-90.0 <= south_lat <= north_lat <= 90.0):
|
|
logger.warning(
|
|
f"Invalid lat range for size calc: {south_lat}, {north_lat}. Clamping."
|
|
)
|
|
south_lat = max(-90.0, south_lat)
|
|
north_lat = min(90.0, north_lat)
|
|
if south_lat >= north_lat:
|
|
logger.error(f"Invalid lat range after clamping: {south_lat}, {north_lat}.")
|
|
return None
|
|
|
|
try:
|
|
geod = pyproj.Geod(ellps="WGS84")
|
|
center_lat = (south_lat + north_lat) / 2.0
|
|
center_lat = max(-89.9, min(89.9, center_lat))
|
|
|
|
# Calculate width along the center latitude. Handles antimeridian crossing correctly in pyproj.Geod.inv.
|
|
_, _, width_m = geod.inv(west_lon, center_lat, east_lon, center_lat)
|
|
width_m = abs(width_m)
|
|
|
|
# Calculate height along the center longitude (or 0/180 meridian if BBox crosses).
|
|
# For simplicity, using center longitude here.
|
|
center_lon_for_height = (west_lon + east_lon) / 2.0
|
|
if west_lon > east_lon:
|
|
center_lon_for_height = (west_lon + east_lon + 360) / 2.0
|
|
if center_lon_for_height > 180:
|
|
center_lon_for_height -= 360
|
|
|
|
center_lon_for_height = max(
|
|
-179.9, min(179.9, center_lon_for_height)
|
|
) # Clamp for geod.inv
|
|
|
|
_, _, height_m = geod.inv(
|
|
center_lon_for_height, south_lat, center_lon_for_height, north_lat
|
|
)
|
|
height_m = abs(height_m)
|
|
|
|
approx_width_km = width_m / 1000.0
|
|
approx_height_km = height_m / 1000.0
|
|
|
|
if approx_width_km <= 1e-9 or approx_height_km <= 1e-9:
|
|
logger.warning(
|
|
f"Calc non-positive W/H for BBox {bounding_box_deg}: ({approx_width_km:.6f}, {approx_height_km:.6f})."
|
|
)
|
|
return None
|
|
if not all(math.isfinite(val) for val in [approx_width_km, approx_height_km]):
|
|
logger.warning(
|
|
f"Calc non-finite W/H: ({approx_width_km}, {approx_height_km})."
|
|
)
|
|
return None
|
|
|
|
return (approx_width_km, approx_height_km)
|
|
|
|
except Exception as e_size_calc:
|
|
logger.exception(f"Error calculating BBox size: {e_size_calc}")
|
|
return None
|
|
|
|
|
|
def get_hgt_tile_geographic_bounds(
|
|
lat_coord: int, lon_coord: int
|
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
"""Calc WGS84 BBox (W,S,E,N) for HGT tile integer coords (S, W)."""
|
|
if not (isinstance(lat_coord, int) and isinstance(lon_coord, int)):
|
|
logger.warning(
|
|
f"Invalid input types for HGT bounds: {type(lat_coord)}, {type(lon_coord)}."
|
|
)
|
|
return None
|
|
# Note: HGT tile coordinates are typically S/W corner integers, e.g., N007E008 is lat_coord=7, lon_coord=8.
|
|
# But ranges are [-90, 89] for lat, [-180, 179] for lon based on standard grid.
|
|
if not (-90 <= lat_coord <= 89):
|
|
logger.warning(f"Lat coord {lat_coord} outside expected HGT range [-90, 89].")
|
|
if not (-180 <= lon_coord <= 179):
|
|
logger.warning(f"Lon coord {lon_coord} outside expected HGT range [-180, 179].")
|
|
|
|
west_lon = float(lon_coord)
|
|
south_lat = float(lat_coord)
|
|
east_lon = float(lon_coord + 1)
|
|
north_lat = float(lat_coord + 1)
|
|
|
|
# Clamp to valid WGS84 ranges defensively
|
|
west_lon = max(-180.0, west_lon)
|
|
south_lat = max(-90.0, south_lat)
|
|
east_lon = min(180.0, east_lon)
|
|
north_lat = min(90.0, north_lat)
|
|
|
|
if south_lat >= north_lat:
|
|
logger.warning(
|
|
f"Calc HGT bounds have invalid lat range: S={south_lat}, N={north_lat}."
|
|
)
|
|
return None
|
|
# Longitude wrapping for a single HGT tile shouldn't happen unless input coords are out of range initially.
|
|
# The check `west_lon > east_lon` is only meaningful if not crossing antimeridian.
|
|
# For HGT tiles, they represent 1x1 degree squares not crossing antimeridian.
|
|
# if west_lon > east_lon and abs(west_lon - east_lon) > 1e-9: logger.warning(f"Calc HGT bounds have W > E: W={west_lon}, E={east_lon}."); return None
|
|
|
|
return (west_lon, south_lat, east_lon, north_lat)
|
|
|
|
|
|
def calculate_zoom_level_for_geographic_size(
|
|
latitude_degrees: float,
|
|
geographic_height_meters: float,
|
|
target_pixel_height: int,
|
|
tile_pixel_size: int = 256,
|
|
) -> Optional[int]:
|
|
"""Calc Web Mercator zoom for geographic height to fit pixel height."""
|
|
if not (
|
|
isinstance(latitude_degrees, (int, float)) and -90.0 <= latitude_degrees <= 90.0
|
|
):
|
|
logger.warning(f"Invalid latitude for zoom calc: {latitude_degrees}")
|
|
return None
|
|
if not (
|
|
isinstance(geographic_height_meters, (int, float))
|
|
and geographic_height_meters > 0
|
|
):
|
|
logger.warning(f"Invalid geographic_height_meters: {geographic_height_meters}.")
|
|
return None
|
|
if not (isinstance(target_pixel_height, int) and target_pixel_height > 0):
|
|
logger.warning(f"Invalid target_pixel_height: {target_pixel_height}.")
|
|
return None
|
|
if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0):
|
|
logger.warning(f"Invalid tile_pixel_size: {tile_pixel_size}.")
|
|
return None
|
|
|
|
try:
|
|
req_res_m_px = geographic_height_meters / target_pixel_height
|
|
if req_res_m_px <= 1e-9 or not math.isfinite(req_res_m_px):
|
|
logger.warning(
|
|
f"Calc non-positive/non-finite required resolution ({req_res_m_px})."
|
|
)
|
|
return None
|
|
|
|
EARTH_CIRCUMFERENCE_METERS = 40075016.686
|
|
clamped_lat = max(-85.05112878, min(85.05112878, latitude_degrees))
|
|
lat_radians = math.radians(clamped_lat)
|
|
cos_lat = math.cos(lat_radians)
|
|
|
|
if abs(cos_lat) < 1e-9:
|
|
logger.warning(
|
|
f"Latitude {latitude_degrees} too close to pole for reliable zoom calc."
|
|
)
|
|
return None
|
|
|
|
if tile_pixel_size <= 0:
|
|
logger.error("Invalid tile_pixel_size (<= 0).")
|
|
return None
|
|
|
|
# res_m_px = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * 2^Z)
|
|
# 2^Z = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (tile_pixel_size * req_res_m_px)
|
|
# Z = log2(...)
|
|
term_for_log = (EARTH_CIRCUMFERENCE_METERS * cos_lat) / (
|
|
tile_pixel_size * req_res_m_px
|
|
)
|
|
if term_for_log <= 1e-9:
|
|
logger.warning(f"Calc non-positive term for log2 ({term_for_log:.2e}).")
|
|
return None
|
|
|
|
precise_zoom = math.log2(term_for_log)
|
|
integer_zoom = int(round(precise_zoom))
|
|
|
|
clamped_zoom = max(
|
|
0, min(integer_zoom, 20)
|
|
) # Clamp to 0-20 range (arbitrary upper cap, services vary)
|
|
|
|
return clamped_zoom
|
|
except Exception as e_zoom_calc:
|
|
logger.exception(f"Error calculating zoom level: {e_zoom_calc}")
|
|
return None
|
|
|
|
|
|
def deg_to_dms_string(degree_value: float, coord_type: str) -> str:
|
|
"""Convert decimal degrees to DMS string."""
|
|
if not isinstance(degree_value, (int, float)) or not math.isfinite(degree_value):
|
|
return "N/A"
|
|
if coord_type.lower() not in ("lat", "lon"):
|
|
logger.warning(f"Unknown coord type '{coord_type}'.")
|
|
return "N/A (Invalid Type)"
|
|
|
|
val_for_suffix = degree_value
|
|
if coord_type.lower() == "lat":
|
|
if not -90.0 <= degree_value <= 90.0:
|
|
logger.warning(
|
|
f"Lat {degree_value} outside range [-90, 90] for suffix. Clamping."
|
|
)
|
|
val_for_suffix = max(-90.0, min(90.0, degree_value))
|
|
elif coord_type.lower() == "lon":
|
|
val_for_suffix = (degree_value + 180) % 360 - 180
|
|
if val_for_suffix == -180.0 and degree_value != -180.0:
|
|
val_for_suffix = 180.0
|
|
|
|
abs_deg = abs(degree_value)
|
|
degrees = int(abs_deg)
|
|
minutes_decimal = (abs_deg - degrees) * 60
|
|
minutes = int(minutes_decimal)
|
|
seconds = (minutes_decimal - minutes) * 60
|
|
|
|
# Round seconds and carry over if necessary
|
|
seconds = round(seconds, 2)
|
|
if seconds >= 60.0: # Can happen after rounding, e.g., 59.996 rounds to 60.00
|
|
seconds = 0.0
|
|
minutes += 1
|
|
|
|
if minutes >= 60:
|
|
minutes = 0
|
|
degrees += 1
|
|
|
|
# Handle degrees reaching 90 or 180 due to rounding, for suffix logic
|
|
if coord_type.lower() == "lat" and degrees > 90:
|
|
degrees = 90
|
|
elif coord_type.lower() == "lon" and degrees > 180:
|
|
degrees = 180
|
|
|
|
suffix = ""
|
|
if coord_type.lower() == "lat":
|
|
suffix = "N" if val_for_suffix >= 0 else "S"
|
|
elif coord_type.lower() == "lon":
|
|
suffix = "E" if val_for_suffix >= 0 else "W"
|
|
if val_for_suffix == 0.0:
|
|
suffix = "" # 0 longitude doesn't need E/W
|
|
|
|
try: # Use constants for symbols
|
|
dms_string = f"{degrees}{map_constants.DMS_DEGREE_SYMBOL} {minutes}{map_constants.DMS_MINUTE_SYMBOL} {seconds:.2f}{map_constants.DMS_SECOND_SYMBOL}"
|
|
except AttributeError:
|
|
logger.warning("map_constants missing DMS symbols, using plain text.")
|
|
dms_string = f"{degrees} Deg {minutes} Min {seconds:.2f} Sec"
|
|
except Exception as e:
|
|
logger.error(f"Error formatting DMS string: {e}. Plain text.", exc_info=False)
|
|
dms_string = f"{degrees} Deg {minutes} Min {seconds:.2f} Sec"
|
|
|
|
if suffix:
|
|
dms_string += f" {suffix}"
|
|
return dms_string
|
|
|
|
|
|
def get_combined_geographic_bounds_from_tile_info_list(
|
|
tile_info_list: List[Dict],
|
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
"""Calc min BBox encompassing all tiles in list."""
|
|
if not tile_info_list:
|
|
logger.warning("Tile info list empty for combined bounds.")
|
|
return None
|
|
|
|
min_lon, min_lat, max_lon, max_lat = 180.0, 90.0, -180.0, -90.0
|
|
initialized = False
|
|
|
|
for tile_info in tile_info_list:
|
|
lat_coord = tile_info.get("latitude_coord")
|
|
lon_coord = tile_info.get("longitude_coord")
|
|
if lat_coord is None or lon_coord is None:
|
|
logger.warning(f"Skipping tile info: missing coords {tile_info}.")
|
|
continue
|
|
|
|
try:
|
|
tile_bounds = get_hgt_tile_geographic_bounds(int(lat_coord), int(lon_coord))
|
|
if tile_bounds:
|
|
if not initialized:
|
|
min_lon, min_lat, max_lon, max_lat = tile_bounds
|
|
initialized = True
|
|
else:
|
|
min_lon = min(min_lon, tile_bounds[0])
|
|
min_lat = min(min_lat, tile_bounds[1])
|
|
max_lon = max(max_lon, tile_bounds[2])
|
|
max_lat = max(max_lat, tile_bounds[3])
|
|
else:
|
|
logger.warning(
|
|
f"Could not get geo bounds for tile ({lat_coord},{lon_coord}), skipping."
|
|
)
|
|
except (ValueError, TypeError) as e:
|
|
logger.warning(
|
|
f"Invalid coord type in tile info ({lat_coord},{lon_coord}): {e}. Skipping.",
|
|
exc_info=False,
|
|
)
|
|
except Exception as e:
|
|
logger.warning(
|
|
f"Error getting bounds for tile ({lat_coord},{lon_coord}): {e}. Skipping.",
|
|
exc_info=True,
|
|
)
|
|
|
|
if not initialized:
|
|
logger.warning("No valid tiles in list for combined bounds.")
|
|
return None
|
|
if min_lat >= max_lat:
|
|
logger.warning(f"Calc invalid combined lat range: S={min_lat}, N={max_lat}.")
|
|
return None
|
|
# Longitude wrapping in combined bounds from multiple tiles is possible, but harder to represent as simple (W,S,E,N) tuple.
|
|
# The current min/max calculation assumes no wrapping. If tiles cross antimeridian, this needs refinement.
|
|
# Given the expected use case (local map views), assuming non-wrapping longitude range is usually fine.
|
|
|
|
return (min_lon, min_lat, max_lon, max_lat)
|
|
|
|
|
|
def calculate_geographic_bbox_from_pixel_size_and_zoom(
|
|
center_latitude_deg: float,
|
|
center_longitude_deg: float,
|
|
target_pixel_width: int,
|
|
target_pixel_height: int,
|
|
zoom_level: int,
|
|
tile_pixel_size: int = 256,
|
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
"""Calc geo BBox centered at point corresponding to pixel size/zoom."""
|
|
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None:
|
|
logger.error("'pyproj' required for BBox from pixel size/zoom.")
|
|
return None
|
|
if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
|
|
logger.error(
|
|
"'mercantile' required for BBox from pixel size/zoom (for mercator conversion)."
|
|
)
|
|
return None
|
|
|
|
if not (
|
|
isinstance(center_latitude_deg, (int, float))
|
|
and -90.0 <= center_latitude_deg <= 90.0
|
|
):
|
|
logger.error(f"Invalid center_latitude_deg: {center_latitude_deg}.")
|
|
return None
|
|
if not (
|
|
isinstance(center_longitude_deg, (int, float))
|
|
and -180.0 <= center_longitude_deg <= 180.0
|
|
):
|
|
logger.error(f"Invalid center_longitude_deg: {center_longitude_deg}.")
|
|
return None
|
|
if not (
|
|
isinstance(target_pixel_width, int)
|
|
and target_pixel_width > 0
|
|
and isinstance(target_pixel_height, int)
|
|
and target_pixel_height > 0
|
|
):
|
|
logger.error(
|
|
f"Invalid pixel dims ({target_pixel_width}x{target_pixel_height})."
|
|
)
|
|
return None
|
|
if not (isinstance(zoom_level, int) and 0 <= zoom_level <= 25):
|
|
logger.warning(f"Invalid zoom level: {zoom_level}. Clamping to.")
|
|
zoom_level = max(0, min(zoom_level, 25)) # Clamp zoom level
|
|
if not (isinstance(tile_pixel_size, int) and tile_pixel_size > 0):
|
|
logger.error(f"Invalid tile_pixel_size: {tile_pixel_size}.")
|
|
return None
|
|
|
|
try:
|
|
# Convert center geo to mercator pixel at zoom 0
|
|
# Origin (0,0) in mercator pixel space at zoom 0 is the top-left corner of the earth map.
|
|
# X increases east, Y increases south. Total size is 256x256 pixels at zoom 0.
|
|
# Using the tile_pixel_size gives the size relative to that.
|
|
origin_mercator_x_at_z0, origin_mercator_y_at_z0 = mercantile.xy(
|
|
-180.0, 85.05112878
|
|
) # Top-left corner of web mercator projection
|
|
center_mercator_x, center_mercator_y = mercantile.xy(
|
|
center_longitude_deg, center_latitude_deg
|
|
)
|
|
|
|
# Calculate the pixel coordinates of the center point at the target zoom level
|
|
# Pixel coordinates at zoom Z are related to mercator coordinates and tile size.
|
|
# Total earth pixel size at zoom Z is tile_pixel_size * 2^Z
|
|
total_earth_pixels_z = tile_pixel_size * (2**zoom_level)
|
|
|
|
# Mercator X/Y range from (-PI * R, PI * R)
|
|
# Total width/height in mercator units is 2 * PI * R (~40075016.686 meters)
|
|
# Resolution at equator at zoom Z is (2 * PI * R) / (tile_pixel_size * 2^Z) meters/pixel
|
|
# A simpler way using mercantile x/y:
|
|
# X pixel = (mercator_x - mercator_min_x) / mercator_total_width * total_earth_pixels_z
|
|
# Y pixel = (mercator_max_y - mercator_y) / mercator_total_height * total_earth_pixels_z (Y is inverted)
|
|
|
|
# mercantile.xy returns coordinates in "Web Mercator" units (meters), relative to the origin of the projection.
|
|
# The origin of the projection is at (-180, +85.0511) geographic, corresponding to mercator (-20037508.34, 20037508.34).
|
|
# The full extent is 2 * 20037508.34 meters in both directions.
|
|
mercator_extent_meters = (
|
|
20037508.34 * 2
|
|
) # Total width/height of the Mercator projection in meters
|
|
|
|
center_pixel_x_z0 = (
|
|
(center_mercator_x - (-mercator_extent_meters / 2))
|
|
/ mercator_extent_meters
|
|
* tile_pixel_size
|
|
) # Pixel relative to 256x256 tile at z0
|
|
center_pixel_y_z0 = (
|
|
((mercator_extent_meters / 2) - center_mercator_y)
|
|
/ mercator_extent_meters
|
|
* tile_pixel_size
|
|
) # Pixel relative to 256x256 tile at z0 (inverted Y)
|
|
|
|
# Pixel coordinates at zoom Z are pixel_at_z0 * 2^Z
|
|
center_pixel_x_z = center_pixel_x_z0 * (2**zoom_level)
|
|
center_pixel_y_z = center_pixel_y_z0 * (2**zoom_level)
|
|
|
|
# The target BBox pixel center is (target_pixel_width/2, target_pixel_height/2) relative to its top-left corner.
|
|
# The top-left pixel of the target BBox corresponds to a mercator coordinate.
|
|
# Let BBox_center_pixel_x, BBox_center_pixel_y be the center pixel of the *rendered* BBox (at the center of the canvas).
|
|
# The top-left pixel of the BBox is (BBox_center_pixel_x - target_pixel_width/2, BBox_center_pixel_y - target_pixel_height/2)
|
|
# The bottom-right pixel is (BBox_center_pixel_x + target_pixel_width/2, BBox_center_pixel_y + target_pixel_height/2)
|
|
# Assuming the stitched image is centered on the geo coordinate center_latitude_deg, center_longitude_deg,
|
|
# the center of the stitched image is at pixel (img_width/2, img_height/2).
|
|
# The pixel coordinates relative to the *top-left of the entire stitched map at zoom Z*
|
|
# that corresponds to the target BBox's top-left corner would be:
|
|
# ul_pixel_x_z = center_pixel_x_z - target_pixel_width / 2.0
|
|
# ul_pixel_y_z = center_pixel_y_z - target_pixel_height / 2.0
|
|
# lr_pixel_x_z = center_pixel_x_z + target_pixel_width / 2.0
|
|
# lr_pixel_y_z = center_pixel_y_z + target_pixel_height / 2.0
|
|
|
|
# Convert these pixel coordinates back to mercator, then to geo.
|
|
# mercator_x = mercator_min_x + (pixel_x_z / total_earth_pixels_z) * mercator_total_width
|
|
# mercator_y = mercator_max_y - (pixel_y_z / total_earth_pixels_z) * mercator_total_height # Inverted Y
|
|
|
|
ul_pixel_x_z = center_pixel_x_z - target_pixel_width / 2.0
|
|
ul_pixel_y_z = center_pixel_y_z - target_pixel_height / 2.0
|
|
lr_pixel_x_z = center_pixel_x_z + target_pixel_width / 2.0
|
|
lr_pixel_y_z = center_pixel_y_z + target_pixel_height / 2.0
|
|
|
|
ul_mercator_x = (-mercator_extent_meters / 2) + (
|
|
ul_pixel_x_z / total_earth_pixels_z
|
|
) * mercator_extent_meters
|
|
ul_mercator_y = (mercator_extent_meters / 2) - (
|
|
ul_pixel_y_z / total_earth_pixels_z
|
|
) * mercator_extent_meters # Inverted Y
|
|
|
|
lr_mercator_x = (-mercator_extent_meters / 2) + (
|
|
lr_pixel_x_z / total_earth_pixels_z
|
|
) * mercator_extent_meters
|
|
lr_mercator_y = (mercator_extent_meters / 2) - (
|
|
lr_pixel_y_z / total_earth_pixels_z
|
|
) * mercator_extent_meters # Inverted Y
|
|
|
|
# Convert mercator corners back to geo (lon, lat)
|
|
ul_lon, ul_lat = mercantile.lnglat(ul_mercator_x, ul_mercator_y)
|
|
lr_lon, lr_lat = mercantile.lnglat(lr_mercator_x, lr_mercator_y)
|
|
|
|
# The bounding box is (west_lon, south_lat, east_lon, north_lat)
|
|
west_lon = ul_lon
|
|
north_lat = ul_lat
|
|
east_lon = lr_lon
|
|
south_lat = lr_lat
|
|
|
|
# Ensure latitudes are within valid range for WGS84
|
|
north_lat = min(90.0, max(-90.0, north_lat))
|
|
south_lat = min(90.0, max(-90.0, south_lat))
|
|
|
|
# Handle longitude wrapping (e.g., if the canvas width spans the antimeridian)
|
|
# mercantile.lnglat handles the -180/180 wrap, but the calculated east_lon might be < west_lon.
|
|
# For consistency, ensure west_lon <= east_lon if not crossing antimeridian.
|
|
# If it crosses, the standard (W,S,E,N) tuple doesn't represent it well.
|
|
# Given our assumption for OpenSky, we expect non-wrapping BBoxes.
|
|
# If lr_lon < ul_lon, it might indicate wrapping, but for a single canvas view,
|
|
# we usually expect ul_lon < lr_lon unless the canvas is extremely wide and centered on antimeridian.
|
|
# For this calculation, we just return the calculated ul_lon and lr_lon as west and east.
|
|
|
|
if abs(west_lon - east_lon) < 1e-9 or abs(south_lat - north_lat) < 1e-9:
|
|
logger.warning("Calc zero-size geo BBox from pixel size/zoom.")
|
|
return None
|
|
if not all(
|
|
math.isfinite(coord) for coord in [west_lon, south_lat, east_lon, north_lat]
|
|
):
|
|
logger.error(
|
|
"Calc geo BBox from pixel size/zoom contains non-finite values."
|
|
)
|
|
return None
|
|
|
|
return (west_lon, south_lat, east_lon, north_lat)
|
|
|
|
except Exception as e_bbox_calc:
|
|
logger.exception(
|
|
f"Error calculating geo BBox from pixel size/zoom: {e_bbox_calc}"
|
|
)
|
|
return None
|
|
|
|
|
|
def calculate_inner_geographic_bbox(
|
|
outer_bbox_deg: Tuple[float, float, float, float],
|
|
reduction_distance_km: float,
|
|
) -> Optional[Tuple[float, float, float, float]]:
|
|
"""Calc inner geo BBox by reducing outer BBox by distance (km). Requires pyproj."""
|
|
if not PYPROJ_MODULE_LOCALLY_AVAILABLE or pyproj is None:
|
|
logger.error("'pyproj' required for inner BBox.")
|
|
return None
|
|
|
|
west_lon, south_lat, east_lon, north_lat = outer_bbox_deg
|
|
reduct_dist_m = reduction_distance_km * 1000.0
|
|
|
|
if not (
|
|
isinstance(reduction_distance_km, (int, float)) and reduction_distance_km >= 0
|
|
):
|
|
logger.error(f"Invalid reduct_dist_km: {reduction_distance_km}.")
|
|
return None
|
|
if not (
|
|
isinstance(west_lon, (int, float))
|
|
and isinstance(south_lat, (int, float))
|
|
and isinstance(east_lon, (int, float))
|
|
and isinstance(north_lat, (int, float))
|
|
):
|
|
logger.warning(f"Invalid coord types for outer_bbox: {outer_bbox_deg}.")
|
|
return None
|
|
if not (-90.0 <= south_lat <= north_lat <= 90.0):
|
|
logger.warning(f"Invalid lat range in outer_bbox: {south_lat}, {north_lat}.")
|
|
return None
|
|
if not (-180.0 <= west_lon <= 180.0 and -180.0 <= east_lon <= 180.0):
|
|
logger.warning(f"Invalid lon range in outer_bbox: {west_lon}, {east_lon}.")
|
|
return None # Assuming non-wrapping input BBox
|
|
|
|
if reduction_distance_km == 0:
|
|
return outer_bbox_deg
|
|
|
|
try:
|
|
geod = pyproj.Geod(ellps="WGS84")
|
|
outer_size_km = calculate_geographic_bbox_size_km(outer_bbox_deg)
|
|
if outer_size_km is None:
|
|
logger.error("Could not calc outer BBox size for inner BBox.")
|
|
return None
|
|
|
|
outer_width_km, outer_height_km = outer_size_km
|
|
inner_width_km = outer_width_km - (2 * reduction_distance_km)
|
|
inner_height_km = outer_height_km - (2 * reduction_distance_km)
|
|
|
|
if inner_width_km <= 1e-9 or inner_height_km <= 1e-9:
|
|
logger.warning(
|
|
f"Reduct dist {reduction_distance_km}km too large. Inner BBox size {inner_width_km:.2f}x{inner_height_km:.2f}km is zero or negative."
|
|
)
|
|
return None
|
|
|
|
center_lat = (south_lat + north_lat) / 2.0
|
|
center_lon = (west_lon + east_lon) / 2.0
|
|
if west_lon > east_lon:
|
|
center_lon = (west_lon + east_lon + 360) / 2.0
|
|
if center_lon > 180:
|
|
center_lon -= 360
|
|
|
|
center_lat_clamped = max(-89.9, min(89.9, center_lat))
|
|
center_lon_clamped = max(
|
|
-179.9, min(179.9, center_lon)
|
|
) # Clamp center_lon for fwd if needed
|
|
|
|
half_inner_width_m = inner_width_km * 500.0
|
|
half_inner_height_m = inner_height_km * 500.0
|
|
|
|
# Calculate inner BBox corners by moving from the center
|
|
_, inner_north_lat, _ = geod.fwd(
|
|
center_lon_clamped, center_lat_clamped, 0.0, half_inner_height_m
|
|
)
|
|
_, inner_south_lat, _ = geod.fwd(
|
|
center_lon_clamped, center_lat_clamped, 180.0, half_inner_height_m
|
|
)
|
|
inner_east_lon, _, _ = geod.fwd(
|
|
center_lon_clamped, center_lat_clamped, 90.0, half_inner_width_m
|
|
)
|
|
inner_west_lon, _, _ = geod.fwd(
|
|
center_lon_clamped, center_lat_clamped, 270.0, half_inner_width_m
|
|
)
|
|
|
|
inner_north_lat = min(90.0, max(-90.0, inner_north_lat))
|
|
inner_south_lat = min(90.0, max(-90.0, inner_south_lat))
|
|
|
|
if (
|
|
abs(inner_west_lon - inner_east_lon) < 1e-9
|
|
or abs(inner_south_lat - inner_north_lat) < 1e-9
|
|
):
|
|
logger.warning("Calc zero-size inner geo BBox.")
|
|
return None
|
|
if not all(
|
|
math.isfinite(coord)
|
|
for coord in [
|
|
inner_west_lon,
|
|
inner_south_lat,
|
|
inner_east_lon,
|
|
inner_north_lat,
|
|
]
|
|
):
|
|
logger.error("Calc inner geo BBox contains non-finite values.")
|
|
return None
|
|
|
|
return (inner_west_lon, inner_south_lat, inner_east_lon, inner_north_lat)
|
|
|
|
except Exception as e_inner_bbox_calc:
|
|
logger.exception(f"Error calculating inner geo BBox: {e_inner_bbox_calc}")
|
|
return None
|
|
|
|
|
|
def _pixel_to_geo(
|
|
canvas_x: int,
|
|
canvas_y: int,
|
|
current_map_geo_bounds: Optional[Tuple[float, float, float, float]],
|
|
current_stitched_map_pixel_shape: Optional[Tuple[int, int]], # (width, height)
|
|
) -> Tuple[Optional[float], Optional[float]]:
|
|
"""
|
|
Convert canvas pixel (x, y) to geo (lon, lat). Requires mercantile.
|
|
Uses current map geo bounds and pixel shape for context.
|
|
"""
|
|
if not MERCANTILE_MODULE_LOCALLY_AVAILABLE or mercantile is None:
|
|
logger.error("_pixel_to_geo: Mercantile missing.")
|
|
return None, None
|
|
if current_map_geo_bounds is None:
|
|
logger.warning("_pixel_to_geo: Map geo bounds missing.")
|
|
return None, None
|
|
if current_stitched_map_pixel_shape is None:
|
|
logger.warning("_pixel_to_geo: Map pixel shape missing.")
|
|
return None
|
|
|
|
img_w, img_h = current_stitched_map_pixel_shape
|
|
|
|
if img_w <= 0 or img_h <= 0:
|
|
logger.warning(f"_pixel_to_geo: Map pixel dims invalid ({img_w}x{img_h}).")
|
|
return None, None
|
|
|
|
map_west_lon, map_south_lat, map_east_lon, map_north_lat = current_map_geo_bounds
|
|
|
|
try:
|
|
map_ul_merc_x, map_ul_merc_y = mercantile.xy(map_west_lon, map_north_lat)
|
|
map_lr_merc_x, map_lr_merc_y = mercantile.xy(map_east_lon, map_south_lat)
|
|
|
|
total_map_width_merc = abs(map_lr_merc_x - map_ul_merc_x)
|
|
total_map_height_merc = abs(map_ul_merc_y - map_lr_merc_y)
|
|
|
|
if total_map_width_merc <= 1e-9 or total_map_height_merc <= 1e-9:
|
|
logger.warning(
|
|
f"_pixel_to_geo: Map mercator dims zero/near-zero ({total_map_width_merc:.2e}x{total_map_height_merc:.2e})."
|
|
)
|
|
return None, None
|
|
|
|
# Calculate the pixel coordinates relative to the top-left of the full mercator projection at the current zoom.
|
|
# Need the zoom level to know the total pixel size of the earth at that zoom.
|
|
# This function doesn't receive the zoom level directly, but it's used by MapCanvasManager which has it.
|
|
# However, the conversion from mercator to pixel relative to the *stitched image* only needs the stitched image geo bounds and pixel size.
|
|
|
|
# Convert pixel position (canvas_x, canvas_y) relative to stitched image to mercator coordinate.
|
|
# relative_x_on_map_pixels is 0 at the left edge of the stitched image, 1 at the right edge.
|
|
relative_x_on_map_pixels = canvas_x / img_w
|
|
# relative_y_on_map_pixels is 0 at the top edge, 1 at the bottom.
|
|
relative_y_on_map_pixels = canvas_y / img_h
|
|
|
|
# Convert relative pixel position to relative mercator position
|
|
# X: 0.0 at map_west_lon (ul_merc_x), 1.0 at map_east_lon (lr_merc_x)
|
|
# Y: 0.0 at map_north_lat (ul_merc_y), 1.0 at map_south_lat (lr_merc_y)
|
|
target_merc_x = map_ul_merc_x + (
|
|
relative_x_on_map_pixels * total_map_width_merc
|
|
)
|
|
target_merc_y = map_ul_merc_y - (
|
|
relative_y_on_map_pixels * total_map_height_merc
|
|
) # Y is inverted relative to map_ul_merc_y
|
|
|
|
# Convert mercator back to geo (lon, lat)
|
|
clicked_lon, clicked_lat = mercantile.lnglat(target_merc_x, target_merc_y)
|
|
|
|
# Clamp latitude to Mercator limits for safety
|
|
MAX_MERCATOR_LATITUDE = 85.05112878
|
|
clicked_lat = max(
|
|
-MAX_MERCATOR_LATITUDE, min(MAX_MERCATOR_LATITUDE, clicked_lat)
|
|
)
|
|
|
|
return clicked_lon, clicked_lat
|
|
|
|
except Exception as e_pixel_to_geo:
|
|
logger.error(
|
|
f"Error converting pixel ({canvas_x}, {canvas_y}) to geo. Error: {e_pixel_to_geo}",
|
|
exc_info=True,
|
|
)
|
|
return None, None
|