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:
Jason
2026-04-12 14:18:34 +05:45
parent b6e8eda130
commit 2106e24952
54 changed files with 4619 additions and 9063 deletions
+17 -9
View File
@@ -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",
File diff suppressed because it is too large Load Diff
+44 -175
View File
@@ -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
+102 -95
View File
@@ -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)+'&deg;</span></div>'
)+
(
'<div class="popup-row"><span class="popup-label">Elevation:</span>'+
'<span class="popup-value">'+t.elevation.toFixed(1)+'&deg;</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+'&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>'
);
}}
@@ -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
+20 -19
View File
@@ -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)
+21 -210
View File
@@ -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
View File
@@ -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