@mytec: iter3.10 start, baseline rc ready
This commit is contained in:
@@ -180,3 +180,93 @@ async def get_terrain_file(region: str):
|
||||
if os.path.exists(terrain_path):
|
||||
return FileResponse(terrain_path)
|
||||
raise HTTPException(status_code=404, detail=f"Region '{region}' not found")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def terrain_status():
|
||||
"""Return terrain data availability info."""
|
||||
cached_tiles = terrain_service.get_cached_tiles()
|
||||
cache_size = terrain_service.get_cache_size_mb()
|
||||
|
||||
# Categorize by resolution based on file size
|
||||
srtm1_tiles = []
|
||||
srtm3_tiles = []
|
||||
for t in cached_tiles:
|
||||
tile_path = terrain_service.terrain_path / f"{t}.hgt"
|
||||
try:
|
||||
if tile_path.stat().st_size == 3601 * 3601 * 2:
|
||||
srtm1_tiles.append(t)
|
||||
else:
|
||||
srtm3_tiles.append(t)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"total_tiles": len(cached_tiles),
|
||||
"srtm1": {
|
||||
"count": len(srtm1_tiles),
|
||||
"resolution_m": 30,
|
||||
"tiles": sorted(srtm1_tiles),
|
||||
},
|
||||
"srtm3": {
|
||||
"count": len(srtm3_tiles),
|
||||
"resolution_m": 90,
|
||||
"tiles": sorted(srtm3_tiles),
|
||||
},
|
||||
"cache_size_mb": round(cache_size, 1),
|
||||
"memory_cached": len(terrain_service._tile_cache),
|
||||
"terra_server": "https://terra.eliah.one",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/download")
|
||||
async def terrain_download(request: dict):
|
||||
"""Pre-download tiles for a region.
|
||||
|
||||
Body: {"center_lat": 48.46, "center_lon": 35.04, "radius_km": 50}
|
||||
Or: {"tiles": ["N48E034", "N48E035", "N47E034", "N47E035"]}
|
||||
"""
|
||||
if "tiles" in request:
|
||||
tile_list = request["tiles"]
|
||||
else:
|
||||
center_lat = request.get("center_lat", 48.46)
|
||||
center_lon = request.get("center_lon", 35.04)
|
||||
radius_km = request.get("radius_km", 50)
|
||||
tile_list = terrain_service.get_required_tiles(center_lat, center_lon, radius_km)
|
||||
|
||||
missing = [t for t in tile_list if not terrain_service.get_tile_path(t).exists()]
|
||||
|
||||
if not missing:
|
||||
return {"status": "ok", "message": "All tiles already cached", "count": len(tile_list)}
|
||||
|
||||
# Download missing tiles
|
||||
downloaded = []
|
||||
failed = []
|
||||
for tile_name in missing:
|
||||
success = await terrain_service.download_tile(tile_name)
|
||||
if success:
|
||||
downloaded.append(tile_name)
|
||||
else:
|
||||
failed.append(tile_name)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"required": len(tile_list),
|
||||
"already_cached": len(tile_list) - len(missing),
|
||||
"downloaded": downloaded,
|
||||
"failed": failed,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/index")
|
||||
async def terrain_index():
|
||||
"""Fetch tile index from terra server."""
|
||||
import httpx
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get("https://terra.eliah.one/api/index")
|
||||
if resp.status_code == 200:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
pass
|
||||
return {"error": "Could not reach terra.eliah.one", "offline": True}
|
||||
|
||||
@@ -526,19 +526,33 @@ class CoverageService:
|
||||
progress_fn("Loading terrain", 0.25)
|
||||
await asyncio.sleep(0)
|
||||
t_terrain = time.time()
|
||||
|
||||
# Check for missing tiles before attempting download
|
||||
radius_km = settings.radius / 1000.0
|
||||
missing_tiles = self.terrain.get_missing_tiles(site.lat, site.lon, radius_km)
|
||||
if missing_tiles:
|
||||
_clog(f"⚠ Missing terrain tiles: {missing_tiles} - will attempt download")
|
||||
|
||||
tile_names = await self.terrain.ensure_tiles_for_bbox(
|
||||
min_lat, min_lon, max_lat, max_lon
|
||||
)
|
||||
for tn in tile_names:
|
||||
self.terrain._load_tile(tn)
|
||||
|
||||
# Check what actually loaded
|
||||
loaded_tiles = [tn for tn in tile_names if tn in self.terrain._tile_cache]
|
||||
failed_tiles = [tn for tn in tile_names if tn not in self.terrain._tile_cache]
|
||||
if failed_tiles:
|
||||
_clog(f"⚠ TERRAIN WARNING: Failed to load tiles {failed_tiles}. "
|
||||
"Coverage accuracy reduced - using flat terrain for affected areas.")
|
||||
|
||||
site_elevation = self.terrain.get_elevation_sync(site.lat, site.lon)
|
||||
|
||||
point_elevations = {}
|
||||
for lat, lon in grid:
|
||||
point_elevations[(lat, lon)] = self.terrain.get_elevation_sync(lat, lon)
|
||||
terrain_time = time.time() - t_terrain
|
||||
_clog(f"Tiles: {len(tile_names)}, site elev: {site_elevation:.0f}m, "
|
||||
_clog(f"Tiles: {len(loaded_tiles)}/{len(tile_names)} loaded, site elev: {site_elevation:.0f}m, "
|
||||
f"pre-computed {len(grid)} elevations")
|
||||
_clog(f"━━━ PHASE 2 done: {terrain_time:.1f}s ━━━")
|
||||
|
||||
|
||||
@@ -277,10 +277,11 @@ class GPUService:
|
||||
lons: np.ndarray,
|
||||
terrain_cache: dict,
|
||||
) -> np.ndarray:
|
||||
"""Look up elevations from cached terrain tiles.
|
||||
"""Look up elevations from cached terrain tiles with bilinear interpolation.
|
||||
|
||||
Vectorized implementation: processes per-tile (1-4 tiles) instead of
|
||||
per-point (thousands of points). Inner operations are all NumPy vectorized.
|
||||
per-point (thousands of points). Uses bilinear interpolation for
|
||||
sub-meter accuracy (vs 15m error with nearest-neighbor at 30m resolution).
|
||||
|
||||
Args:
|
||||
lats, lons: Flattened arrays of coordinates
|
||||
@@ -313,16 +314,39 @@ class GPUService:
|
||||
tile_lons = lons[mask]
|
||||
|
||||
size = tile.shape[0]
|
||||
# Vectorized row/col calculation
|
||||
rows = ((1 - (tile_lats - lat_int)) * (size - 1)).astype(int)
|
||||
cols = ((tile_lons - lon_int) * (size - 1)).astype(int)
|
||||
rows = np.clip(rows, 0, size - 1)
|
||||
cols = np.clip(cols, 0, size - 1)
|
||||
|
||||
# Vectorized lookup - single operation for ALL points in tile
|
||||
tile_elevs = tile[rows, cols].astype(np.float64)
|
||||
tile_elevs[tile_elevs == -32768] = 0.0
|
||||
elevations[mask] = tile_elevs
|
||||
# Vectorized bilinear interpolation
|
||||
lat_frac = tile_lats - lat_int
|
||||
lon_frac = tile_lons - lon_int
|
||||
|
||||
row_exact = (1.0 - lat_frac) * (size - 1)
|
||||
col_exact = lon_frac * (size - 1)
|
||||
|
||||
r0 = np.clip(row_exact.astype(int), 0, size - 2)
|
||||
c0 = np.clip(col_exact.astype(int), 0, size - 2)
|
||||
r1 = r0 + 1
|
||||
c1 = c0 + 1
|
||||
|
||||
dr = row_exact - r0
|
||||
dc = col_exact - c0
|
||||
|
||||
# Get four corner values for all points at once
|
||||
z00 = tile[r0, c0].astype(np.float64)
|
||||
z01 = tile[r0, c1].astype(np.float64)
|
||||
z10 = tile[r1, c0].astype(np.float64)
|
||||
z11 = tile[r1, c1].astype(np.float64)
|
||||
|
||||
# Bilinear interpolation (vectorized)
|
||||
result = (z00 * (1 - dr) * (1 - dc) +
|
||||
z01 * (1 - dr) * dc +
|
||||
z10 * dr * (1 - dc) +
|
||||
z11 * dr * dc)
|
||||
|
||||
# Handle void values (-32768) - set to 0
|
||||
void_mask = (z00 == -32768) | (z01 == -32768) | (z10 == -32768) | (z11 == -32768)
|
||||
result[void_mask] = 0.0
|
||||
|
||||
elevations[mask] = result
|
||||
|
||||
return elevations
|
||||
|
||||
|
||||
@@ -20,8 +20,24 @@ class TerrainService:
|
||||
"""
|
||||
|
||||
SRTM_SOURCES = [
|
||||
"https://elevation-tiles-prod.s3.amazonaws.com/skadi/{lat_dir}/{tile_name}.hgt.gz",
|
||||
"https://s3.amazonaws.com/elevation-tiles-prod/skadi/{lat_dir}/{tile_name}.hgt.gz",
|
||||
# Our tile server — SRTM1 (30m) preferred, uncompressed
|
||||
{
|
||||
"url": "https://terra.eliah.one/srtm1/{tile_name}.hgt",
|
||||
"compressed": False,
|
||||
"resolution": "srtm1",
|
||||
},
|
||||
# Our tile server — SRTM3 (90m) fallback
|
||||
{
|
||||
"url": "https://terra.eliah.one/srtm3/{tile_name}.hgt",
|
||||
"compressed": False,
|
||||
"resolution": "srtm3",
|
||||
},
|
||||
# Public AWS mirror — SRTM1, gzip compressed
|
||||
{
|
||||
"url": "https://elevation-tiles-prod.s3.amazonaws.com/skadi/{lat_dir}/{tile_name}.hgt.gz",
|
||||
"compressed": True,
|
||||
"resolution": "srtm1",
|
||||
},
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
@@ -48,7 +64,7 @@ class TerrainService:
|
||||
return self.terrain_path / f"{tile_name}.hgt"
|
||||
|
||||
async def download_tile(self, tile_name: str) -> bool:
|
||||
"""Download SRTM tile if not cached locally"""
|
||||
"""Download SRTM tile from configured sources, preferring highest resolution."""
|
||||
tile_path = self.get_tile_path(tile_name)
|
||||
|
||||
if tile_path.exists():
|
||||
@@ -56,33 +72,45 @@ class TerrainService:
|
||||
|
||||
lat_dir = tile_name[:3] # e.g., "N48"
|
||||
|
||||
async with httpx.AsyncClient(timeout=60.0) as client:
|
||||
for source_url in self.SRTM_SOURCES:
|
||||
url = source_url.format(lat_dir=lat_dir, tile_name=tile_name)
|
||||
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
||||
for source in self.SRTM_SOURCES:
|
||||
url = source["url"].format(lat_dir=lat_dir, tile_name=tile_name)
|
||||
try:
|
||||
response = await client.get(url)
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.content
|
||||
|
||||
if url.endswith('.gz'):
|
||||
data = gzip.decompress(data)
|
||||
elif url.endswith('.zip'):
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith('.hgt'):
|
||||
data = zf.read(name)
|
||||
break
|
||||
# Skip empty responses
|
||||
if len(data) < 1000:
|
||||
continue
|
||||
|
||||
if source["compressed"]:
|
||||
if url.endswith('.gz'):
|
||||
data = gzip.decompress(data)
|
||||
elif url.endswith('.zip'):
|
||||
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
||||
for name in zf.namelist():
|
||||
if name.endswith('.hgt'):
|
||||
data = zf.read(name)
|
||||
break
|
||||
|
||||
# Validate tile size (SRTM1: 25,934,402 bytes, SRTM3: 2,884,802 bytes)
|
||||
if len(data) not in (3601 * 3601 * 2, 1201 * 1201 * 2):
|
||||
print(f"[Terrain] Invalid tile size {len(data)} from {url}")
|
||||
continue
|
||||
|
||||
tile_path.write_bytes(data)
|
||||
print(f"[Terrain] Downloaded {tile_name} ({len(data)} bytes)")
|
||||
res = source["resolution"]
|
||||
size_mb = len(data) / 1048576
|
||||
print(f"[Terrain] Downloaded {tile_name} ({res}, {size_mb:.1f} MB)")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"[Terrain] Failed from {url}: {e}")
|
||||
continue
|
||||
|
||||
print(f"[Terrain] Could not download {tile_name}")
|
||||
print(f"[Terrain] Could not download {tile_name} from any source")
|
||||
return False
|
||||
|
||||
def _load_tile(self, tile_name: str) -> Optional[np.ndarray]:
|
||||
@@ -149,56 +177,179 @@ class TerrainService:
|
||||
|
||||
return self._load_tile(tile_name)
|
||||
|
||||
def _bilinear_sample(self, tile: np.ndarray, lat: float, lon: float) -> float:
|
||||
"""Sample elevation with bilinear interpolation for sub-meter accuracy.
|
||||
|
||||
SRTM1 at 30m means nearest-neighbor can have 15m positional error.
|
||||
Bilinear interpolation reduces this to sub-meter accuracy.
|
||||
"""
|
||||
size = tile.shape[0]
|
||||
|
||||
# Tile southwest corner
|
||||
lat_int = int(lat) if lat >= 0 else int(lat) - 1
|
||||
lon_int = int(lon) if lon >= 0 else int(lon) - 1
|
||||
|
||||
# Fractional position within tile (0.0 to 1.0)
|
||||
lat_frac = lat - lat_int # 0 = south edge, 1 = north edge
|
||||
lon_frac = lon - lon_int # 0 = west edge, 1 = east edge
|
||||
|
||||
# Convert to row/col (note: rows go north to south!)
|
||||
row_exact = (1.0 - lat_frac) * (size - 1) # 0 = north, size-1 = south
|
||||
col_exact = lon_frac * (size - 1) # 0 = west, size-1 = east
|
||||
|
||||
# Four surrounding grid points
|
||||
r0 = int(row_exact)
|
||||
c0 = int(col_exact)
|
||||
r1 = min(r0 + 1, size - 1)
|
||||
c1 = min(c0 + 1, size - 1)
|
||||
|
||||
# Fractional position between grid points
|
||||
dr = row_exact - r0
|
||||
dc = col_exact - c0
|
||||
|
||||
# Get four corner values
|
||||
z00 = tile[r0, c0]
|
||||
z01 = tile[r0, c1]
|
||||
z10 = tile[r1, c0]
|
||||
z11 = tile[r1, c1]
|
||||
|
||||
# Handle void (-32768) values - fall back to nearest valid
|
||||
void_val = -32768
|
||||
corners = [(z00, r0, c0), (z01, r0, c1), (z10, r1, c0), (z11, r1, c1)]
|
||||
if z00 == void_val or z01 == void_val or z10 == void_val or z11 == void_val:
|
||||
valid = [(z, r, c) for z, r, c in corners if z != void_val]
|
||||
if not valid:
|
||||
return 0.0
|
||||
# Return nearest valid value
|
||||
return float(valid[0][0])
|
||||
|
||||
# Bilinear interpolation
|
||||
elevation = (z00 * (1 - dr) * (1 - dc) +
|
||||
z01 * (1 - dr) * dc +
|
||||
z10 * dr * (1 - dc) +
|
||||
z11 * dr * dc)
|
||||
|
||||
return float(elevation)
|
||||
|
||||
async def get_elevation(self, lat: float, lon: float) -> float:
|
||||
"""Get elevation at specific coordinate (meters above sea level)"""
|
||||
"""Get elevation at specific coordinate with bilinear interpolation."""
|
||||
tile_name = self.get_tile_name(lat, lon)
|
||||
tile = await self.load_tile(tile_name)
|
||||
|
||||
if tile is None:
|
||||
return 0.0
|
||||
|
||||
size = tile.shape[0]
|
||||
|
||||
# Calculate position within tile
|
||||
lat_int = int(lat) if lat >= 0 else int(lat) - 1
|
||||
lon_int = int(lon) if lon >= 0 else int(lon) - 1
|
||||
|
||||
lat_frac = lat - lat_int
|
||||
lon_frac = lon - lon_int
|
||||
|
||||
# Row 0 = north edge, last row = south edge
|
||||
row = int((1 - lat_frac) * (size - 1))
|
||||
col = int(lon_frac * (size - 1))
|
||||
|
||||
row = max(0, min(row, size - 1))
|
||||
col = max(0, min(col, size - 1))
|
||||
|
||||
elevation = tile[row, col]
|
||||
|
||||
# -32768 = void/no data
|
||||
if elevation == -32768:
|
||||
return 0.0
|
||||
|
||||
return float(elevation)
|
||||
return self._bilinear_sample(tile, lat, lon)
|
||||
|
||||
def get_elevation_sync(self, lat: float, lon: float) -> float:
|
||||
"""Sync elevation lookup from memory cache. Returns 0.0 if tile not loaded."""
|
||||
"""Sync elevation lookup with bilinear interpolation. Returns 0.0 if tile not loaded."""
|
||||
tile_name = self.get_tile_name(lat, lon)
|
||||
tile = self._tile_cache.get(tile_name)
|
||||
if tile is None:
|
||||
return 0.0
|
||||
|
||||
size = tile.shape[0]
|
||||
lat_int = int(lat) if lat >= 0 else int(lat) - 1
|
||||
lon_int = int(lon) if lon >= 0 else int(lon) - 1
|
||||
return self._bilinear_sample(tile, lat, lon)
|
||||
|
||||
row = int((1 - (lat - lat_int)) * (size - 1))
|
||||
col = int((lon - lon_int) * (size - 1))
|
||||
row = max(0, min(row, size - 1))
|
||||
col = max(0, min(col, size - 1))
|
||||
def get_elevations_batch(self, lats: np.ndarray, lons: np.ndarray) -> np.ndarray:
|
||||
"""Vectorized elevation lookup with bilinear interpolation.
|
||||
|
||||
elevation = tile[row, col]
|
||||
return 0.0 if elevation == -32768 else float(elevation)
|
||||
Handles points spanning multiple tiles efficiently.
|
||||
Groups points by tile, processes each tile with full NumPy vectorization.
|
||||
Tiles must be pre-loaded into memory cache.
|
||||
|
||||
Args:
|
||||
lats: Array of latitudes
|
||||
lons: Array of longitudes
|
||||
|
||||
Returns:
|
||||
Array of elevations (0.0 for missing tiles or void data)
|
||||
"""
|
||||
elevations = np.zeros(len(lats), dtype=np.float32)
|
||||
|
||||
# Compute tile indices for each point
|
||||
lat_ints = np.floor(lats).astype(int)
|
||||
lon_ints = np.floor(lons).astype(int)
|
||||
|
||||
# Group by tile using unique key
|
||||
unique_tiles = set(zip(lat_ints, lon_ints))
|
||||
|
||||
for lat_int, lon_int in unique_tiles:
|
||||
# Get tile name
|
||||
lat_letter = 'N' if lat_int >= 0 else 'S'
|
||||
lon_letter = 'E' if lon_int >= 0 else 'W'
|
||||
tile_name = f"{lat_letter}{abs(lat_int):02d}{lon_letter}{abs(lon_int):03d}"
|
||||
|
||||
tile = self._tile_cache.get(tile_name)
|
||||
if tile is None:
|
||||
continue
|
||||
|
||||
# Mask for points in this tile
|
||||
mask = (lat_ints == lat_int) & (lon_ints == lon_int)
|
||||
tile_lats = lats[mask]
|
||||
tile_lons = lons[mask]
|
||||
|
||||
size = tile.shape[0]
|
||||
|
||||
# Vectorized bilinear interpolation for all points in this tile
|
||||
lat_frac = tile_lats - lat_int
|
||||
lon_frac = tile_lons - lon_int
|
||||
|
||||
row_exact = (1.0 - lat_frac) * (size - 1)
|
||||
col_exact = lon_frac * (size - 1)
|
||||
|
||||
r0 = np.clip(row_exact.astype(int), 0, size - 2)
|
||||
c0 = np.clip(col_exact.astype(int), 0, size - 2)
|
||||
r1 = r0 + 1
|
||||
c1 = c0 + 1
|
||||
|
||||
dr = row_exact - r0
|
||||
dc = col_exact - c0
|
||||
|
||||
# Get four corner values for all points at once
|
||||
z00 = tile[r0, c0].astype(np.float32)
|
||||
z01 = tile[r0, c1].astype(np.float32)
|
||||
z10 = tile[r1, c0].astype(np.float32)
|
||||
z11 = tile[r1, c1].astype(np.float32)
|
||||
|
||||
# Bilinear interpolation (vectorized)
|
||||
result = (z00 * (1 - dr) * (1 - dc) +
|
||||
z01 * (1 - dr) * dc +
|
||||
z10 * dr * (1 - dc) +
|
||||
z11 * dr * dc)
|
||||
|
||||
# Handle void values (-32768) - set to 0
|
||||
void_mask = (z00 == -32768) | (z01 == -32768) | (z10 == -32768) | (z11 == -32768)
|
||||
result[void_mask] = 0.0
|
||||
|
||||
elevations[mask] = result
|
||||
|
||||
return elevations
|
||||
|
||||
def get_required_tiles(self, center_lat: float, center_lon: float, radius_km: float) -> list:
|
||||
"""Determine which tiles are needed for a coverage calculation."""
|
||||
# Convert radius to degrees (approximate)
|
||||
lat_delta = radius_km / 111.0 # ~111 km per degree latitude
|
||||
lon_delta = radius_km / (111.0 * np.cos(np.radians(center_lat)))
|
||||
|
||||
min_lat = center_lat - lat_delta
|
||||
max_lat = center_lat + lat_delta
|
||||
min_lon = center_lon - lon_delta
|
||||
max_lon = center_lon + lon_delta
|
||||
|
||||
tiles = []
|
||||
for lat in range(int(np.floor(min_lat)), int(np.floor(max_lat)) + 1):
|
||||
for lon in range(int(np.floor(min_lon)), int(np.floor(max_lon)) + 1):
|
||||
lat_letter = 'N' if lat >= 0 else 'S'
|
||||
lon_letter = 'E' if lon >= 0 else 'W'
|
||||
tile_name = f"{lat_letter}{abs(lat):02d}{lon_letter}{abs(lon):03d}"
|
||||
tiles.append(tile_name)
|
||||
|
||||
return tiles
|
||||
|
||||
def get_missing_tiles(self, center_lat: float, center_lon: float, radius_km: float) -> list:
|
||||
"""Check which needed tiles are not available locally."""
|
||||
required = self.get_required_tiles(center_lat, center_lon, radius_km)
|
||||
return [t for t in required if not self.get_tile_path(t).exists()]
|
||||
|
||||
async def get_elevation_profile(
|
||||
self,
|
||||
|
||||
Reference in New Issue
Block a user