@mytec: stack done, rust next

This commit is contained in:
2026-02-07 12:56:25 +02:00
parent 1d8375af02
commit 833dead43c
15 changed files with 1609 additions and 141 deletions

View File

@@ -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