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

11 KiB
Raw Blame History

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):

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:

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):

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):

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:

# 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

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