Coverage for services/inference/src/utils/uncertainty.py: 84%
56 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-25 16:18 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-25 16:18 +0000
1"""Uncertainty ellipse calculations for Phase 6 Inference Service."""
2import numpy as np
3from typing import Dict
4import math
5import logging
7logger = logging.getLogger(__name__)
10def compute_uncertainty_ellipse(
11 sigma_x: float,
12 sigma_y: float,
13 covariance_xy: float = 0.0,
14 confidence_level: float = 0.68, # 1-sigma
15) -> Dict:
16 """
17 Convert (sigma_x, sigma_y) to uncertainty ellipse parameters.
19 Computes semi-major and semi-minor axes and rotation angle
20 from the covariance matrix eigenvalues/eigenvectors.
22 Args:
23 sigma_x: Standard deviation in X direction (meters)
24 sigma_y: Standard deviation in Y direction (meters)
25 covariance_xy: Covariance between X and Y (default 0 = uncorrelated)
26 confidence_level: Confidence interval
27 0.68 = 1-sigma (68%)
28 0.95 = 2-sigma (95%)
29 0.997 = 3-sigma (99.7%)
31 Returns:
32 Dict with ellipse parameters:
33 {
34 "semi_major_axis": float, # Meters
35 "semi_minor_axis": float, # Meters
36 "rotation_angle": float, # Degrees (-180 to 180)
37 "confidence_interval": float,
38 "area_m2": float, # Area of ellipse
39 "semi_major_scaled": float, # Scaled for confidence level
40 "semi_minor_scaled": float, # Scaled for confidence level
41 }
42 """
43 try:
44 # Create covariance matrix
45 cov_matrix = np.array([
46 [sigma_x**2, covariance_xy],
47 [covariance_xy, sigma_y**2],
48 ])
50 # Compute eigenvalues and eigenvectors
51 eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
53 # Sort eigenvalues in descending order (largest first)
54 idx = eigenvalues.argsort()[::-1]
55 eigenvalues = eigenvalues[idx]
56 eigenvectors = eigenvectors[:, idx]
58 # Semi-major and semi-minor axes (1-sigma)
59 semi_major = math.sqrt(max(eigenvalues[0], 0.0)) # Avoid negative due to numerical errors
60 semi_minor = math.sqrt(max(eigenvalues[1], 0.0))
62 # Rotation angle (eigenvector orientation)
63 rotation_rad = math.atan2(eigenvectors[1, 0], eigenvectors[0, 0])
64 rotation_deg = math.degrees(rotation_rad)
66 # Normalize rotation to [-180, 180]
67 while rotation_deg > 180:
68 rotation_deg -= 360
69 while rotation_deg < -180:
70 rotation_deg += 360
72 # Scale for confidence level
73 # For 1-sigma: scale = 1.0
74 # For 2-sigma: scale = 2.0
75 # For 95%: scale ≈ 2.45 (chi-square distribution for 2D)
76 if confidence_level >= 0.95:
77 scale_factor = 2.45 # 2-sigma ≈ 95% for Gaussian
78 elif confidence_level >= 0.90:
79 scale_factor = 2.15
80 else:
81 scale_factor = 1.0
83 result = {
84 "semi_major_axis": float(semi_major),
85 "semi_minor_axis": float(semi_minor),
86 "rotation_angle": float(rotation_deg),
87 "confidence_interval": float(confidence_level),
88 "area_m2": float(math.pi * semi_major * semi_minor),
89 "semi_major_scaled": float(semi_major * scale_factor),
90 "semi_minor_scaled": float(semi_minor * scale_factor),
91 }
93 logger.debug(f"Uncertainty ellipse computed: {result}")
94 return result
96 except Exception as e:
97 logger.error(f"Failed to compute uncertainty ellipse: {e}", exc_info=True)
98 raise
101def ellipse_to_geojson(
102 center_lat: float,
103 center_lon: float,
104 semi_major_m: float,
105 semi_minor_m: float,
106 rotation_deg: float,
107 num_points: int = 64,
108) -> Dict:
109 """
110 Convert uncertainty ellipse to GeoJSON Feature for Mapbox visualization.
112 Generates a polygon approximating the ellipse for rendering on map.
113 Handles the conversion from meters to geographic coordinates (lat/lon).
115 Args:
116 center_lat: Center latitude in decimal degrees (-90 to 90)
117 center_lon: Center longitude in decimal degrees (-180 to 180)
118 semi_major_m: Semi-major axis in meters
119 semi_minor_m: Semi-minor axis in meters
120 rotation_deg: Rotation angle in degrees
121 num_points: Number of points to approximate ellipse (default 64 for smooth curve)
123 Returns:
124 GeoJSON Feature dict with Polygon geometry:
125 {
126 "type": "Feature",
127 "geometry": {
128 "type": "Polygon",
129 "coordinates": [[[lon, lat], [lon, lat], ...]]
130 },
131 "properties": {
132 "name": "Uncertainty Ellipse",
133 "semi_major_m": float,
134 "semi_minor_m": float,
135 "rotation_deg": float,
136 }
137 }
138 """
139 try:
140 # Earth radius in meters (WGS84)
141 EARTH_RADIUS_M = 6371000.0
143 # Convert meter-based ellipse to degrees
144 # At equator: 1 degree ≈ 111km
145 lat_scale = semi_major_m / EARTH_RADIUS_M
146 lon_scale = semi_minor_m / (EARTH_RADIUS_M * math.cos(math.radians(center_lat)))
148 # Generate ellipse points
149 angles = np.linspace(0, 2 * np.pi, num_points, endpoint=False)
150 rotation_rad = math.radians(rotation_deg)
152 coordinates = []
153 for angle in angles:
154 # Ellipse parametric equations
155 x = semi_major_m * math.cos(angle)
156 y = semi_minor_m * math.sin(angle)
158 # Apply rotation
159 x_rot = x * math.cos(rotation_rad) - y * math.sin(rotation_rad)
160 y_rot = x * math.sin(rotation_rad) + y * math.cos(rotation_rad)
162 # Convert to lat/lon
163 lat = center_lat + y_rot / EARTH_RADIUS_M
164 lon = center_lon + x_rot / (EARTH_RADIUS_M * math.cos(math.radians(center_lat)))
166 coordinates.append([lon, lat])
168 # Close polygon (first point == last point)
169 coordinates.append(coordinates[0])
171 result = {
172 "type": "Feature",
173 "geometry": {
174 "type": "Polygon",
175 "coordinates": [coordinates],
176 },
177 "properties": {
178 "name": "Uncertainty Ellipse",
179 "semi_major_m": float(semi_major_m),
180 "semi_minor_m": float(semi_minor_m),
181 "rotation_deg": float(rotation_deg),
182 "center_lat": float(center_lat),
183 "center_lon": float(center_lon),
184 },
185 }
187 logger.debug(f"GeoJSON ellipse generated with {len(coordinates)} points")
188 return result
190 except Exception as e:
191 logger.error(f"Failed to convert ellipse to GeoJSON: {e}", exc_info=True)
192 raise
195def create_uncertainty_circle(
196 center_lat: float,
197 center_lon: float,
198 radius_m: float,
199 num_points: int = 32,
200) -> Dict:
201 """
202 Create a circle (special case of ellipse) as GeoJSON.
204 Useful for simpler visualization when sigma_x ≈ sigma_y.
206 Args:
207 center_lat: Center latitude
208 center_lon: Center longitude
209 radius_m: Radius in meters
210 num_points: Number of points (default 32)
212 Returns:
213 GeoJSON Feature with circular polygon
214 """
215 return ellipse_to_geojson(
216 center_lat, center_lon, radius_m, radius_m, 0.0, num_points
217 )