Files
rfcp/RFCP-Iteration-3.1.0-LOD-Optimization.md
2026-02-01 23:51:21 +02:00

333 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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"*