Initial commit: Phase 1 & Phase 2 infrastructure complete
This commit is contained in:
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
GigLez GPS Package
|
||||
|
||||
GPS validation and utilities
|
||||
"""
|
||||
|
||||
from .validator import (
|
||||
validate_gps_coordinates,
|
||||
is_null_island,
|
||||
is_valid_accuracy,
|
||||
GPSValidator,
|
||||
GPSCoordinate
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'validate_gps_coordinates',
|
||||
'is_null_island',
|
||||
'is_valid_accuracy',
|
||||
'GPSValidator',
|
||||
'GPSCoordinate'
|
||||
]
|
||||
@@ -0,0 +1,464 @@
|
||||
"""
|
||||
GPS Coordinate Validation
|
||||
|
||||
Based on Wigle Android client GPS validation patterns
|
||||
See docs/wigle_analysis.md Section 3 for details
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
from datetime import datetime
|
||||
from loguru import logger
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GPS VALIDATION THRESHOLDS (Wigle-derived)
|
||||
# =============================================================================
|
||||
|
||||
# Maximum GPS accuracy threshold (meters)
|
||||
# Wigle uses 32m, we use 50m for broader acceptance
|
||||
MAX_GPS_ACCURACY = 50.0
|
||||
|
||||
# Minimum GPS accuracy for high-quality captures
|
||||
MIN_GPS_ACCURACY = 10.0
|
||||
|
||||
# Null Island detection threshold (degrees)
|
||||
# Coordinates within this distance of (0,0) are rejected
|
||||
NULL_ISLAND_THRESHOLD = 0.001
|
||||
|
||||
# Valid coordinate ranges
|
||||
MIN_LATITUDE = -90.0
|
||||
MAX_LATITUDE = 90.0
|
||||
MIN_LONGITUDE = -180.0
|
||||
MAX_LONGITUDE = 180.0
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DATA CLASSES
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class GPSCoordinate:
|
||||
"""GPS coordinate with metadata"""
|
||||
|
||||
latitude: float
|
||||
longitude: float
|
||||
altitude: Optional[float] = None
|
||||
accuracy: Optional[float] = None
|
||||
timestamp: Optional[datetime] = None
|
||||
provider: Optional[str] = None # 'gps', 'network', 'fused'
|
||||
|
||||
def __post_init__(self):
|
||||
"""Validate after initialization"""
|
||||
if not validate_gps_coordinates(self.latitude, self.longitude, self.accuracy):
|
||||
raise ValueError(
|
||||
f"Invalid GPS coordinates: lat={self.latitude}, lon={self.longitude}, "
|
||||
f"accuracy={self.accuracy}"
|
||||
)
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary"""
|
||||
return {
|
||||
'latitude': self.latitude,
|
||||
'longitude': self.longitude,
|
||||
'altitude': self.altitude,
|
||||
'accuracy': self.accuracy,
|
||||
'timestamp': self.timestamp.isoformat() if self.timestamp else None,
|
||||
'provider': self.provider
|
||||
}
|
||||
|
||||
def is_high_quality(self) -> bool:
|
||||
"""Check if this is a high-quality GPS fix"""
|
||||
if self.accuracy is None:
|
||||
return False
|
||||
return self.accuracy <= MIN_GPS_ACCURACY
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# VALIDATION FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def validate_gps_coordinates(
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
accuracy: Optional[float] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Validate GPS coordinates using Wigle patterns
|
||||
|
||||
Checks:
|
||||
1. Valid coordinate ranges (-90 to 90, -180 to 180)
|
||||
2. Null Island detection (reject 0.0, 0.0)
|
||||
3. Accuracy threshold (< 50 meters)
|
||||
|
||||
Args:
|
||||
latitude: Latitude in decimal degrees
|
||||
longitude: Longitude in decimal degrees
|
||||
accuracy: GPS accuracy in meters (optional)
|
||||
|
||||
Returns:
|
||||
True if valid, False otherwise
|
||||
|
||||
Example:
|
||||
>>> validate_gps_coordinates(40.7128, -74.0060, 5.0)
|
||||
True
|
||||
>>> validate_gps_coordinates(0.0, 0.0)
|
||||
False
|
||||
>>> validate_gps_coordinates(40.7128, -74.0060, 100.0)
|
||||
False
|
||||
"""
|
||||
|
||||
# Check coordinate ranges
|
||||
if not (MIN_LATITUDE <= latitude <= MAX_LATITUDE):
|
||||
logger.warning(f"Latitude out of range: {latitude}")
|
||||
return False
|
||||
|
||||
if not (MIN_LONGITUDE <= longitude <= MAX_LONGITUDE):
|
||||
logger.warning(f"Longitude out of range: {longitude}")
|
||||
return False
|
||||
|
||||
# Check for Null Island (0, 0)
|
||||
if is_null_island(latitude, longitude):
|
||||
logger.warning(f"Null Island detected: ({latitude}, {longitude})")
|
||||
return False
|
||||
|
||||
# Check accuracy threshold
|
||||
if accuracy is not None and not is_valid_accuracy(accuracy):
|
||||
logger.warning(f"GPS accuracy too low: {accuracy}m")
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def is_null_island(latitude: float, longitude: float) -> bool:
|
||||
"""
|
||||
Check if coordinates are at Null Island (0, 0)
|
||||
|
||||
Null Island is the point where the prime meridian and equator intersect.
|
||||
GPS errors often result in (0.0, 0.0) coordinates, so we reject these.
|
||||
|
||||
Args:
|
||||
latitude: Latitude in decimal degrees
|
||||
longitude: Longitude in decimal degrees
|
||||
|
||||
Returns:
|
||||
True if coordinates are near (0, 0), False otherwise
|
||||
"""
|
||||
return (abs(latitude) < NULL_ISLAND_THRESHOLD and
|
||||
abs(longitude) < NULL_ISLAND_THRESHOLD)
|
||||
|
||||
|
||||
def is_valid_accuracy(accuracy: float) -> bool:
|
||||
"""
|
||||
Check if GPS accuracy is within acceptable threshold
|
||||
|
||||
Wigle uses 32m threshold. We use 50m for broader acceptance.
|
||||
|
||||
Args:
|
||||
accuracy: GPS accuracy in meters
|
||||
|
||||
Returns:
|
||||
True if accuracy is acceptable, False otherwise
|
||||
"""
|
||||
return 0 < accuracy <= MAX_GPS_ACCURACY
|
||||
|
||||
|
||||
def check_coordinate_bounds(latitude: float, longitude: float) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Check if coordinates are within valid bounds
|
||||
|
||||
Args:
|
||||
latitude: Latitude in decimal degrees
|
||||
longitude: Longitude in decimal degrees
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, error_message)
|
||||
"""
|
||||
if not (MIN_LATITUDE <= latitude <= MAX_LATITUDE):
|
||||
return False, f"Latitude {latitude} out of range [{MIN_LATITUDE}, {MAX_LATITUDE}]"
|
||||
|
||||
if not (MIN_LONGITUDE <= longitude <= MAX_LONGITUDE):
|
||||
return False, f"Longitude {longitude} out of range [{MIN_LONGITUDE}, {MAX_LONGITUDE}]"
|
||||
|
||||
return True, None
|
||||
|
||||
|
||||
def anonymize_gps(
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
precision_meters: int = 100
|
||||
) -> Tuple[float, float]:
|
||||
"""
|
||||
Anonymize GPS coordinates by reducing precision
|
||||
|
||||
Wigle pattern: Allow users to control GPS precision for privacy
|
||||
|
||||
Args:
|
||||
latitude: Original latitude
|
||||
longitude: Original longitude
|
||||
precision_meters: Desired precision in meters (10, 100, 1000)
|
||||
|
||||
Returns:
|
||||
Tuple of (anonymized_lat, anonymized_lon)
|
||||
|
||||
Example:
|
||||
>>> anonymize_gps(40.7128456, -74.0059728, 100)
|
||||
(40.71, -74.01)
|
||||
"""
|
||||
# Rough conversion: 1 degree ≈ 111 km
|
||||
# 100m precision ≈ 0.001 degrees
|
||||
# 1000m precision ≈ 0.01 degrees
|
||||
|
||||
if precision_meters <= 10:
|
||||
decimals = 5 # ~1.1m
|
||||
elif precision_meters <= 100:
|
||||
decimals = 3 # ~111m
|
||||
elif precision_meters <= 1000:
|
||||
decimals = 2 # ~1.1km
|
||||
else:
|
||||
decimals = 1 # ~11km
|
||||
|
||||
lat_anon = round(latitude, decimals)
|
||||
lon_anon = round(longitude, decimals)
|
||||
|
||||
return lat_anon, lon_anon
|
||||
|
||||
|
||||
def calculate_distance(
|
||||
lat1: float, lon1: float,
|
||||
lat2: float, lon2: float
|
||||
) -> float:
|
||||
"""
|
||||
Calculate distance between two GPS coordinates using Haversine formula
|
||||
|
||||
Args:
|
||||
lat1: First latitude
|
||||
lon1: First longitude
|
||||
lat2: Second latitude
|
||||
lon2: Second longitude
|
||||
|
||||
Returns:
|
||||
Distance in kilometers
|
||||
|
||||
Example:
|
||||
>>> calculate_distance(40.7128, -74.0060, 40.7614, -73.9776)
|
||||
8.67 # ~8.67 km from downtown Manhattan to Central Park
|
||||
"""
|
||||
from math import radians, sin, cos, sqrt, atan2
|
||||
|
||||
R = 6371.0 # Earth radius in kilometers
|
||||
|
||||
lat1_rad = radians(lat1)
|
||||
lon1_rad = radians(lon1)
|
||||
lat2_rad = radians(lat2)
|
||||
lon2_rad = radians(lon2)
|
||||
|
||||
dlat = lat2_rad - lat1_rad
|
||||
dlon = lon2_rad - lon1_rad
|
||||
|
||||
a = sin(dlat / 2)**2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2)**2
|
||||
c = 2 * atan2(sqrt(a), sqrt(1 - a))
|
||||
|
||||
distance = R * c
|
||||
return distance
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# GPS VALIDATOR CLASS
|
||||
# =============================================================================
|
||||
|
||||
class GPSValidator:
|
||||
"""
|
||||
GPS validator with configurable thresholds
|
||||
|
||||
Based on Wigle's multi-level validation approach
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
max_accuracy: float = MAX_GPS_ACCURACY,
|
||||
min_accuracy: float = MIN_GPS_ACCURACY,
|
||||
allow_null_island: bool = False,
|
||||
strict_mode: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize GPS validator
|
||||
|
||||
Args:
|
||||
max_accuracy: Maximum acceptable accuracy (meters)
|
||||
min_accuracy: Minimum accuracy for high-quality captures
|
||||
allow_null_island: Allow (0, 0) coordinates (not recommended)
|
||||
strict_mode: Reject coordinates without accuracy data
|
||||
"""
|
||||
self.max_accuracy = max_accuracy
|
||||
self.min_accuracy = min_accuracy
|
||||
self.allow_null_island = allow_null_island
|
||||
self.strict_mode = strict_mode
|
||||
|
||||
# Statistics
|
||||
self.total_validated = 0
|
||||
self.total_accepted = 0
|
||||
self.total_rejected = 0
|
||||
self.rejection_reasons = {}
|
||||
|
||||
def validate(
|
||||
self,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
accuracy: Optional[float] = None
|
||||
) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate GPS coordinates
|
||||
|
||||
Args:
|
||||
latitude: Latitude in decimal degrees
|
||||
longitude: Longitude in decimal degrees
|
||||
accuracy: GPS accuracy in meters (optional)
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, rejection_reason)
|
||||
"""
|
||||
self.total_validated += 1
|
||||
|
||||
# Check bounds
|
||||
is_valid, error = check_coordinate_bounds(latitude, longitude)
|
||||
if not is_valid:
|
||||
self.total_rejected += 1
|
||||
self._record_rejection("out_of_bounds")
|
||||
return False, error
|
||||
|
||||
# Check Null Island (unless allowed)
|
||||
if not self.allow_null_island and is_null_island(latitude, longitude):
|
||||
self.total_rejected += 1
|
||||
self._record_rejection("null_island")
|
||||
return False, "Null Island (0.0, 0.0) detected"
|
||||
|
||||
# Check accuracy
|
||||
if self.strict_mode and accuracy is None:
|
||||
self.total_rejected += 1
|
||||
self._record_rejection("missing_accuracy")
|
||||
return False, "Missing accuracy data (strict mode)"
|
||||
|
||||
if accuracy is not None and not (0 < accuracy <= self.max_accuracy):
|
||||
self.total_rejected += 1
|
||||
self._record_rejection("poor_accuracy")
|
||||
return False, f"GPS accuracy {accuracy}m exceeds threshold {self.max_accuracy}m"
|
||||
|
||||
# All checks passed
|
||||
self.total_accepted += 1
|
||||
return True, None
|
||||
|
||||
def validate_coordinate(self, coord: GPSCoordinate) -> Tuple[bool, Optional[str]]:
|
||||
"""
|
||||
Validate a GPSCoordinate object
|
||||
|
||||
Args:
|
||||
coord: GPSCoordinate object
|
||||
|
||||
Returns:
|
||||
Tuple of (is_valid, rejection_reason)
|
||||
"""
|
||||
return self.validate(coord.latitude, coord.longitude, coord.accuracy)
|
||||
|
||||
def _record_rejection(self, reason: str):
|
||||
"""Record rejection reason for statistics"""
|
||||
self.rejection_reasons[reason] = self.rejection_reasons.get(reason, 0) + 1
|
||||
|
||||
def get_statistics(self) -> dict:
|
||||
"""Get validation statistics"""
|
||||
return {
|
||||
'total_validated': self.total_validated,
|
||||
'total_accepted': self.total_accepted,
|
||||
'total_rejected': self.total_rejected,
|
||||
'acceptance_rate': (
|
||||
self.total_accepted / self.total_validated
|
||||
if self.total_validated > 0 else 0.0
|
||||
),
|
||||
'rejection_reasons': self.rejection_reasons
|
||||
}
|
||||
|
||||
def reset_statistics(self):
|
||||
"""Reset validation statistics"""
|
||||
self.total_validated = 0
|
||||
self.total_accepted = 0
|
||||
self.total_rejected = 0
|
||||
self.rejection_reasons = {}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# CONVENIENCE FUNCTIONS
|
||||
# =============================================================================
|
||||
|
||||
def create_gps_coordinate(
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
altitude: Optional[float] = None,
|
||||
accuracy: Optional[float] = None,
|
||||
timestamp: Optional[datetime] = None,
|
||||
provider: Optional[str] = None
|
||||
) -> Optional[GPSCoordinate]:
|
||||
"""
|
||||
Create a validated GPSCoordinate
|
||||
|
||||
Args:
|
||||
latitude: Latitude in decimal degrees
|
||||
longitude: Longitude in decimal degrees
|
||||
altitude: Altitude in meters (optional)
|
||||
accuracy: GPS accuracy in meters (optional)
|
||||
timestamp: Capture timestamp (optional)
|
||||
provider: GPS provider (optional)
|
||||
|
||||
Returns:
|
||||
GPSCoordinate if valid, None otherwise
|
||||
"""
|
||||
try:
|
||||
return GPSCoordinate(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
altitude=altitude,
|
||||
accuracy=accuracy,
|
||||
timestamp=timestamp,
|
||||
provider=provider
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to create GPS coordinate: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# TESTING
|
||||
# =============================================================================
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Test validation
|
||||
print("GPS Validator Tests")
|
||||
print("=" * 60)
|
||||
|
||||
# Test cases
|
||||
test_cases = [
|
||||
# (lat, lon, accuracy, expected_valid, description)
|
||||
(40.7128, -74.0060, 5.0, True, "NYC - high quality"),
|
||||
(40.7128, -74.0060, 25.0, True, "NYC - acceptable quality"),
|
||||
(40.7128, -74.0060, 100.0, False, "NYC - poor accuracy"),
|
||||
(0.0, 0.0, 5.0, False, "Null Island"),
|
||||
(91.0, 0.0, 5.0, False, "Latitude out of range"),
|
||||
(0.0, 181.0, 5.0, False, "Longitude out of range"),
|
||||
(51.5074, -0.1278, 8.0, True, "London - high quality"),
|
||||
]
|
||||
|
||||
validator = GPSValidator()
|
||||
|
||||
for lat, lon, acc, expected, desc in test_cases:
|
||||
is_valid, reason = validator.validate(lat, lon, acc)
|
||||
status = "✅" if is_valid == expected else "❌"
|
||||
print(f"{status} {desc}")
|
||||
print(f" lat={lat}, lon={lon}, accuracy={acc}m")
|
||||
print(f" Valid: {is_valid}, Reason: {reason}")
|
||||
print()
|
||||
|
||||
# Print statistics
|
||||
print("Validation Statistics:")
|
||||
print("=" * 60)
|
||||
stats = validator.get_statistics()
|
||||
for key, value in stats.items():
|
||||
print(f"{key}: {value}")
|
||||
Reference in New Issue
Block a user