coordinates_utils.py#

"""
Geodesic utilities for great-circle calculations on a spherical Earth model.

This module implements the classic "destination point" formula to derive a new
latitude/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
"""

import logging
import math
from typing import Tuple

logger = logging.getLogger(__name__)

# Constants
EARTH_RADIUS_KM: float = 6371.0088  # Mean Earth radius in kilometers
MIN_LATITUDE: float = -90.0
MAX_LATITUDE: float = 90.0
MIN_LONGITUDE: float = -180.0
MAX_LONGITUDE: float = 180.0
FULL_CIRCLE_DEGREES: float = 360.0
LONGITUDE_NORMALIZATION_OFFSET: float = 540.0


def _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
    """
    if not MIN_LATITUDE <= latitude <= MAX_LATITUDE:
        logger.error(
            f"Latitude {latitude:.6f} out of range [{MIN_LATITUDE}, {MAX_LATITUDE}]"
        )
        raise ValueError(
            f"Latitude must be between {MIN_LATITUDE} and {MAX_LATITUDE} degrees, got {latitude:.6f}."
        )
    if not MIN_LONGITUDE <= longitude <= MAX_LONGITUDE:
        logger.error(
            f"Longitude {longitude:.6f} out of range [{MIN_LONGITUDE}, {MAX_LONGITUDE}]"
        )
        raise ValueError(
            f"Longitude must be between {MIN_LONGITUDE} and {MAX_LONGITUDE} degrees, got {longitude:.6f}."
        )
    if distance_km < 0.0:
        logger.error(f"Negative distance: {distance_km:.3f} km")
        raise ValueError(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),
    )
    return dest_lat_rad, dest_lon_rad


def _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_LONGITUDE
    assert (
        MIN_LONGITUDE <= normalized <= MAX_LONGITUDE
    ), f"Normalized longitude {normalized:.6f} out of expected range"
    return normalized


def _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_KM
    return start_lat_rad, start_lon_rad, bearing_rad, angular_distance


def _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}]"
    return latitude, longitude


def _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})"
    )


def calculate_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,
    )
    return destination_latitude, destination_longitude