Initial commit: Phase 1 & Phase 2 infrastructure complete

This commit is contained in:
2026-01-12 11:21:17 -08:00
commit eb225771bc
53 changed files with 13645 additions and 0 deletions
+21
View File
@@ -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'
]
+464
View File
@@ -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}")