"""
Tests for coordinate frame transformation module.
"""
import math
import pytest
from aerospace_mcp.integrations._array_backend import np
from aerospace_mcp.integrations.frames import (
ASTROPY_AVAILABLE,
SKYFIELD_AVAILABLE,
_manual_ecef_to_geodetic,
_manual_ecef_to_geodetic_vectorized,
_manual_geodetic_to_ecef,
_manual_geodetic_to_ecef_vectorized,
ecef_to_geodetic,
geodetic_to_ecef,
get_frame_info,
transform_frames,
transform_frames_batch,
)
class TestGeodeticECEFConversions:
"""Test geodetic <-> ECEF coordinate conversions."""
def test_manual_conversions_round_trip(self):
"""Test manual conversions maintain round-trip accuracy."""
# Test with known coordinates
lat, lon, alt = 37.7749, -122.4194, 100.0 # San Francisco-ish
# Convert to ECEF and back
x, y, z = _manual_geodetic_to_ecef(lat, lon, alt)
lat_out, lon_out, alt_out = _manual_ecef_to_geodetic(x, y, z)
# Should round-trip within reasonable precision
assert abs(lat_out - lat) < 1e-9
assert abs(lon_out - lon) < 1e-9
assert abs(alt_out - alt) < 2e-6 # meters (adjusted for numerical precision)
def test_known_ecef_values(self):
"""Test conversion against known ECEF values."""
# Equator, Greenwich meridian, sea level
lat, lon, alt = 0.0, 0.0, 0.0
x, y, z = _manual_geodetic_to_ecef(lat, lon, alt)
# Should be on equatorial plane at Earth radius
assert abs(x - 6378137.0) < 1.0 # WGS84 semi-major axis
assert abs(y) < 1e-6
assert abs(z) < 1e-6
# North pole
lat, lon, alt = 90.0, 0.0, 0.0
x, y, z = _manual_geodetic_to_ecef(lat, lon, alt)
assert abs(x) < 1e-6
assert abs(y) < 1e-6
assert abs(z - 6356752.314245) < 1.0 # WGS84 semi-minor axis
def test_geodetic_validation(self):
"""Test geodetic coordinate validation."""
# Valid coordinates should work
result = geodetic_to_ecef(45.0, -90.0, 1000.0)
assert result.frame == "ECEF"
# At longitude -90°, x should be near zero (on negative y-axis)
assert abs(result.x) < 1.0 # Should be essentially zero
assert abs(result.y) > 4000000 # Should be large negative y value
# Invalid latitude
with pytest.raises(ValueError, match="Latitude must be between"):
geodetic_to_ecef(91.0, 0.0, 0.0)
with pytest.raises(ValueError, match="Latitude must be between"):
geodetic_to_ecef(-91.0, 0.0, 0.0)
# Invalid longitude
with pytest.raises(ValueError, match="Longitude must be between"):
geodetic_to_ecef(0.0, 181.0, 0.0)
with pytest.raises(ValueError, match="Longitude must be between"):
geodetic_to_ecef(0.0, -181.0, 0.0)
def test_altitude_effects(self):
"""Test that altitude changes affect ECEF coordinates properly."""
lat, lon = 45.0, 45.0
# Compare sea level vs high altitude
ecef_low = geodetic_to_ecef(lat, lon, 0.0)
ecef_high = geodetic_to_ecef(lat, lon, 10000.0) # 10km up
# Distance from origin should increase
dist_low = math.sqrt(ecef_low.x**2 + ecef_low.y**2 + ecef_low.z**2)
dist_high = math.sqrt(ecef_high.x**2 + ecef_high.y**2 + ecef_high.z**2)
assert dist_high > dist_low
assert abs(dist_high - dist_low - 10000.0) < 1.0 # Should be ~10km difference
class TestFrameTransformations:
"""Test coordinate frame transformations."""
def test_same_frame_no_transform(self):
"""Test that same frame transformation returns unchanged coordinates."""
xyz = [1000000.0, 2000000.0, 3000000.0]
result = transform_frames(xyz, "ECEF", "ECEF")
assert result.x == xyz[0]
assert result.y == xyz[1]
assert result.z == xyz[2]
assert result.frame == "ECEF"
def test_ecef_geodetic_transforms(self):
"""Test ECEF <-> GEODETIC transformations."""
# Start with geodetic coordinates
lat, lon, alt = 37.7749, -122.4194, 500.0
# Transform to ECEF
ecef_result = transform_frames([lat, lon, alt], "GEODETIC", "ECEF")
assert ecef_result.frame == "ECEF"
assert abs(ecef_result.x) > 1000000 # Reasonable ECEF magnitude
# Transform back to geodetic
geodetic_result = transform_frames(
[ecef_result.x, ecef_result.y, ecef_result.z], "ECEF", "GEODETIC"
)
assert geodetic_result.frame == "GEODETIC"
assert abs(geodetic_result.x - lat) < 1e-6 # Should round-trip
assert abs(geodetic_result.y - lon) < 1e-6
assert abs(geodetic_result.z - alt) < 0.001
def test_coordinate_validation(self):
"""Test coordinate input validation."""
# Wrong number of coordinates
with pytest.raises(ValueError, match="xyz must be a list of 3 coordinates"):
transform_frames([1, 2], "ECEF", "ECI")
with pytest.raises(ValueError, match="xyz must be a list of 3 coordinates"):
transform_frames([1, 2, 3, 4], "ECEF", "ECI")
def test_invalid_frames(self):
"""Test invalid frame names."""
xyz = [1000000.0, 2000000.0, 3000000.0]
with pytest.raises(ValueError, match="Frame must be one of"):
transform_frames(xyz, "INVALID", "ECEF")
with pytest.raises(ValueError, match="Frame must be one of"):
transform_frames(xyz, "ECEF", "INVALID")
def test_eci_ecef_approximate_transform(self):
"""Test ECI <-> ECEF approximate transformation."""
xyz = [6500000.0, 0.0, 0.0] # Point on equatorial plane
# Transform ECI to ECEF (with astropy, accounts for Earth rotation)
result = transform_frames(xyz, "ECI", "ECEF")
assert result.frame == "ECEF"
# With proper transformation, coordinates will be significantly different due to Earth rotation
# Just check that the magnitude is reasonable (should be similar distance from Earth center)
original_magnitude = (xyz[0] ** 2 + xyz[1] ** 2 + xyz[2] ** 2) ** 0.5
result_magnitude = (result.x**2 + result.y**2 + result.z**2) ** 0.5
assert (
abs(result_magnitude - original_magnitude) < 1000
) # Magnitude should be preserved
@pytest.mark.skipif(not ASTROPY_AVAILABLE, reason="astropy not available")
def test_astropy_integration(self):
"""Test astropy integration if available."""
xyz = [6500000.0, 1000000.0, 2000000.0]
# Should use astropy for high-precision transforms if available
result = transform_frames(xyz, "ECI", "ECEF", "2023-01-01T00:00:00")
assert result.frame == "ECEF"
assert isinstance(result.x, float)
assert isinstance(result.y, float)
assert isinstance(result.z, float)
def test_approximate_transforms(self):
"""Test that approximate transformations work without high-precision libraries."""
xyz = [1000000.0, 2000000.0, 3000000.0]
# Without high-precision libraries, should still provide approximate results
if not ASTROPY_AVAILABLE and not SKYFIELD_AVAILABLE:
result = transform_frames(xyz, "GCRS", "ITRF")
assert result.frame == "ITRF"
assert result.x == xyz[0] # Simplified approximation keeps same coordinates
assert result.y == xyz[1]
assert result.z == xyz[2]
class TestConvenienceFunctions:
"""Test convenience functions."""
def test_ecef_to_geodetic_function(self):
"""Test ECEF to geodetic convenience function."""
# Known ECEF coordinates (approximately San Francisco)
x, y, z = -2699490.0, -4293565.0, 3855273.0
result = ecef_to_geodetic(x, y, z)
# Should be reasonable geodetic coordinates
assert -90 <= result.latitude_deg <= 90
assert -180 <= result.longitude_deg <= 180
assert result.altitude_m > -1000 # Not too far underground
def test_geodetic_to_ecef_function(self):
"""Test geodetic to ECEF convenience function."""
result = geodetic_to_ecef(37.7749, -122.4194, 100.0)
assert result.frame == "ECEF"
assert isinstance(result.x, float)
assert isinstance(result.y, float)
assert isinstance(result.z, float)
# Should be reasonable ECEF magnitude for Earth surface
distance = math.sqrt(result.x**2 + result.y**2 + result.z**2)
assert 6.3e6 < distance < 6.5e6 # Earth radius range
class TestFrameInfo:
"""Test frame information utilities."""
def test_get_frame_info(self):
"""Test frame information retrieval."""
info = get_frame_info()
assert "available_frames" in info
assert "ECEF" in info["available_frames"]
assert "GEODETIC" in info["available_frames"]
assert "ECI" in info["available_frames"]
assert "libraries" in info
assert "astropy" in info["libraries"]
assert "skyfield" in info["libraries"]
assert "manual_transforms" in info
assert "notes" in info
# Check library availability reporting
assert info["libraries"]["astropy"] == ASTROPY_AVAILABLE
assert info["libraries"]["skyfield"] == SKYFIELD_AVAILABLE
assert info["high_precision_available"] == ASTROPY_AVAILABLE
class TestNumericalStability:
"""Test numerical stability and edge cases."""
def test_polar_coordinates(self):
"""Test coordinate transformations at poles."""
# North pole
result = geodetic_to_ecef(90.0, 0.0, 1000.0)
# At pole, X and Y should be near zero
assert abs(result.x) < 1e-6
assert abs(result.y) < 1e-6
assert result.z > 6350000 # Should be near polar radius + altitude
# South pole
result = geodetic_to_ecef(-90.0, 180.0, 0.0)
assert abs(result.x) < 1e-6
assert abs(result.y) < 1e-6
assert result.z < -6350000 # Should be negative
def test_equatorial_coordinates(self):
"""Test coordinates on equator."""
# Prime meridian on equator
result = geodetic_to_ecef(0.0, 0.0, 0.0)
assert abs(result.x - 6378137.0) < 1.0 # Earth radius
assert abs(result.y) < 1e-6
assert abs(result.z) < 1e-6
# 90° east on equator
result = geodetic_to_ecef(0.0, 90.0, 0.0)
assert abs(result.x) < 1e-6
assert abs(result.y - 6378137.0) < 1.0
assert abs(result.z) < 1e-6
def test_high_altitude(self):
"""Test very high altitude coordinates."""
# Satellite altitude
result = geodetic_to_ecef(0.0, 0.0, 20200000.0) # GPS orbit altitude
# Distance from center should be Earth radius + altitude
distance = math.sqrt(result.x**2 + result.y**2 + result.z**2)
expected = 6378137.0 + 20200000.0
assert abs(distance - expected) < 1.0
def test_precision_consistency(self):
"""Test that precision is consistent across different coordinate ranges."""
test_cases = [
(0.0, 0.0, 0.0),
(45.0, 45.0, 1000.0),
(-45.0, -45.0, 1000.0),
(89.9, 179.9, 10000.0),
(-89.9, -179.9, 10000.0),
]
for lat, lon, alt in test_cases:
# Round trip test
ecef = geodetic_to_ecef(lat, lon, alt)
geodetic = ecef_to_geodetic(ecef.x, ecef.y, ecef.z)
# Should maintain precision
assert abs(geodetic.latitude_deg - lat) < 1e-9
assert abs(geodetic.longitude_deg - lon) < 1e-9
assert (
abs(geodetic.altitude_m - alt) < 5e-3
) # Relaxed for high latitude/altitude cases
class TestVectorizedCoordinateTransforms:
"""Test vectorized coordinate transformation functions."""
def test_vectorized_ecef_to_geodetic(self):
"""Test vectorized ECEF to geodetic conversion."""
# Test points (San Francisco, Tokyo, London approximately)
x = np.array([-2699490.0, -3946792.0, 3979261.0])
y = np.array([-4293565.0, 3366090.0, -14416.0])
z = np.array([3855273.0, 3698041.0, 4969426.0])
lat, lon, alt = _manual_ecef_to_geodetic_vectorized(x, y, z)
# Check correct array lengths
assert len(lat) == 3
assert len(lon) == 3
assert len(alt) == 3
# All latitudes should be in valid range
assert all(-90 <= lat_val <= 90 for lat_val in lat)
# All longitudes should be in valid range
assert all(-180 <= lon_val <= 180 for lon_val in lon)
def test_vectorized_geodetic_to_ecef(self):
"""Test vectorized geodetic to ECEF conversion."""
lat = np.array([37.7749, 35.6762, 51.5074])
lon = np.array([-122.4194, 139.6503, -0.1278])
alt = np.array([0.0, 0.0, 0.0])
x, y, z = _manual_geodetic_to_ecef_vectorized(lat, lon, alt)
# Check correct array lengths
assert len(x) == 3
assert len(y) == 3
assert len(z) == 3
# All coordinates should be within Earth surface range
for i in range(3):
dist = math.sqrt(float(x[i]) ** 2 + float(y[i]) ** 2 + float(z[i]) ** 2)
assert 6.3e6 < dist < 6.5e6
def test_vectorized_consistency_with_scalar(self):
"""Test that vectorized transforms match scalar transforms."""
# Test ECEF to geodetic
x, y, z = -2699490.0, -4293565.0, 3855273.0
scalar_result = _manual_ecef_to_geodetic(x, y, z)
vector_result = _manual_ecef_to_geodetic_vectorized(
np.array([x]), np.array([y]), np.array([z])
)
assert abs(scalar_result[0] - float(vector_result[0][0])) < 1e-10
assert abs(scalar_result[1] - float(vector_result[1][0])) < 1e-10
assert abs(scalar_result[2] - float(vector_result[2][0])) < 1e-6
# Test geodetic to ECEF
lat, lon, alt = 37.7749, -122.4194, 100.0
scalar_result = _manual_geodetic_to_ecef(lat, lon, alt)
vector_result = _manual_geodetic_to_ecef_vectorized(
np.array([lat]), np.array([lon]), np.array([alt])
)
assert abs(scalar_result[0] - float(vector_result[0][0])) < 1e-6
assert abs(scalar_result[1] - float(vector_result[1][0])) < 1e-6
assert abs(scalar_result[2] - float(vector_result[2][0])) < 1e-6
class TestBatchTransformations:
"""Test batch coordinate transformation function."""
def test_transform_frames_batch_same_frame(self):
"""Test batch transformation with same source and target frame."""
xyz_list = [[1e6, 2e6, 3e6], [4e6, 5e6, 6e6], [7e6, 8e6, 9e6]]
results = transform_frames_batch(xyz_list, "ECEF", "ECEF")
assert len(results) == 3
for i, result in enumerate(results):
assert result.frame == "ECEF"
assert result.x == xyz_list[i][0]
assert result.y == xyz_list[i][1]
assert result.z == xyz_list[i][2]
def test_transform_frames_batch_ecef_to_geodetic(self):
"""Test batch ECEF to GEODETIC conversion."""
xyz_list = [
[-2699490.0, -4293565.0, 3855273.0], # San Francisco
[3979261.0, -14416.0, 4969426.0], # London
]
results = transform_frames_batch(xyz_list, "ECEF", "GEODETIC")
assert len(results) == 2
for result in results:
assert result.frame == "GEODETIC"
assert -90 <= result.x <= 90 # latitude
assert -180 <= result.y <= 180 # longitude
def test_transform_frames_batch_geodetic_to_ecef(self):
"""Test batch GEODETIC to ECEF conversion."""
# Lat, Lon, Alt as x, y, z
xyz_list = [
[37.7749, -122.4194, 0.0], # San Francisco
[51.5074, -0.1278, 0.0], # London
]
results = transform_frames_batch(xyz_list, "GEODETIC", "ECEF")
assert len(results) == 2
for result in results:
assert result.frame == "ECEF"
# Should be valid ECEF coordinates
dist = math.sqrt(result.x**2 + result.y**2 + result.z**2)
assert 6.3e6 < dist < 6.5e6
def test_transform_frames_batch_empty_list(self):
"""Test batch transformation with empty list."""
results = transform_frames_batch([], "ECEF", "GEODETIC")
assert results == []
if __name__ == "__main__":
pytest.main([__file__])