# 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='&copy; OpenStreetMap'
    url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
  />
  
  {/* Terrain overlay - ABOVE base map, BELOW heatmap */}
  {showTerrain && (
    <TileLayer
      attribution='&copy; 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!
