420 lines
11 KiB
Markdown
420 lines
11 KiB
Markdown
# RFCP - Iteration 4: Critical Fixes
|
|
|
|
## Issues Found in Production
|
|
|
|
After Iteration 3 deployment, three critical issues identified:
|
|
|
|
1. ❌ **Antenna directivity not working** - Coverage is omni even for sector antennas
|
|
2. ❌ **Heatmap gradient broken** - Everything shows as solid red/orange
|
|
3. ❌ **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:
|
|
|
|
```typescript
|
|
// 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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
// 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`
|
|
|
|
```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;
|
|
|
|
// 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**
|
|
```typescript
|
|
const minRSRP = -140; // Extend weak end
|
|
const maxRSRP = -50; // Extend strong end
|
|
```
|
|
|
|
**Option B: Add RSRP clipping in worker**
|
|
```typescript
|
|
// 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:**
|
|
1. Opacity too low
|
|
2. Wrong layer order (under heatmap)
|
|
3. Tile URL might be wrong
|
|
|
|
### Fix Implementation
|
|
|
|
**File:** `frontend/src/components/map/Map.tsx`
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
{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`
|
|
|
|
```typescript
|
|
<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:
|
|
|
|
```bash
|
|
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:**
|
|
|
|
1. **Directivity:**
|
|
- Sector antenna (75°) shows wedge-shaped coverage
|
|
- Coverage concentrated in azimuth direction
|
|
- Visual sector indicator on map
|
|
|
|
2. **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)
|
|
|
|
3. **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!
|