57de32b172
Fixes 25 remaining manual lint errors after auto-fix pass (94 auto-fixed earlier): - GUI_V6.py: noqa on availability imports, bare except, unused vars, F811 redefs - GUI_V6_Demo.py: unused app variable - v7/models.py: noqa F401 on 8 try/except availability-check imports - FPGA cosim: unused header/status/span vars, ambiguous 'l' renamed to 'line', E701 while-on-one-line split, F841 padding vars annotated Also adds v7/ module, GUI_PyQt_Map.py, and GUI_V7_PyQt.py to version control. Expands CI lint job to cover all 21 maintained Python files (was 4). All 58 Python tests pass. Zero ruff errors on all target files.
390 lines
13 KiB
Python
390 lines
13 KiB
Python
"""
|
|
v7.workers — QThread-based workers and demo target simulator.
|
|
|
|
Classes:
|
|
- RadarDataWorker — reads from FT2232HQ, parses packets,
|
|
emits signals with processed data.
|
|
- GPSDataWorker — reads GPS frames from STM32 CDC, emits GPSData signals.
|
|
- TargetSimulator — QTimer-based demo target generator (from GUI_PyQt_Map.py).
|
|
"""
|
|
|
|
import math
|
|
import time
|
|
import random
|
|
import logging
|
|
from typing import List
|
|
|
|
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
|
|
|
|
from .models import RadarTarget, RadarSettings, GPSData
|
|
from .hardware import FT2232HQInterface, STM32USBInterface
|
|
from .processing import (
|
|
RadarProcessor, RadarPacketParser, USBPacketParser,
|
|
apply_pitch_correction,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# =============================================================================
|
|
# Utility: polar → geographic
|
|
# =============================================================================
|
|
|
|
def polar_to_geographic(
|
|
radar_lat: float,
|
|
radar_lon: float,
|
|
range_m: float,
|
|
azimuth_deg: float,
|
|
) -> tuple:
|
|
"""
|
|
Convert polar coordinates (range, azimuth) relative to radar
|
|
to geographic (latitude, longitude).
|
|
|
|
azimuth_deg: 0 = North, clockwise.
|
|
Returns (lat, lon).
|
|
"""
|
|
R = 6_371_000 # Earth radius in meters
|
|
|
|
lat1 = math.radians(radar_lat)
|
|
lon1 = math.radians(radar_lon)
|
|
bearing = math.radians(azimuth_deg)
|
|
|
|
lat2 = math.asin(
|
|
math.sin(lat1) * math.cos(range_m / R)
|
|
+ math.cos(lat1) * math.sin(range_m / R) * math.cos(bearing)
|
|
)
|
|
lon2 = lon1 + math.atan2(
|
|
math.sin(bearing) * math.sin(range_m / R) * math.cos(lat1),
|
|
math.cos(range_m / R) - math.sin(lat1) * math.sin(lat2),
|
|
)
|
|
return (math.degrees(lat2), math.degrees(lon2))
|
|
|
|
|
|
# =============================================================================
|
|
# Radar Data Worker (QThread)
|
|
# =============================================================================
|
|
|
|
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.
|
|
|
|
Signals:
|
|
packetReceived(dict) — a single parsed packet dict
|
|
targetsUpdated(list) — list of RadarTarget after processing
|
|
errorOccurred(str) — error message
|
|
statsUpdated(dict) — packet/byte counters
|
|
"""
|
|
|
|
packetReceived = pyqtSignal(dict)
|
|
targetsUpdated = pyqtSignal(list)
|
|
errorOccurred = pyqtSignal(str)
|
|
statsUpdated = pyqtSignal(dict)
|
|
|
|
def __init__(
|
|
self,
|
|
ft2232hq: FT2232HQInterface,
|
|
processor: RadarProcessor,
|
|
packet_parser: RadarPacketParser,
|
|
settings: RadarSettings,
|
|
gps_data_ref: GPSData,
|
|
parent=None,
|
|
):
|
|
super().__init__(parent)
|
|
self._ft2232hq = ft2232hq
|
|
self._processor = processor
|
|
self._parser = packet_parser
|
|
self._settings = settings
|
|
self._gps = gps_data_ref
|
|
self._running = False
|
|
|
|
# Counters
|
|
self._packet_count = 0
|
|
self._byte_count = 0
|
|
self._error_count = 0
|
|
|
|
def stop(self):
|
|
self._running = False
|
|
|
|
def run(self):
|
|
"""Main loop: read → parse → process → emit."""
|
|
self._running = True
|
|
buffer = bytearray()
|
|
|
|
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)
|
|
|
|
# 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
|
|
|
|
pkt, consumed = result
|
|
buffer = buffer[consumed:]
|
|
self._packet_count += 1
|
|
|
|
# Process the packet
|
|
self._process_packet(pkt)
|
|
self.packetReceived.emit(pkt)
|
|
|
|
# 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:
|
|
self._error_count += 1
|
|
self.errorOccurred.emit(str(e))
|
|
logger.error(f"RadarDataWorker error: {e}")
|
|
self.msleep(100)
|
|
|
|
# ---- internal packet handling ------------------------------------------
|
|
|
|
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"]
|
|
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"],
|
|
)
|
|
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)
|
|
|
|
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}")
|
|
|
|
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
|
|
|
|
|
|
# =============================================================================
|
|
# GPS Data Worker (QThread)
|
|
# =============================================================================
|
|
|
|
class GPSDataWorker(QThread):
|
|
"""
|
|
Background worker that reads GPS frames from the STM32 USB CDC interface
|
|
and emits parsed GPSData objects.
|
|
|
|
Signals:
|
|
gpsReceived(GPSData)
|
|
errorOccurred(str)
|
|
"""
|
|
|
|
gpsReceived = pyqtSignal(object) # GPSData
|
|
errorOccurred = pyqtSignal(str)
|
|
|
|
def __init__(
|
|
self,
|
|
stm32: STM32USBInterface,
|
|
usb_parser: USBPacketParser,
|
|
parent=None,
|
|
):
|
|
super().__init__(parent)
|
|
self._stm32 = stm32
|
|
self._parser = usb_parser
|
|
self._running = False
|
|
self._gps_count = 0
|
|
|
|
@property
|
|
def gps_count(self) -> int:
|
|
return self._gps_count
|
|
|
|
def stop(self):
|
|
self._running = False
|
|
|
|
def run(self):
|
|
self._running = True
|
|
while self._running:
|
|
if not (self._stm32 and self._stm32.is_open):
|
|
self.msleep(100)
|
|
continue
|
|
|
|
try:
|
|
data = self._stm32.read_data(64, timeout=100)
|
|
if data:
|
|
gps = self._parser.parse_gps_data(data)
|
|
if gps:
|
|
self._gps_count += 1
|
|
self.gpsReceived.emit(gps)
|
|
except Exception as e:
|
|
self.errorOccurred.emit(str(e))
|
|
logger.error(f"GPSDataWorker error: {e}")
|
|
self.msleep(100)
|
|
|
|
|
|
# =============================================================================
|
|
# Target Simulator (Demo Mode) — QTimer-based
|
|
# =============================================================================
|
|
|
|
class TargetSimulator(QObject):
|
|
"""
|
|
Generates simulated radar targets for demo/testing.
|
|
|
|
Uses a QTimer on the main thread (or whichever thread owns this object).
|
|
Emits ``targetsUpdated`` with a list[RadarTarget] on each tick.
|
|
"""
|
|
|
|
targetsUpdated = pyqtSignal(list)
|
|
|
|
def __init__(self, radar_position: GPSData, parent=None):
|
|
super().__init__(parent)
|
|
self._radar_pos = radar_position
|
|
self._targets: List[RadarTarget] = []
|
|
self._next_id = 1
|
|
self._timer = QTimer(self)
|
|
self._timer.timeout.connect(self._tick)
|
|
self._initialize_targets(8)
|
|
|
|
# ---- public API --------------------------------------------------------
|
|
|
|
def start(self, interval_ms: int = 500):
|
|
self._timer.start(interval_ms)
|
|
|
|
def stop(self):
|
|
self._timer.stop()
|
|
|
|
def set_radar_position(self, gps: GPSData):
|
|
self._radar_pos = gps
|
|
|
|
def add_random_target(self):
|
|
self._add_random_target()
|
|
|
|
# ---- internals ---------------------------------------------------------
|
|
|
|
def _initialize_targets(self, count: int):
|
|
for _ in range(count):
|
|
self._add_random_target()
|
|
|
|
def _add_random_target(self):
|
|
range_m = random.uniform(5000, 40000)
|
|
azimuth = random.uniform(0, 360)
|
|
velocity = random.uniform(-100, 100)
|
|
elevation = random.uniform(-5, 45)
|
|
|
|
lat, lon = polar_to_geographic(
|
|
self._radar_pos.latitude,
|
|
self._radar_pos.longitude,
|
|
range_m,
|
|
azimuth,
|
|
)
|
|
|
|
target = RadarTarget(
|
|
id=self._next_id,
|
|
range=range_m,
|
|
velocity=velocity,
|
|
azimuth=azimuth,
|
|
elevation=elevation,
|
|
latitude=lat,
|
|
longitude=lon,
|
|
snr=random.uniform(10, 35),
|
|
timestamp=time.time(),
|
|
track_id=self._next_id,
|
|
classification=random.choice(["aircraft", "drone", "bird", "unknown"]),
|
|
)
|
|
self._next_id += 1
|
|
self._targets.append(target)
|
|
|
|
def _tick(self):
|
|
"""Update all simulated targets and emit."""
|
|
updated: List[RadarTarget] = []
|
|
|
|
for t in self._targets:
|
|
new_range = t.range - t.velocity * 0.5
|
|
if new_range < 500 or new_range > 50000:
|
|
continue # target exits coverage — drop it
|
|
|
|
new_vel = max(-150, min(150, t.velocity + random.uniform(-2, 2)))
|
|
new_az = (t.azimuth + random.uniform(-0.5, 0.5)) % 360
|
|
|
|
lat, lon = polar_to_geographic(
|
|
self._radar_pos.latitude,
|
|
self._radar_pos.longitude,
|
|
new_range,
|
|
new_az,
|
|
)
|
|
|
|
updated.append(RadarTarget(
|
|
id=t.id,
|
|
range=new_range,
|
|
velocity=new_vel,
|
|
azimuth=new_az,
|
|
elevation=t.elevation + random.uniform(-0.1, 0.1),
|
|
latitude=lat,
|
|
longitude=lon,
|
|
snr=t.snr + random.uniform(-1, 1),
|
|
timestamp=time.time(),
|
|
track_id=t.track_id,
|
|
classification=t.classification,
|
|
))
|
|
|
|
# Maintain a reasonable target count
|
|
if len(updated) < 5 or (random.random() < 0.05 and len(updated) < 15):
|
|
self._add_random_target()
|
|
updated.append(self._targets[-1])
|
|
|
|
self._targets = updated
|
|
self.targetsUpdated.emit(updated)
|