@mytec: stack done, rust next
This commit is contained in:
@@ -0,0 +1,463 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user