506 lines
14 KiB
Markdown
506 lines
14 KiB
Markdown
# RFCP - Iteration 5: RF Accuracy & UX Polish
|
|
|
|
## Issues Identified
|
|
|
|
1. ❌ **Antenna back lobe missing** - Real antennas radiate backwards (F/B ratio ~20-25 dB)
|
|
2. ❌ **Heatmap colors change with zoom** - Same RSRP shows different colors at different zoom levels
|
|
3. ❌ **Antenna gain ignored** - 25 dBi vs 0 dBi produces identical coverage
|
|
4. ❌ **Antenna height ignored** - 10m vs 100m towers show same coverage
|
|
5. ❌ **Batch edit UX issues** - Edit panel stays open, values not updated dynamically
|
|
|
|
---
|
|
|
|
## CRITICAL FIX 1: Accurate 3GPP Antenna Pattern
|
|
|
|
**Problem:** Current implementation has hard cutoff at beamwidth/2. Real sector antennas have:
|
|
- Main lobe with gradual attenuation
|
|
- Side lobes (~15-20 dB down)
|
|
- Back lobe (~20-25 dB down, not -∞)
|
|
|
|
### Correct 3GPP Formula
|
|
|
|
**File:** `frontend/src/workers/rf-worker.js`
|
|
|
|
Replace antenna pattern calculation:
|
|
|
|
```javascript
|
|
function calculate3GPPPattern(azimuth, bearing, beamwidth = 65, frontBackRatio = 25) {
|
|
// Normalize angle difference to -180...+180
|
|
let angleDiff = bearing - azimuth;
|
|
while (angleDiff > 180) angleDiff -= 360;
|
|
while (angleDiff < -180) angleDiff += 360;
|
|
|
|
const theta = Math.abs(angleDiff);
|
|
|
|
// 3GPP TR 36.814 Horizontal Pattern
|
|
// A(θ) = -min[12(θ/θ_3dB)², A_m]
|
|
const theta3dB = beamwidth / 2; // Half-power beamwidth
|
|
const Am = frontBackRatio; // Maximum attenuation (front-to-back ratio)
|
|
|
|
let attenuation;
|
|
|
|
if (theta <= 180) {
|
|
// Front hemisphere (includes main lobe and side lobes)
|
|
attenuation = -Math.min(12 * Math.pow(theta / theta3dB, 2), Am);
|
|
} else {
|
|
// This shouldn't happen after normalization, but just in case
|
|
attenuation = -Am;
|
|
}
|
|
|
|
return attenuation; // Returns dB loss (negative value)
|
|
}
|
|
|
|
// In coverage calculation:
|
|
if (site.antennaType === 'sector') {
|
|
const bearing = calculateBearing(site.lat, site.lon, lat, lon);
|
|
const azimuth = site.azimuth || 0;
|
|
const beamwidth = site.beamwidth || 65;
|
|
|
|
// Get antenna pattern loss
|
|
const patternLoss = calculate3GPPPattern(azimuth, bearing, beamwidth, 25);
|
|
|
|
// Add pattern loss to path loss
|
|
const effectiveRSRP = site.power + site.antennaGain + patternLoss - fspl;
|
|
|
|
// No hard cutoff! Back lobe will be ~25 dB weaker
|
|
}
|
|
```
|
|
|
|
### Visualization Update
|
|
|
|
Show actual pattern with gradient opacity:
|
|
|
|
**File:** `frontend/src/components/map/SiteMarker.tsx`
|
|
|
|
```typescript
|
|
// Generate sector wedge with gradient
|
|
function generateSectorWedge(site: Site) {
|
|
const mainLobe = generateArc(site, site.azimuth, site.beamwidth, 0.8); // Main beam
|
|
const sideLobe = generateArc(site, site.azimuth, 180, 0.3); // Sides
|
|
const backLobe = generateArc(site, site.azimuth + 180, 60, 0.1); // Back
|
|
|
|
return [mainLobe, sideLobe, backLobe]; // Array of polygons
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CRITICAL FIX 2: Zoom-Independent Heatmap Colors
|
|
|
|
**Problem:** `maxIntensity` changes with zoom, so same RSRP value maps to different colors.
|
|
|
|
**Solution:** Keep RSRP thresholds constant, adjust only visual radius/blur.
|
|
|
|
**File:** `frontend/src/components/map/Heatmap.tsx`
|
|
|
|
```typescript
|
|
export function Heatmap({ points, visible, opacity = 0.7 }: HeatmapProps) {
|
|
const map = useMap();
|
|
const [mapZoom, setMapZoom] = useState(map.getZoom());
|
|
|
|
useEffect(() => {
|
|
const handleZoomEnd = () => setMapZoom(map.getZoom());
|
|
map.on('zoomend', handleZoomEnd);
|
|
return () => { map.off('zoomend', handleZoomEnd); };
|
|
}, [map]);
|
|
|
|
if (!visible || points.length === 0) return null;
|
|
|
|
// FIXED: Normalize RSRP consistently regardless of zoom
|
|
const normalizeRSRP = (rsrp: number): number => {
|
|
const minRSRP = -120; // Very weak
|
|
const maxRSRP = -70; // Excellent
|
|
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
|
|
return Math.max(0, Math.min(1, normalized));
|
|
};
|
|
|
|
// Zoom-dependent visual parameters ONLY
|
|
const radius = Math.max(8, Math.min(40, 50 - mapZoom * 2.5));
|
|
const blur = Math.max(6, Math.min(20, 30 - mapZoom * 1.5));
|
|
|
|
// CRITICAL FIX: maxIntensity is now CONSTANT
|
|
const maxIntensity = 1.0; // Always 1.0, NEVER changes with zoom
|
|
|
|
const heatmapPoints = points.map(p => [
|
|
p.lat,
|
|
p.lon,
|
|
normalizeRSRP(p.rsrp)
|
|
] as [number, number, number]);
|
|
|
|
return (
|
|
<div style={{ opacity }}>
|
|
<HeatmapLayer
|
|
points={heatmapPoints}
|
|
longitudeExtractor={(p) => p[1]}
|
|
latitudeExtractor={(p) => p[0]}
|
|
intensityExtractor={(p) => p[2]}
|
|
gradient={{
|
|
0.0: '#0d47a1', // -120 dBm (dark blue, no service)
|
|
0.2: '#00bcd4', // -110 dBm (cyan, weak)
|
|
0.4: '#4caf50', // -100 dBm (green, fair)
|
|
0.6: '#ffeb3b', // -85 dBm (yellow, good)
|
|
0.8: '#ff9800', // -75 dBm (orange, strong)
|
|
1.0: '#f44336', // -70 dBm (red, excellent)
|
|
}}
|
|
radius={radius}
|
|
blur={blur}
|
|
max={maxIntensity} // ALWAYS 1.0
|
|
minOpacity={0.3}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
### Add Legend with Actual dBm Values
|
|
|
|
**File:** `frontend/src/components/map/Legend.tsx`
|
|
|
|
```typescript
|
|
export function Legend() {
|
|
return (
|
|
<div className="legend">
|
|
<h4>Signal Strength (RSRP)</h4>
|
|
<div className="legend-item">
|
|
<span className="color" style={{ background: '#f44336' }}></span>
|
|
<span>Excellent (> -70 dBm)</span>
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color" style={{ background: '#ff9800' }}></span>
|
|
<span>Strong (-75 to -70 dBm)</span>
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color" style={{ background: '#ffeb3b' }}></span>
|
|
<span>Good (-85 to -75 dBm)</span>
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color" style={{ background: '#4caf50' }}></span>
|
|
<span>Fair (-100 to -85 dBm)</span>
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color" style={{ background: '#00bcd4' }}></span>
|
|
<span>Weak (-110 to -100 dBm)</span>
|
|
</div>
|
|
<div className="legend-item">
|
|
<span className="color" style={{ background: '#0d47a1' }}></span>
|
|
<span>No Service (< -120 dBm)</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## CRITICAL FIX 3: Antenna Gain Effect
|
|
|
|
**Problem:** Antenna gain is stored but not used in RSRP calculation.
|
|
|
|
**Formula:** `RSRP = TxPower + TxGain - PathLoss - PatternLoss`
|
|
|
|
**File:** `frontend/src/workers/rf-worker.js`
|
|
|
|
```javascript
|
|
// In calculateCoverage function:
|
|
|
|
for (const site of sites) {
|
|
for (let latIdx = 0; latIdx < latPoints; latIdx++) {
|
|
for (let lonIdx = 0; lonIdx < lonPoints; lonIdx++) {
|
|
const lat = minLat + latIdx * latStep;
|
|
const lon = minLon + lonIdx * lonStep;
|
|
|
|
const distance = calculateDistance(site.lat, site.lon, lat, lon);
|
|
if (distance > radius) continue;
|
|
|
|
// Path loss (FSPL)
|
|
const fspl = calculateFSPL(distance, site.frequency);
|
|
|
|
// Antenna pattern loss
|
|
let patternLoss = 0;
|
|
if (site.antennaType === 'sector') {
|
|
const bearing = calculateBearing(site.lat, site.lon, lat, lon);
|
|
patternLoss = -calculate3GPPPattern(site.azimuth, bearing, site.beamwidth, 25);
|
|
}
|
|
|
|
// CRITICAL: Include antenna gain!
|
|
const antennaGain = site.antennaGain || 0; // dBi
|
|
|
|
// Final RSRP calculation
|
|
const rsrp = site.power + antennaGain - fspl - patternLoss;
|
|
|
|
// Store point
|
|
if (rsrp > rsrpThreshold) {
|
|
points.push({ lat, lon, rsrp, siteId: site.id });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Expected Behavior:**
|
|
- 0 dBi omni (dipole): baseline coverage
|
|
- 8 dBi omni (collinear): +8 dB → ~2x distance
|
|
- 15 dBi sector (panel): +15 dB → ~5x distance
|
|
- 25 dBi sector (parabolic): +25 dB → ~17x distance
|
|
|
|
---
|
|
|
|
## CRITICAL FIX 4: Antenna Height Effect
|
|
|
|
**Problem:** Height is stored but ignored. In reality, height affects:
|
|
1. **Line-of-sight distance** (radio horizon)
|
|
2. **Terrain shadowing** (future Phase 4)
|
|
|
|
For now, implement **radio horizon** effect:
|
|
|
|
**Formula:** `d_horizon = 3.57 * sqrt(h)` km, where h is in meters
|
|
|
|
**File:** `frontend/src/workers/rf-worker.js`
|
|
|
|
```javascript
|
|
function calculateRadioHorizon(heightMeters) {
|
|
// Earth curvature formula
|
|
// Assumes 4/3 Earth radius (k=4/3 for standard atmosphere)
|
|
return 3.57 * Math.sqrt(heightMeters); // km
|
|
}
|
|
|
|
// In coverage calculation:
|
|
for (const site of sites) {
|
|
const siteHorizon = calculateRadioHorizon(site.height);
|
|
const maxRange = Math.min(radius, siteHorizon);
|
|
|
|
for (let latIdx = 0; latIdx < latPoints; latIdx++) {
|
|
for (let lonIdx = 0; lonIdx < lonPoints; lonIdx++) {
|
|
const lat = minLat + latIdx * latStep;
|
|
const lon = minLon + lonIdx * lonStep;
|
|
|
|
const distance = calculateDistance(site.lat, site.lon, lat, lon);
|
|
|
|
// Check horizon limit
|
|
if (distance > maxRange) continue;
|
|
|
|
// ... rest of calculation
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
**Expected Behavior:**
|
|
- 10m tower: horizon = 11.3 km
|
|
- 30m tower: horizon = 19.5 km
|
|
- 100m tower: horizon = 35.7 km
|
|
|
|
**Note:** This is a simplified model. Phase 4 will add terrain-aware line-of-sight.
|
|
|
|
---
|
|
|
|
## FIX 5: Batch Edit UX Improvements
|
|
|
|
**Problem:**
|
|
1. Edit panel stays open during batch operations
|
|
2. Selected site values don't update dynamically
|
|
3. Unclear what changed
|
|
|
|
### Solution A: Close Edit Panel on Batch Operation
|
|
|
|
**File:** `frontend/src/components/panels/SiteList.tsx`
|
|
|
|
```typescript
|
|
const handleBatchUpdate = (delta: number) => {
|
|
batchUpdateHeight(delta);
|
|
|
|
// Close edit panel if currently editing a selected site
|
|
if (editingSiteId && selectedSiteIds.includes(editingSiteId)) {
|
|
setEditingSiteId(null);
|
|
}
|
|
|
|
toast.success(`Updated ${selectedSiteIds.length} sites by ${delta > 0 ? '+' : ''}${delta}m`);
|
|
};
|
|
|
|
const handleBatchSet = (height: number) => {
|
|
batchSetHeight(height);
|
|
|
|
// Close edit panel
|
|
if (editingSiteId && selectedSiteIds.includes(editingSiteId)) {
|
|
setEditingSiteId(null);
|
|
}
|
|
|
|
toast.success(`Set ${selectedSiteIds.length} sites to ${height}m`);
|
|
};
|
|
```
|
|
|
|
### Solution B: Live Update Edit Panel
|
|
|
|
**File:** `frontend/src/components/panels/SiteForm.tsx`
|
|
|
|
```typescript
|
|
// Watch for external changes to site being edited
|
|
useEffect(() => {
|
|
if (site) {
|
|
// Sync form with latest site data
|
|
setFormData({
|
|
name: site.name,
|
|
frequency: site.frequency,
|
|
power: site.power,
|
|
height: site.height, // Updated dynamically!
|
|
antennaType: site.antennaType,
|
|
azimuth: site.azimuth,
|
|
beamwidth: site.beamwidth,
|
|
antennaGain: site.antennaGain,
|
|
});
|
|
}
|
|
}, [site.height, site.power]); // Re-sync when these change
|
|
```
|
|
|
|
### Visual Feedback
|
|
|
|
Add flash animation when batch updated:
|
|
|
|
```typescript
|
|
// In SiteList item
|
|
<div
|
|
className={cn(
|
|
'site-item',
|
|
isSelected && 'selected',
|
|
wasBatchUpdated && 'flash-update'
|
|
)}
|
|
>
|
|
{/* ... */}
|
|
</div>
|
|
```
|
|
|
|
```css
|
|
@keyframes flash-update {
|
|
0%, 100% { background-color: transparent; }
|
|
50% { background-color: rgba(59, 130, 246, 0.3); }
|
|
}
|
|
|
|
.flash-update {
|
|
animation: flash-update 0.6s ease-in-out;
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## TESTING CHECKLIST
|
|
|
|
### Antenna Pattern Test:
|
|
- [ ] Sector antenna (65° beam, 25 dBi gain):
|
|
- [ ] Main lobe strongest (0 dB loss)
|
|
- [ ] ±30° from azimuth: ~-12 dB loss (visible but weaker)
|
|
- [ ] ±90° from azimuth: ~-25 dB loss (faint coverage)
|
|
- [ ] 180° from azimuth: ~-25 dB loss (back lobe visible)
|
|
- [ ] Omni antenna: perfect circle (no directivity)
|
|
|
|
### Heatmap Color Consistency Test:
|
|
- [ ] Place site, calculate coverage at zoom 8
|
|
- [ ] Note color at specific location (e.g., 5 km away)
|
|
- [ ] Zoom to level 12, color at same location UNCHANGED
|
|
- [ ] Zoom to level 16, color STILL THE SAME
|
|
- [ ] Legend shows actual dBm values
|
|
|
|
### Antenna Gain Test:
|
|
- [ ] Site A: 0 dBi omni, 43 dBm power → note coverage radius
|
|
- [ ] Site B: 15 dBi sector, 43 dBm power → coverage should be ~5x larger
|
|
- [ ] Site C: 25 dBi sector, 43 dBm power → coverage should be ~17x larger
|
|
|
|
### Antenna Height Test:
|
|
- [ ] Site A: 10m height → coverage radius ~11 km max
|
|
- [ ] Site B: 30m height → coverage radius ~19 km max
|
|
- [ ] Site C: 100m height → coverage radius ~35 km max
|
|
- [ ] Even with high power, coverage stops at horizon
|
|
|
|
### Batch Edit UX Test:
|
|
- [ ] Select 3 sites
|
|
- [ ] Edit one of them (panel opens)
|
|
- [ ] Click +10m batch operation
|
|
- [ ] Edit panel closes OR values update live
|
|
- [ ] Flash animation on updated sites
|
|
- [ ] Toast shows "Updated 3 sites by +10m"
|
|
|
|
---
|
|
|
|
## BUILD & DEPLOY
|
|
|
|
```bash
|
|
cd /opt/rfcp/frontend
|
|
npm run build
|
|
sudo systemctl reload caddy
|
|
|
|
# Test
|
|
curl https://rfcp.eliah.one/api/health
|
|
```
|
|
|
|
---
|
|
|
|
## COMMIT MESSAGE
|
|
|
|
```
|
|
fix(rf): implement accurate 3GPP antenna patterns
|
|
|
|
- Added proper 3GPP TR 36.814 horizontal pattern formula
|
|
- Sector antennas now show main lobe, side lobes, and back lobe
|
|
- Front-to-back ratio 25 dB (realistic for sector panels)
|
|
- Removed hard cutoff at beamwidth/2
|
|
|
|
fix(heatmap): ensure zoom-independent color mapping
|
|
|
|
- Removed dynamic maxIntensity (now constant 1.0)
|
|
- Same RSRP now shows same color regardless of zoom level
|
|
- Added Legend component with actual dBm thresholds
|
|
- Only radius and blur adjust with zoom (visual quality)
|
|
|
|
fix(rf): apply antenna gain to coverage calculations
|
|
|
|
- Antenna gain now affects RSRP: TxPower + Gain - PathLoss
|
|
- 0 dBi vs 25 dBi now shows massive coverage difference
|
|
- Formula: RSRP = Power + AntennaGain - FSPL - PatternLoss
|
|
|
|
fix(rf): implement radio horizon based on antenna height
|
|
|
|
- Added Earth curvature calculation (d = 3.57 * sqrt(h))
|
|
- 10m tower: 11 km horizon | 100m tower: 35 km horizon
|
|
- Coverage now limited by line-of-sight distance
|
|
- Prepares for Phase 4 terrain-aware LOS
|
|
|
|
fix(ux): improve batch edit workflow
|
|
|
|
- Edit panel closes when batch operation affects edited site
|
|
- Added flash animation on batch-updated sites
|
|
- Toast shows count of affected sites
|
|
- Clear visual feedback for all operations
|
|
```
|
|
|
|
---
|
|
|
|
## Expected Results
|
|
|
|
**After all fixes:**
|
|
|
|
1. **Antenna Pattern:**
|
|
- Sector shows wedge with weak back lobe
|
|
- No sudden cutoff, gradual attenuation
|
|
- Realistic F/B ratio ~25 dB
|
|
|
|
2. **Heatmap:**
|
|
- Colors consistent across ALL zoom levels
|
|
- Blue = weak, red = strong, ALWAYS
|
|
- Legend shows exact dBm ranges
|
|
|
|
3. **Antenna Gain:**
|
|
- High gain = much larger coverage
|
|
- 25 dBi sector vs 0 dBi omni: huge difference
|
|
- Realistic for real equipment
|
|
|
|
4. **Antenna Height:**
|
|
- Tall towers = wider coverage
|
|
- Coverage stops at radio horizon
|
|
- 100m tower can't reach 100 km (limited by curvature)
|
|
|
|
5. **UX:**
|
|
- Batch edit doesn't confuse users
|
|
- Clear feedback on what changed
|
|
- Smooth, professional workflow
|
|
|
|
🚀 Ready to implement!
|