Files
rfcp/RFCP-Iteration5-RF-Accuracy.md
2026-01-30 12:05:05 +02:00

14 KiB

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:

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

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

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 (
    <div style={{ opacity }}>
      <HeatmapLayer
        points={heatmapPoints}
        longitudeExtractor={(p) => 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}
      />
    </div>
  );
}

Add Legend with Actual dBm Values

File: frontend/src/components/map/Legend.tsx

export function Legend() {
  return (
    <div className="legend">
      <h4>Signal Strength (RSRP)</h4>
      <div className="legend-item">
        <span className="color" style={{ background: '#f44336' }}></span>
        <span>Excellent (&gt; -70 dBm)</span>
      </div>
      <div className="legend-item">
        <span className="color" style={{ background: '#ff9800' }}></span>
        <span>Strong (-75 to -70 dBm)</span>
      </div>
      <div className="legend-item">
        <span className="color" style={{ background: '#ffeb3b' }}></span>
        <span>Good (-85 to -75 dBm)</span>
      </div>
      <div className="legend-item">
        <span className="color" style={{ background: '#4caf50' }}></span>
        <span>Fair (-100 to -85 dBm)</span>
      </div>
      <div className="legend-item">
        <span className="color" style={{ background: '#00bcd4' }}></span>
        <span>Weak (-110 to -100 dBm)</span>
      </div>
      <div className="legend-item">
        <span className="color" style={{ background: '#0d47a1' }}></span>
        <span>No Service (&lt; -120 dBm)</span>
      </div>
    </div>
  );
}

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

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

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

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

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

// In SiteList item
<div 
  className={cn(
    'site-item',
    isSelected && 'selected',
    wasBatchUpdated && 'flash-update'
  )}
>
  {/* ... */}
</div>
@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

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!