Files
rfcp/docs/devlog/front/RFCP-Iteration4-Critical-Fixes.md
2026-01-30 20:39:13 +02:00

11 KiB

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:

// 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:

  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

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:

{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:

  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!