# 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 (
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} />
); } ``` ### Add Legend with Actual dBm Values **File:** `frontend/src/components/map/Legend.tsx` ```typescript export function Legend() { return (

Signal Strength (RSRP)

Excellent (> -70 dBm)
Strong (-75 to -70 dBm)
Good (-85 to -75 dBm)
Fair (-100 to -85 dBm)
Weak (-110 to -100 dBm)
No Service (< -120 dBm)
); } ``` --- ## 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
{/* ... */}
``` ```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!