@mytec: iter3.10 start, baseline rc ready

This commit is contained in:
2026-02-04 15:56:09 +02:00
parent e392b449cc
commit 81e078e92a
17 changed files with 1486 additions and 414 deletions

View File

@@ -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}

View File

@@ -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 ━━━")

View File

@@ -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

View File

@@ -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,