Files
rfcp/RFCP-3.10-LinkBudget-Fresnel-Interference.md

16 KiB

RFCP — Iteration 3.10: Link Budget, Fresnel Zone & Interference Modeling

Overview

Add three interconnected RF analysis features: link budget calculator panel, Fresnel zone visualization on terrain profiles, and basic interference (C/I) modeling for multi-site scenarios. These build on existing infrastructure — propagation models, terrain profiles, and multi-site coverage.

Priority Order

  1. Link Budget Calculator (simplest, standalone UI)
  2. Fresnel Zone Visualization (extends terrain profile)
  3. Interference Modeling (extends coverage engine)

Description

A panel/dialog that shows the complete RF link budget as a table — from transmitter to receiver. Uses existing propagation model values but presents them in the standard telecom link budget format.

Implementation

New component: frontend/src/components/panels/LinkBudgetPanel.tsx

The panel should display a table with rows for each element in the link chain. It should use the currently selected site's parameters and a configurable receiver point (either clicked on map or manually entered coordinates).

Link Budget Table Structure:

TRANSMITTER
  Tx Power (dBm)              [from site config, e.g. 43 dBm]
  Tx Antenna Gain (dBi)       [from site config, e.g. 18 dBi]  
  Tx Cable/Connector Loss (dB) [new field, default 2 dB]
  EIRP (dBm)                  = Tx Power + Gain - Cable Loss

PATH
  Distance (km)               [calculated from Tx to Rx point]
  Free Space Path Loss (dB)   [existing formula: 20log(d) + 20log(f) + 32.45]
  Terrain Diffraction Loss (dB) [from terrain_los model if available]
  Vegetation Loss (dB)         [from vegetation model if available]
  Atmospheric Loss (dB)        [from atmospheric model if available]
  Total Path Loss (dB)         = sum of all path losses

RECEIVER  
  Rx Antenna Gain (dBi)        [configurable, default 0 dBi for handset]
  Rx Cable Loss (dB)           [configurable, default 0 dB]
  Rx Sensitivity (dBm)         [configurable, default -100 dBm]

RESULT
  Received Power (dBm)         = EIRP - Total Path Loss + Rx Gain - Rx Cable
  Link Margin (dB)             = Received Power - Rx Sensitivity
  Status                       = "OK" if margin > 0, "FAIL" if < 0

Backend addition: Add a new endpoint or extend existing coverage API.

File: backend/app/api/routes/coverage.py (or new link_budget.py)

@router.post("/api/link-budget")
async def calculate_link_budget(request: dict):
    """Calculate point-to-point link budget.
    
    Body: {
        "site_id": "...",           # or tx_lat/tx_lon/tx_params
        "tx_lat": 48.46,
        "tx_lon": 35.04,
        "tx_power_dbm": 43,
        "tx_gain_dbi": 18,
        "tx_cable_loss_db": 2,
        "tx_height_m": 30,
        "rx_lat": 48.50,
        "rx_lon": 35.10,
        "rx_gain_dbi": 0,
        "rx_cable_loss_db": 0,
        "rx_sensitivity_dbm": -100,
        "rx_height_m": 1.5,
        "frequency_mhz": 1800
    }
    """
    from app.services.terrain_service import terrain_service
    
    # Calculate distance
    distance_m = terrain_service.haversine_distance(
        request["tx_lat"], request["tx_lon"],
        request["rx_lat"], request["rx_lon"]
    )
    distance_km = distance_m / 1000
    
    # Get elevations
    tx_elev = await terrain_service.get_elevation(request["tx_lat"], request["tx_lon"])
    rx_elev = await terrain_service.get_elevation(request["rx_lat"], request["rx_lon"])
    
    # EIRP
    eirp_dbm = request["tx_power_dbm"] + request["tx_gain_dbi"] - request["tx_cable_loss_db"]
    
    # Free space path loss
    freq = request["frequency_mhz"]
    fspl_db = 20 * math.log10(distance_km) + 20 * math.log10(freq) + 32.45 if distance_km > 0 else 0
    
    # Terrain profile for LOS check
    profile = await terrain_service.get_elevation_profile(
        request["tx_lat"], request["tx_lon"],
        request["rx_lat"], request["rx_lon"],
        num_points=100
    )
    
    # Simple LOS check - does terrain block line of sight?
    tx_total_height = tx_elev + request.get("tx_height_m", 30)
    rx_total_height = rx_elev + request.get("rx_height_m", 1.5)
    
    terrain_loss_db = 0
    los_clear = True
    for i, point in enumerate(profile):
        if i == 0 or i == len(profile) - 1:
            continue
        # Linear interpolation of LOS line at this point
        fraction = i / (len(profile) - 1)
        los_height = tx_total_height + fraction * (rx_total_height - tx_total_height)
        if point["elevation"] > los_height:
            los_clear = False
            # Simple knife-edge diffraction estimate
            terrain_loss_db += 6  # ~6dB per obstruction (simplified)
    
    total_path_loss = fspl_db + terrain_loss_db
    
    # Received power
    rx_power_dbm = eirp_dbm - total_path_loss + request["rx_gain_dbi"] - request["rx_cable_loss_db"]
    
    # Link margin
    margin_db = rx_power_dbm - request["rx_sensitivity_dbm"]
    
    return {
        "distance_km": round(distance_km, 2),
        "distance_m": round(distance_m, 1),
        "tx_elevation_m": round(tx_elev, 1),
        "rx_elevation_m": round(rx_elev, 1),
        "eirp_dbm": round(eirp_dbm, 1),
        "fspl_db": round(fspl_db, 1),
        "terrain_loss_db": round(terrain_loss_db, 1),
        "total_path_loss_db": round(total_path_loss, 1),
        "los_clear": los_clear,
        "rx_power_dbm": round(rx_power_dbm, 1),
        "margin_db": round(margin_db, 1),
        "status": "OK" if margin_db >= 0 else "FAIL",
        "profile": profile,
    }

UI Requirements

  • New panel accessible from sidebar or toolbar button (calculator icon)
  • Click on map to set Rx point (with crosshair cursor)
  • Auto-populates Tx params from selected site
  • Shows result table with color coding (green margin = OK, red = FAIL)
  • Optionally draws line on map from Tx to Rx

Feature 2: Fresnel Zone Visualization

Description

Draw Fresnel zone ellipse overlay on the Terrain Profile chart, showing where terrain intrudes into the first Fresnel zone. This is critical for understanding if a radio link will actually work — even if terrain doesn't block direct LOS, Fresnel zone obstruction causes significant signal loss.

Implementation

Modify: The existing Terrain Profile component/chart

Fresnel Zone Radius Formula:

import math

def fresnel_radius(n: int, frequency_mhz: float, d1_m: float, d2_m: float) -> float:
    """Calculate nth Fresnel zone radius at a point along the path.
    
    Args:
        n: Fresnel zone number (1 = first zone, most important)
        frequency_mhz: Frequency in MHz
        d1_m: Distance from transmitter to this point (meters)
        d2_m: Distance from this point to receiver (meters)
    
    Returns:
        Radius of nth Fresnel zone in meters
    """
    wavelength = 300.0 / frequency_mhz  # meters
    d_total = d1_m + d2_m
    if d_total == 0:
        return 0
    radius = math.sqrt((n * wavelength * d1_m * d2_m) / d_total)
    return radius

Backend endpoint: backend/app/api/routes/coverage.py

@router.post("/api/fresnel-profile")
async def fresnel_profile(request: dict):
    """Calculate terrain profile with Fresnel zone boundaries.
    
    Body: {
        "tx_lat": 48.46, "tx_lon": 35.04, "tx_height_m": 30,
        "rx_lat": 48.50, "rx_lon": 35.10, "rx_height_m": 1.5,
        "frequency_mhz": 1800,
        "num_points": 100
    }
    """
    from app.services.terrain_service import terrain_service
    
    tx_lat, tx_lon = request["tx_lat"], request["tx_lon"]
    rx_lat, rx_lon = request["rx_lat"], request["rx_lon"]
    tx_height = request.get("tx_height_m", 30)
    rx_height = request.get("rx_height_m", 1.5)
    freq = request.get("frequency_mhz", 1800)
    num_points = request.get("num_points", 100)
    
    # Get terrain profile
    profile = await terrain_service.get_elevation_profile(
        tx_lat, tx_lon, rx_lat, rx_lon, num_points
    )
    
    total_distance = profile[-1]["distance"] if profile else 0
    
    # Get endpoint elevations
    tx_elev = profile[0]["elevation"] if profile else 0
    rx_elev = profile[-1]["elevation"] if profile else 0
    tx_total = tx_elev + tx_height
    rx_total = rx_elev + rx_height
    
    wavelength = 300.0 / freq  # meters
    
    # Calculate Fresnel zone at each profile point
    fresnel_data = []
    los_blocked = False
    fresnel_blocked = False
    worst_clearance = float('inf')
    
    for i, point in enumerate(profile):
        d1 = point["distance"]  # distance from tx
        d2 = total_distance - d1  # distance to rx
        
        # LOS height at this point (linear interpolation)
        if total_distance > 0:
            fraction = d1 / total_distance
        else:
            fraction = 0
        los_height = tx_total + fraction * (rx_total - tx_total)
        
        # First Fresnel zone radius
        if d1 > 0 and d2 > 0 and total_distance > 0:
            f1_radius = math.sqrt((1 * wavelength * d1 * d2) / total_distance)
        else:
            f1_radius = 0
        
        # Fresnel zone boundaries (height above sea level)
        fresnel_top = los_height + f1_radius
        fresnel_bottom = los_height - f1_radius
        
        # Clearance: how much space between terrain and Fresnel bottom
        clearance = fresnel_bottom - point["elevation"]
        
        if clearance < worst_clearance:
            worst_clearance = clearance
        
        if point["elevation"] > los_height:
            los_blocked = True
        if point["elevation"] > fresnel_bottom:
            fresnel_blocked = True
        
        fresnel_data.append({
            "distance": point["distance"],
            "lat": point["lat"],
            "lon": point["lon"],
            "terrain_elevation": point["elevation"],
            "los_height": round(los_height, 1),
            "fresnel_top": round(fresnel_top, 1),
            "fresnel_bottom": round(fresnel_bottom, 1),
            "f1_radius": round(f1_radius, 1),
            "clearance": round(clearance, 1),
        })
    
    return {
        "profile": fresnel_data,
        "total_distance_m": round(total_distance, 1),
        "tx_elevation": round(tx_elev, 1),
        "rx_elevation": round(rx_elev, 1),
        "frequency_mhz": freq,
        "wavelength_m": round(wavelength, 4),
        "los_clear": not los_blocked,
        "fresnel_clear": not fresnel_blocked,
        "worst_clearance_m": round(worst_clearance, 1),
        "recommendation": (
            "Clear — excellent link" if not fresnel_blocked
            else "Fresnel zone partially blocked — expect 3-6 dB additional loss"
            if not los_blocked
            else "LOS blocked — significant diffraction loss expected"
        ),
    }

Frontend Visualization

On the existing Terrain Profile chart:

  • Draw the LOS line (straight line from Tx to Rx) — this may already exist
  • Draw first Fresnel zone as a semi-transparent elliptical area around the LOS line
    • Upper boundary = fresnel_top series
    • Lower boundary = fresnel_bottom series
    • Color: light blue with ~20% opacity
  • Where terrain intersects Fresnel zone, highlight in red/orange
  • Show clearance info in the profile tooltip
  • Add a summary badge: "LOS Clear ✓" / "Fresnel 60% Clear ⚠" / "LOS Blocked ✗"

Feature 3: Interference Modeling (C/I Ratio)

Description

Add carrier-to-interference ratio calculation to the coverage engine. For each grid point, calculate the C/I ratio: the signal from the serving cell vs the sum of signals from all other cells on the same frequency. Display as a separate heatmap layer.

Implementation

Backend changes:

File: backend/app/services/coverage_service.py (or gpu_service.py)

Add C/I calculation after existing coverage computation:

def calculate_interference(self, sites: list, coverage_results: dict) -> np.ndarray:
    """Calculate C/I ratio for each grid point.
    
    For each point:
    - C = signal strength from strongest (serving) cell
    - I = sum of signal strengths from all other co-frequency cells
    - C/I = C - 10*log10(sum of linear interference powers)
    
    Returns array of C/I values in dB.
    """
    # Get all RSRP grids (already calculated)
    # For each point, find:
    #   1. Best server (strongest signal) = C
    #   2. Sum of all others on same frequency = I
    #   3. C/I = C(dBm) - I(dBm)
    
    # Group sites by frequency
    freq_groups = {}
    for site in sites:
        freq = site.get("frequency_mhz", 1800)
        if freq not in freq_groups:
            freq_groups[freq] = []
        freq_groups[freq].append(site)
    
    # Only calculate interference for frequency groups with 2+ sites
    # For single-site frequencies, C/I = infinity (no interference)
    
    # The RSRP values are already in dBm, need to convert to linear for summing
    # P_linear = 10^(P_dBm / 10)
    # I_total_linear = sum(P_linear for all interferers)
    # I_total_dBm = 10 * log10(I_total_linear)
    # C/I = C_dBm - I_total_dBm
    pass

Key algorithm (for GPU pipeline in gpu_service.py):

# After computing RSRP for all sites at all grid points:
# rsrp_grid shape: (num_sites, num_points) in dBm

# Convert to linear (mW)
rsrp_linear = 10 ** (rsrp_grid / 10.0)  # CuPy array

# For each point, best server
best_server_idx = cp.argmax(rsrp_grid, axis=0)
best_rsrp_linear = cp.take_along_axis(rsrp_linear, best_server_idx[cp.newaxis, :], axis=0)[0]

# Total power from all sites
total_power = cp.sum(rsrp_linear, axis=0)

# Interference = total - serving
interference_linear = total_power - best_rsrp_linear

# C/I ratio in dB
# Avoid log10(0) with small epsilon
epsilon = 1e-30
ci_ratio_db = 10 * cp.log10(best_rsrp_linear / (interference_linear + epsilon))

# Clip to reasonable range
ci_ratio_db = cp.clip(ci_ratio_db, -20, 50)

Frontend Visualization

  • Add a toggle in the coverage controls: "Show: Signal (RSRP) | Interference (C/I)"
  • C/I heatmap uses different color scale:
    • Dark red: < 0 dB (interference dominant — no service)
    • Orange: 0-10 dB (marginal)
    • Yellow: 10-20 dB (acceptable)
    • Green: 20-30 dB (good)
    • Blue: > 30 dB (excellent, minimal interference)
  • The C/I map only makes sense with 2+ sites on same frequency
  • Show warning if all sites are on different frequencies (no co-channel interference)

API Response Extension

Add ci_ratio field to coverage calculation response alongside existing rsrp values.


Testing Checklist

  • Panel opens from toolbar/sidebar
  • Click on map sets Rx point
  • Tx parameters auto-populate from selected site
  • Link budget table shows all rows correctly
  • Margin calculation is correct (manual verification)
  • Color coding: green for positive margin, red for negative
  • Line drawn on map from Tx to Rx

Fresnel Zone

  • Terrain profile shows Fresnel zone overlay
  • Fresnel ellipse is widest at midpoint (correct shape)
  • Red highlighting where terrain enters Fresnel zone
  • Summary shows LOS/Fresnel status
  • Works at different frequencies (zone size changes with frequency)
  • Clearance values are reasonable (first Fresnel zone at 1800 MHz, 10km = ~22m radius at midpoint)

Interference

  • C/I toggle appears when 2+ sites exist
  • C/I heatmap renders with correct color scale
  • Single-site scenario shows "no interference" or infinite C/I
  • Two sites on same frequency show interference zones between them
  • C/I values are reasonable (> 20 dB near serving cell, < 10 dB at cell edge)

Build & Deploy

cd D:\root\rfcp

# Backend — just restart uvicorn (Python, no build)
cd backend
python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

# Frontend — rebuild if UI components changed
cd frontend  
npm run build

# Full installer rebuild if needed
# (use existing build script)

Commit Message

feat(rf): add link budget, Fresnel zone, and interference modeling

- Add /api/link-budget endpoint with full path analysis
- Add /api/fresnel-profile endpoint with zone clearance calculation
- Add C/I ratio computation to GPU coverage pipeline
- Add LinkBudgetPanel frontend component
- Add Fresnel zone overlay to terrain profile chart
- Add C/I heatmap toggle alongside RSRP display
- Group interference by frequency for co-channel analysis

Success Criteria

  1. Link budget shows correct margin for known test case (Dnipro, 10km, 1800MHz)
  2. Fresnel zone visually shows ellipse on terrain profile
  3. Two co-frequency sites show interference pattern between them
  4. All three features work with existing terrain data (no new downloads needed)
  5. GPU pipeline performance not significantly degraded by C/I calculation