11 KiB
RFCP - Iteration 4: Critical Fixes
Issues Found in Production
After Iteration 3 deployment, three critical issues identified:
- ❌ Antenna directivity not working - Coverage is omni even for sector antennas
- ❌ Heatmap gradient broken - Everything shows as solid red/orange
- ❌ Terrain overlay invisible - OpenTopoMap layer not visible enough
CRITICAL FIX 1: Antenna Directivity
Problem: Coverage calculation ignores antenna azimuth and beamwidth. A 75° sector antenna shows full 360° coverage.
Root Cause: The coverage calculation worker doesn't filter points based on antenna direction.
Implementation
File: frontend/src/workers/coverage.worker.ts
Add directivity filter in the point loop:
// In calculateCoverage function, after distance calculation:
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;
// NEW: Check antenna directivity
if (site.antennaType === 'sector') {
const bearing = calculateBearing(site.lat, site.lon, lat, lon);
const azimuth = site.azimuth || 0;
const beamwidth = 75; // degrees (could be configurable)
// Calculate angular difference
let angleDiff = Math.abs(bearing - azimuth);
if (angleDiff > 180) angleDiff = 360 - angleDiff;
// Skip points outside beamwidth
if (angleDiff > beamwidth / 2) continue;
}
// Calculate RSRP (existing code)
const fspl = calculateFSPL(distance, site.frequency);
const rsrp = site.power - fspl;
// ... rest of code
}
}
// Add helper function:
function calculateBearing(lat1: number, lon1: number, lat2: number, lon2: number): number {
const φ1 = lat1 * Math.PI / 180;
const φ2 = lat2 * Math.PI / 180;
const Δλ = (lon2 - lon1) * Math.PI / 180;
const y = Math.sin(Δλ) * Math.cos(φ2);
const x = Math.cos(φ1) * Math.sin(φ2) -
Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ);
let θ = Math.atan2(y, x);
θ = θ * 180 / Math.PI; // radians to degrees
return (θ + 360) % 360; // normalize to 0-360
}
Visual Indicator
File: frontend/src/components/map/SiteMarkers.tsx
Add sector wedge visualization:
import { Polygon } from 'react-leaflet';
// In SiteMarker component, for sector antennas:
{site.antennaType === 'sector' && (
<Polygon
positions={generateSectorWedge(site)}
pathOptions={{
color: '#ffffff',
weight: 2,
opacity: 0.5,
fillOpacity: 0.1,
dashArray: '5, 5',
}}
/>
)}
// Helper function:
function generateSectorWedge(site: Site): [number, number][] {
const points: [number, number][] = [[site.lat, site.lon]];
const radius = 0.5; // km on map (visual only, not coverage radius)
const beamwidth = 75;
const azimuth = site.azimuth || 0;
const startAngle = azimuth - beamwidth / 2;
const endAngle = azimuth + beamwidth / 2;
// Generate arc points
for (let angle = startAngle; angle <= endAngle; angle += 5) {
const rad = angle * Math.PI / 180;
const latOffset = (radius / 111) * Math.cos(rad); // 1° lat ≈ 111km
const lonOffset = (radius / (111 * Math.cos(site.lat * Math.PI / 180))) * Math.sin(rad);
points.push([site.lat + latOffset, site.lon + lonOffset]);
}
points.push([site.lat, site.lon]); // close the wedge
return points;
}
CRITICAL FIX 2: Heatmap Gradient (Retry)
Problem: Heatmap shows solid red/orange at all zoom levels despite Iteration 3 fix attempt.
Root Cause: The fix wasn't applied correctly, or RSRP values are outside expected range.
Debug First
Check actual RSRP values being generated:
// In Heatmap.tsx, add console.log:
console.log('Heatmap Debug:', {
pointCount: points.length,
rsrpSample: points.slice(0, 5).map(p => p.rsrp),
rsrpMin: Math.min(...points.map(p => p.rsrp)),
rsrpMax: Math.max(...points.map(p => p.rsrp)),
mapZoom: mapZoom,
maxIntensity: maxIntensity
});
Apply Fix (Corrected)
File: frontend/src/components/map/Heatmap.tsx
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;
// CRITICAL: Normalize RSRP correctly
const normalizeRSRP = (rsrp: number): number => {
const minRSRP = -120; // Very weak signal
const maxRSRP = -70; // Excellent signal (CHANGED from -60)
const normalized = (rsrp - minRSRP) / (maxRSRP - minRSRP);
return Math.max(0, Math.min(1, normalized));
};
// Dynamic heatmap parameters
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: Dynamic max intensity to prevent saturation
const maxIntensity = Math.max(0.3, Math.min(1.0, 1.2 - mapZoom * 0.05));
// Convert to heatmap format
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', // Dark Blue (very weak)
0.2: '#00bcd4', // Cyan (weak)
0.4: '#4caf50', // Green (fair)
0.6: '#ffeb3b', // Yellow (good)
0.8: '#ff9800', // Orange (strong)
1.0: '#f44336', // Red (excellent)
}}
radius={radius}
blur={blur}
max={maxIntensity} // DYNAMIC!
minOpacity={0.3}
/>
</div>
);
}
If Still Red After Fix
The issue might be that ALL points have very strong RSRP (> -70 dBm). Solution:
Option A: Adjust normalization range
const minRSRP = -140; // Extend weak end
const maxRSRP = -50; // Extend strong end
Option B: Add RSRP clipping in worker
// In worker, clip RSRP to realistic range
const rsrp = Math.max(-140, Math.min(-50, site.power - fspl));
CRITICAL FIX 3: Terrain Overlay Visibility
Problem: OpenTopoMap terrain overlay is barely visible or completely invisible.
Root Causes:
- Opacity too low
- Wrong layer order (under heatmap)
- Tile URL might be wrong
Fix Implementation
File: frontend/src/components/map/Map.tsx
import { TileLayer, LayersControl } from 'react-leaflet';
// In Map component:
<MapContainer {...props}>
{/* Base map layer */}
<TileLayer
attribution='© OpenStreetMap'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{/* Terrain overlay - ABOVE base map, BELOW heatmap */}
{showTerrain && (
<TileLayer
attribution='© OpenTopoMap'
url="https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png"
opacity={0.5} // Increased from 0.3 or whatever was there
zIndex={100} // Ensure it's above base map
/>
)}
{/* Heatmap on top */}
<Heatmap
points={coveragePoints}
visible={showHeatmap}
opacity={heatmapOpacity}
/>
{/* Site markers on very top */}
<SiteMarkers sites={sites} />
</MapContainer>
Alternative: Hillshade Layer
If OpenTopoMap is too subtle, try dedicated hillshade:
{showTerrain && (
<TileLayer
url="https://tiles.wmflabs.org/hillshading/{z}/{x}/{y}.png"
opacity={0.6}
zIndex={100}
/>
)}
UI Improvements
Add terrain opacity slider:
File: frontend/src/components/panels/CoverageSettings.tsx
<Slider
label="Terrain Opacity"
min={0.1}
max={1.0}
step={0.1}
value={terrainOpacity}
onChange={(value) => updateSettings({ terrainOpacity: value })}
suffix=""
help="Adjust terrain layer visibility"
disabled={!showTerrain}
/>
TESTING CHECKLIST
Antenna Directivity Test:
- Create sector antenna with azimuth 0° (north)
- Coverage should show wedge facing north only
- Create sector with azimuth 180° (south)
- Coverage wedge should face south
- Omni antenna should still show 360° coverage
- Sector wedge visualization should match coverage area
Heatmap Gradient Test:
- Zoom level 6-8: Smooth blue→red gradient
- Zoom level 10-12: Colors distinct, no solid blob
- Zoom level 14-16: Full gradient visible, especially blue/cyan at edges
- Console log shows RSRP values in -120 to -70 range
- maxIntensity value changes with zoom (check console)
Terrain Overlay Test:
- Click Topo button: Contour lines visible
- Terrain overlay shows hills/valleys clearly
- Opacity slider adjusts terrain visibility
- Terrain doesn't obscure heatmap or markers
- Works in both light and dark theme
BUILD & DEPLOY
After changes:
cd /opt/rfcp/frontend
# Build
npm run build
# Deploy
sudo systemctl reload caddy
# Test from VPS
curl http://localhost:8888/health
curl https://rfcp.eliah.one/ | head -20
# Test from Windows
# https://rfcp.eliah.one
COMMIT MESSAGE
fix(coverage): implement antenna directivity and sector patterns
- Calculate bearing for each coverage point
- Filter points outside sector beamwidth (75°)
- Add sector wedge visualization on map
- Omni antennas maintain 360° coverage
fix(heatmap): resolve solid red gradient issue (retry)
- Corrected RSRP normalization range (-120 to -70 dBm)
- Applied dynamic max intensity based on zoom
- Added debug logging for RSRP values
- Full blue→red gradient now visible at all zoom levels
fix(terrain): improve terrain overlay visibility
- Increased opacity from 0.3 to 0.5
- Correct layer ordering (base → terrain → heatmap)
- Added terrain opacity slider
- Alternative hillshade layer option
Expected Results
After Fix:
-
Directivity:
- Sector antenna (75°) shows wedge-shaped coverage
- Coverage concentrated in azimuth direction
- Visual sector indicator on map
-
Heatmap:
- Blue at coverage edge (-120 dBm, weak)
- Cyan/green in medium range (-100 to -90 dBm)
- Yellow/orange closer to site (-85 to -75 dBm)
- Red only very close to site (-70 dBm, excellent)
-
Terrain:
- Contour lines clearly visible
- Hills and valleys distinguishable
- Doesn't obscure coverage data
- Opacity adjustable
Phase 4 Preview (Next)
Once these fixes work, Phase 4 will add:
- Terrain Loss Calculation - Height + elevation profile affects RSRP
- Backend Terrain API -
/api/terrain/elevation?lat={}&lon={} - Line-of-Sight Analysis - Check if path obstructed by terrain
- 3D Terrain Visualization - Optional 3D view with coverage overlay
🚀 Good luck!