"""Geodesic utilities for great-circle calculations on a spherical Earth model.This module implements the classic "destination point" formula to derive a newlatitude/longitude from a start point, a great-circle distance in kilometers,and a bearing measured clockwise from geographic north.Warnings--------- The underlying spherical approximation is sufficient for short distances and most grid seeding use cases. For long-haul routes or high-latitude travel, consider ellipsoidal solutions (e.g., Vincenty) to reduce error.See Also--------app.coordinates_manager : Seeds surrounding points using this computation.app.coordinates_setup : Validates inputs and orchestrates seeding/listing.Notes------ Primary role: compute destination coordinates using spherical trigonometry and provide small helpers for input validation and longitude normalization.- Key dependencies: standard library ``math`` and a stable mean Earth radius constant; consumed by ``app.coordinates_manager`` and ``app.coordinates_setup``.- Invariants: latitude must be within ``[-90, 90]`` and longitude within ``[-180, 180]``. Distances are non-negative kilometers. Longitudes are normalized to ``[-180, 180]``.Examples-------->>> # Move ~1 km to the north from (0, 0)>>> lat, lon = calculate_destination_point(0.0, 0.0, 1.0, 0.0)>>> isinstance(lat, float) and isinstance(lon, float)True"""importloggingimportmathfromtypingimportTuplelogger=logging.getLogger(__name__)# ConstantsEARTH_RADIUS_KM:float=6371.0088# Mean Earth radius in kilometersMIN_LATITUDE:float=-90.0MAX_LATITUDE:float=90.0MIN_LONGITUDE:float=-180.0MAX_LONGITUDE:float=180.0FULL_CIRCLE_DEGREES:float=360.0LONGITUDE_NORMALIZATION_OFFSET:float=540.0def_validate_parameters(latitude:float,longitude:float,distance_km:float)->None:"""Validate input parameters for coordinate calculations. Parameters ---------- latitude : float Latitude in decimal degrees (must be within [-90, 90]). longitude : float Longitude in decimal degrees (must be within [-180, 180]). distance_km : float Non-negative travel distance in kilometers. Raises ------ ValueError If any parameter is out of its valid range. See Also -------- - app.coordinates_utils.calculate_destination_point """ifnotMIN_LATITUDE<=latitude<=MAX_LATITUDE:logger.error(f"Latitude {latitude:.6f} out of range [{MIN_LATITUDE}, {MAX_LATITUDE}]")raiseValueError(f"Latitude must be between {MIN_LATITUDE} and {MAX_LATITUDE} degrees, got {latitude:.6f}.")ifnotMIN_LONGITUDE<=longitude<=MAX_LONGITUDE:logger.error(f"Longitude {longitude:.6f} out of range [{MIN_LONGITUDE}, {MAX_LONGITUDE}]")raiseValueError(f"Longitude must be between {MIN_LONGITUDE} and {MAX_LONGITUDE} degrees, got {longitude:.6f}.")ifdistance_km<0.0:logger.error(f"Negative distance: {distance_km:.3f} km")raiseValueError(f"Distance must be non-negative, got {distance_km:.3f} km.")def_compute_destination_radians(start_lat_rad:float,start_lon_rad:float,bearing_rad:float,angular_distance:float,)->Tuple[float,float]:"""Compute destination coordinates in radians. Parameters ---------- start_lat_rad : float Starting latitude in radians. start_lon_rad : float Starting longitude in radians. bearing_rad : float Bearing in radians clockwise from north. angular_distance : float Angular distance (``distance_km / EARTH_RADIUS_KM``). Returns ------- tuple[float, float] ``(destination_lat_rad, destination_lon_rad)``. Notes ----- Implements the standard great-circle destination formula. Inputs are assumed to be validated and pre-converted to radians. """dest_lat_rad=math.asin(math.sin(start_lat_rad)*math.cos(angular_distance)+math.cos(start_lat_rad)*math.sin(angular_distance)*math.cos(bearing_rad))dest_lon_rad=start_lon_rad+math.atan2(math.sin(bearing_rad)*math.sin(angular_distance)*math.cos(start_lat_rad),math.cos(angular_distance)-math.sin(start_lat_rad)*math.sin(dest_lat_rad),)returndest_lat_rad,dest_lon_raddef_normalize_longitude(degrees:float)->float:"""Normalize longitude to the ``[-180, 180]`` range. Parameters ---------- degrees : float Longitude in decimal degrees. Returns ------- float Normalized longitude in decimal degrees. Notes ----- Uses a common wrap-around technique: ``((x + 540) % 360) - 180`` to ensure values land within the inclusive range. See Also -------- - app.coordinates_utils._convert_radians_to_degrees_and_normalize """normalized=(degrees+LONGITUDE_NORMALIZATION_OFFSET)%FULL_CIRCLE_DEGREES-MAX_LONGITUDEassert(MIN_LONGITUDE<=normalized<=MAX_LONGITUDE),f"Normalized longitude {normalized:.6f} out of expected range"returnnormalizeddef_convert_input_to_radians(latitude:float,longitude:float,distance_km:float,bearing_deg:float,)->Tuple[float,float,float,float]:"""Convert input parameters into radians for calculation. Parameters ---------- latitude : float Starting latitude in decimal degrees. longitude : float Starting longitude in decimal degrees. distance_km : float Travel distance in kilometers. bearing_deg : float Bearing in degrees clockwise from north. Returns ------- tuple[float, float, float, float] ``(start_lat_rad, start_lon_rad, bearing_rad, angular_distance)``. Notes ----- ``bearing_deg`` may be any real number. No explicit normalization is required since trigonometric functions are periodic. """start_lat_rad=math.radians(latitude)start_lon_rad=math.radians(longitude)bearing_rad=math.radians(bearing_deg)angular_distance=distance_km/EARTH_RADIUS_KMreturnstart_lat_rad,start_lon_rad,bearing_rad,angular_distancedef_convert_radians_to_degrees_and_normalize(dest_lat_rad:float,dest_lon_rad:float)->Tuple[float,float]:"""Convert radians to decimal degrees and normalize longitude. Parameters ---------- dest_lat_rad : float Destination latitude in radians. dest_lon_rad : float Destination longitude in radians. Returns ------- tuple[float, float] ``(latitude, normalized_longitude)`` in decimal degrees. Raises ------ AssertionError If computed results are out of expected ranges. Notes ----- Applies longitude normalization to ensure deterministic output within the inclusive interval ``[-180, 180]``. """latitude=math.degrees(dest_lat_rad)longitude=math.degrees(dest_lon_rad)longitude=_normalize_longitude(longitude)assert(MIN_LATITUDE<=latitude<=MAX_LATITUDE),f"Destination latitude {latitude:.6f} out of expected range [{MIN_LATITUDE}, {MAX_LATITUDE}]"assert(MIN_LONGITUDE<=longitude<=MAX_LONGITUDE),f"Normalized longitude {longitude:.6f} out of expected range [{MIN_LONGITUDE}, {MAX_LONGITUDE}]"returnlatitude,longitudedef_log_destination_info(start_latitude:float,start_longitude:float,distance_km:float,bearing_deg:float,destination_latitude:float,destination_longitude:float,)->None:"""Log detailed information about the computed destination point. Parameters ---------- start_latitude : float Starting latitude in decimal degrees. start_longitude : float Starting longitude in decimal degrees. distance_km : float Distance traveled. bearing_deg : float Bearing in degrees clockwise from north. destination_latitude : float Computed destination latitude. destination_longitude : float Computed destination longitude. """logger.debug(f"Computed destination point: start=({start_latitude:.6f}, {start_longitude:.6f}), "f"distance={distance_km:.3f} km, bearing={bearing_deg:.1f}°, "f"dest=({destination_latitude:.6f}, {destination_longitude:.6f})")defcalculate_destination_point(latitude:float,longitude:float,distance_km:float,bearing_deg:float,)->Tuple[float,float]:"""Calculate destination point given start lat/lon, distance, and bearing. Parameters ---------- latitude : float Starting latitude in decimal degrees (within ``[-90, 90]``). longitude : float Starting longitude in decimal degrees (within ``[-180, 180]``). distance_km : float Distance to travel from the starting point in kilometers (non-negative). bearing_deg : float Bearing in degrees (clockwise from north). Values outside ``[0, 360]`` are accepted; trigonometric periodicity handles normalization. Returns ------- tuple[float, float] ``(latitude, longitude)`` for the destination point in decimal degrees. Raises ------ ValueError If ``latitude`` or ``longitude`` are out of range, or ``distance_km`` is negative. Examples -------- >>> # 0.0,0.0 moved ~1 km to the east or north yields valid coordinates >>> lat, lon = calculate_destination_point(0.0, 0.0, 1.0, 90.0) >>> -90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0 True >>> # Zero distance returns the starting point >>> calculate_destination_point(0.0, 0.0, 0.0, 123.0) (0.0, 0.0) Notes ----- - Uses a spherical Earth with mean radius ``EARTH_RADIUS_KM``. For higher-precision geodesics (long distances, high latitudes), consider ellipsoidal formulas (e.g., Vincenty). - Longitudes are normalized to ``[-180, 180]``, so anti-meridian crossings are handled deterministically. See Also -------- - app.coordinates_manager.seed_coordinates_if_needed - app.coordinates_setup """_validate_parameters(latitude,longitude,distance_km)(start_lat_rad,start_lon_rad,bearing_rad,angular_distance,)=_convert_input_to_radians(latitude,longitude,distance_km,bearing_deg)dest_lat_rad,dest_lon_rad=_compute_destination_radians(start_lat_rad,start_lon_rad,bearing_rad,angular_distance)(destination_latitude,destination_longitude,)=_convert_radians_to_degrees_and_normalize(dest_lat_rad,dest_lon_rad)_log_destination_info(latitude,longitude,distance_km,bearing_deg,destination_latitude,destination_longitude,)returndestination_latitude,destination_longitude