fix: enforce strict ruff lint (17 rule sets) across entire repo
- Expand ruff config from E/F to 17 rule sets (B, RUF, SIM, PIE, T20, ARG, ERA, A, BLE, RET, ISC, TCH, UP, C4, PERF) - Fix 907 lint errors across all Python files (GUI, FPGA cosim, schematics scripts, simulations, utilities, tools) - Replace all blind except-Exception with specific exception types - Remove commented-out dead code (ERA001) from cosim/simulation files - Modernize typing: deprecated typing.List/Dict/Tuple to builtins - Fix unused args/loop vars, ambiguous unicode, perf anti-patterns - Delete legacy GUI files V1-V4 - Add V7 test suite, requirements files - All CI jobs pass: ruff (0 errors), py_compile, pytest (92/92), MCU tests (20/20), FPGA regression (25/25)
This commit is contained in:
@@ -19,19 +19,25 @@ from .models import (
|
||||
DARK_TREEVIEW, DARK_TREEVIEW_ALT,
|
||||
DARK_SUCCESS, DARK_WARNING, DARK_ERROR, DARK_INFO,
|
||||
USB_AVAILABLE, FTDI_AVAILABLE, SCIPY_AVAILABLE,
|
||||
SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE,
|
||||
SKLEARN_AVAILABLE, FILTERPY_AVAILABLE,
|
||||
)
|
||||
|
||||
# Hardware interfaces
|
||||
# Hardware interfaces — production protocol via radar_protocol.py
|
||||
from .hardware import (
|
||||
FT2232HQInterface,
|
||||
FT2232HConnection,
|
||||
ReplayConnection,
|
||||
RadarProtocol,
|
||||
Opcode,
|
||||
RadarAcquisition,
|
||||
RadarFrame,
|
||||
StatusResponse,
|
||||
DataRecorder,
|
||||
STM32USBInterface,
|
||||
)
|
||||
|
||||
# Processing pipeline
|
||||
from .processing import (
|
||||
RadarProcessor,
|
||||
RadarPacketParser,
|
||||
USBPacketParser,
|
||||
apply_pitch_correction,
|
||||
)
|
||||
@@ -56,7 +62,7 @@ from .dashboard import (
|
||||
RangeDopplerCanvas,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
__all__ = [ # noqa: RUF022
|
||||
# models
|
||||
"RadarTarget", "RadarSettings", "GPSData", "ProcessingConfig", "TileServer",
|
||||
"DARK_BG", "DARK_FG", "DARK_ACCENT", "DARK_HIGHLIGHT", "DARK_BORDER",
|
||||
@@ -64,11 +70,13 @@ __all__ = [
|
||||
"DARK_TREEVIEW", "DARK_TREEVIEW_ALT",
|
||||
"DARK_SUCCESS", "DARK_WARNING", "DARK_ERROR", "DARK_INFO",
|
||||
"USB_AVAILABLE", "FTDI_AVAILABLE", "SCIPY_AVAILABLE",
|
||||
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE", "CRCMOD_AVAILABLE",
|
||||
# hardware
|
||||
"FT2232HQInterface", "STM32USBInterface",
|
||||
"SKLEARN_AVAILABLE", "FILTERPY_AVAILABLE",
|
||||
# hardware — production FPGA protocol
|
||||
"FT2232HConnection", "ReplayConnection", "RadarProtocol", "Opcode",
|
||||
"RadarAcquisition", "RadarFrame", "StatusResponse", "DataRecorder",
|
||||
"STM32USBInterface",
|
||||
# processing
|
||||
"RadarProcessor", "RadarPacketParser", "USBPacketParser",
|
||||
"RadarProcessor", "USBPacketParser",
|
||||
"apply_pitch_correction",
|
||||
# workers
|
||||
"RadarDataWorker", "GPSDataWorker", "TargetSimulator",
|
||||
|
||||
+539
-393
File diff suppressed because it is too large
Load Diff
@@ -1,141 +1,62 @@
|
||||
"""
|
||||
v7.hardware — Hardware interface classes for the PLFM Radar GUI V7.
|
||||
|
||||
Provides two USB hardware interfaces:
|
||||
- FT2232HQInterface (PRIMARY — USB 2.0, VID 0x0403 / PID 0x6010)
|
||||
- STM32USBInterface (USB CDC for commands and GPS)
|
||||
Provides:
|
||||
- FT2232H radar data + command interface via production radar_protocol module
|
||||
- ReplayConnection for offline .npy replay via production radar_protocol module
|
||||
- STM32USBInterface for GPS data only (USB CDC)
|
||||
|
||||
The FT2232H interface uses the production protocol layer (radar_protocol.py)
|
||||
which sends 4-byte {opcode, addr, value_hi, value_lo} register commands and
|
||||
parses 0xAA data / 0xBB status packets from the FPGA. The old magic-packet
|
||||
and 'SET'...'END' binary settings protocol has been removed — it was
|
||||
incompatible with the FPGA register interface.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import sys
|
||||
import os
|
||||
import logging
|
||||
from typing import List, Dict, Optional
|
||||
from typing import ClassVar
|
||||
|
||||
from .models import (
|
||||
USB_AVAILABLE, FTDI_AVAILABLE,
|
||||
RadarSettings,
|
||||
)
|
||||
from .models import USB_AVAILABLE
|
||||
|
||||
if USB_AVAILABLE:
|
||||
import usb.core
|
||||
import usb.util
|
||||
|
||||
if FTDI_AVAILABLE:
|
||||
from pyftdi.ftdi import Ftdi
|
||||
from pyftdi.usbtools import UsbTools
|
||||
# Import production protocol layer — single source of truth for FPGA comms
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
from radar_protocol import ( # noqa: F401 — re-exported for v7 package
|
||||
FT2232HConnection,
|
||||
ReplayConnection,
|
||||
RadarProtocol,
|
||||
Opcode,
|
||||
RadarAcquisition,
|
||||
RadarFrame,
|
||||
StatusResponse,
|
||||
DataRecorder,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# FT2232HQ Interface — PRIMARY data path (USB 2.0)
|
||||
# =============================================================================
|
||||
|
||||
class FT2232HQInterface:
|
||||
"""
|
||||
Interface for FT2232HQ (USB 2.0 Hi-Speed) in synchronous FIFO mode.
|
||||
|
||||
This is the **primary** radar data interface.
|
||||
VID/PID: 0x0403 / 0x6010
|
||||
"""
|
||||
|
||||
VID = 0x0403
|
||||
PID = 0x6010
|
||||
|
||||
def __init__(self):
|
||||
self.ftdi: Optional[object] = None
|
||||
self.is_open: bool = False
|
||||
|
||||
# ---- enumeration -------------------------------------------------------
|
||||
|
||||
def list_devices(self) -> List[Dict]:
|
||||
"""List available FT2232H devices using pyftdi."""
|
||||
if not FTDI_AVAILABLE:
|
||||
logger.warning("pyftdi not available — cannot enumerate FT2232H devices")
|
||||
return []
|
||||
|
||||
try:
|
||||
devices = []
|
||||
for device_desc in UsbTools.find_all([(self.VID, self.PID)]):
|
||||
devices.append({
|
||||
"description": f"FT2232H Device {device_desc}",
|
||||
"url": f"ftdi://{device_desc}/1",
|
||||
})
|
||||
return devices
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing FT2232H devices: {e}")
|
||||
return []
|
||||
|
||||
# ---- open / close ------------------------------------------------------
|
||||
|
||||
def open_device(self, device_url: str) -> bool:
|
||||
"""Open FT2232H device in synchronous FIFO mode."""
|
||||
if not FTDI_AVAILABLE:
|
||||
logger.error("pyftdi not available — cannot open device")
|
||||
return False
|
||||
|
||||
try:
|
||||
self.ftdi = Ftdi()
|
||||
self.ftdi.open_from_url(device_url)
|
||||
|
||||
# Synchronous FIFO mode
|
||||
self.ftdi.set_bitmode(0xFF, Ftdi.BitMode.SYNCFF)
|
||||
|
||||
# Low-latency timer (2 ms)
|
||||
self.ftdi.set_latency_timer(2)
|
||||
|
||||
# Purge stale data
|
||||
self.ftdi.purge_buffers()
|
||||
|
||||
self.is_open = True
|
||||
logger.info(f"FT2232H device opened: {device_url}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error opening FT2232H device: {e}")
|
||||
self.ftdi = None
|
||||
return False
|
||||
|
||||
def close(self):
|
||||
"""Close FT2232H device."""
|
||||
if self.ftdi and self.is_open:
|
||||
try:
|
||||
self.ftdi.close()
|
||||
except Exception as e:
|
||||
logger.error(f"Error closing FT2232H device: {e}")
|
||||
finally:
|
||||
self.is_open = False
|
||||
self.ftdi = None
|
||||
|
||||
# ---- data I/O ----------------------------------------------------------
|
||||
|
||||
def read_data(self, bytes_to_read: int = 4096) -> Optional[bytes]:
|
||||
"""Read data from FT2232H."""
|
||||
if not self.is_open or self.ftdi is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
data = self.ftdi.read_data(bytes_to_read)
|
||||
if data:
|
||||
return bytes(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Error reading from FT2232H: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# STM32 USB CDC Interface — commands & GPS data
|
||||
# STM32 USB CDC Interface — GPS data ONLY
|
||||
# =============================================================================
|
||||
|
||||
class STM32USBInterface:
|
||||
"""
|
||||
Interface for STM32 USB CDC (Virtual COM Port).
|
||||
|
||||
Used to:
|
||||
- Send start flag and radar settings to the MCU
|
||||
- Receive GPS data from the MCU
|
||||
Used ONLY for receiving GPS data from the MCU.
|
||||
|
||||
FPGA register commands are sent via FT2232H (see FT2232HConnection
|
||||
from radar_protocol.py). The old send_start_flag() / send_settings()
|
||||
methods have been removed — they used an incompatible magic-packet
|
||||
protocol that the FPGA does not understand.
|
||||
"""
|
||||
|
||||
STM32_VID_PIDS = [
|
||||
STM32_VID_PIDS: ClassVar[list[tuple[int, int]]] = [
|
||||
(0x0483, 0x5740), # STM32 Virtual COM Port
|
||||
(0x0483, 0x3748), # STM32 Discovery
|
||||
(0x0483, 0x374B),
|
||||
@@ -152,7 +73,7 @@ class STM32USBInterface:
|
||||
|
||||
# ---- enumeration -------------------------------------------------------
|
||||
|
||||
def list_devices(self) -> List[Dict]:
|
||||
def list_devices(self) -> list[dict]:
|
||||
"""List available STM32 USB CDC devices."""
|
||||
if not USB_AVAILABLE:
|
||||
logger.warning("pyusb not available — cannot enumerate STM32 devices")
|
||||
@@ -174,20 +95,20 @@ class STM32USBInterface:
|
||||
"product_id": pid,
|
||||
"device": dev,
|
||||
})
|
||||
except Exception:
|
||||
except (usb.core.USBError, ValueError):
|
||||
devices.append({
|
||||
"description": f"STM32 CDC (VID:{vid:04X}, PID:{pid:04X})",
|
||||
"vendor_id": vid,
|
||||
"product_id": pid,
|
||||
"device": dev,
|
||||
})
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError) as e:
|
||||
logger.error(f"Error listing STM32 devices: {e}")
|
||||
return devices
|
||||
|
||||
# ---- open / close ------------------------------------------------------
|
||||
|
||||
def open_device(self, device_info: Dict) -> bool:
|
||||
def open_device(self, device_info: dict) -> bool:
|
||||
"""Open STM32 USB CDC device."""
|
||||
if not USB_AVAILABLE:
|
||||
logger.error("pyusb not available — cannot open STM32 device")
|
||||
@@ -225,7 +146,7 @@ class STM32USBInterface:
|
||||
self.is_open = True
|
||||
logger.info(f"STM32 USB device opened: {device_info.get('description', '')}")
|
||||
return True
|
||||
except Exception as e:
|
||||
except (usb.core.USBError, ValueError) as e:
|
||||
logger.error(f"Error opening STM32 device: {e}")
|
||||
return False
|
||||
|
||||
@@ -234,74 +155,22 @@ class STM32USBInterface:
|
||||
if self.device and self.is_open:
|
||||
try:
|
||||
usb.util.dispose_resources(self.device)
|
||||
except Exception as e:
|
||||
except usb.core.USBError as e:
|
||||
logger.error(f"Error closing STM32 device: {e}")
|
||||
self.is_open = False
|
||||
self.device = None
|
||||
self.ep_in = None
|
||||
self.ep_out = None
|
||||
|
||||
# ---- commands ----------------------------------------------------------
|
||||
# ---- GPS data I/O ------------------------------------------------------
|
||||
|
||||
def send_start_flag(self) -> bool:
|
||||
"""Send start flag to STM32 (4-byte magic)."""
|
||||
start_packet = bytes([23, 46, 158, 237])
|
||||
logger.info("Sending start flag to STM32 via USB...")
|
||||
return self._send_data(start_packet)
|
||||
|
||||
def send_settings(self, settings: RadarSettings) -> bool:
|
||||
"""Send radar settings binary packet to STM32."""
|
||||
try:
|
||||
packet = self._create_settings_packet(settings)
|
||||
logger.info("Sending radar settings to STM32 via USB...")
|
||||
return self._send_data(packet)
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending settings via USB: {e}")
|
||||
return False
|
||||
|
||||
# ---- data I/O ----------------------------------------------------------
|
||||
|
||||
def read_data(self, size: int = 64, timeout: int = 1000) -> Optional[bytes]:
|
||||
"""Read data from STM32 via USB CDC."""
|
||||
def read_data(self, size: int = 64, timeout: int = 1000) -> bytes | None:
|
||||
"""Read GPS data from STM32 via USB CDC."""
|
||||
if not self.is_open or self.ep_in is None:
|
||||
return None
|
||||
try:
|
||||
data = self.ep_in.read(size, timeout=timeout)
|
||||
return bytes(data)
|
||||
except Exception:
|
||||
except usb.core.USBError:
|
||||
# Timeout or other USB error
|
||||
return None
|
||||
|
||||
# ---- internal helpers --------------------------------------------------
|
||||
|
||||
def _send_data(self, data: bytes) -> bool:
|
||||
if not self.is_open or self.ep_out is None:
|
||||
return False
|
||||
try:
|
||||
packet_size = 64
|
||||
for i in range(0, len(data), packet_size):
|
||||
chunk = data[i : i + packet_size]
|
||||
if len(chunk) < packet_size:
|
||||
chunk += b"\x00" * (packet_size - len(chunk))
|
||||
self.ep_out.write(chunk)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending data via USB: {e}")
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _create_settings_packet(settings: RadarSettings) -> bytes:
|
||||
"""Create binary settings packet: 'SET' ... 'END'."""
|
||||
packet = b"SET"
|
||||
packet += struct.pack(">d", settings.system_frequency)
|
||||
packet += struct.pack(">d", settings.chirp_duration_1)
|
||||
packet += struct.pack(">d", settings.chirp_duration_2)
|
||||
packet += struct.pack(">I", settings.chirps_per_position)
|
||||
packet += struct.pack(">d", settings.freq_min)
|
||||
packet += struct.pack(">d", settings.freq_max)
|
||||
packet += struct.pack(">d", settings.prf1)
|
||||
packet += struct.pack(">d", settings.prf2)
|
||||
packet += struct.pack(">d", settings.max_distance)
|
||||
packet += struct.pack(">d", settings.map_size)
|
||||
packet += b"END"
|
||||
return packet
|
||||
|
||||
@@ -12,7 +12,6 @@ coverage circle, target trails, velocity-based color coding, popups, legend.
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
from PyQt6.QtWidgets import (
|
||||
QWidget, QVBoxLayout, QHBoxLayout, QFrame,
|
||||
@@ -65,7 +64,7 @@ class MapBridge(QObject):
|
||||
|
||||
@pyqtSlot(str)
|
||||
def logFromJS(self, message: str):
|
||||
logger.debug(f"[JS] {message}")
|
||||
logger.info(f"[JS] {message}")
|
||||
|
||||
@property
|
||||
def is_ready(self) -> bool:
|
||||
@@ -96,7 +95,8 @@ class RadarMapWidget(QWidget):
|
||||
latitude=radar_lat, longitude=radar_lon,
|
||||
altitude=0.0, pitch=0.0, heading=0.0,
|
||||
)
|
||||
self._targets: List[RadarTarget] = []
|
||||
self._targets: list[RadarTarget] = []
|
||||
self._pending_targets: list[RadarTarget] | None = None
|
||||
self._coverage_radius = 50_000 # metres
|
||||
self._tile_server = TileServer.OPENSTREETMAP
|
||||
self._show_coverage = True
|
||||
@@ -282,15 +282,10 @@ function initMap() {{
|
||||
.setView([{lat}, {lon}], 10);
|
||||
setTileServer('osm');
|
||||
|
||||
var radarIcon = L.divIcon({{
|
||||
className:'radar-icon',
|
||||
html:'<div style="background:radial-gradient(circle,#FF5252 0%,#D32F2F 100%);'+
|
||||
'width:24px;height:24px;border-radius:50%;border:3px solid white;'+
|
||||
'box-shadow:0 2px 8px rgba(0,0,0,0.5);"></div>',
|
||||
iconSize:[24,24], iconAnchor:[12,12]
|
||||
}});
|
||||
|
||||
radarMarker = L.marker([{lat},{lon}], {{ icon:radarIcon, zIndexOffset:1000 }}).addTo(map);
|
||||
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}], {{
|
||||
@@ -366,102 +361,99 @@ function updateRadarPosition(lat,lon,alt,pitch,heading) {{
|
||||
}}
|
||||
|
||||
function updateTargets(targetsJson) {{
|
||||
var targets = JSON.parse(targetsJson);
|
||||
var currentIds = {{}};
|
||||
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 sz = Math.max(10, Math.min(20, 10+t.snr/3));
|
||||
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(!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].setIcon(makeIcon(color,sz));
|
||||
if(targetTrails[t.id]) {{
|
||||
targetTrails[t.id].setLatLngs(targetTrailHistory[t.id]);
|
||||
targetTrails[t.id].setStyle({{ color:color }});
|
||||
}}
|
||||
}} else {{
|
||||
var marker = L.marker([lat,lon], {{ icon:makeIcon(color,sz) }}).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'
|
||||
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];
|
||||
}}
|
||||
}}
|
||||
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 makeIcon(color,sz) {{
|
||||
return L.divIcon({{
|
||||
className:'target-icon',
|
||||
html:'<div style="background-color:'+color+';width:'+sz+'px;height:'+sz+'px;'+
|
||||
(
|
||||
'border-radius:50%;border:2px solid white;'+
|
||||
'box-shadow:0 2px 6px rgba(0,0,0,0.4);'
|
||||
)+'</div>',
|
||||
iconSize:[sz,sz], iconAnchor:[sz/2,sz/2]
|
||||
}});
|
||||
}}
|
||||
|
||||
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">'+t.range.toFixed(1)+' m</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Velocity:</span>'+
|
||||
'<span class="popup-value">'+t.velocity.toFixed(1)+' m/s</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Azimuth:</span>'+
|
||||
'<span class="popup-value">'+t.azimuth.toFixed(1)+'°</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
|
||||
'<span class="popup-value">'+t.elevation.toFixed(1)+'°</span></div>'
|
||||
)+
|
||||
(
|
||||
'<div class="popup-row"><span class="popup-label">SNR:</span>'+
|
||||
'<span class="popup-value">'+t.snr.toFixed(1)+' 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>'
|
||||
)
|
||||
'<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+'°</span></div>'+
|
||||
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
|
||||
'<span class="popup-value">'+el+'°</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>'
|
||||
);
|
||||
}}
|
||||
|
||||
@@ -531,12 +523,19 @@ document.addEventListener('DOMContentLoaded', function() {{
|
||||
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):
|
||||
self._web_view.page().runJavaScript(script)
|
||||
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 ---------------------------------------------
|
||||
|
||||
@@ -571,12 +570,20 @@ document.addEventListener('DOMContentLoaded', function() {{
|
||||
f"{gps.altitude},{gps.pitch},{gps.heading})"
|
||||
)
|
||||
|
||||
def set_targets(self, targets: List[RadarTarget]):
|
||||
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 = json.dumps(data).replace("'", "\\'")
|
||||
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}')")
|
||||
self._run_js(f"updateTargets('{js_payload}')")
|
||||
|
||||
def set_coverage_radius(self, radius_m: float):
|
||||
self._coverage_radius = radius_m
|
||||
|
||||
@@ -54,13 +54,6 @@ except ImportError:
|
||||
FILTERPY_AVAILABLE = False
|
||||
logging.warning("filterpy not available. Kalman tracking will be disabled.")
|
||||
|
||||
try:
|
||||
import crcmod as _crcmod # noqa: F401 — availability check
|
||||
CRCMOD_AVAILABLE = True
|
||||
except ImportError:
|
||||
CRCMOD_AVAILABLE = False
|
||||
logging.warning("crcmod not available. CRC validation will use fallback.")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dark theme color constants (shared by all modules)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -105,15 +98,19 @@ class RadarTarget:
|
||||
|
||||
@dataclass
|
||||
class RadarSettings:
|
||||
"""Radar system configuration parameters."""
|
||||
system_frequency: float = 10e9 # Hz
|
||||
chirp_duration_1: float = 30e-6 # Long chirp duration (s)
|
||||
chirp_duration_2: float = 0.5e-6 # Short chirp duration (s)
|
||||
chirps_per_position: int = 32
|
||||
freq_min: float = 10e6 # Hz
|
||||
freq_max: float = 30e6 # Hz
|
||||
prf1: float = 1000 # PRF 1 (Hz)
|
||||
prf2: float = 2000 # PRF 2 (Hz)
|
||||
"""Radar system display/map configuration.
|
||||
|
||||
FPGA register parameters (chirp timing, CFAR, MTI, gain, etc.) are
|
||||
controlled directly via 4-byte opcode commands — see the FPGA Control
|
||||
tab and Opcode enum in radar_protocol.py. This dataclass holds only
|
||||
host-side display/map settings and physical-unit conversion factors.
|
||||
|
||||
range_resolution and velocity_resolution should be calibrated to
|
||||
the actual waveform parameters.
|
||||
"""
|
||||
system_frequency: float = 10e9 # Hz (carrier, used for velocity calc)
|
||||
range_resolution: float = 781.25 # Meters per range bin (default: 50km/64)
|
||||
velocity_resolution: float = 1.0 # m/s per Doppler bin (calibrate to waveform)
|
||||
max_distance: float = 50000 # Max detection range (m)
|
||||
map_size: float = 50000 # Map display size (m)
|
||||
coverage_radius: float = 50000 # Map coverage radius (m)
|
||||
@@ -139,10 +136,14 @@ class GPSData:
|
||||
|
||||
@dataclass
|
||||
class ProcessingConfig:
|
||||
"""Signal processing pipeline configuration.
|
||||
"""Host-side signal processing pipeline configuration.
|
||||
|
||||
Controls: MTI filter, CFAR detector, DC notch removal,
|
||||
windowing, detection threshold, DBSCAN clustering, and Kalman tracking.
|
||||
These control host-side DSP that runs AFTER the FPGA processing
|
||||
pipeline. FPGA-side MTI, CFAR, and DC notch are controlled via
|
||||
register opcodes from the FPGA Control tab.
|
||||
|
||||
Controls: DBSCAN clustering, Kalman tracking, and optional
|
||||
host-side reprocessing (MTI, CFAR, windowing, DC notch).
|
||||
"""
|
||||
|
||||
# MTI (Moving Target Indication)
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
"""
|
||||
v7.processing — Radar signal processing, packet parsing, and GPS parsing.
|
||||
v7.processing — Radar signal processing and GPS parsing.
|
||||
|
||||
Classes:
|
||||
- RadarProcessor — dual-CPI fusion, multi-PRF unwrap, DBSCAN clustering,
|
||||
association, Kalman tracking
|
||||
- RadarPacketParser — parse raw byte streams into typed radar packets
|
||||
(FIX: returns (parsed_dict, bytes_consumed) tuple)
|
||||
- USBPacketParser — parse GPS text/binary frames from STM32 CDC
|
||||
|
||||
Bug fixes vs V6:
|
||||
1. RadarPacketParser.parse_packet() now returns (dict, bytes_consumed) tuple
|
||||
so the caller knows exactly how many bytes to strip from the buffer.
|
||||
2. apply_pitch_correction() is a proper standalone function.
|
||||
Note: RadarPacketParser (old A5/C3 sync + CRC16 format) was removed.
|
||||
All packet parsing now uses production RadarProtocol (0xAA/0xBB format)
|
||||
from radar_protocol.py.
|
||||
"""
|
||||
|
||||
import struct
|
||||
import time
|
||||
import logging
|
||||
import math
|
||||
from typing import Optional, Tuple, List, Dict
|
||||
|
||||
import numpy as np
|
||||
|
||||
from .models import (
|
||||
RadarTarget, GPSData, ProcessingConfig,
|
||||
SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE, CRCMOD_AVAILABLE,
|
||||
SCIPY_AVAILABLE, SKLEARN_AVAILABLE, FILTERPY_AVAILABLE,
|
||||
)
|
||||
|
||||
if SKLEARN_AVAILABLE:
|
||||
@@ -33,9 +29,6 @@ if SKLEARN_AVAILABLE:
|
||||
if FILTERPY_AVAILABLE:
|
||||
from filterpy.kalman import KalmanFilter
|
||||
|
||||
if CRCMOD_AVAILABLE:
|
||||
import crcmod
|
||||
|
||||
if SCIPY_AVAILABLE:
|
||||
from scipy.signal import windows as scipy_windows
|
||||
|
||||
@@ -64,14 +57,14 @@ class RadarProcessor:
|
||||
|
||||
def __init__(self):
|
||||
self.range_doppler_map = np.zeros((1024, 32))
|
||||
self.detected_targets: List[RadarTarget] = []
|
||||
self.detected_targets: list[RadarTarget] = []
|
||||
self.track_id_counter: int = 0
|
||||
self.tracks: Dict[int, dict] = {}
|
||||
self.tracks: dict[int, dict] = {}
|
||||
self.frame_count: int = 0
|
||||
self.config = ProcessingConfig()
|
||||
|
||||
# MTI state: store previous frames for cancellation
|
||||
self._mti_history: List[np.ndarray] = []
|
||||
self._mti_history: list[np.ndarray] = []
|
||||
|
||||
# ---- Configuration -----------------------------------------------------
|
||||
|
||||
@@ -160,12 +153,11 @@ class RadarProcessor:
|
||||
h = self._mti_history
|
||||
if order == 1:
|
||||
return h[-1] - h[-2]
|
||||
elif order == 2:
|
||||
if order == 2:
|
||||
return h[-1] - 2.0 * h[-2] + h[-3]
|
||||
elif order == 3:
|
||||
if order == 3:
|
||||
return h[-1] - 3.0 * h[-2] + 3.0 * h[-3] - h[-4]
|
||||
else:
|
||||
return h[-1] - h[-2]
|
||||
return h[-1] - h[-2]
|
||||
|
||||
# ---- CFAR (Constant False Alarm Rate) -----------------------------------
|
||||
|
||||
@@ -234,7 +226,7 @@ class RadarProcessor:
|
||||
|
||||
# ---- Full processing pipeline -------------------------------------------
|
||||
|
||||
def process_frame(self, raw_frame: np.ndarray) -> Tuple[np.ndarray, np.ndarray]:
|
||||
def process_frame(self, raw_frame: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Run the full signal processing chain on a Range x Doppler frame.
|
||||
|
||||
Parameters
|
||||
@@ -289,34 +281,10 @@ class RadarProcessor:
|
||||
"""Dual-CPI fusion for better detection."""
|
||||
return np.mean(range_profiles_1, axis=0) + np.mean(range_profiles_2, axis=0)
|
||||
|
||||
# ---- Multi-PRF velocity unwrapping -------------------------------------
|
||||
|
||||
def multi_prf_unwrap(self, doppler_measurements, prf1: float, prf2: float):
|
||||
"""Multi-PRF velocity unwrapping (Chinese Remainder Theorem)."""
|
||||
lam = 3e8 / 10e9
|
||||
v_max1 = prf1 * lam / 2
|
||||
v_max2 = prf2 * lam / 2
|
||||
|
||||
unwrapped = []
|
||||
for doppler in doppler_measurements:
|
||||
v1 = doppler * lam / 2
|
||||
v2 = doppler * lam / 2
|
||||
velocity = self._solve_chinese_remainder(v1, v2, v_max1, v_max2)
|
||||
unwrapped.append(velocity)
|
||||
return unwrapped
|
||||
|
||||
@staticmethod
|
||||
def _solve_chinese_remainder(v1, v2, max1, max2):
|
||||
for k in range(-5, 6):
|
||||
candidate = v1 + k * max1
|
||||
if abs(candidate - v2) < max2 / 2:
|
||||
return candidate
|
||||
return v1
|
||||
|
||||
# ---- DBSCAN clustering -------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def clustering(detections: List[RadarTarget],
|
||||
def clustering(detections: list[RadarTarget],
|
||||
eps: float = 100, min_samples: int = 2) -> list:
|
||||
"""DBSCAN clustering of detections (requires sklearn)."""
|
||||
if not SKLEARN_AVAILABLE or len(detections) == 0:
|
||||
@@ -339,8 +307,8 @@ class RadarProcessor:
|
||||
|
||||
# ---- Association -------------------------------------------------------
|
||||
|
||||
def association(self, detections: List[RadarTarget],
|
||||
clusters: list) -> List[RadarTarget]:
|
||||
def association(self, detections: list[RadarTarget],
|
||||
_clusters: list) -> list[RadarTarget]:
|
||||
"""Associate detections to existing tracks (nearest-neighbour)."""
|
||||
associated = []
|
||||
for det in detections:
|
||||
@@ -366,7 +334,7 @@ class RadarProcessor:
|
||||
|
||||
# ---- Kalman tracking ---------------------------------------------------
|
||||
|
||||
def tracking(self, associated_detections: List[RadarTarget]):
|
||||
def tracking(self, associated_detections: list[RadarTarget]):
|
||||
"""Kalman filter tracking (requires filterpy)."""
|
||||
if not FILTERPY_AVAILABLE:
|
||||
return
|
||||
@@ -412,158 +380,6 @@ class RadarProcessor:
|
||||
del self.tracks[tid]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Radar Packet Parser
|
||||
# =============================================================================
|
||||
|
||||
class RadarPacketParser:
|
||||
"""
|
||||
Parse binary radar packets from the raw byte stream.
|
||||
|
||||
Packet format:
|
||||
[Sync 2][Type 1][Length 1][Payload N][CRC16 2]
|
||||
Sync pattern: 0xA5 0xC3
|
||||
|
||||
Bug fix vs V6:
|
||||
parse_packet() now returns ``(parsed_dict, bytes_consumed)`` so the
|
||||
caller can correctly advance the read pointer in the buffer.
|
||||
"""
|
||||
|
||||
SYNC = b"\xA5\xC3"
|
||||
|
||||
def __init__(self):
|
||||
if CRCMOD_AVAILABLE:
|
||||
self.crc16_func = crcmod.mkCrcFun(
|
||||
0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
|
||||
)
|
||||
else:
|
||||
self.crc16_func = None
|
||||
|
||||
# ---- main entry point --------------------------------------------------
|
||||
|
||||
def parse_packet(self, data: bytes) -> Optional[Tuple[dict, int]]:
|
||||
"""
|
||||
Attempt to parse one radar packet from *data*.
|
||||
|
||||
Returns
|
||||
-------
|
||||
(parsed_dict, bytes_consumed) on success, or None if no valid packet.
|
||||
"""
|
||||
if len(data) < 6:
|
||||
return None
|
||||
|
||||
idx = data.find(self.SYNC)
|
||||
if idx == -1:
|
||||
return None
|
||||
|
||||
pkt = data[idx:]
|
||||
if len(pkt) < 6:
|
||||
return None
|
||||
|
||||
pkt_type = pkt[2]
|
||||
length = pkt[3]
|
||||
total_len = 4 + length + 2 # sync(2) + type(1) + len(1) + payload + crc(2)
|
||||
|
||||
if len(pkt) < total_len:
|
||||
return None
|
||||
|
||||
payload = pkt[4 : 4 + length]
|
||||
crc_received = struct.unpack("<H", pkt[4 + length : 4 + length + 2])[0]
|
||||
|
||||
# CRC check
|
||||
if self.crc16_func is not None:
|
||||
crc_calc = self.crc16_func(pkt[0 : 4 + length])
|
||||
if crc_calc != crc_received:
|
||||
logger.warning(
|
||||
f"CRC mismatch: got {crc_received:04X}, calc {crc_calc:04X}"
|
||||
)
|
||||
return None
|
||||
|
||||
# Bytes consumed = offset to sync + total packet length
|
||||
consumed = idx + total_len
|
||||
|
||||
parsed = None
|
||||
if pkt_type == 0x01:
|
||||
parsed = self._parse_range(payload)
|
||||
elif pkt_type == 0x02:
|
||||
parsed = self._parse_doppler(payload)
|
||||
elif pkt_type == 0x03:
|
||||
parsed = self._parse_detection(payload)
|
||||
else:
|
||||
logger.warning(f"Unknown packet type: {pkt_type:02X}")
|
||||
|
||||
if parsed is None:
|
||||
return None
|
||||
return (parsed, consumed)
|
||||
|
||||
# ---- sub-parsers -------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _parse_range(payload: bytes) -> Optional[dict]:
|
||||
if len(payload) < 12:
|
||||
return None
|
||||
try:
|
||||
range_val = struct.unpack(">I", payload[0:4])[0]
|
||||
elevation = payload[4] & 0x1F
|
||||
azimuth = payload[5] & 0x3F
|
||||
chirp = payload[6] & 0x1F
|
||||
return {
|
||||
"type": "range",
|
||||
"range": range_val,
|
||||
"elevation": elevation,
|
||||
"azimuth": azimuth,
|
||||
"chirp": chirp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing range packet: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_doppler(payload: bytes) -> Optional[dict]:
|
||||
if len(payload) < 12:
|
||||
return None
|
||||
try:
|
||||
real = struct.unpack(">h", payload[0:2])[0]
|
||||
imag = struct.unpack(">h", payload[2:4])[0]
|
||||
elevation = payload[4] & 0x1F
|
||||
azimuth = payload[5] & 0x3F
|
||||
chirp = payload[6] & 0x1F
|
||||
return {
|
||||
"type": "doppler",
|
||||
"doppler_real": real,
|
||||
"doppler_imag": imag,
|
||||
"elevation": elevation,
|
||||
"azimuth": azimuth,
|
||||
"chirp": chirp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing doppler packet: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_detection(payload: bytes) -> Optional[dict]:
|
||||
if len(payload) < 8:
|
||||
return None
|
||||
try:
|
||||
detected = (payload[0] & 0x01) != 0
|
||||
elevation = payload[1] & 0x1F
|
||||
azimuth = payload[2] & 0x3F
|
||||
chirp = payload[3] & 0x1F
|
||||
return {
|
||||
"type": "detection",
|
||||
"detected": detected,
|
||||
"elevation": elevation,
|
||||
"azimuth": azimuth,
|
||||
"chirp": chirp,
|
||||
"timestamp": time.time(),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Error parsing detection packet: {e}")
|
||||
return None
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# USB / GPS Packet Parser
|
||||
# =============================================================================
|
||||
@@ -578,14 +394,9 @@ class USBPacketParser:
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
if CRCMOD_AVAILABLE:
|
||||
self.crc16_func = crcmod.mkCrcFun(
|
||||
0x11021, rev=False, initCrc=0xFFFF, xorOut=0x0000
|
||||
)
|
||||
else:
|
||||
self.crc16_func = None
|
||||
pass
|
||||
|
||||
def parse_gps_data(self, data: bytes) -> Optional[GPSData]:
|
||||
def parse_gps_data(self, data: bytes) -> GPSData | None:
|
||||
"""Attempt to parse GPS data from a raw USB CDC frame."""
|
||||
if not data:
|
||||
return None
|
||||
@@ -607,12 +418,12 @@ class USBPacketParser:
|
||||
# Binary format: [GPSB 4][lat 8][lon 8][alt 4][pitch 4][CRC 2] = 30 bytes
|
||||
if len(data) >= 30 and data[0:4] == b"GPSB":
|
||||
return self._parse_binary_gps(data)
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logger.error(f"Error parsing GPS data: {e}")
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _parse_binary_gps(data: bytes) -> Optional[GPSData]:
|
||||
def _parse_binary_gps(data: bytes) -> GPSData | None:
|
||||
"""Parse 30-byte binary GPS frame."""
|
||||
try:
|
||||
if len(data) < 30:
|
||||
@@ -637,6 +448,6 @@ class USBPacketParser:
|
||||
pitch=pitch,
|
||||
timestamp=time.time(),
|
||||
)
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
logger.error(f"Error parsing binary GPS: {e}")
|
||||
return None
|
||||
|
||||
+168
-119
@@ -2,24 +2,39 @@
|
||||
v7.workers — QThread-based workers and demo target simulator.
|
||||
|
||||
Classes:
|
||||
- RadarDataWorker — reads from FT2232HQ, parses packets,
|
||||
emits signals with processed data.
|
||||
- RadarDataWorker — reads from FT2232H via production RadarAcquisition,
|
||||
parses 0xAA/0xBB packets, assembles 64x32 frames,
|
||||
runs host-side DSP, emits PyQt signals.
|
||||
- GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals.
|
||||
- TargetSimulator — QTimer-based demo target generator (from GUI_PyQt_Map.py).
|
||||
- TargetSimulator — QTimer-based demo target generator.
|
||||
|
||||
The old V6/V7 packet parsing (sync A5 C3 + type + CRC16) has been removed.
|
||||
All packet parsing now uses the production radar_protocol.py which matches
|
||||
the actual FPGA packet format (0xAA data 11-byte, 0xBB status 26-byte).
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import random
|
||||
import queue
|
||||
import struct
|
||||
import logging
|
||||
from typing import List
|
||||
|
||||
import numpy as np
|
||||
|
||||
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
|
||||
|
||||
from .models import RadarTarget, RadarSettings, GPSData
|
||||
from .hardware import FT2232HQInterface, STM32USBInterface
|
||||
from .models import RadarTarget, GPSData, RadarSettings
|
||||
from .hardware import (
|
||||
RadarAcquisition,
|
||||
RadarFrame,
|
||||
StatusResponse,
|
||||
DataRecorder,
|
||||
STM32USBInterface,
|
||||
)
|
||||
from .processing import (
|
||||
RadarProcessor, RadarPacketParser, USBPacketParser,
|
||||
RadarProcessor,
|
||||
USBPacketParser,
|
||||
apply_pitch_correction,
|
||||
)
|
||||
|
||||
@@ -61,162 +76,196 @@ def polar_to_geographic(
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Radar Data Worker (QThread)
|
||||
# Radar Data Worker (QThread) — production protocol
|
||||
# =============================================================================
|
||||
|
||||
class RadarDataWorker(QThread):
|
||||
"""
|
||||
Background worker that continuously reads radar data from the primary
|
||||
FT2232HQ interface, parses packets, runs the processing pipeline, and
|
||||
emits signals with results.
|
||||
Background worker that reads radar data from FT2232H (or ReplayConnection),
|
||||
parses 0xAA/0xBB packets via production RadarAcquisition, runs optional
|
||||
host-side DSP, and emits PyQt signals with results.
|
||||
|
||||
This replaces the old V7 worker which used an incompatible packet format.
|
||||
Now uses production radar_protocol.py for all packet parsing and frame
|
||||
assembly (11-byte 0xAA data packets → 64x32 RadarFrame).
|
||||
|
||||
Signals:
|
||||
packetReceived(dict) — a single parsed packet dict
|
||||
targetsUpdated(list) — list of RadarTarget after processing
|
||||
errorOccurred(str) — error message
|
||||
statsUpdated(dict) — packet/byte counters
|
||||
frameReady(RadarFrame) — a complete 64x32 radar frame
|
||||
statusReceived(object) — StatusResponse from FPGA
|
||||
targetsUpdated(list) — list of RadarTarget after host-side DSP
|
||||
errorOccurred(str) — error message
|
||||
statsUpdated(dict) — frame/byte counters
|
||||
"""
|
||||
|
||||
packetReceived = pyqtSignal(dict)
|
||||
targetsUpdated = pyqtSignal(list)
|
||||
frameReady = pyqtSignal(object) # RadarFrame
|
||||
statusReceived = pyqtSignal(object) # StatusResponse
|
||||
targetsUpdated = pyqtSignal(list) # List[RadarTarget]
|
||||
errorOccurred = pyqtSignal(str)
|
||||
statsUpdated = pyqtSignal(dict)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ft2232hq: FT2232HQInterface,
|
||||
processor: RadarProcessor,
|
||||
packet_parser: RadarPacketParser,
|
||||
settings: RadarSettings,
|
||||
gps_data_ref: GPSData,
|
||||
connection, # FT2232HConnection or ReplayConnection
|
||||
processor: RadarProcessor | None = None,
|
||||
recorder: DataRecorder | None = None,
|
||||
gps_data_ref: GPSData | None = None,
|
||||
settings: RadarSettings | None = None,
|
||||
parent=None,
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._ft2232hq = ft2232hq
|
||||
self._connection = connection
|
||||
self._processor = processor
|
||||
self._parser = packet_parser
|
||||
self._settings = settings
|
||||
self._recorder = recorder
|
||||
self._gps = gps_data_ref
|
||||
self._settings = settings or RadarSettings()
|
||||
self._running = False
|
||||
|
||||
# Frame queue for production RadarAcquisition → this thread
|
||||
self._frame_queue: queue.Queue = queue.Queue(maxsize=4)
|
||||
|
||||
# Production acquisition thread (does the actual parsing)
|
||||
self._acquisition: RadarAcquisition | None = None
|
||||
|
||||
# Counters
|
||||
self._packet_count = 0
|
||||
self._frame_count = 0
|
||||
self._byte_count = 0
|
||||
self._error_count = 0
|
||||
|
||||
def stop(self):
|
||||
self._running = False
|
||||
if self._acquisition:
|
||||
self._acquisition.stop()
|
||||
|
||||
def run(self):
|
||||
"""Main loop: read → parse → process → emit."""
|
||||
"""
|
||||
Start production RadarAcquisition thread, then poll its frame queue
|
||||
and emit PyQt signals for each complete frame.
|
||||
"""
|
||||
self._running = True
|
||||
buffer = bytearray()
|
||||
|
||||
# Create and start the production acquisition thread
|
||||
self._acquisition = RadarAcquisition(
|
||||
connection=self._connection,
|
||||
frame_queue=self._frame_queue,
|
||||
recorder=self._recorder,
|
||||
status_callback=self._on_status,
|
||||
)
|
||||
self._acquisition.start()
|
||||
logger.info("RadarDataWorker started (production protocol)")
|
||||
|
||||
while self._running:
|
||||
# Use FT2232HQ interface
|
||||
iface = None
|
||||
if self._ft2232hq and self._ft2232hq.is_open:
|
||||
iface = self._ft2232hq
|
||||
|
||||
if iface is None:
|
||||
self.msleep(100)
|
||||
continue
|
||||
|
||||
try:
|
||||
data = iface.read_data(4096)
|
||||
if data:
|
||||
buffer.extend(data)
|
||||
self._byte_count += len(data)
|
||||
# Poll for complete frames from production acquisition
|
||||
frame: RadarFrame = self._frame_queue.get(timeout=0.1)
|
||||
self._frame_count += 1
|
||||
|
||||
# Parse as many packets as possible
|
||||
while len(buffer) >= 6:
|
||||
result = self._parser.parse_packet(bytes(buffer))
|
||||
if result is None:
|
||||
# No valid packet at current position — skip one byte
|
||||
if len(buffer) > 1:
|
||||
buffer = buffer[1:]
|
||||
else:
|
||||
break
|
||||
continue
|
||||
# Emit raw frame
|
||||
self.frameReady.emit(frame)
|
||||
|
||||
pkt, consumed = result
|
||||
buffer = buffer[consumed:]
|
||||
self._packet_count += 1
|
||||
# Run host-side DSP if processor is configured
|
||||
if self._processor is not None:
|
||||
targets = self._run_host_dsp(frame)
|
||||
if targets:
|
||||
self.targetsUpdated.emit(targets)
|
||||
|
||||
# Process the packet
|
||||
self._process_packet(pkt)
|
||||
self.packetReceived.emit(pkt)
|
||||
# Emit stats
|
||||
self.statsUpdated.emit({
|
||||
"frames": self._frame_count,
|
||||
"detection_count": frame.detection_count,
|
||||
"errors": self._error_count,
|
||||
})
|
||||
|
||||
# Emit stats periodically
|
||||
self.statsUpdated.emit({
|
||||
"packets": self._packet_count,
|
||||
"bytes": self._byte_count,
|
||||
"errors": self._error_count,
|
||||
"active_tracks": len(self._processor.tracks),
|
||||
"targets": len(self._processor.detected_targets),
|
||||
})
|
||||
else:
|
||||
self.msleep(10)
|
||||
except Exception as e:
|
||||
except queue.Empty:
|
||||
continue
|
||||
except (ValueError, IndexError) as e:
|
||||
self._error_count += 1
|
||||
self.errorOccurred.emit(str(e))
|
||||
logger.error(f"RadarDataWorker error: {e}")
|
||||
self.msleep(100)
|
||||
|
||||
# ---- internal packet handling ------------------------------------------
|
||||
# Stop acquisition thread
|
||||
if self._acquisition:
|
||||
self._acquisition.stop()
|
||||
self._acquisition.join(timeout=2.0)
|
||||
self._acquisition = None
|
||||
|
||||
def _process_packet(self, pkt: dict):
|
||||
"""Route a parsed packet through the processing pipeline."""
|
||||
try:
|
||||
if pkt["type"] == "range":
|
||||
range_m = pkt["range"] * 0.1
|
||||
raw_elev = pkt["elevation"]
|
||||
logger.info("RadarDataWorker stopped")
|
||||
|
||||
def _on_status(self, status: StatusResponse):
|
||||
"""Callback from production RadarAcquisition on status packet."""
|
||||
self.statusReceived.emit(status)
|
||||
|
||||
def _run_host_dsp(self, frame: RadarFrame) -> list[RadarTarget]:
|
||||
"""
|
||||
Run host-side DSP on a complete frame.
|
||||
This is where DBSCAN clustering, Kalman tracking, and other
|
||||
non-timing-critical processing happens.
|
||||
|
||||
The FPGA already does: FFT, MTI, CFAR, DC notch.
|
||||
Host-side DSP adds: clustering, tracking, geo-coordinate mapping.
|
||||
|
||||
Bin-to-physical conversion uses RadarSettings.range_resolution
|
||||
and velocity_resolution (should be calibrated to actual waveform).
|
||||
"""
|
||||
targets: list[RadarTarget] = []
|
||||
|
||||
cfg = self._processor.config
|
||||
if not (cfg.clustering_enabled or cfg.tracking_enabled):
|
||||
return targets
|
||||
|
||||
# Extract detections from FPGA CFAR flags
|
||||
det_indices = np.argwhere(frame.detections > 0)
|
||||
r_res = self._settings.range_resolution
|
||||
v_res = self._settings.velocity_resolution
|
||||
|
||||
for idx in det_indices:
|
||||
rbin, dbin = idx
|
||||
mag = frame.magnitude[rbin, dbin]
|
||||
snr = 10 * np.log10(max(mag, 1)) if mag > 0 else 0
|
||||
|
||||
# Convert bin indices to physical units
|
||||
range_m = float(rbin) * r_res
|
||||
# Doppler: centre bin (16) = 0 m/s; positive bins = approaching
|
||||
velocity_ms = float(dbin - 16) * v_res
|
||||
|
||||
# Apply pitch correction if GPS data available
|
||||
raw_elev = 0.0 # FPGA doesn't send elevation per-detection
|
||||
corr_elev = raw_elev
|
||||
if self._gps:
|
||||
corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch)
|
||||
|
||||
target = RadarTarget(
|
||||
id=pkt["chirp"],
|
||||
range=range_m,
|
||||
velocity=0,
|
||||
azimuth=pkt["azimuth"],
|
||||
elevation=corr_elev,
|
||||
snr=20.0,
|
||||
timestamp=pkt["timestamp"],
|
||||
# Compute geographic position if GPS available
|
||||
lat, lon = 0.0, 0.0
|
||||
azimuth = 0.0 # No azimuth from single-beam; set to heading
|
||||
if self._gps:
|
||||
azimuth = self._gps.heading
|
||||
lat, lon = polar_to_geographic(
|
||||
self._gps.latitude, self._gps.longitude,
|
||||
range_m, azimuth,
|
||||
)
|
||||
self._update_rdm(target)
|
||||
|
||||
elif pkt["type"] == "doppler":
|
||||
lam = 3e8 / self._settings.system_frequency
|
||||
velocity = (pkt["doppler_real"] / 32767.0) * (
|
||||
self._settings.prf1 * lam / 2
|
||||
)
|
||||
self._update_velocity(pkt, velocity)
|
||||
target = RadarTarget(
|
||||
id=len(targets),
|
||||
range=range_m,
|
||||
velocity=velocity_ms,
|
||||
azimuth=azimuth,
|
||||
elevation=corr_elev,
|
||||
latitude=lat,
|
||||
longitude=lon,
|
||||
snr=snr,
|
||||
timestamp=frame.timestamp,
|
||||
)
|
||||
targets.append(target)
|
||||
|
||||
elif pkt["type"] == "detection":
|
||||
if pkt["detected"]:
|
||||
raw_elev = pkt["elevation"]
|
||||
corr_elev = apply_pitch_correction(raw_elev, self._gps.pitch)
|
||||
logger.info(
|
||||
f"CFAR Detection: raw={raw_elev}, corr={corr_elev:.1f}, "
|
||||
f"pitch={self._gps.pitch:.1f}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing packet: {e}")
|
||||
# DBSCAN clustering
|
||||
if cfg.clustering_enabled and len(targets) > 0:
|
||||
clusters = self._processor.clustering(
|
||||
targets, cfg.clustering_eps, cfg.clustering_min_samples)
|
||||
# Associate and track
|
||||
if cfg.tracking_enabled:
|
||||
targets = self._processor.association(targets, clusters)
|
||||
self._processor.tracking(targets)
|
||||
|
||||
def _update_rdm(self, target: RadarTarget):
|
||||
range_bin = min(int(target.range / 50), 1023)
|
||||
doppler_bin = min(abs(int(target.velocity)), 31)
|
||||
self._processor.range_doppler_map[range_bin, doppler_bin] += 1
|
||||
self._processor.detected_targets.append(target)
|
||||
if len(self._processor.detected_targets) > 100:
|
||||
self._processor.detected_targets = self._processor.detected_targets[-100:]
|
||||
|
||||
def _update_velocity(self, pkt: dict, velocity: float):
|
||||
for t in self._processor.detected_targets:
|
||||
if (t.azimuth == pkt["azimuth"]
|
||||
and t.elevation == pkt["elevation"]
|
||||
and t.id == pkt["chirp"]):
|
||||
t.velocity = velocity
|
||||
break
|
||||
return targets
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -269,7 +318,7 @@ class GPSDataWorker(QThread):
|
||||
if gps:
|
||||
self._gps_count += 1
|
||||
self.gpsReceived.emit(gps)
|
||||
except Exception as e:
|
||||
except (ValueError, struct.error) as e:
|
||||
self.errorOccurred.emit(str(e))
|
||||
logger.error(f"GPSDataWorker error: {e}")
|
||||
self.msleep(100)
|
||||
@@ -292,7 +341,7 @@ class TargetSimulator(QObject):
|
||||
def __init__(self, radar_position: GPSData, parent=None):
|
||||
super().__init__(parent)
|
||||
self._radar_pos = radar_position
|
||||
self._targets: List[RadarTarget] = []
|
||||
self._targets: list[RadarTarget] = []
|
||||
self._next_id = 1
|
||||
self._timer = QTimer(self)
|
||||
self._timer.timeout.connect(self._tick)
|
||||
@@ -349,7 +398,7 @@ class TargetSimulator(QObject):
|
||||
|
||||
def _tick(self):
|
||||
"""Update all simulated targets and emit."""
|
||||
updated: List[RadarTarget] = []
|
||||
updated: list[RadarTarget] = []
|
||||
|
||||
for t in self._targets:
|
||||
new_range = t.range - t.velocity * 0.5
|
||||
|
||||
Reference in New Issue
Block a user