@mytec: iter3.2.1 start
This commit is contained in:
332
RFCP-Iteration-3.1.0-LOD-Optimization.md
Normal file
332
RFCP-Iteration-3.1.0-LOD-Optimization.md
Normal file
@@ -0,0 +1,332 @@
|
||||
# RFCP - Iteration 3.1.0: LOD (Level of Detail) Optimization
|
||||
|
||||
## Overview
|
||||
|
||||
Detailed preset times out at 300s because dominant_path_service calculates expensive geometry for ALL 868 points. This iteration adds distance-based LOD to skip or simplify calculations for distant points, reducing total time to <60s.
|
||||
|
||||
**Current:** 302.8ms/point × 868 points = 262s (TIMEOUT)
|
||||
**Target:** ~33s total (8x speedup)
|
||||
|
||||
---
|
||||
|
||||
## Issues Identified
|
||||
|
||||
**Problem 1: All points get full dominant_path calculation**
|
||||
- Root Cause: No distance-based filtering
|
||||
- Impact: Points >3km from TX still check 25+ buildings × 150+ walls
|
||||
- At these distances, building-level detail provides minimal accuracy benefit
|
||||
|
||||
**Problem 2: dominant_path is O(points × buildings × walls)**
|
||||
- Root Cause: Algorithmic complexity
|
||||
- Impact: 868 × 25 × 150 = 3.2M intersection checks
|
||||
- Each check is ~0.1ms = 320 seconds theoretical minimum
|
||||
|
||||
---
|
||||
|
||||
## Solution: Distance-Based LOD
|
||||
|
||||
### LOD Levels
|
||||
|
||||
```
|
||||
Distance > 3km → LOD_NONE → Skip dominant_path entirely (0 buildings)
|
||||
Distance 1.5-3km → LOD_SIMPLIFIED → Check only 5 nearest buildings
|
||||
Distance < 1.5km → LOD_FULL → Full calculation (current behavior)
|
||||
```
|
||||
|
||||
### Expected Performance
|
||||
|
||||
| LOD Level | Distance | Points (~) | Time/point | Total |
|
||||
|-------------|-----------|------------|------------|---------|
|
||||
| NONE | >3km | 600 (70%) | ~2ms | 1.2s |
|
||||
| SIMPLIFIED | 1.5-3km | 180 (20%) | ~30ms | 5.4s |
|
||||
| FULL | <1.5km | 88 (10%) | ~300ms | 26.4s |
|
||||
| **TOTAL** | | 868 | | **~33s**|
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Step 1: Add LOD constants to dominant_path_service.py
|
||||
|
||||
**File:** `backend/app/services/dominant_path_service.py`
|
||||
|
||||
**Add at top of file (after imports):**
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
class LODLevel(Enum):
|
||||
"""Level of Detail for dominant path calculations"""
|
||||
NONE = "none" # Skip dominant path entirely
|
||||
SIMPLIFIED = "simplified" # Check only nearest buildings
|
||||
FULL = "full" # Full calculation
|
||||
|
||||
# LOD distance thresholds (meters)
|
||||
LOD_THRESHOLD_NONE = 3000 # >3km: skip dominant path
|
||||
LOD_THRESHOLD_SIMPLIFIED = 1500 # 1.5-3km: simplified mode
|
||||
|
||||
# Simplified mode limits
|
||||
SIMPLIFIED_MAX_BUILDINGS = 5
|
||||
SIMPLIFIED_MAX_WALLS = 50
|
||||
```
|
||||
|
||||
### Step 2: Add get_lod_level() function
|
||||
|
||||
**File:** `backend/app/services/dominant_path_service.py`
|
||||
|
||||
**Add function:**
|
||||
|
||||
```python
|
||||
def get_lod_level(distance_m: float) -> LODLevel:
|
||||
"""
|
||||
Determine LOD level based on TX-RX distance.
|
||||
|
||||
At long distances, building-level multipath contributes
|
||||
minimally to path loss - macro propagation models suffice.
|
||||
"""
|
||||
if distance_m > LOD_THRESHOLD_NONE:
|
||||
return LODLevel.NONE
|
||||
elif distance_m > LOD_THRESHOLD_SIMPLIFIED:
|
||||
return LODLevel.SIMPLIFIED
|
||||
else:
|
||||
return LODLevel.FULL
|
||||
```
|
||||
|
||||
### Step 3: Create find_dominant_path_with_lod() wrapper
|
||||
|
||||
**File:** `backend/app/services/dominant_path_service.py`
|
||||
|
||||
**Add function (this wraps existing logic):**
|
||||
|
||||
```python
|
||||
def find_dominant_path_with_lod(
|
||||
tx_lat: float, tx_lon: float, tx_height: float,
|
||||
rx_lat: float, rx_lon: float, rx_height: float,
|
||||
frequency_mhz: float,
|
||||
buildings: list,
|
||||
distance_m: float = None
|
||||
) -> dict:
|
||||
"""
|
||||
Find dominant path with LOD optimization.
|
||||
|
||||
Args:
|
||||
tx_lat, tx_lon, tx_height: Transmitter position
|
||||
rx_lat, rx_lon, rx_height: Receiver position
|
||||
frequency_mhz: Operating frequency
|
||||
buildings: List of building dicts from OSM
|
||||
distance_m: Pre-calculated TX-RX distance (optional, saves recalc)
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
- path_loss_db: Additional path loss from buildings (0 if skipped)
|
||||
- lod_level: Which LOD was applied
|
||||
- buildings_checked: How many buildings were evaluated
|
||||
- walls_checked: How many walls were evaluated
|
||||
- skipped: True if dominant_path was skipped entirely
|
||||
"""
|
||||
from app.services.terrain_service import TerrainService
|
||||
|
||||
# Calculate distance if not provided
|
||||
if distance_m is None:
|
||||
distance_m = TerrainService.haversine_distance(tx_lat, tx_lon, rx_lat, rx_lon)
|
||||
|
||||
lod = get_lod_level(distance_m)
|
||||
|
||||
# LOD_NONE: Skip dominant path entirely
|
||||
if lod == LODLevel.NONE:
|
||||
return {
|
||||
"path_loss_db": 0.0,
|
||||
"lod_level": "none",
|
||||
"buildings_checked": 0,
|
||||
"walls_checked": 0,
|
||||
"skipped": True
|
||||
}
|
||||
|
||||
# Filter buildings for LOD_SIMPLIFIED
|
||||
buildings_to_check = buildings
|
||||
if lod == LODLevel.SIMPLIFIED and buildings:
|
||||
if len(buildings) > SIMPLIFIED_MAX_BUILDINGS:
|
||||
# Sort by distance to path midpoint and take nearest
|
||||
mid_lat = (tx_lat + rx_lat) / 2
|
||||
mid_lon = (tx_lon + rx_lon) / 2
|
||||
|
||||
buildings_with_dist = []
|
||||
for b in buildings:
|
||||
# Get building centroid from geometry
|
||||
geom = b.get('geometry', {})
|
||||
coords = geom.get('coordinates', [[]])[0] if isinstance(geom, dict) else b.get('geometry', [[]])
|
||||
|
||||
if coords and len(coords) > 0:
|
||||
# Handle both formats: [[lon,lat],...] or [{'lon':..,'lat':..},...]
|
||||
if isinstance(coords[0], (list, tuple)):
|
||||
blat = sum(c[1] for c in coords) / len(coords)
|
||||
blon = sum(c[0] for c in coords) / len(coords)
|
||||
else:
|
||||
blat = sum(c.get('lat', c.get('y', 0)) for c in coords) / len(coords)
|
||||
blon = sum(c.get('lon', c.get('x', 0)) for c in coords) / len(coords)
|
||||
|
||||
dist = TerrainService.haversine_distance(mid_lat, mid_lon, blat, blon)
|
||||
buildings_with_dist.append((dist, b))
|
||||
|
||||
buildings_with_dist.sort(key=lambda x: x[0])
|
||||
buildings_to_check = [b for _, b in buildings_with_dist[:SIMPLIFIED_MAX_BUILDINGS]]
|
||||
|
||||
# Call existing dominant path function
|
||||
# Look for existing function: find_dominant_path_vectorized, find_dominant_paths, etc.
|
||||
try:
|
||||
# Try vectorized version first
|
||||
result = find_dominant_path_vectorized(
|
||||
tx_lat, tx_lon,
|
||||
rx_lat, rx_lon,
|
||||
buildings_to_check,
|
||||
frequency_mhz
|
||||
)
|
||||
except (NameError, AttributeError):
|
||||
# Fall back to sync version if vectorized not available
|
||||
try:
|
||||
result = dominant_path_service.find_dominant_paths(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz,
|
||||
buildings_to_check
|
||||
)
|
||||
except:
|
||||
# If no dominant path function works, return zero loss
|
||||
result = {"path_loss_db": 0.0}
|
||||
|
||||
# Ensure result is dict
|
||||
if not isinstance(result, dict):
|
||||
result = {"path_loss_db": float(result) if result else 0.0}
|
||||
|
||||
# Add LOD metadata
|
||||
result["lod_level"] = lod.value
|
||||
result["buildings_checked"] = len(buildings_to_check)
|
||||
result["skipped"] = False
|
||||
|
||||
return result
|
||||
```
|
||||
|
||||
### Step 4: Add logging for LOD decisions
|
||||
|
||||
**File:** `backend/app/services/dominant_path_service.py`
|
||||
|
||||
**Add after LOD decision (inside find_dominant_path_with_lod):**
|
||||
|
||||
```python
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Add this right after lod = get_lod_level(distance_m):
|
||||
if lod == LODLevel.NONE:
|
||||
logger.debug(f"[DOMINANT_PATH] LOD=none, dist={distance_m:.0f}m, skipped")
|
||||
elif lod == LODLevel.SIMPLIFIED:
|
||||
logger.debug(f"[DOMINANT_PATH] LOD=simplified, dist={distance_m:.0f}m, buildings={len(buildings_to_check)}")
|
||||
else:
|
||||
logger.debug(f"[DOMINANT_PATH] LOD=full, dist={distance_m:.0f}m, buildings={len(buildings_to_check)}")
|
||||
```
|
||||
|
||||
### Step 5: Update coverage calculation to use LOD wrapper
|
||||
|
||||
**File:** `backend/app/services/coverage_service.py` OR `backend/app/services/parallel_coverage_service.py`
|
||||
|
||||
**Find where dominant_path is called and replace with LOD version:**
|
||||
|
||||
```python
|
||||
# BEFORE (find lines like this):
|
||||
dominant_result = find_dominant_path_vectorized(tx, rx, buildings, ...)
|
||||
# or
|
||||
dominant_result = dominant_path_service.find_dominant_paths(...)
|
||||
|
||||
# AFTER (replace with):
|
||||
from app.services.dominant_path_service import find_dominant_path_with_lod
|
||||
|
||||
dominant_result = find_dominant_path_with_lod(
|
||||
tx_lat, tx_lon, tx_height,
|
||||
rx_lat, rx_lon, rx_height,
|
||||
frequency_mhz,
|
||||
buildings,
|
||||
distance_m=point_distance # Pass pre-calculated distance if available
|
||||
)
|
||||
|
||||
# Use the result
|
||||
if not dominant_result.get("skipped", False):
|
||||
total_loss += dominant_result.get("path_loss_db", 0.0)
|
||||
```
|
||||
|
||||
### Step 6: Update worker function (if using parallel processing)
|
||||
|
||||
**File:** `backend/app/parallel/worker.py` OR wherever worker calculates points
|
||||
|
||||
**Same pattern - use find_dominant_path_with_lod instead of direct calls.**
|
||||
|
||||
---
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
- [ ] LODLevel enum imports correctly
|
||||
- [ ] get_lod_level(4000) returns LODLevel.NONE
|
||||
- [ ] get_lod_level(2000) returns LODLevel.SIMPLIFIED
|
||||
- [ ] get_lod_level(1000) returns LODLevel.FULL
|
||||
- [ ] Detailed preset completes without timeout
|
||||
- [ ] Detailed preset time < 90 seconds (target: ~33s)
|
||||
- [ ] Standard preset still works (regression check)
|
||||
- [ ] Logs show LOD decisions: "LOD=none", "LOD=simplified", "LOD=full"
|
||||
- [ ] Coverage map looks reasonable (no obvious artifacts at LOD boundaries)
|
||||
|
||||
---
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
```powershell
|
||||
# Backend
|
||||
cd D:\root\rfcp\backend
|
||||
pip install -e .
|
||||
|
||||
# Test
|
||||
cd D:\root\rfcp\installer
|
||||
.\test-detailed-quick.bat
|
||||
|
||||
# If works, rebuild executable
|
||||
cd D:\root\rfcp\installer
|
||||
pyinstaller rfcp-server.spec --clean
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Commit Message
|
||||
|
||||
```
|
||||
feat(backend): add LOD optimization for dominant_path (v3.1.0)
|
||||
|
||||
- Add LODLevel enum (NONE, SIMPLIFIED, FULL)
|
||||
- Add distance thresholds: >3km skip, 1.5-3km simplified, <1.5km full
|
||||
- Create find_dominant_path_with_lod() wrapper
|
||||
- Update coverage calculation to use LOD
|
||||
- Expected: 8x speedup for Detailed preset (262s -> ~33s)
|
||||
|
||||
Phase 3.1.0: Performance Optimization
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
1. **Performance:** Detailed preset completes in <90 seconds (target ~33s)
|
||||
2. **No regression:** Standard preset still works, same speed
|
||||
3. **Logging:** Can see LOD level in server output
|
||||
4. **Quality:** Coverage map visually acceptable (no obvious LOD boundary artifacts)
|
||||
|
||||
---
|
||||
|
||||
## Notes for Claude Code
|
||||
|
||||
- The existing codebase has multiple dominant_path functions - find the one actually being used
|
||||
- Check both `coverage_service.py` and `parallel_coverage_service.py`
|
||||
- Worker processes may have their own copy of the function - update those too
|
||||
- If `find_dominant_path_vectorized` doesn't exist as standalone function, look for it in a class
|
||||
- haversine_distance might be in TerrainService or as standalone function - check imports
|
||||
- Building geometry format varies - handle both `[[lon,lat],...]` and `[{lon:...,lat:...},...]`
|
||||
|
||||
---
|
||||
|
||||
*"Not all points are created equal - distant ones deserve less attention"*
|
||||
Reference in New Issue
Block a user