diff --git a/RFCP-Iteration5-RF-Accuracy.md b/RFCP-Iteration5-RF-Accuracy.md
new file mode 100644
index 0000000..a556410
--- /dev/null
+++ b/RFCP-Iteration5-RF-Accuracy.md
@@ -0,0 +1,505 @@
+# 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:
+
+```javascript
+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`
+
+```typescript
+// 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`
+
+```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;
+
+ // 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 (
+
+ 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}
+ />
+
+ );
+}
+```
+
+### Add Legend with Actual dBm Values
+
+**File:** `frontend/src/components/map/Legend.tsx`
+
+```typescript
+export function Legend() {
+ return (
+
+
Signal Strength (RSRP)
+
+
+ Excellent (> -70 dBm)
+
+
+
+ Strong (-75 to -70 dBm)
+
+
+
+ Good (-85 to -75 dBm)
+
+
+
+ Fair (-100 to -85 dBm)
+
+
+
+ Weak (-110 to -100 dBm)
+
+
+
+ No Service (< -120 dBm)
+
+
+ );
+}
+```
+
+---
+
+## 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`
+
+```javascript
+// 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`
+
+```javascript
+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`
+
+```typescript
+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`
+
+```typescript
+// 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:
+
+```typescript
+// In SiteList item
+
+ {/* ... */}
+
+```
+
+```css
+@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
+
+```bash
+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!