Files
Jason 76cfc71b19 fix(gui): align radar parameters to FPGA truth (radar_scene.py)
- Bandwidth 500 MHz -> 20 MHz, sample rate 4 MHz -> 100 MHz (DDC output)
- Range formula: deramped FMCW -> matched-filter c/(2*Fs)*decimation
- Velocity formula: use PRI (167 us) and chirps_per_subframe (16)
- Carrier frequency: 10.525 GHz -> 10.5 GHz per radar_scene.py
- Range per bin: 4.8 m -> 24 m, max range: 307 m -> 1536 m
- Fix simulator target spawn range to match new coverage (50-1400 m)
- Remove dead BANDWIDTH constant, add SAMPLE_RATE to V65 Tk
- All 174 tests pass, ruff clean
2026-04-16 21:35:01 +05:45

608 lines
22 KiB
Python

"""
v7.map_widget — Embedded Leaflet.js map widget for the PLFM Radar GUI V7.
Classes:
- MapBridge — QObject exposed to JavaScript via QWebChannel
- RadarMapWidget — QWidget wrapping QWebEngineView with Leaflet map
The full HTML/CSS/JS for Leaflet is generated inline (no external files).
Supports: OSM, Google, Google Sat, Google Hybrid, ESRI Sat tile servers;
coverage circle, target trails, velocity-based color coding, popups, legend.
"""
import json
import logging
from PyQt6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QFrame,
QComboBox, QCheckBox, QPushButton, QLabel,
)
from PyQt6.QtCore import Qt, QUrl, pyqtSignal, pyqtSlot, QObject
from PyQt6.QtWebEngineCore import QWebEngineSettings
from PyQt6.QtWebEngineWidgets import QWebEngineView
from PyQt6.QtWebChannel import QWebChannel
from .models import (
GPSData, RadarTarget, TileServer,
DARK_BG, DARK_FG, DARK_ACCENT, DARK_BORDER,
DARK_TEXT, DARK_BUTTON, DARK_BUTTON_HOVER,
DARK_SUCCESS, DARK_INFO,
)
logger = logging.getLogger(__name__)
# =============================================================================
# MapBridge — Python <-> JavaScript
# =============================================================================
class MapBridge(QObject):
"""Bridge object registered with QWebChannel for JS ↔ Python calls."""
mapClicked = pyqtSignal(float, float) # lat, lon
markerClicked = pyqtSignal(int) # target_id
mapReady = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._map_ready = False
@pyqtSlot(float, float)
def onMapClick(self, lat: float, lon: float):
logger.debug(f"Map clicked: {lat}, {lon}")
self.mapClicked.emit(lat, lon)
@pyqtSlot(int)
def onMarkerClick(self, target_id: int):
logger.debug(f"Marker clicked: #{target_id}")
self.markerClicked.emit(target_id)
@pyqtSlot()
def onMapReady(self):
logger.info("Leaflet map ready")
self._map_ready = True
self.mapReady.emit()
@pyqtSlot(str)
def logFromJS(self, message: str):
logger.info(f"[JS] {message}")
@property
def is_ready(self) -> bool:
return self._map_ready
# =============================================================================
# RadarMapWidget
# =============================================================================
class RadarMapWidget(QWidget):
"""
Embeds a Leaflet.js interactive map inside a QWebEngineView.
Public methods mirror the V6 map API:
set_radar_position(gps), set_targets(list), set_coverage_radius(r),
set_zoom(level)
"""
targetSelected = pyqtSignal(int)
def __init__(self, radar_lat: float = 41.9028, radar_lon: float = 12.4964,
parent=None):
super().__init__(parent)
# State
self._radar_position = GPSData(
latitude=radar_lat, longitude=radar_lon,
altitude=0.0, pitch=0.0, heading=0.0,
)
self._targets: list[RadarTarget] = []
self._pending_targets: list[RadarTarget] | None = None
self._coverage_radius = 1_536 # metres (64 bins x ~24 m/bin)
self._tile_server = TileServer.OPENSTREETMAP
self._show_coverage = True
self._show_trails = False
# Build UI
self._setup_ui()
# Bridge + channel
self._bridge = MapBridge(self)
self._bridge.mapReady.connect(self._on_map_ready)
self._bridge.markerClicked.connect(self._on_marker_clicked)
self._channel = QWebChannel()
self._channel.registerObject("bridge", self._bridge)
self._web_view.page().setWebChannel(self._channel)
# Load the Leaflet map
self._load_map()
# ---- UI setup ----------------------------------------------------------
def _setup_ui(self):
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
# Control bar
bar = QFrame()
bar.setStyleSheet(f"background-color: {DARK_ACCENT}; border-radius: 4px;")
bar_layout = QHBoxLayout(bar)
bar_layout.setContentsMargins(8, 4, 8, 4)
# Tile selector
self._tile_combo = QComboBox()
self._tile_combo.addItem("OpenStreetMap", TileServer.OPENSTREETMAP)
self._tile_combo.addItem("Google Maps", TileServer.GOOGLE_MAPS)
self._tile_combo.addItem("Google Satellite", TileServer.GOOGLE_SATELLITE)
self._tile_combo.addItem("Google Hybrid", TileServer.GOOGLE_HYBRID)
self._tile_combo.addItem("ESRI Satellite", TileServer.ESRI_SATELLITE)
self._tile_combo.currentIndexChanged.connect(self._on_tile_changed)
self._tile_combo.setStyleSheet(f"""
QComboBox {{
background-color: {DARK_BUTTON}; color: {DARK_FG};
border: 1px solid {DARK_BORDER}; padding: 4px 8px; border-radius: 4px;
}}
""")
bar_layout.addWidget(QLabel("Tiles:"))
bar_layout.addWidget(self._tile_combo)
# Toggles
self._coverage_check = QCheckBox("Coverage")
self._coverage_check.setChecked(True)
self._coverage_check.stateChanged.connect(self._on_coverage_toggled)
bar_layout.addWidget(self._coverage_check)
self._trails_check = QCheckBox("Trails")
self._trails_check.setChecked(False)
self._trails_check.stateChanged.connect(self._on_trails_toggled)
bar_layout.addWidget(self._trails_check)
btn_style = f"""
QPushButton {{
background-color: {DARK_BUTTON}; color: {DARK_FG};
border: 1px solid {DARK_BORDER}; padding: 4px 12px; border-radius: 4px;
}}
QPushButton:hover {{ background-color: {DARK_BUTTON_HOVER}; }}
"""
center_btn = QPushButton("Center")
center_btn.clicked.connect(self._center_on_radar)
center_btn.setStyleSheet(btn_style)
bar_layout.addWidget(center_btn)
fit_btn = QPushButton("Fit All")
fit_btn.clicked.connect(self._fit_all)
fit_btn.setStyleSheet(btn_style)
bar_layout.addWidget(fit_btn)
bar_layout.addStretch()
self._status_label = QLabel("Loading map...")
self._status_label.setStyleSheet(f"color: {DARK_INFO};")
bar_layout.addWidget(self._status_label)
layout.addWidget(bar)
# Web view
self._web_view = QWebEngineView()
self._web_view.setMinimumSize(400, 300)
layout.addWidget(self._web_view, stretch=1)
# ---- HTML generation ---------------------------------------------------
def _get_map_html(self) -> str:
lat = self._radar_position.latitude
lon = self._radar_position.longitude
cov = self._coverage_radius
# Using {{ / }} for literal braces inside the f-string
return f'''<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radar Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="qrc:///qtwebchannel/qwebchannel.js"></script>
<style>
* {{ margin:0; padding:0; box-sizing:border-box; }}
html, body {{ height:100%; width:100%; font-family:'Segoe UI',Arial,sans-serif; }}
#map {{ height:100%; width:100%; background-color:{DARK_BG}; }}
.leaflet-container {{ background-color:{DARK_BG} !important; }}
.leaflet-popup-content-wrapper {{
background-color:{DARK_ACCENT}; color:{DARK_FG};
border-radius:8px; box-shadow:0 4px 12px rgba(0,0,0,0.4);
}}
.leaflet-popup-tip {{ background-color:{DARK_ACCENT}; }}
.leaflet-popup-content {{ margin:12px; }}
.popup-title {{
font-size:14px; font-weight:bold; color:#4e9eff;
margin-bottom:8px; border-bottom:1px solid {DARK_BORDER}; padding-bottom:6px;
}}
.popup-row {{ display:flex; justify-content:space-between; margin:4px 0; font-size:12px; }}
.popup-label {{ color:{DARK_TEXT}; }}
.popup-value {{ color:{DARK_FG}; font-weight:500; }}
.status-approaching {{ color:#F44336; }}
.status-receding {{ color:#2196F3; }}
.status-stationary {{ color:#9E9E9E; }}
.legend {{
background-color:{DARK_ACCENT}; color:{DARK_FG};
padding:10px 14px; border-radius:6px; font-size:12px;
box-shadow:0 2px 8px rgba(0,0,0,0.3);
}}
.legend-title {{ font-weight:bold; margin-bottom:8px; color:#4e9eff; }}
.legend-item {{ display:flex; align-items:center; margin:4px 0; }}
.legend-color {{
width:14px; height:14px; border-radius:50%;
margin-right:8px; border:1px solid white;
}}
</style>
</head>
<body>
<div id="map"></div>
<script>
var map, radarMarker, coverageCircle;
var targetMarkers = {{}};
var targetTrails = {{}};
var targetTrailHistory = {{}};
var bridge = null;
var currentTileLayer = null;
var showCoverage = true;
var showTrails = false;
var maxTrailLength = 30;
var tileServers = {{
'osm': {{
url:'https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',
attribution:'&copy; OpenStreetMap', maxZoom:19
}},
'google': {{
url:'https://mt0.google.com/vt/lyrs=m&hl=en&x={{x}}&y={{y}}&z={{z}}&s=Ga',
attribution:'&copy; Google Maps', maxZoom:22
}},
'google_sat': {{
url:'https://mt0.google.com/vt/lyrs=s&hl=en&x={{x}}&y={{y}}&z={{z}}&s=Ga',
attribution:'&copy; Google Maps', maxZoom:22
}},
'google_hybrid': {{
url:'https://mt0.google.com/vt/lyrs=y&hl=en&x={{x}}&y={{y}}&z={{z}}&s=Ga',
attribution:'&copy; Google Maps', maxZoom:22
}},
'esri_sat': {{
url:'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{{z}}/{{y}}/{{x}}',
attribution:'&copy; Esri', maxZoom:19
}}
}};
function initMap() {{
map = L.map('map', {{ preferCanvas:true, zoomControl:true }})
.setView([{lat}, {lon}], 10);
setTileServer('osm');
radarMarker = L.circleMarker([{lat},{lon}], {{
radius:12, fillColor:'#FF5252', color:'white',
weight:3, opacity:1, fillOpacity:1
}}).addTo(map);
updateRadarPopup();
coverageCircle = L.circle([{lat},{lon}], {{
radius:{cov}, color:'#FF5252', fillColor:'#FF5252',
fillOpacity:0.08, weight:2, dashArray:'8, 8'
}}).addTo(map);
addLegend();
map.on('click', function(e){{ if(bridge) bridge.onMapClick(e.latlng.lat,e.latlng.lng); }});
}}
function setTileServer(id) {{
var cfg = tileServers[id]; if(!cfg) return;
if(currentTileLayer) map.removeLayer(currentTileLayer);
currentTileLayer = L.tileLayer(
cfg.url,
{{ attribution:cfg.attribution, maxZoom:cfg.maxZoom }}
).addTo(map);
}}
function updateRadarPopup() {{
if(!radarMarker) return;
var ll = radarMarker.getLatLng();
radarMarker.bindPopup(
'<div class="popup-title">Radar System</div>'+
(
'<div class="popup-row"><span class="popup-label">Lat:</span>'+
'<span class="popup-value">'+ll.lat.toFixed(6)+'</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Lon:</span>'+
'<span class="popup-value">'+ll.lng.toFixed(6)+'</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Status:</span>'+
'<span class="popup-value status-approaching">Active</span></div>'
)
);
}}
function addLegend() {{
var legend = L.control({{ position:'bottomright' }});
legend.onAdd = function() {{
var d = L.DomUtil.create('div','legend');
d.innerHTML =
'<div class="legend-title">Target Legend</div>'+
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#F44336"></div>Approaching</div>'
)+
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#2196F3"></div>Receding</div>'
)+
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#9E9E9E"></div>Stationary</div>'
)+
(
'<div class="legend-item"><div class="legend-color" '+
'style="background:#FF5252"></div>Radar</div>'
);
return d;
}};
legend.addTo(map);
}}
function updateRadarPosition(lat,lon,alt,pitch,heading) {{
if(!radarMarker||!coverageCircle) return;
radarMarker.setLatLng([lat,lon]);
coverageCircle.setLatLng([lat,lon]);
updateRadarPopup();
}}
function updateTargets(targetsJson) {{
try {{
if(!map) {{
if(bridge) bridge.logFromJS('updateTargets: map not ready yet');
return;
}}
var targets = JSON.parse(targetsJson);
if(bridge) bridge.logFromJS('updateTargets: parsed '+targets.length+' targets');
var currentIds = {{}};
targets.forEach(function(t) {{
currentIds[t.id] = true;
var lat=t.latitude, lon=t.longitude;
var color = getTargetColor(t.velocity);
var radius = Math.max(5, Math.min(12, 5+(t.snr||0)/5));
if(!targetTrailHistory[t.id]) targetTrailHistory[t.id] = [];
targetTrailHistory[t.id].push([lat,lon]);
if(targetTrailHistory[t.id].length > maxTrailLength)
targetTrailHistory[t.id].shift();
if(targetMarkers[t.id]) {{
targetMarkers[t.id].setLatLng([lat,lon]);
targetMarkers[t.id].setStyle({{
fillColor:color, color:'white', radius:radius
}});
if(targetTrails[t.id]) {{
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
targetTrails[t.id].setStyle({{ color:color }});
}}
}} else {{
var marker = L.circleMarker([lat,lon], {{
radius:radius, fillColor:color, color:'white',
weight:2, opacity:1, fillOpacity:0.9
}}).addTo(map);
marker.on(
'click',
(function(id){{
return function(){{ if(bridge) bridge.onMarkerClick(id); }};
}})(t.id)
);
targetMarkers[t.id] = marker;
if(showTrails) {{
targetTrails[t.id] = L.polyline(targetTrailHistory[t.id], {{
color:color, weight:3, opacity:0.7,
lineCap:'round', lineJoin:'round'
}}).addTo(map);
}}
}}
updateTargetPopup(t);
}});
for(var id in targetMarkers) {{
if(!currentIds[id]) {{
map.removeLayer(targetMarkers[id]); delete targetMarkers[id];
if(targetTrails[id]) {{
map.removeLayer(targetTrails[id]);
delete targetTrails[id];
}}
delete targetTrailHistory[id];
}}
}}
}} catch(e) {{
if(bridge) bridge.logFromJS('updateTargets ERROR: '+e.message);
}}
}}
function updateTargetPopup(t) {{
if(!targetMarkers[t.id]) return;
var sc = t.velocity>1
? 'status-approaching'
: (t.velocity<-1 ? 'status-receding' : 'status-stationary');
var st = t.velocity>1?'Approaching':(t.velocity<-1?'Receding':'Stationary');
var rng = (typeof t.range === 'number') ? t.range.toFixed(1) : '?';
var vel = (typeof t.velocity === 'number') ? t.velocity.toFixed(1) : '?';
var az = (typeof t.azimuth === 'number') ? t.azimuth.toFixed(1) : '?';
var el = (typeof t.elevation === 'number') ? t.elevation.toFixed(1) : '?';
var snr = (typeof t.snr === 'number') ? t.snr.toFixed(1) : '?';
targetMarkers[t.id].bindPopup(
'<div class="popup-title">Target #'+t.id+'</div>'+
'<div class="popup-row"><span class="popup-label">Range:</span>'+
'<span class="popup-value">'+rng+' m</span></div>'+
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+
'<span class="popup-value">'+vel+' m/s</span></div>'+
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
'<span class="popup-value">'+az+'&deg;</span></div>'+
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
'<span class="popup-value">'+el+'&deg;</span></div>'+
'<div class="popup-row"><span class="popup-label">SNR:</span>'+
'<span class="popup-value">'+snr+' dB</span></div>'+
'<div class="popup-row"><span class="popup-label">Track:</span>'+
'<span class="popup-value">'+t.track_id+'</span></div>'+
'<div class="popup-row"><span class="popup-label">Status:</span>'+
'<span class="popup-value '+sc+'">'+st+'</span></div>'
);
}}
function getTargetColor(v) {{
if(v>50) return '#FF1744';
if(v>10) return '#FF5252';
if(v>1) return '#FF8A65';
if(v<-50) return '#1565C0';
if(v<-10) return '#2196F3';
if(v<-1) return '#64B5F6';
return '#9E9E9E';
}}
function setCoverageVisible(vis) {{
showCoverage = vis;
if(coverageCircle) {{
if(vis) coverageCircle.addTo(map); else map.removeLayer(coverageCircle);
}}
}}
function setCoverageRadius(r) {{ if(coverageCircle) coverageCircle.setRadius(r); }}
function setTrailsVisible(vis) {{
showTrails = vis;
if(vis) {{
for(var id in targetMarkers) {{
if(!targetTrails[id] && targetTrailHistory[id] && targetTrailHistory[id].length>1) {{
targetTrails[id] = L.polyline(targetTrailHistory[id], {{
color:'#4CAF50', weight:3, opacity:0.7, lineCap:'round', lineJoin:'round'
}}).addTo(map);
}} else if(targetTrails[id]) {{
targetTrails[id].addTo(map);
}}
}}
}} else {{
for(var id in targetTrails) {{ map.removeLayer(targetTrails[id]); }}
}}
}}
function centerOnRadar() {{ if(radarMarker) map.setView(radarMarker.getLatLng(), map.getZoom()); }}
function fitAllTargets() {{
var b = L.latLngBounds([]);
if(radarMarker) b.extend(radarMarker.getLatLng());
for(var id in targetMarkers) b.extend(targetMarkers[id].getLatLng());
if(b.isValid()) map.fitBounds(b, {{ padding:[50,50] }});
}}
function setZoom(lvl) {{ map.setZoom(lvl); }}
document.addEventListener('DOMContentLoaded', function() {{
new QWebChannel(qt.webChannelTransport, function(ch) {{
bridge = ch.objects.bridge;
initMap();
if(bridge) bridge.onMapReady();
}});
}});
</script>
</body>
</html>'''
# ---- load / helpers ----------------------------------------------------
def _load_map(self):
# Enable remote resource access so Leaflet CDN scripts/tiles can load.
settings = self._web_view.page().settings()
settings.setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls,
True,
)
# Provide an HTTP base URL so the page has a proper origin;
# without this, setHtml() defaults to about:blank which blocks
# external resource loading in modern Chromium.
self._web_view.setHtml(
self._get_map_html(),
QUrl("http://localhost/radar_map"),
)
logger.info("Leaflet map HTML loaded (with HTTP base URL)")
def _on_map_ready(self):
self._status_label.setText(f"Map ready - {len(self._targets)} targets")
self._status_label.setStyleSheet(f"color: {DARK_SUCCESS};")
# Flush any targets that arrived before the map was ready
if self._pending_targets is not None:
self.set_targets(self._pending_targets)
self._pending_targets = None
def _on_marker_clicked(self, tid: int):
self.targetSelected.emit(tid)
def _run_js(self, script: str):
def _js_callback(result):
if result is not None:
logger.info("JS result: %s", result)
self._web_view.page().runJavaScript(script, 0, _js_callback)
# ---- control bar callbacks ---------------------------------------------
def _on_tile_changed(self, _index: int):
server = self._tile_combo.currentData()
if server:
self._tile_server = server
self._run_js(f"setTileServer('{server.value}')")
def _on_coverage_toggled(self, state: int):
vis = state == Qt.CheckState.Checked.value
self._show_coverage = vis
self._run_js(f"setCoverageVisible({str(vis).lower()})")
def _on_trails_toggled(self, state: int):
vis = state == Qt.CheckState.Checked.value
self._show_trails = vis
self._run_js(f"setTrailsVisible({str(vis).lower()})")
def _center_on_radar(self):
self._run_js("centerOnRadar()")
def _fit_all(self):
self._run_js("fitAllTargets()")
# ---- public API --------------------------------------------------------
def set_radar_position(self, gps: GPSData):
self._radar_position = gps
self._run_js(
f"updateRadarPosition({gps.latitude},{gps.longitude},"
f"{gps.altitude},{gps.pitch},{gps.heading})"
)
def set_targets(self, targets: list[RadarTarget]):
self._targets = targets
if not self._bridge.is_ready:
logger.info("Map not ready yet — queuing %d targets", len(targets))
self._pending_targets = targets
return
data = [t.to_dict() for t in targets]
js_payload = json.dumps(data).replace("\\", "\\\\").replace("'", "\\'")
logger.info(
"set_targets: %d targets, JSON len=%d, first 200 chars: %s",
len(targets), len(js_payload), js_payload[:200],
)
self._status_label.setText(f"{len(targets)} targets tracked")
self._run_js(f"updateTargets('{js_payload}')")
def set_coverage_radius(self, radius_m: float):
self._coverage_radius = radius_m
self._run_js(f"setCoverageRadius({radius_m})")
def set_zoom(self, level: int):
level = max(0, min(22, level))
self._run_js(f"setZoom({level})")