# Satellite Data Manager

## Interface Definition

**Interface Name**: `ISatelliteDataManager`

### Interface Methods

```python
class ISatelliteDataManager(ABC):
    @abstractmethod
    def fetch_tile(self, lat: float, lon: float, zoom: int) -> Optional[np.ndarray]:
        pass
    
    @abstractmethod
    def fetch_tile_grid(self, center_lat: float, center_lon: float, grid_size: int, zoom: int) -> Dict[str, np.ndarray]:
        pass
    
    @abstractmethod
    def prefetch_route_corridor(self, waypoints: List[GPSPoint], corridor_width_m: float, zoom: int) -> bool:
        pass
    
    @abstractmethod
    def progressive_fetch(self, center_lat: float, center_lon: float, grid_sizes: List[int], zoom: int) -> Iterator[Dict[str, np.ndarray]]:
        pass
    
    @abstractmethod
    def cache_tile(self, flight_id: str, tile_coords: TileCoords, tile_data: np.ndarray) -> bool:
        pass
    
    @abstractmethod
    def get_cached_tile(self, flight_id: str, tile_coords: TileCoords) -> Optional[np.ndarray]:
        pass
    
    @abstractmethod
    def get_tile_grid(self, center: TileCoords, grid_size: int) -> List[TileCoords]:
        pass
    
    @abstractmethod
    def compute_tile_coords(self, lat: float, lon: float, zoom: int) -> TileCoords:
        pass
    
    @abstractmethod
    def expand_search_grid(self, center: TileCoords, current_size: int, new_size: int) -> List[TileCoords]:
        pass
    
    @abstractmethod
    def compute_tile_bounds(self, tile_coords: TileCoords) -> TileBounds:
        pass
    
    @abstractmethod
    def clear_flight_cache(self, flight_id: str) -> bool:
        pass
```

## Component Description

### Responsibilities
- Fetch satellite tiles from external provider API
- Manage local tile cache per flight
- Calculate tile coordinates and grid layouts
- Support progressive tile grid expansion (1→4→9→16→25)
- Handle Web Mercator projection calculations
- Coordinate corridor prefetching for flight routes

### Scope
- **HTTP client** for Satellite Provider API
- **Local caching** with disk storage
- **Grid calculations** for search patterns
- **Tile coordinate transformations** (GPS↔Tile coordinates)
- **Progressive retrieval** for "kidnapped robot" recovery

## API Methods

### `fetch_tile(lat: float, lon: float, zoom: int) -> Optional[np.ndarray]`

**Description**: Fetches a single satellite tile by GPS coordinates.

**Called By**:
- F09 Metric Refinement (single tile for drift correction)
- Internal (during prefetching)

**Input**:
```python
lat: float  # Latitude
lon: float  # Longitude
zoom: int  # Zoom level (19 for 0.3m/pixel at Ukraine latitude)
```

**Output**:
```python
np.ndarray: Tile image (H×W×3 RGB) or None if failed
```

**HTTP Request**:
```
GET /api/satellite/tiles/latlon?lat={lat}&lon={lon}&zoom={zoom}
```

**Processing Flow**:
1. Convert GPS to tile coordinates
2. Check cache
3. If not cached, fetch from satellite provider
4. Cache tile
5. Return tile image

**Error Conditions**:
- Returns `None`: Tile unavailable, HTTP error
- Logs errors for monitoring

**Test Cases**:
1. **Cache hit**: Tile in cache → returns immediately
2. **Cache miss**: Fetches from API → caches → returns
3. **API error**: Returns None
4. **Invalid coordinates**: Returns None

---

### `fetch_tile_grid(center_lat: float, center_lon: float, grid_size: int, zoom: int) -> Dict[str, np.ndarray]`

**Description**: Fetches NxN grid of tiles centered on GPS coordinates.

**Called By**:
- F09 Metric Refinement (for progressive search)
- F11 Failure Recovery Coordinator

**Input**:
```python
center_lat: float
center_lon: float
grid_size: int  # 1, 4 (2×2), 9 (3×3), 16 (4×4), or 25 (5×5)
zoom: int
```

**Output**:
```python
Dict[str, np.ndarray]  # tile_id -> tile_image
```

**Processing Flow**:
1. Compute tile grid centered on coordinates
2. For each tile in grid:
   - Check cache
   - If not cached, fetch from API
3. Return dict of tiles

**HTTP Request** (if using batch endpoint):
```
GET /api/satellite/tiles/batch?tiles=[...]
```

**Error Conditions**:
- Returns partial dict if some tiles fail
- Empty dict if all tiles fail

**Test Cases**:
1. **2×2 grid**: Returns 4 tiles
2. **3×3 grid**: Returns 9 tiles
3. **5×5 grid**: Returns 25 tiles
4. **Partial failure**: Some tiles unavailable → returns available tiles
5. **All cached**: Fast retrieval without HTTP requests

---

### `prefetch_route_corridor(waypoints: List[GPSPoint], corridor_width_m: float, zoom: int) -> bool`

**Description**: Prefetches satellite tiles along route corridor for a flight.

**Called By**:
- F02.1 Flight Lifecycle Manager (during flight creation)

**Input**:
```python
waypoints: List[GPSPoint]  # Rough route waypoints
corridor_width_m: float  # Corridor width in meters (e.g., 1000m)
zoom: int
```

**Output**:
```python
bool: True if prefetch completed, False on error
```

**Processing Flow**:
1. For each waypoint pair:
   - Calculate corridor polygon
   - Determine tiles covering corridor
2. Fetch tiles (async, parallel)
3. Cache all tiles with flight_id reference

**Algorithm**:
- Use H06 Web Mercator Utils for tile calculations
- Parallel fetching (10-20 concurrent requests)
- Progress tracking for monitoring

**Error Conditions**:
- Returns `False`: Major error preventing prefetch
- Logs warnings for individual tile failures

**Test Cases**:
1. **Simple route**: 10 waypoints → prefetches 50-100 tiles
2. **Long route**: 50 waypoints → prefetches 200-500 tiles
3. **Partial failure**: Some tiles fail → continues, returns True
4. **Complete failure**: All tiles fail → returns False

---

### `progressive_fetch(center_lat: float, center_lon: float, grid_sizes: List[int], zoom: int) -> Iterator[Dict[str, np.ndarray]]`

**Description**: Progressively fetches expanding tile grids for "kidnapped robot" recovery.

**Called By**:
- F11 Failure Recovery Coordinator (progressive search)

**Input**:
```python
center_lat: float
center_lon: float
grid_sizes: List[int]  # e.g., [1, 4, 9, 16, 25]
zoom: int
```

**Output**:
```python
Iterator yielding Dict[str, np.ndarray] for each grid size
```

**Processing Flow**:
1. For each grid_size in sequence:
   - Fetch tile grid
   - Yield tiles
   - If match found by caller, iterator can be stopped

**Usage Pattern**:
```python
for tiles in progressive_fetch(lat, lon, [1, 4, 9, 16, 25], 19):
    if litesam_match_found(tiles):
        break  # Stop expanding search
```

**Test Cases**:
1. **Progressive search**: Yields 1, then 4, then 9 tiles
2. **Early termination**: Match on 4 tiles → doesn't fetch 9, 16, 25
3. **Full search**: No match → fetches all grid sizes

---

### `cache_tile(flight_id: str, tile_coords: TileCoords, tile_data: np.ndarray) -> bool`

**Description**: Caches a satellite tile to disk with flight_id association.

**Called By**:
- Internal (after fetching tiles)

**Input**:
```python
flight_id: str  # Flight this tile belongs to
tile_coords: TileCoords:
    x: int
    y: int
    zoom: int
tile_data: np.ndarray
```

**Output**:
```python
bool: True if cached successfully
```

**Processing Flow**:
1. Generate cache path: `/satellite_cache/{flight_id}/{zoom}/{tile_x}_{tile_y}.png`
2. Create flight cache directory if not exists
3. Serialize tile_data (PNG format)
4. Write to disk cache directory
5. Update cache index with flight_id association

**Error Conditions**:
- Returns `False`: Disk write error, space full

**Test Cases**:
1. **Cache new tile**: Writes successfully
2. **Overwrite existing**: Updates tile
3. **Disk full**: Returns False

---

### `get_cached_tile(flight_id: str, tile_coords: TileCoords) -> Optional[np.ndarray]`

**Description**: Retrieves a cached tile from disk, checking flight-specific cache first.

**Called By**:
- Internal (before fetching from API)
- F09 Metric Refinement (direct cache lookup)

**Input**:
```python
flight_id: str  # Flight to check cache for
tile_coords: TileCoords
```

**Output**:
```python
Optional[np.ndarray]: Tile image or None if not cached
```

**Processing Flow**:
1. Generate cache path: `/satellite_cache/{flight_id}/{zoom}/{tile_x}_{tile_y}.png`
2. Check flight-specific cache first
3. If not found, check global cache (shared tiles)
4. If file exists, load and deserialize
5. Return tile_data or None

**Error Conditions**:
- Returns `None`: Not cached, corrupted file

**Test Cases**:
1. **Cache hit**: Returns tile quickly
2. **Cache miss**: Returns None
3. **Corrupted cache**: Returns None, logs warning

---

### `get_tile_grid(center: TileCoords, grid_size: int) -> List[TileCoords]`

**Description**: Calculates tile coordinates for NxN grid centered on a tile.

**Called By**:
- Internal (for grid fetching)
- F11 Failure Recovery Coordinator

**Input**:
```python
center: TileCoords
grid_size: int  # 1, 4, 9, 16, 25
```

**Output**:
```python
List[TileCoords]  # List of tile coordinates in grid
```

**Algorithm**:
- For grid_size=9 (3×3): tiles from center-1 to center+1 in both x and y
- For grid_size=16 (4×4): asymmetric grid with center slightly off-center

**Test Cases**:
1. **1-tile grid**: Returns [center]
2. **4-tile grid (2×2)**: Returns 4 tiles
3. **9-tile grid (3×3)**: Returns 9 tiles centered
4. **25-tile grid (5×5)**: Returns 25 tiles

---

### `compute_tile_coords(lat: float, lon: float, zoom: int) -> TileCoords`

**Description**: Converts GPS coordinates to tile coordinates.

**Called By**:
- All methods that need tile coordinates from GPS

**Input**:
```python
lat: float
lon: float
zoom: int
```

**Output**:
```python
TileCoords:
    x: int
    y: int
    zoom: int
```

**Algorithm** (Web Mercator):
```python
n = 2 ** zoom
x = int((lon + 180) / 360 * n)
lat_rad = lat * π / 180
y = int((1 - log(tan(lat_rad) + sec(lat_rad)) / π) / 2 * n)
```

**Test Cases**:
1. **Ukraine coordinates**: Produces valid tile coords
2. **Edge cases**: lat=0, lon=0, lat=90, lon=180

---

### `expand_search_grid(center: TileCoords, current_size: int, new_size: int) -> List[TileCoords]`

**Description**: Returns only NEW tiles when expanding from current grid to larger grid.

**Called By**:
- F11 Failure Recovery Coordinator (progressive search optimization)

**Input**:
```python
center: TileCoords
current_size: int  # e.g., 4
new_size: int  # e.g., 9
```

**Output**:
```python
List[TileCoords]  # Only tiles not in current_size grid
```

**Purpose**: Avoid re-fetching tiles already tried in smaller grid.

**Test Cases**:
1. **4→9 expansion**: Returns 5 new tiles (9-4)
2. **9→16 expansion**: Returns 7 new tiles
3. **1→4 expansion**: Returns 3 new tiles

---

### `compute_tile_bounds(tile_coords: TileCoords) -> TileBounds`

**Description**: Computes GPS bounding box of a tile.

**Called By**:
- F09 Metric Refinement (for homography calculations)
- H06 Web Mercator Utils (shared calculation)

**Input**:
```python
tile_coords: TileCoords
```

**Output**:
```python
TileBounds:
    nw: GPSPoint  # North-West corner
    ne: GPSPoint  # North-East corner
    sw: GPSPoint  # South-West corner
    se: GPSPoint  # South-East corner
    center: GPSPoint
    gsd: float  # Ground Sampling Distance (meters/pixel)
```

**Algorithm**:
- Inverse Web Mercator projection
- GSD calculation: `156543.03392 * cos(lat * π/180) / 2^zoom`

**Test Cases**:
1. **Zoom 19 at Ukraine**: GSD ≈ 0.3 m/pixel
2. **Tile bounds**: Valid GPS coordinates

---

### `clear_flight_cache(flight_id: str) -> bool`

**Description**: Clears cached tiles for a completed flight.

**Called By**:
- F02.1 Flight Lifecycle Manager (cleanup after flight completion)

**Input**:
```python
flight_id: str
```

**Output**:
```python
bool: True if cleared successfully
```

**Processing Flow**:
1. Find all tiles associated with flight_id
2. Delete tile files
3. Update cache index

**Test Cases**:
1. **Clear flight cache**: Removes all associated tiles
2. **Non-existent flight**: Returns True (no-op)

## Integration Tests

### Test 1: Prefetch and Retrieval
1. prefetch_route_corridor() with 20 waypoints
2. Verify tiles cached
3. get_cached_tile() for each tile → all hit cache
4. clear_flight_cache() → cache cleared

### Test 2: Progressive Search Simulation
1. progressive_fetch() with [1, 4, 9, 16, 25]
2. Simulate match on 9 tiles
3. Verify only 1, 4, 9 fetched (not 16, 25)

### Test 3: Grid Expansion
1. fetch_tile_grid(4) → 4 tiles
2. expand_search_grid(4, 9) → 5 new tiles
3. Verify no duplicate fetches

## Non-Functional Requirements

### Performance
- **fetch_tile**: < 200ms (cached: < 10ms)
- **fetch_tile_grid(9)**: < 1 second
- **prefetch_route_corridor**: < 30 seconds for 500 tiles
- **Cache lookup**: < 5ms

### Scalability
- Cache 10,000+ tiles per flight
- Support 100 concurrent tile fetches
- Handle 10GB+ cache size

### Reliability
- Retry failed HTTP requests (3 attempts)
- Graceful degradation on partial failures
- Cache corruption recovery

## Dependencies

### Internal Components
- **H06 Web Mercator Utils**: Tile coordinate calculations

**Note on Tile Coordinate Calculations**: F04 delegates ALL tile coordinate calculations to H06 Web Mercator Utils:
- `compute_tile_coords()` → internally calls `H06.latlon_to_tile()`
- `compute_tile_bounds()` → internally calls `H06.compute_tile_bounds()`
- `get_tile_grid()` → uses H06 for coordinate math

This ensures single source of truth for Web Mercator projection logic and avoids duplication with H06.

### External Dependencies
- **Satellite Provider API**: HTTP tile source
- **requests** or **httpx**: HTTP client
- **numpy**: Image handling
- **opencv-python**: Image I/O
- **diskcache**: Persistent cache

## Data Models

### TileCoords
```python
class TileCoords(BaseModel):
    x: int
    y: int
    zoom: int
```

### TileBounds
```python
class TileBounds(BaseModel):
    nw: GPSPoint
    ne: GPSPoint
    sw: GPSPoint
    se: GPSPoint
    center: GPSPoint
    gsd: float  # meters/pixel
```

### CacheConfig
```python
class CacheConfig(BaseModel):
    cache_dir: str = "./satellite_cache"
    max_size_gb: int = 50
    eviction_policy: str = "lru"
    ttl_days: int = 30
```

