Files
rfcp/RFCP-3.9.1-Terra-Integration.md

8.3 KiB

RFCP — Iteration 3.9.1: Terra Tile Server Integration

Overview

Connect terrain_service.py to our SRTM tile server (terra.eliah.one) as primary download source, add terrain status API endpoint, and create a bulk pre-download utility. The data/terrain/ directory already exists.

Context

  • terra.eliah.one is live and serving tiles via Caddy file_server
  • SRTM3 (90m): 187 tiles, 515 MB — full Ukraine coverage (N44-N51, E018-E041)
  • SRTM1 (30m): 160 tiles, 3.9 GB — same coverage area
  • terrain_service.py already has bilinear interpolation (3.9.0)
  • Backend runs on Windows with RTX 4060, tiles stored locally in data/terrain/
  • Server is download source, NOT used during realtime calculations

Changes Required

1. Update SRTM_SOURCES in terrain_service.py

File: backend/app/services/terrain_service.py

Replace current SRTM_SOURCES (lines 22-25):

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",
]

With prioritized source list:

SRTM_SOURCES = [
    # 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",
    },
]

Update download_tile() to handle the new source format:

async def download_tile(self, tile_name: str) -> bool:
    """Download SRTM tile from configured sources, preferring highest resolution."""
    tile_path = self.get_tile_path(tile_name)

    if tile_path.exists():
        return True

    lat_dir = tile_name[:3]  # e.g., "N48"

    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

                    # 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
                    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)
                    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} from any source")
    return False

2. Add Terrain Status API Endpoint

File: backend/app/api/routes.py (or wherever API routes are defined)

Add a new endpoint:

@router.get("/api/terrain/status")
async def terrain_status():
    """Return terrain data availability info."""
    from app.services.terrain_service import terrain_service

    cached_tiles = terrain_service.get_cached_tiles()
    cache_size = terrain_service.get_cache_size_mb()

    # Categorize by resolution
    srtm1_tiles = [t for t in cached_tiles
                   if (terrain_service.terrain_path / f"{t}.hgt").stat().st_size == 3601 * 3601 * 2]
    srtm3_tiles = [t for t in cached_tiles if t not in srtm1_tiles]

    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",
    }

3. Add Bulk Pre-Download Endpoint

File: Same routes file

@router.post("/api/terrain/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"]}
    """
    from app.services.terrain_service import terrain_service

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

4. Add Tile Index Endpoint

File: Same routes file

@router.get("/api/terrain/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}

Testing Checklist

  • GET /api/terrain/status returns tile counts and sizes
  • POST /api/terrain/download {"center_lat": 48.46, "center_lon": 35.04, "radius_km": 10} downloads missing tiles from terra.eliah.one
  • Tiles downloaded from terra are valid HGT format (2,884,802 or 25,934,402 bytes)
  • SRTM1 is preferred over SRTM3 when downloading
  • Existing tiles are not re-downloaded
  • Coverage calculation works with terrain data (test with Dnipro coordinates)
  • GET /api/terrain/index returns terra server tile list

Build & Deploy

cd D:\root\rfcp\backend
# No build needed — Python backend, just restart
# Kill existing uvicorn and restart:
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

Commit Message

feat(terrain): integrate terra.eliah.one tile server

- Add terra.eliah.one as primary SRTM source (SRTM1 30m preferred)
- Keep AWS S3 as fallback source
- Add /api/terrain/status endpoint (tile inventory)
- Add /api/terrain/download endpoint (bulk pre-download)
- Add /api/terrain/index endpoint (terra server index)
- Validate tile size before saving
- Add follow_redirects=True to httpx client

Success Criteria

  1. terrain_service downloads from terra.eliah.one first
  2. /api/terrain/status shows correct tile counts by resolution
  3. /api/terrain/download fetches tiles for any Ukrainian coordinate
  4. Offline mode works — no downloads attempted if tiles exist locally
  5. Coverage calculation uses real elevation data instead of flat terrain