@mytec iter3.5.1 start
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
# RFCP Iteration 3.4.0 — Large Radius Support (20-50km)
|
||||
|
||||
## Goal
|
||||
|
||||
Enable 50km radius calculations without OOM by implementing memory-efficient processing patterns.
|
||||
|
||||
**Current limitation:** > 10-20km radius causes OOM (5+ GB RAM usage)
|
||||
**Target:** 50km radius with < 4GB RAM peak
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Memory-Mapped Terrain
|
||||
|
||||
### 1.1 Terrain mmap Loading
|
||||
|
||||
Change terrain_service to use memory-mapped files instead of loading full arrays into RAM.
|
||||
|
||||
**File:** `backend/app/services/terrain_service.py`
|
||||
|
||||
```python
|
||||
# Before (loads ~25 MB per tile into RAM):
|
||||
terrain = np.fromfile(f, dtype='>i2').reshape((rows, cols))
|
||||
|
||||
# After (near-zero RAM, OS pages from disk):
|
||||
terrain = np.memmap(f, dtype='>i2', mode='r', shape=(rows, cols))
|
||||
```
|
||||
|
||||
**Expected impact:** -200-400 MB RAM per tile
|
||||
|
||||
### 1.2 Terrain Disk Cache
|
||||
|
||||
- Save downloaded .hgt files to persistent disk cache
|
||||
- Don't keep raw arrays in memory after initial processing
|
||||
- Implement LRU eviction if cache exceeds 2GB
|
||||
- Location: `~/.rfcp/terrain_cache/`
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Tile-Based Processing
|
||||
|
||||
### 2.1 Split Large Calculations
|
||||
|
||||
If radius > 10km, split calculation area into 5km sub-tiles.
|
||||
|
||||
**File:** `backend/app/services/coverage_service.py` (or new `tile_processor.py`)
|
||||
|
||||
```python
|
||||
def calculate_coverage_tiled(site, radius_m, resolution_m, settings):
|
||||
"""Tile-based calculation for large radius."""
|
||||
|
||||
# Small radius — use existing single-pass
|
||||
if radius_m <= 10000:
|
||||
return calculate_coverage_single(site, radius_m, resolution_m, settings)
|
||||
|
||||
# Large radius — split into tiles
|
||||
TILE_SIZE = 5000 # 5km tiles
|
||||
tiles = generate_tile_grid(site.lat, site.lon, radius_m, TILE_SIZE)
|
||||
|
||||
all_results = []
|
||||
|
||||
for i, tile in enumerate(tiles):
|
||||
log(f"Processing tile {i+1}/{len(tiles)}: {tile.bbox}")
|
||||
|
||||
# Load data for this tile only
|
||||
tile_terrain = load_terrain_for_bbox(tile.bbox)
|
||||
tile_buildings = load_buildings_for_bbox(tile.bbox)
|
||||
|
||||
# Calculate coverage for tile
|
||||
tile_points = generate_grid_for_tile(tile, resolution_m)
|
||||
tile_results = calculate_points(tile_points, site, settings,
|
||||
tile_terrain, tile_buildings)
|
||||
|
||||
all_results.extend(tile_results)
|
||||
|
||||
# Free memory
|
||||
del tile_terrain, tile_buildings
|
||||
gc.collect()
|
||||
|
||||
# Report progress
|
||||
progress = (i + 1) / len(tiles) * 100
|
||||
yield_progress(progress, f"Tile {i+1}/{len(tiles)}")
|
||||
|
||||
return merge_and_dedupe_results(all_results)
|
||||
|
||||
|
||||
def generate_tile_grid(center_lat, center_lon, radius_m, tile_size_m):
|
||||
"""Generate grid of tiles covering the calculation area."""
|
||||
tiles = []
|
||||
|
||||
# Calculate bbox of full area
|
||||
lat_delta = radius_m / 111000
|
||||
lon_delta = radius_m / (111000 * cos(radians(center_lat)))
|
||||
|
||||
# Generate tile grid
|
||||
n_tiles = ceil(radius_m * 2 / tile_size_m)
|
||||
|
||||
for i in range(n_tiles):
|
||||
for j in range(n_tiles):
|
||||
tile_bbox = calculate_tile_bbox(center_lat, center_lon,
|
||||
i, j, n_tiles, tile_size_m)
|
||||
|
||||
# Only include tiles that intersect with coverage circle
|
||||
if tile_intersects_circle(tile_bbox, center_lat, center_lon, radius_m):
|
||||
tiles.append(Tile(bbox=tile_bbox, index=(i, j)))
|
||||
|
||||
return tiles
|
||||
```
|
||||
|
||||
### 2.2 Progressive Results via WebSocket
|
||||
|
||||
Send results per-tile as they complete, so user sees coverage growing.
|
||||
|
||||
**File:** `backend/app/api/websocket.py`
|
||||
|
||||
```python
|
||||
async def calculate_coverage_ws(websocket, params):
|
||||
for tile_results in calculate_coverage_tiled_generator(params):
|
||||
# Send partial results
|
||||
await websocket.send_json({
|
||||
"type": "partial_results",
|
||||
"points": tile_results.points,
|
||||
"progress": tile_results.progress,
|
||||
"tile": tile_results.tile_index,
|
||||
"status": f"Tile {tile_results.tile_index} complete"
|
||||
})
|
||||
|
||||
# Final message
|
||||
await websocket.send_json({
|
||||
"type": "complete",
|
||||
"total_points": total_points,
|
||||
"computation_time": elapsed
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: SQLite Cache for OSM Data
|
||||
|
||||
### 3.1 Create Local Database
|
||||
|
||||
Replace in-memory OSM cache with SQLite database with spatial indexing.
|
||||
|
||||
**File:** `backend/app/services/cache_db.py` (NEW)
|
||||
|
||||
```python
|
||||
import sqlite3
|
||||
import json
|
||||
|
||||
class OSMCacheDB:
|
||||
def __init__(self, db_path="~/.rfcp/osm_cache.db"):
|
||||
self.conn = sqlite3.connect(db_path)
|
||||
self._init_tables()
|
||||
|
||||
def _init_tables(self):
|
||||
self.conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS buildings (
|
||||
id INTEGER PRIMARY KEY,
|
||||
osm_id TEXT UNIQUE,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
height REAL DEFAULT 10.0,
|
||||
geometry TEXT, -- GeoJSON
|
||||
cell_key TEXT, -- grid cell for batch loading
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_buildings_lat ON buildings(lat);
|
||||
CREATE INDEX IF NOT EXISTS idx_buildings_lon ON buildings(lon);
|
||||
CREATE INDEX IF NOT EXISTS idx_buildings_cell ON buildings(cell_key);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS vegetation (
|
||||
id INTEGER PRIMARY KEY,
|
||||
osm_id TEXT UNIQUE,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
type TEXT,
|
||||
geometry TEXT,
|
||||
cell_key TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_veg_lat ON vegetation(lat);
|
||||
CREATE INDEX IF NOT EXISTS idx_veg_lon ON vegetation(lon);
|
||||
|
||||
-- Metadata for cache invalidation
|
||||
CREATE TABLE IF NOT EXISTS cache_meta (
|
||||
cell_key TEXT PRIMARY KEY,
|
||||
data_type TEXT,
|
||||
fetched_at TIMESTAMP,
|
||||
item_count INTEGER
|
||||
);
|
||||
""")
|
||||
self.conn.commit()
|
||||
|
||||
def query_buildings_bbox(self, min_lat, max_lat, min_lon, max_lon, limit=20000):
|
||||
"""Query buildings within bounding box."""
|
||||
cursor = self.conn.execute("""
|
||||
SELECT osm_id, lat, lon, height, geometry
|
||||
FROM buildings
|
||||
WHERE lat BETWEEN ? AND ?
|
||||
AND lon BETWEEN ? AND ?
|
||||
LIMIT ?
|
||||
""", (min_lat, max_lat, min_lon, max_lon, limit))
|
||||
|
||||
return [self._row_to_building(row) for row in cursor]
|
||||
|
||||
def insert_buildings(self, buildings, cell_key):
|
||||
"""Bulk insert buildings from OSM fetch."""
|
||||
self.conn.executemany("""
|
||||
INSERT OR IGNORE INTO buildings
|
||||
(osm_id, lat, lon, height, geometry, cell_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", [
|
||||
(b['id'], b['lat'], b['lon'], b.get('height', 10),
|
||||
json.dumps(b.get('geometry')), cell_key)
|
||||
for b in buildings
|
||||
])
|
||||
self.conn.commit()
|
||||
|
||||
def is_cell_cached(self, cell_key, data_type, max_age_hours=24):
|
||||
"""Check if cell data is cached and fresh."""
|
||||
cursor = self.conn.execute("""
|
||||
SELECT fetched_at FROM cache_meta
|
||||
WHERE cell_key = ? AND data_type = ?
|
||||
AND fetched_at > datetime('now', ?)
|
||||
""", (cell_key, data_type, f'-{max_age_hours} hours'))
|
||||
|
||||
return cursor.fetchone() is not None
|
||||
```
|
||||
|
||||
### 3.2 Update OSM Client
|
||||
|
||||
Modify OSM client to use SQLite cache.
|
||||
|
||||
**File:** `backend/app/services/osm_client.py`
|
||||
|
||||
```python
|
||||
class OSMClient:
|
||||
def __init__(self):
|
||||
self.cache_db = OSMCacheDB()
|
||||
|
||||
def get_buildings(self, bbox, max_count=20000):
|
||||
min_lat, min_lon, max_lat, max_lon = bbox
|
||||
cell_key = self._bbox_to_cell_key(bbox)
|
||||
|
||||
# Check cache first
|
||||
if self.cache_db.is_cell_cached(cell_key, 'buildings'):
|
||||
return self.cache_db.query_buildings_bbox(
|
||||
min_lat, max_lat, min_lon, max_lon, max_count
|
||||
)
|
||||
|
||||
# Fetch from Overpass API
|
||||
buildings = self._fetch_from_overpass(bbox, 'buildings')
|
||||
|
||||
# Store in cache
|
||||
self.cache_db.insert_buildings(buildings, cell_key)
|
||||
|
||||
return buildings[:max_count]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Worker Memory Optimization
|
||||
|
||||
### 4.1 Per-Tile Building Loading
|
||||
|
||||
Workers receive only tile bbox and query buildings themselves (or receive pre-filtered list).
|
||||
|
||||
```python
|
||||
def _pool_worker_tiled(args):
|
||||
"""Worker that loads buildings for its tile only."""
|
||||
tile_bbox, terrain_shm_refs, config = args
|
||||
|
||||
# Load only buildings for this tile
|
||||
cache_db = OSMCacheDB()
|
||||
buildings = cache_db.query_buildings_bbox(*tile_bbox, limit=5000)
|
||||
|
||||
# Much smaller memory footprint per worker
|
||||
# ...rest of calculation
|
||||
```
|
||||
|
||||
### 4.2 Adaptive Worker Count
|
||||
|
||||
Reduce workers for large radius to prevent combined memory explosion.
|
||||
|
||||
```python
|
||||
def get_worker_count_for_radius(radius_m, base_workers):
|
||||
"""Scale down workers for large calculations."""
|
||||
if radius_m > 30000:
|
||||
return min(base_workers, 2)
|
||||
elif radius_m > 20000:
|
||||
return min(base_workers, 3)
|
||||
elif radius_m > 10000:
|
||||
return min(base_workers, 4)
|
||||
return base_workers
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Frontend Progressive Rendering
|
||||
|
||||
### 5.1 Accumulate Partial Results
|
||||
|
||||
**File:** `frontend/src/store/coverage.ts`
|
||||
|
||||
```typescript
|
||||
interface CoverageState {
|
||||
points: CoveragePoint[];
|
||||
isCalculating: boolean;
|
||||
progress: number;
|
||||
// NEW:
|
||||
partialResults: CoveragePoint[];
|
||||
tilesCompleted: number;
|
||||
totalTiles: number;
|
||||
}
|
||||
|
||||
// Handle partial results
|
||||
case 'partial_results':
|
||||
set(state => ({
|
||||
partialResults: [...state.partialResults, ...message.points],
|
||||
progress: message.progress,
|
||||
tilesCompleted: state.tilesCompleted + 1
|
||||
}));
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
set(state => ({
|
||||
points: state.partialResults, // Finalize
|
||||
partialResults: [],
|
||||
isCalculating: false
|
||||
}));
|
||||
break;
|
||||
```
|
||||
|
||||
### 5.2 Incremental Heatmap Render
|
||||
|
||||
**File:** `frontend/src/components/map/CoverageHeatmap.tsx`
|
||||
|
||||
```typescript
|
||||
function CoverageHeatmap() {
|
||||
const { points, partialResults, isCalculating } = useCoverageStore();
|
||||
|
||||
// Show partial results while calculating
|
||||
const displayPoints = isCalculating ? partialResults : points;
|
||||
|
||||
// Throttle re-renders during streaming (every 500 points)
|
||||
const throttledPoints = useThrottle(displayPoints, 500);
|
||||
|
||||
return <HeatmapLayer points={throttledPoints} />;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Order
|
||||
|
||||
### Priority 1 — Biggest Impact
|
||||
1. **Tile-based processing** (Phase 2.1) — enables large radius
|
||||
2. **SQLite cache** (Phase 3) — reduces memory, speeds up repeated calcs
|
||||
|
||||
### Priority 2 — Memory Reduction
|
||||
3. **Terrain mmap** (Phase 1.1) — easy win, minimal code change
|
||||
4. **Per-tile building loading** (Phase 4.1)
|
||||
|
||||
### Priority 3 — UX Improvement
|
||||
5. **Progressive WebSocket** (Phase 2.2)
|
||||
6. **Frontend streaming** (Phase 5)
|
||||
|
||||
### Priority 4 — Polish
|
||||
7. **Terrain disk cache** (Phase 1.2)
|
||||
8. **Adaptive worker count** (Phase 4.2)
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
| Radius | Max Time | Max RAM |
|
||||
|--------|----------|---------|
|
||||
| 20 km | < 3 min | < 3 GB |
|
||||
| 30 km | < 5 min | < 3.5 GB |
|
||||
| 50 km | < 10 min | < 4 GB |
|
||||
|
||||
- No OOM crashes at any radius up to 50km
|
||||
- Progressive results visible within 30s of starting
|
||||
- Cache reuse speeds up repeated calculations 5-10x
|
||||
|
||||
---
|
||||
|
||||
## Files to Modify
|
||||
|
||||
### Backend (Python)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `terrain_service.py` | mmap loading, disk cache |
|
||||
| `coverage_service.py` | tile-based routing |
|
||||
| `parallel_coverage_service.py` | adaptive workers |
|
||||
| `osm_client.py` | SQLite integration |
|
||||
| `websocket.py` | streaming results |
|
||||
| **NEW** `tile_processor.py` | tile generation & processing |
|
||||
| **NEW** `cache_db.py` | SQLite cache layer |
|
||||
|
||||
### Frontend (TypeScript)
|
||||
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `store/coverage.ts` | partial results handling |
|
||||
| `CoverageHeatmap.tsx` | incremental rendering |
|
||||
| `App.tsx` | progress for tiled calc |
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Test 20km radius
|
||||
curl -X POST http://localhost:8888/api/coverage/calculate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"radius": 20000, "resolution": 500, "preset": "standard"}'
|
||||
|
||||
# Monitor memory
|
||||
watch -n 1 'ps aux | grep rfcp-server | awk "{print \$6/1024\" MB\"}"'
|
||||
|
||||
# Test 50km radius
|
||||
curl -X POST http://localhost:8888/api/coverage/calculate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"radius": 50000, "resolution": 1000, "preset": "standard"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Tile size 5km is a balance — smaller = more overhead, larger = more memory
|
||||
- SQLite R-tree extension would be faster but requires compilation
|
||||
- For Rust version, all of this will be native and faster
|
||||
|
||||
---
|
||||
|
||||
*"Think in tiles, stream results, cache everything"* 🗺️
|
||||
1096
docs/devlog/gpu_supp/RFCP-Iteration-3.5.0-GPU-Acceleration.md
Normal file
1096
docs/devlog/gpu_supp/RFCP-Iteration-3.5.0-GPU-Acceleration.md
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user