# 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) --- ## Feature 1: Link Budget Calculator ### 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`) ```python @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:** ```python 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` ```python @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: ```python 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):** ```python # 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 ### Link Budget - [ ] 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 ```bash 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