# 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"*