Compare commits

..

15 Commits

Author SHA1 Message Date
NawfalMotii79 a8aefc4f61 Merge pull request #119 from NawfalMotii79/fix/mcu-fault-ack-emergency-clear
AERIS-10 CI / MCU Firmware Tests (push) Has been cancelled
AERIS-10 CI / FPGA Regression (push) Has been cancelled
AERIS-10 CI / Cross-Layer Contract Tests (push) Has been cancelled
AERIS-10 CI / Python Lint + Tests (push) Has been cancelled
fix(mcu): FAULT_ACK USB command clears system_emergency_state (closes #83)
2026-04-21 23:11:42 +01:00
Jason 5b84af68f6 fix(mcu): add FAULT_ACK command to clear system_emergency_state via USB (closes #83)
The volatile fix in the companion PR (#118) makes the safe-mode blink loop
escapable in principle, but no firmware path existed to actually clear
system_emergency_state at runtime — hardware reset was the only exit, which
fires the IWDG and re-energises the PA rails that Emergency_Stop() cut.

This change adds a FAULT_ACK command (opcode 0x40): the host sends an exact
4-byte CDC packet [0x40, 0x00, 0x00, 0x00]; USBHandler detects it regardless
of USB state and sets fault_ack_received; the blink loop checks the flag each
250 ms iteration and clears system_emergency_state, allowing a controlled
operator-acknowledged recovery without triggering a watchdog reset.

Detection is guarded to exact 4-byte packets only. Scanning larger packets
for the subsequence would false-trigger on the IEEE 754 big-endian encoding
of 2.0 (0x4000000000000000), which starts with the same 4 bytes and can
appear in normal settings doubles.

FAULT_ACK is excluded from the FPGA opcode enum to preserve the
Python/Verilog bidirectional contract test; contract_parser.py reads the
new MCU_ONLY_OPCODES frozenset in radar_protocol.py to filter it.

7 new test vectors in test_gap3_fault_ack_clears_emergency.c cover:
detection, loop exit, loop hold without ack, settings false-positive
immunity, truncated packet, wrong opcode, and multi-iteration sequence.

Reported-by: shaun0927 (Junghwan) <https://github.com/shaun0927>
2026-04-21 03:57:55 +05:45
Jason 846a0debe8 Merge pull request #118 from NawfalMotii79/fix/mcu-volatile-emergency-state-agc-holdoff
fix(mcu): volatile emergency state + AGC holdoff zero-guard (closes #83)
2026-04-21 00:57:08 +03:00
Jason e979363730 fix(mcu): volatile emergency state + AGC holdoff zero-guard (closes #83)
Bug 1 (main.cpp:630): system_emergency_state lacked volatile. Under -O1+
the compiler is permitted to hoist the read outside the blink loop, making
while (system_emergency_state) unconditionally infinite. Once entered, the
only escape was the 4 s IWDG timeout — which resets the MCU and
re-energizes the PA rails that Emergency_Stop() explicitly cut. Marking the
variable volatile forces a memory read on every iteration so an external
clear (ISR, USB command, manual reset) can break the loop correctly.

Bug 2 (ADAR1000_AGC.cpp:59): holdoff_frames is a public uint8_t; if a
caller sets it to 0, the condition holdoff_counter >= holdoff_frames is
always true (any uint8_t >= 0), causing the AGC outer loop to increase gain
on every non-saturated frame with no holdoff delay. With alternating
sat/no-sat frames this produces a ±step oscillation that prevents the
receiver from settling. Fix: clamp holdoff_frames to a minimum of 1 in the
constructor, preserving all existing test assertions (none use 0; default
remains 4).

Reported-by: shaun0927 (Junghwan) <https://github.com/shaun0927>
2026-04-21 03:35:48 +05:45
Jason 2e9a848908 Merge pull request #117 from NawfalMotii79/fix/agc-gain-arithmetic-overflow
fix(fpga): widen AGC gain arithmetic to 6-bit to prevent wraparound
2026-04-21 00:26:33 +03:00
Jason 3366ac6417 fix(fpga): widen AGC gain arithmetic to 6-bit to prevent wraparound
5-bit signed subtraction in clamp_gain wrapped for agc_attack >= 10 or
agc_decay >= 9 when |agc_gain| + step > 16, inverting gain polarity
instead of clamping — e.g. gain=-7, attack=10 produced +7 (max amplify)
rather than -7 (max attenuate), causing ADC saturation on strong returns.

Widen clamp_gain input to [5:0] and sign-extend both operands to 6 bits
({agc_gain[3],agc_gain[3],agc_gain} and {2'b00,agc_attack/decay}),
covering the full [-22,+22] range before clamping. Default attack/decay
values (1-4) are unaffected; behaviour changes only for values >= 10/9.
2026-04-21 03:06:32 +05:45
Jason 607399ec28 Merge pull request #115 from joyshmitz/fix/live-replay-physical-units-consistency
fix(v7): align live host-DSP units with replay path
2026-04-21 00:01:40 +03:00
Jason f48448970b fix(v7): wrap long n_doppler fallback line for ruff E501
Line exceeded 100-char limit; wrap with parentheses to stay within
project line-length setting.
2026-04-21 02:40:21 +05:45
Jason ebd96c90ce fix(v7): store WaveformConfig on self; add set_waveform parity; fix magic 32
- Move WaveformConfig() from per-frame local in _run_host_dsp to
  self._waveform in __init__, mirroring ReplayWorker pattern.
- Add set_waveform() to RadarDataWorker for injection symmetry with
  ReplayWorker.set_waveform() — live path is now configurable.
- Replace hardcoded fallback 32 with self._waveform.n_doppler_bins.
- Update AST contract tests: WaveformConfig() check moves to __init__
  parse; attribute chains updated from ("wf", ...) to
  ("self", "_waveform", ...) to match renamed accessor.
2026-04-21 02:35:53 +05:45
Jason db80baf34d Merge remote-tracking branch 'origin/main' into develop 2026-04-21 01:33:27 +05:45
NawfalMotii79 33d21da7f2 Remove radar system image from README
Removed the AERIS-10 Radar System image from the README.
2026-04-20 19:04:08 +01:00
NawfalMotii79 18901be04a Fix image link and update mixer model in README
Updated image link and corrected mixer model in specifications.
2026-04-19 19:06:44 +01:00
NawfalMotii79 9f899b96e9 Add files via upload 2026-04-19 19:04:48 +01:00
Serhii f895c0244c fix(v7): align live host-DSP units with replay path
Use WaveformConfig for live range/velocity conversion in RadarDataWorker
and add headless AST-based regression checks in test_v7.py.

Before: RadarDataWorker._run_host_dsp used RadarSettings.velocity_resolution
(default 1.0 in models.py:113), while ReplayWorker used WaveformConfig
(~5.343 m/s/bin). Live GUI under-reported velocity by factor ~5.34x.

Fix: local WaveformConfig() in _run_host_dsp, mirroring ReplayWorker
pattern. Doppler center derived from frame shape, matching processing.py:520.

Test: TestLiveReplayPhysicalUnitsParity in test_v7.py uses ast.parse on
workers.py (no v7.workers import, headless-CI-safe despite PyQt6 dep)
and asserts AST Call/Attribute/BinOp nodes for both RadarDataWorker
and ReplayWorker paths.
2026-04-19 19:28:03 +03:00
NawfalMotii79 88ca1910ec Merge pull request #109 from NawfalMotii79/develop
Release: merge develop into main
2026-04-19 01:27:15 +01:00
15 changed files with 360 additions and 196 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

@@ -24,6 +24,7 @@ ADAR1000_AGC::ADAR1000_AGC()
, saturation_event_count(0) , saturation_event_count(0)
{ {
memset(cal_offset, 0, sizeof(cal_offset)); memset(cal_offset, 0, sizeof(cal_offset));
if (holdoff_frames == 0) holdoff_frames = 1;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -13,6 +13,7 @@ void USBHandler::reset() {
start_flag_received = false; start_flag_received = false;
buffer_index = 0; buffer_index = 0;
current_settings.resetToDefaults(); current_settings.resetToDefaults();
fault_ack_received = false;
} }
void USBHandler::processUSBData(const uint8_t* data, uint32_t length) { void USBHandler::processUSBData(const uint8_t* data, uint32_t length) {
@@ -23,6 +24,18 @@ void USBHandler::processUSBData(const uint8_t* data, uint32_t length) {
DIAG("USB", "processUSBData: %lu bytes, state=%d", (unsigned long)length, (int)current_state); DIAG("USB", "processUSBData: %lu bytes, state=%d", (unsigned long)length, (int)current_state);
// FAULT_ACK: host sends exactly 4 bytes [0x40, 0x00, 0x00, 0x00].
// Requires exact 4-byte packet length: settings packets are always
// >= 82 bytes, so a lone 4-byte payload is unambiguous. Scanning
// inside larger packets would false-trigger on the IEEE 754
// encoding of 2.0 (0x4000000000000000) embedded in settings doubles.
static const uint8_t FAULT_ACK_SEQ[4] = {0x40, 0x00, 0x00, 0x00};
if (length == 4 && memcmp(data, FAULT_ACK_SEQ, 4) == 0) {
fault_ack_received = true;
DIAG("USB", "FAULT_ACK received");
return;
}
switch (current_state) { switch (current_state) {
case USBState::WAITING_FOR_START: case USBState::WAITING_FOR_START:
processStartFlag(data, length); processStartFlag(data, length);
@@ -29,6 +29,11 @@ public:
// Reset USB handler // Reset USB handler
void reset(); void reset();
// Fault-acknowledgement: host sends FAULT_ACK (0x40) to clear
// system_emergency_state and exit the safe-mode blink loop.
bool isFaultAckReceived() const { return fault_ack_received; }
void clearFaultAck() { fault_ack_received = false; }
private: private:
RadarSettings current_settings; RadarSettings current_settings;
USBState current_state; USBState current_state;
@@ -38,6 +43,7 @@ private:
static constexpr uint32_t MAX_BUFFER_SIZE = 256; static constexpr uint32_t MAX_BUFFER_SIZE = 256;
uint8_t usb_buffer[MAX_BUFFER_SIZE]; uint8_t usb_buffer[MAX_BUFFER_SIZE];
uint32_t buffer_index; uint32_t buffer_index;
bool fault_ack_received;
void processStartFlag(const uint8_t* data, uint32_t length); void processStartFlag(const uint8_t* data, uint32_t length);
void processSettingsData(const uint8_t* data, uint32_t length); void processSettingsData(const uint8_t* data, uint32_t length);
@@ -627,7 +627,7 @@ typedef enum {
static SystemError_t last_error = ERROR_NONE; static SystemError_t last_error = ERROR_NONE;
static uint32_t error_count = 0; static uint32_t error_count = 0;
static bool system_emergency_state = false; static volatile bool system_emergency_state = false;
// Error handler function // Error handler function
SystemError_t checkSystemHealth(void) { SystemError_t checkSystemHealth(void) {
@@ -2054,6 +2054,10 @@ int main(void)
HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin); HAL_GPIO_TogglePin(LED_3_GPIO_Port, LED_3_Pin);
HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin); HAL_GPIO_TogglePin(LED_4_GPIO_Port, LED_4_Pin);
HAL_Delay(250); HAL_Delay(250);
if (usbHandler.isFaultAckReceived()) {
system_emergency_state = false;
usbHandler.clearFaultAck();
}
} }
DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared"); DIAG("SYS", "Exited safe mode blink loop -- system_emergency_state cleared");
} }
@@ -70,7 +70,8 @@ TESTS_STANDALONE := test_bug12_pa_cal_loop_inverted \
test_gap3_idq_periodic_reread \ test_gap3_idq_periodic_reread \
test_gap3_emergency_state_ordering \ test_gap3_emergency_state_ordering \
test_gap3_overtemp_emergency_stop \ test_gap3_overtemp_emergency_stop \
test_gap3_health_watchdog_cold_start test_gap3_health_watchdog_cold_start \
test_gap3_fault_ack_clears_emergency
# Tests that need platform_noos_stm32.o + mocks # Tests that need platform_noos_stm32.o + mocks
TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only TESTS_WITH_PLATFORM := test_bug11_platform_spi_transmit_only
@@ -178,6 +179,9 @@ test_gap3_overtemp_emergency_stop: test_gap3_overtemp_emergency_stop.c
test_gap3_health_watchdog_cold_start: test_gap3_health_watchdog_cold_start.c test_gap3_health_watchdog_cold_start: test_gap3_health_watchdog_cold_start.c
$(CC) $(CFLAGS) $< -o $@ $(CC) $(CFLAGS) $< -o $@
test_gap3_fault_ack_clears_emergency: test_gap3_fault_ack_clears_emergency.c
$(CC) $(CFLAGS) $< -o $@
# Tests that need platform_noos_stm32.o + mocks # Tests that need platform_noos_stm32.o + mocks
$(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ) $(TESTS_WITH_PLATFORM): %: %.c $(MOCK_OBJS) $(PLATFORM_OBJ)
$(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@ $(CC) $(CFLAGS) $(INCLUDES) $< $(MOCK_OBJS) $(PLATFORM_OBJ) -o $@
@@ -0,0 +1,121 @@
/*******************************************************************************
* test_gap3_fault_ack_clears_emergency.c
*
* Verifies the FAULT_ACK clear path for system_emergency_state:
* - USBHandler detects exactly [0x40, 0x00, 0x00, 0x00] in a 4-byte packet
* - Detection is false-positive-free: larger packets (settings data) carrying
* the same bytes as a subsequence must NOT trigger the ack
* - Main-loop blink logic clears system_emergency_state on receipt
*
* Logic extracted from USBHandler.cpp + main.cpp to mirror the actual code
* paths without requiring HAL headers.
******************************************************************************/
#include <assert.h>
#include <stdio.h>
#include <stdbool.h>
#include <string.h>
#include <stdint.h>
/* ── Simulated USBHandler state ─────────────────────────────────────────── */
static bool fault_ack_received = false;
static volatile bool system_emergency_state = false;
static const uint8_t FAULT_ACK_SEQ[4] = {0x40, 0x00, 0x00, 0x00};
/* Mirrors USBHandler::processUSBData() detection logic */
static void sim_processUSBData(const uint8_t *data, uint32_t length)
{
if (data == NULL || length == 0) return;
if (length == 4 && memcmp(data, FAULT_ACK_SEQ, 4) == 0) {
fault_ack_received = true;
return;
}
/* (normal state machine omitted — not under test here) */
}
/* Mirrors one iteration of the blink loop in main.cpp */
static void sim_blink_iteration(void)
{
/* HAL_GPIO_TogglePin + HAL_Delay omitted */
if (fault_ack_received) {
system_emergency_state = false;
fault_ack_received = false;
}
}
int main(void)
{
printf("=== Gap-3 FAULT_ACK clears system_emergency_state ===\n");
/* Test 1: exact 4-byte FAULT_ACK packet sets the flag */
printf(" Test 1: exact FAULT_ACK packet detected... ");
fault_ack_received = false;
const uint8_t ack_pkt[4] = {0x40, 0x00, 0x00, 0x00};
sim_processUSBData(ack_pkt, 4);
assert(fault_ack_received == true);
printf("PASS\n");
/* Test 2: flag cleared and system_emergency_state exits blink loop */
printf(" Test 2: blink loop exits on FAULT_ACK... ");
system_emergency_state = true;
fault_ack_received = true;
sim_blink_iteration();
assert(system_emergency_state == false);
assert(fault_ack_received == false);
printf("PASS\n");
/* Test 3: blink loop does NOT exit without ack */
printf(" Test 3: blink loop holds without ack... ");
system_emergency_state = true;
fault_ack_received = false;
sim_blink_iteration();
assert(system_emergency_state == true);
printf("PASS\n");
/* Test 4: settings-sized packet carrying [0x40,0x00,0x00,0x00] as first
* 4 bytes does NOT trigger ack (IEEE 754 double 2.0 = 0x4000000000000000) */
printf(" Test 4: settings packet with 2.0 double does not false-trigger... ");
fault_ack_received = false;
uint8_t settings_pkt[82];
memset(settings_pkt, 0, sizeof(settings_pkt));
/* First 4 bytes look like FAULT_ACK but packet length is 82 */
settings_pkt[0] = 0x40; settings_pkt[1] = 0x00;
settings_pkt[2] = 0x00; settings_pkt[3] = 0x00;
sim_processUSBData(settings_pkt, sizeof(settings_pkt));
assert(fault_ack_received == false);
printf("PASS\n");
/* Test 5: 3-byte packet (truncated) does not trigger */
printf(" Test 5: truncated 3-byte packet ignored... ");
fault_ack_received = false;
const uint8_t short_pkt[3] = {0x40, 0x00, 0x00};
sim_processUSBData(short_pkt, 3);
assert(fault_ack_received == false);
printf("PASS\n");
/* Test 6: wrong opcode byte in 4-byte packet does not trigger */
printf(" Test 6: wrong opcode (0x28 AGC_ENABLE) not detected as FAULT_ACK... ");
fault_ack_received = false;
const uint8_t agc_pkt[4] = {0x28, 0x00, 0x00, 0x01};
sim_processUSBData(agc_pkt, 4);
assert(fault_ack_received == false);
printf("PASS\n");
/* Test 7: multiple blink iterations — loop stays active until ack */
printf(" Test 7: loop stays active across multiple iterations until ack... ");
system_emergency_state = true;
fault_ack_received = false;
sim_blink_iteration();
assert(system_emergency_state == true);
sim_blink_iteration();
assert(system_emergency_state == true);
/* Now ack arrives */
sim_processUSBData(ack_pkt, 4);
assert(fault_ack_received == true);
sim_blink_iteration();
assert(system_emergency_state == false);
printf("PASS\n");
printf("\n=== Gap-3 FAULT_ACK: ALL 7 TESTS PASSED ===\n\n");
return 0;
}
@@ -18,23 +18,8 @@
# Bank 35: VCCO = 3.3V (FT2232H USB 2.0 FIFO — 15 signals) # Bank 35: VCCO = 3.3V (FT2232H USB 2.0 FIFO — 15 signals)
# #
# DRC Fix History: # DRC Fix History:
# - PLIO-9 (REVERTED): Previously moved clk_120m_dac from C13 (N-type) to # - PLIO-9: Moved clk_120m_dac from C13 (N-type) to D13 (P-type MRCC).
# D13 (P-type MRCC) to satisfy the MRCC preference. However, a schematic # Clock inputs must use the P-type pin of a Multi-Region Clock-Capable pair.
# audit (KiCad netlist export from the Eagle schematic, U42 pad->net map)
# revealed that D13 is UNCONNECTED on the physical PCB. The real
# /FPGA_DAC_CLOCK net from AD9523 OUT11 lands on C13 (IO_L11N_T1_SRCC_15,
# N-type). Moved back to C13 and added CLOCK_DEDICATED_ROUTE FALSE,
# matching the ft_clkout treatment on C4 (N-type MRCC).
# - Schematic audit added pin constraints for previously-unconstrained
# signals connected to the FPGA in hardware: ADC_OR_P/N (M6/N6, AD9484
# overflow flag), /FPGA_ADC_CLOCK_P/N (N11/N12, 400 MHz observation tap
# of the AD9523->AD9484 sample clock). Added to 50T wrapper as
# anchored-but-unused inputs to secure pin assignment and prevent
# accidental future contention; full RTL consumers are a follow-up.
# - PLIO-9 (original, historical): FT2232H CLKOUT routed to C4
# (IO_L12N_T1_MRCC_35, N-type). Clock inputs normally use P-type MRCC
# pins, but IBUFG works correctly on N-type. Demote PLIO-9 to warning
# in build script.
# - BIVC-1 / Place 30-372: Bank 14 must have a single VCCO. LVDS_25 forces # - BIVC-1 / Place 30-372: Bank 14 must have a single VCCO. LVDS_25 forces
# VCCO=2.5V, so adc_pwdn was changed from LVCMOS33 to LVCMOS25 to match. # VCCO=2.5V, so adc_pwdn was changed from LVCMOS33 to LVCMOS25 to match.
# IBUFDS input buffers are VCCO-independent. BIVC-1 also waived via # IBUFDS input buffers are VCCO-independent. BIVC-1 also waived via
@@ -43,6 +28,9 @@
# - UCIO/NSTD: Unconstrained ports (FT601 ports inactive with USB_MODE=1, # - UCIO/NSTD: Unconstrained ports (FT601 ports inactive with USB_MODE=1,
# status/debug outputs have no physical pins). Handled with SEVERITY # status/debug outputs have no physical pins). Handled with SEVERITY
# demotion + default IOSTANDARD. # demotion + default IOSTANDARD.
# - PLIO-9: FT2232H CLKOUT routed to C4 (IO_L12N_T1_MRCC_35, N-type).
# Clock inputs normally use P-type MRCC pins, but IBUFG works correctly
# on N-type. Demote PLIO-9 to warning in build script.
# ============================================================================ # ============================================================================
# ============================================================================ # ============================================================================
@@ -78,7 +66,7 @@ set_property IOSTANDARD LVCMOS33 [get_ports {clk_100m}]
create_clock -name clk_100m -period 10.0 [get_ports {clk_100m}] create_clock -name clk_100m -period 10.0 [get_ports {clk_100m}]
set_input_jitter [get_clocks clk_100m] 0.1 set_input_jitter [get_clocks clk_100m] 0.1
# 120MHz DAC Clock (AD9523 OUT11 → /FPGA_DAC_CLOCK → Bank 15 pin C13) # 120MHz DAC Clock (AD9523 OUT11 → FPGA_DAC_CLOCK → Bank 15 MRCC pin D13)
# NOTE: The physical DAC (U3, AD9708) receives its clock directly from the # NOTE: The physical DAC (U3, AD9708) receives its clock directly from the
# AD9523 via a separate net (DAC_CLOCK), NOT from the FPGA. The FPGA # AD9523 via a separate net (DAC_CLOCK), NOT from the FPGA. The FPGA
# uses this clock input for internal DAC data timing only. The RTL port # uses this clock input for internal DAC data timing only. The RTL port
@@ -86,19 +74,12 @@ set_input_jitter [get_clocks clk_100m] 0.1
# physical pin on the 50T board and is left unconnected here. The port # physical pin on the 50T board and is left unconnected here. The port
# CANNOT be removed from the RTL because the 200T board uses it with # CANNOT be removed from the RTL because the 200T board uses it with
# ODDR clock forwarding (pin H17, see xc7a200t_fbg484.xdc). # ODDR clock forwarding (pin H17, see xc7a200t_fbg484.xdc).
# # FIX: Moved from C13 (IO_L12N = N-type) to D13 (IO_L12P = P-type MRCC).
# PIN: C13 is IO_L11N_T1_SRCC_15 (N-type SRCC). A prior commit attempted to # Clock inputs must use the P-type pin of an MRCC pair (PLIO-9 DRC).
# move this to D13 (MRCC P-type) to satisfy PLIO-9, but the schematic audit set_property PACKAGE_PIN D13 [get_ports {clk_120m_dac}]
# showed D13 is UNCONNECTED on the PCB — the /FPGA_DAC_CLOCK net physically
# lands on C13. Moving to D13 made the DAC clock input float. Restored to
# C13 and forced CLOCK_DEDICATED_ROUTE FALSE (same mechanism as ft_clkout on
# C4), which routes the IBUFG output through general fabric to a BUFG.
set_property PACKAGE_PIN C13 [get_ports {clk_120m_dac}]
set_property IOSTANDARD LVCMOS33 [get_ports {clk_120m_dac}] set_property IOSTANDARD LVCMOS33 [get_ports {clk_120m_dac}]
create_clock -name clk_120m_dac -period 8.333 [get_ports {clk_120m_dac}] create_clock -name clk_120m_dac -period 8.333 [get_ports {clk_120m_dac}]
set_input_jitter [get_clocks clk_120m_dac] 0.1 set_input_jitter [get_clocks clk_120m_dac] 0.1
# C13 is N-type SRCC (not dedicated-clock-capable); override the DRC check.
set_property CLOCK_DEDICATED_ROUTE FALSE [get_nets {clk_120m_dac_IBUF}]
# ADC DCO Clock (400MHz LVDS — AD9523 OUT5 → AD9484 → FPGA, Bank 14 MRCC) # ADC DCO Clock (400MHz LVDS — AD9523 OUT5 → AD9484 → FPGA, Bank 14 MRCC)
# NOTE: LVDS_25 is the only valid differential input standard on 7-series HR # NOTE: LVDS_25 is the only valid differential input standard on 7-series HR
@@ -302,45 +283,6 @@ set_input_delay -clock [get_clocks adc_dco_p] -min 0.2 [get_ports {adc_d_p[*]}]
set_input_delay -clock [get_clocks adc_dco_p] -max 1.0 -clock_fall [get_ports {adc_d_p[*]}] -add_delay set_input_delay -clock [get_clocks adc_dco_p] -max 1.0 -clock_fall [get_ports {adc_d_p[*]}] -add_delay
set_input_delay -clock [get_clocks adc_dco_p] -min 0.2 -clock_fall [get_ports {adc_d_p[*]}] -add_delay set_input_delay -clock [get_clocks adc_dco_p] -min 0.2 -clock_fall [get_ports {adc_d_p[*]}] -add_delay
# --------------------------------------------------------------------------
# AD9484 Overflow / Out-Of-Range flag (schematic nets ADC_OR_P / ADC_OR_N)
# --------------------------------------------------------------------------
# AD9484 differential OR output on FPGA pads M6 (OR_P) / N6 (OR_N), Bank 14.
# This is the AD9484's full-scale overflow indicator, useful for AGC /
# gain-ranging feedback. The 50T RTL wrapper anchors this with an IBUFDS
# (DONT_TOUCH) so the pads cannot be accidentally driven as outputs (which
# would cause contention with the AD9484 driver). A future PR should wire
# the buffered signal into the receive-path status flags.
set_property PACKAGE_PIN M6 [get_ports {adc_or_p}]
set_property PACKAGE_PIN N6 [get_ports {adc_or_n}]
set_property IOSTANDARD LVDS_25 [get_ports {adc_or_p}]
set_property IOSTANDARD LVDS_25 [get_ports {adc_or_n}]
set_property DIFF_TERM TRUE [get_ports {adc_or_p}]
# --------------------------------------------------------------------------
# FPGA observation of AD9523->AD9484 sample clock (/FPGA_ADC_CLOCK_P/N)
# --------------------------------------------------------------------------
# AD9523 drives the AD9484 sample clock directly; the same differential
# pair is tapped to FPGA pads N11 (P) / N12 (N), Bank 14, MRCC-capable.
# This is an INPUT-ONLY tap (FPGA must never drive these pads — that would
# contend with the AD9523 driver feeding the ADC). The 50T wrapper anchors
# with IBUFDS + DONT_TOUCH so the pad assignment is preserved across all
# synthesis/optimization stages. The buffered net is unconsumed for now;
# create_clock and clock_groups are deferred until an RTL consumer exists
# (see commented template below).
set_property PACKAGE_PIN N11 [get_ports {fpga_adc_clock_p}]
set_property PACKAGE_PIN N12 [get_ports {fpga_adc_clock_n}]
set_property IOSTANDARD LVDS_25 [get_ports {fpga_adc_clock_p}]
set_property IOSTANDARD LVDS_25 [get_ports {fpga_adc_clock_n}]
set_property DIFF_TERM TRUE [get_ports {fpga_adc_clock_p}]
# No create_clock here on purpose: the IBUFDS output is unconsumed (anchored
# via DONT_TOUCH only), so declaring it as a clock would only generate
# "clock has no registered destinations" warnings. When a follow-up PR adds
# an actual consumer, add:
# create_clock -name fpga_adc_clock -period 2.5 [get_ports {fpga_adc_clock_p}]
# set_input_jitter [get_clocks fpga_adc_clock] 0.05
# set_clock_groups -asynchronous -group [get_clocks fpga_adc_clock] ...
# ============================================================================ # ============================================================================
# FT2232H USB 2.0 INTERFACE (Bank 35, VCCO=3.3V) # FT2232H USB 2.0 INTERFACE (Bank 35, VCCO=3.3V)
# ============================================================================ # ============================================================================
@@ -405,49 +347,29 @@ set_property DRIVE 8 [get_ports {ft_data[*]}]
# FPGA Write Path (FPGA drives data, FT2232H samples): # FPGA Write Path (FPGA drives data, FT2232H samples):
# - Data setup before next CLKOUT rising: t_su = 5.0 ns # - Data setup before next CLKOUT rising: t_su = 5.0 ns
# - Data hold after CLKOUT rising: t_hd = 0.0 ns # - Data hold after CLKOUT rising: t_hd = 0.0 ns
# - Board trace skew budget: ~0.5 ns # - Output delay max = period - t_su = 16.667 - 5.0 = 11.667 ns
# - Output delay max = t_su + trace_max = 5.0 + 0.5 = 5.5 ns # - Output delay min = t_hd = 0.0 ns
# - Output delay min = t_hd - trace_min = 0.0 - 0.0 = 0.0 ns
#
# NOTE: Historical XDC used 'period - t_su = 11.667 ns' for output_delay -max,
# which is the wrong interpretation: set_output_delay takes the external setup
# requirement (+trace), not the remaining timing budget. The old value forced
# Vivado to close a path assuming FT2232H requires 11.667 ns of setup, which
# it does not, and caused WNS=-5.350 ns failures on ft_data/ft_rd_n/ft_wr_n/
# ft_oe_n/ft_siwu paths given the 5.513 ns clock insertion delay on the
# non-dedicated C4 routing.
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Input delays: FT2232H → FPGA (data bus and status signals) # Input delays: FT2232H → FPGA (data bus and status signals)
#
# -min revision (Build N+1): was 0.0 ns, now 1.0 ns.
# Rationale: set_input_delay -min is the EARLIEST time data can change at the
# FPGA pin after the launch clock edge, i.e. FT2232H Tco_min + trace_min.
# Setting -min 0.0 claimed data could change simultaneously with the clock
# edge, which is pessimistically tight for hold analysis and caused a
# -0.079 ns hold violation on ft_rxf_n → FSM_sequential_wr_state in Build N
# (due to 2.895 ns clock insertion delay on non-dedicated C4 routing).
# FT2232H Sync FIFO Tco is spec'd 14 ns; using 1.0 ns is conservative and
# still covers worst-case silicon. Invariant preserved: hold_margin =
# Tco_min + trace_min - clk_insertion_delay - Th_fpga ≥ 0.
set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_data[*]}] set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_data[*]}]
set_input_delay -clock [get_clocks ft_clkout] -min 1.0 [get_ports {ft_data[*]}] set_input_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_data[*]}]
set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_rxf_n}] set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_rxf_n}]
set_input_delay -clock [get_clocks ft_clkout] -min 1.0 [get_ports {ft_rxf_n}] set_input_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_rxf_n}]
set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_txe_n}] set_input_delay -clock [get_clocks ft_clkout] -max 9.667 [get_ports {ft_txe_n}]
set_input_delay -clock [get_clocks ft_clkout] -min 1.0 [get_ports {ft_txe_n}] set_input_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_txe_n}]
# Output delays: FPGA → FT2232H (control strobes and data bus when writing) # Output delays: FPGA → FT2232H (control strobes and data bus when writing)
set_output_delay -clock [get_clocks ft_clkout] -max 5.5 [get_ports {ft_data[*]}] set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_data[*]}]
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_data[*]}] set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_data[*]}]
set_output_delay -clock [get_clocks ft_clkout] -max 5.5 [get_ports {ft_rd_n}] set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_rd_n}]
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_rd_n}] set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_rd_n}]
set_output_delay -clock [get_clocks ft_clkout] -max 5.5 [get_ports {ft_wr_n}] set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_wr_n}]
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_wr_n}] set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_wr_n}]
set_output_delay -clock [get_clocks ft_clkout] -max 5.5 [get_ports {ft_oe_n}] set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_oe_n}]
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_oe_n}] set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_oe_n}]
set_output_delay -clock [get_clocks ft_clkout] -max 5.5 [get_ports {ft_siwu}] set_output_delay -clock [get_clocks ft_clkout] -max 11.667 [get_ports {ft_siwu}]
set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_siwu}] set_output_delay -clock [get_clocks ft_clkout] -min 0.0 [get_ports {ft_siwu}]
# ============================================================================ # ============================================================================
# STATUS / DEBUG OUTPUTS — NO PHYSICAL CONNECTIONS # STATUS / DEBUG OUTPUTS — NO PHYSICAL CONNECTIONS
@@ -489,42 +411,24 @@ set_false_path -from [get_ports {stm32_mixers_enable}]
set_false_path -from [get_cells reset_sync_reg[*]] -to [get_pins -filter {REF_PIN_NAME == CLR} -of_objects [get_cells -hierarchical -filter {PRIMITIVE_TYPE =~ REGISTER.*.*}]] set_false_path -from [get_cells reset_sync_reg[*]] -to [get_pins -filter {REF_PIN_NAME == CLR} -of_objects [get_cells -hierarchical -filter {PRIMITIVE_TYPE =~ REGISTER.*.*}]]
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Clock Domain Crossing — asynchronous clock groups # Clock Domain Crossing false paths
#
# Rationale: prefer `set_clock_groups -asynchronous` over pairwise
# `set_false_path -from CLK -to CLK`. The latter is an STA antipattern:
# it disables *all* paths between the two domains, including the
# synchronizer paths themselves and any future inadvertent crossings,
# which can mask real CDC bugs that only show up at temperature/voltage
# corners. Clock-groups is the idiomatic way to declare domains async
# while still letting STA flag newly-introduced unrelated paths.
#
# Register-level false_paths (e.g. reset_sync_reg above) remain
# appropriate — those restrict the waiver to specific, audited endpoints.
#
# Groups declared here mirror the pairwise false_paths that existed
# previously; no new pair is declared async.
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# clk_100m ↔ adc_dco_p (400 MHz): DDC has internal CDC synchronizers # clk_100m ↔ adc_dco_p (400 MHz): DDC has internal CDC synchronizers
set_clock_groups -asynchronous \ set_false_path -from [get_clocks clk_100m] -to [get_clocks adc_dco_p]
-group [get_clocks clk_100m] \ set_false_path -from [get_clocks adc_dco_p] -to [get_clocks clk_100m]
-group [get_clocks adc_dco_p]
# clk_100m ↔ clk_120m_dac: CDC via synchronizers in radar_system_top # clk_100m ↔ clk_120m_dac: CDC via synchronizers in radar_system_top
set_clock_groups -asynchronous \ set_false_path -from [get_clocks clk_100m] -to [get_clocks clk_120m_dac]
-group [get_clocks clk_100m] \ set_false_path -from [get_clocks clk_120m_dac] -to [get_clocks clk_100m]
-group [get_clocks clk_120m_dac]
# FT2232H CDC: clk_100m ↔ ft_clkout (60 MHz), toggle CDC in RTL # FT2232H CDC: clk_100m ↔ ft_clkout (60 MHz), toggle CDC in RTL
set_clock_groups -asynchronous \ set_false_path -from [get_clocks clk_100m] -to [get_clocks ft_clkout]
-group [get_clocks clk_100m] \ set_false_path -from [get_clocks ft_clkout] -to [get_clocks clk_100m]
-group [get_clocks ft_clkout]
# FT2232H CDC: clk_120m_dac ↔ ft_clkout (no direct crossing, but belt-and-suspenders) # FT2232H CDC: clk_120m_dac ↔ ft_clkout (no direct crossing, but belt-and-suspenders)
set_clock_groups -asynchronous \ set_false_path -from [get_clocks clk_120m_dac] -to [get_clocks ft_clkout]
-group [get_clocks clk_120m_dac] \ set_false_path -from [get_clocks ft_clkout] -to [get_clocks clk_120m_dac]
-group [get_clocks ft_clkout]
# ============================================================================ # ============================================================================
# PHYSICAL CONSTRAINTS # PHYSICAL CONSTRAINTS
@@ -62,20 +62,6 @@ module radar_system_top_50t (
input wire adc_dco_n, input wire adc_dco_n,
output wire adc_pwdn, output wire adc_pwdn,
// ----- AD9484 overflow flag (differential) -----
// Schematic pads M6 (OR_P) / N6 (OR_N). Anchored-only for now; a future
// PR will wire this into the receive-path status flags for AGC feedback.
input wire adc_or_p,
input wire adc_or_n,
// ----- Tap of AD9523 -> AD9484 sample clock (differential) -----
// Schematic pads N11 (P) / N12 (N). Must remain input-only driving
// these pads as outputs would contend with the AD9523 driver feeding
// the ADC. Anchored with an IBUFDS (DONT_TOUCH) below; buffered net is
// unconsumed pending a follow-up PR.
input wire fpga_adc_clock_p,
input wire fpga_adc_clock_n,
// ===== STM32 Control (Bank 15: 3.3V) ===== // ===== STM32 Control (Bank 15: 3.3V) =====
input wire stm32_new_chirp, input wire stm32_new_chirp,
input wire stm32_new_elevation, input wire stm32_new_elevation,
@@ -98,38 +84,6 @@ module radar_system_top_50t (
output wire gpio_dig7 // DIG_7 (H12PD15): reserved output wire gpio_dig7 // DIG_7 (H12PD15): reserved
); );
// =====================================================================
// Anchored-but-unused schematic inputs (secured via IBUFDS + DONT_TOUCH)
// =====================================================================
// Without these buffer instantiations, synthesis would remove the
// orphan input ports (UCIO / NSTD warnings) and the XDC pin constraints
// would fail to bind. DONT_TOUCH forces Vivado to retain the buffer
// primitives and their package-pin connections across all optimization
// stages. The buffered nets are intentionally left unconsumed here;
// they will be wired into the RTL in a follow-up PR once the ADC
// status-flag and sample-clock-tap features are implemented.
(* DONT_TOUCH = "TRUE" *) wire adc_or_buf;
(* DONT_TOUCH = "TRUE" *) IBUFDS #(
.DIFF_TERM ("TRUE"),
.IBUF_LOW_PWR("FALSE"),
.IOSTANDARD ("LVDS_25")
) u_ibufds_adc_or (
.O (adc_or_buf),
.I (adc_or_p),
.IB (adc_or_n)
);
(* DONT_TOUCH = "TRUE" *) wire fpga_adc_clock_buf;
(* DONT_TOUCH = "TRUE" *) IBUFDS #(
.DIFF_TERM ("TRUE"),
.IBUF_LOW_PWR("FALSE"),
.IOSTANDARD ("LVDS_25")
) u_ibufds_fpga_adc_clk (
.O (fpga_adc_clock_buf),
.I (fpga_adc_clock_p),
.IB (fpga_adc_clock_n)
);
// ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) ===== // ===== Tie-off wires for unconstrained FT601 inputs (inactive with USB_MODE=1) =====
wire ft601_txe_tied = 1'b0; wire ft601_txe_tied = 1'b0;
wire ft601_rxf_tied = 1'b0; wire ft601_rxf_tied = 1'b0;
+7 -7
View File
@@ -169,11 +169,11 @@ endfunction
// ========================================================================= // =========================================================================
// Clamp a wider signed value to [-7, +7] // Clamp a wider signed value to [-7, +7]
function signed [3:0] clamp_gain; function signed [3:0] clamp_gain;
input signed [4:0] val; // 5-bit to handle overflow from add input signed [5:0] val; // 6-bit: covers [-22,+22] (max |gain|+step = 7+15)
begin begin
if (val > 5'sd7) if (val > 6'sd7)
clamp_gain = 4'sd7; clamp_gain = 4'sd7;
else if (val < -5'sd7) else if (val < -6'sd7)
clamp_gain = -4'sd7; clamp_gain = -4'sd7;
else else
clamp_gain = val[3:0]; clamp_gain = val[3:0];
@@ -246,15 +246,15 @@ always @(posedge clk or negedge reset_n) begin
// Use inclusive counts/peaks (accounting for simultaneous valid_in) // Use inclusive counts/peaks (accounting for simultaneous valid_in)
if (wire_frame_sat_incr || frame_sat_count > 8'd0) begin if (wire_frame_sat_incr || frame_sat_count > 8'd0) begin
// Clipping detected: reduce gain immediately (attack) // Clipping detected: reduce gain immediately (attack)
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) - agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain[3], agc_gain}) -
$signed({1'b0, agc_attack})); $signed({2'b00, agc_attack}));
holdoff_counter <= agc_holdoff; // Reset holdoff holdoff_counter <= agc_holdoff; // Reset holdoff
end else if ((wire_frame_peak_update ? max_iq[14:7] : frame_peak[14:7]) end else if ((wire_frame_peak_update ? max_iq[14:7] : frame_peak[14:7])
< agc_target) begin < agc_target) begin
// Signal too weak: increase gain after holdoff expires // Signal too weak: increase gain after holdoff expires
if (holdoff_counter == 4'd0) begin if (holdoff_counter == 4'd0) begin
agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain}) + agc_gain <= clamp_gain($signed({agc_gain[3], agc_gain[3], agc_gain}) +
$signed({1'b0, agc_decay})); $signed({2'b00, agc_decay}));
end else begin end else begin
holdoff_counter <= holdoff_counter - 4'd1; holdoff_counter <= holdoff_counter - 4'd1;
end end
+9
View File
@@ -103,6 +103,15 @@ class Opcode(IntEnum):
STATUS_REQUEST = 0xFF STATUS_REQUEST = 0xFF
# MCU-only commands — NOT dispatched to the FPGA opcode switch.
# These values have no corresponding case in radar_system_top.v.
# Listed here so the GUI can build and send them via build_command().
# contract_parser.py filters MCU_ONLY_OPCODES out of the Python/Verilog
# bidirectional check.
FAULT_ACK = 0x40 # Exact 4-byte CDC packet; clears system_emergency_state
MCU_ONLY_OPCODES: frozenset[int] = frozenset({0x40})
# ============================================================================ # ============================================================================
# Data Structures # Data Structures
# ============================================================================ # ============================================================================
+129
View File
@@ -586,6 +586,135 @@ class TestSoftwareFPGA(unittest.TestCase):
self.assertEqual(fpga.agc_holdoff, 0x0F) self.assertEqual(fpga.agc_holdoff, 0x0F)
# ============================================================================
# Test: live vs replay physical-unit parity — regression guard for unit drift
#
# Uses AST parse of workers.py (not inspect.getsource / import) so the test
# runs in headless CI without PyQt6 — v7.workers imports PyQt6 unconditionally
# at workers.py:24, and other worker tests here already use skipUnless(
# _pyqt6_available()). Contract enforcement must not be gated on GUI deps.
#
# Asserts on AST nodes (Call / Attribute / BinOp), not source substrings, so
# false-pass on comments or docstring wording is impossible.
# ============================================================================
class TestLiveReplayPhysicalUnitsParity(unittest.TestCase):
"""Contract: live path (RadarDataWorker._run_host_dsp) and replay path
(ReplayWorker._emit_frame) both derive bin-to-physical conversion from
WaveformConfig — same source of truth, identical (range_m, velocity_ms)
for identical detections.
Regression context: before the fix, live path used
RadarSettings.velocity_resolution (default 1.0 in models.py:113) while
replay used WaveformConfig.velocity_resolution_mps (~5.343). Live GUI
therefore under-reported velocity by factor ~5.34x vs replay for
identical frames. See test_v7.py:449 for the WaveformConfig pin.
"""
@staticmethod
def _parse_method(class_name: str, method_name: str):
"""Return AST FunctionDef for class_name.method_name from workers.py,
without importing v7.workers (PyQt6-independent)."""
import ast
from pathlib import Path
path = Path(__file__).parent / "v7" / "workers.py"
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == class_name:
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == method_name:
return item
raise RuntimeError(f"{class_name}.{method_name} not found in workers.py")
@staticmethod
def _has_attribute_chain(tree, chain):
"""True if AST tree contains a dotted attribute access matching chain.
Chain ('self', '_settings', 'range_resolution') matches
``self._settings.range_resolution`` exactly.
"""
import ast
for n in ast.walk(tree):
if isinstance(n, ast.Attribute):
parts = [n.attr]
cur = n.value
while isinstance(cur, ast.Attribute):
parts.append(cur.attr)
cur = cur.value
if isinstance(cur, ast.Name):
parts.append(cur.id)
parts.reverse()
if tuple(parts) == tuple(chain):
return True
return False
@staticmethod
def _has_call_to(tree, func_name):
"""True if AST tree contains a call to a bare name (func_name())."""
import ast
for n in ast.walk(tree):
if (isinstance(n, ast.Call) and isinstance(n.func, ast.Name)
and n.func.id == func_name):
return True
return False
@staticmethod
def _has_dbin_minus(tree, literal):
"""True if AST tree contains ``dbin - <literal>`` binary op."""
import ast
for n in ast.walk(tree):
if (isinstance(n, ast.BinOp) and isinstance(n.op, ast.Sub)
and isinstance(n.left, ast.Name) and n.left.id == "dbin"
and isinstance(n.right, ast.Constant)
and n.right.value == literal):
return True
return False
def test_live_path_uses_waveform_config(self):
"""RadarDataWorker.__init__ must instantiate WaveformConfig() into
self._waveform; _run_host_dsp must read self._waveform.range_resolution_m
/ velocity_resolution_mps — not self._settings equivalents."""
init = self._parse_method("RadarDataWorker", "__init__")
self.assertTrue(self._has_call_to(init, "WaveformConfig"),
"RadarDataWorker.__init__ must instantiate WaveformConfig() into self._waveform.")
method = self._parse_method("RadarDataWorker", "_run_host_dsp")
self.assertTrue(
self._has_attribute_chain(method, ("self", "_waveform", "range_resolution_m")),
"Live path must read self._waveform.range_resolution_m.")
self.assertTrue(
self._has_attribute_chain(method, ("self", "_waveform", "velocity_resolution_mps")),
"Live path must read self._waveform.velocity_resolution_mps. "
"RadarSettings.velocity_resolution default 1.0 caused ~5.34x "
"underreport vs replay (test_v7.py:449 pins ~5.343).")
self.assertFalse(self._has_attribute_chain(
method, ("self", "_settings", "range_resolution")),
"Live path still reads stale RadarSettings.range_resolution.")
self.assertFalse(self._has_attribute_chain(
method, ("self", "_settings", "velocity_resolution")),
"Live path still reads stale RadarSettings.velocity_resolution.")
def test_live_path_doppler_center_not_hardcoded(self):
"""_run_host_dsp must derive doppler_center from frame shape, not
use hardcoded ``dbin - 16`` — mirrors processing.py:520."""
method = self._parse_method("RadarDataWorker", "_run_host_dsp")
self.assertFalse(self._has_dbin_minus(method, 16),
"Hardcoded doppler_center=16 breaks if frame shape changes. "
"Use frame.detections.shape[1] // 2 like processing.py:520.")
def test_replay_path_still_uses_waveform_config(self):
"""Parity half: replay path (ReplayWorker._emit_frame) must keep
reading self._waveform.range_resolution_m / velocity_resolution_mps —
guards against someone breaking the replay side of the invariant."""
method = self._parse_method("ReplayWorker", "_emit_frame")
self.assertTrue(self._has_attribute_chain(
method, ("self", "_waveform", "range_resolution_m")),
"Replay path lost WaveformConfig range source of truth.")
self.assertTrue(self._has_attribute_chain(
method, ("self", "_waveform", "velocity_resolution_mps")),
"Replay path lost WaveformConfig velocity source of truth.")
class TestSoftwareFPGASignalChain(unittest.TestCase): class TestSoftwareFPGASignalChain(unittest.TestCase):
"""SoftwareFPGA.process_chirps with real co-sim data.""" """SoftwareFPGA.process_chirps with real co-sim data."""
+15 -7
View File
@@ -23,7 +23,7 @@ import numpy as np
from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal from PyQt6.QtCore import QThread, QObject, QTimer, pyqtSignal
from .models import RadarTarget, GPSData, RadarSettings from .models import RadarTarget, GPSData, RadarSettings, WaveformConfig
from .hardware import ( from .hardware import (
RadarAcquisition, RadarAcquisition,
RadarFrame, RadarFrame,
@@ -84,6 +84,7 @@ class RadarDataWorker(QThread):
self._recorder = recorder self._recorder = recorder
self._gps = gps_data_ref self._gps = gps_data_ref
self._settings = settings or RadarSettings() self._settings = settings or RadarSettings()
self._waveform = WaveformConfig()
self._running = False self._running = False
# Frame queue for production RadarAcquisition → this thread # Frame queue for production RadarAcquisition → this thread
@@ -97,6 +98,9 @@ class RadarDataWorker(QThread):
self._byte_count = 0 self._byte_count = 0
self._error_count = 0 self._error_count = 0
def set_waveform(self, wf: "WaveformConfig") -> None:
self._waveform = wf
def stop(self): def stop(self):
self._running = False self._running = False
if self._acquisition: if self._acquisition:
@@ -169,8 +173,8 @@ class RadarDataWorker(QThread):
The FPGA already does: FFT, MTI, CFAR, DC notch. The FPGA already does: FFT, MTI, CFAR, DC notch.
Host-side DSP adds: clustering, tracking, geo-coordinate mapping. Host-side DSP adds: clustering, tracking, geo-coordinate mapping.
Bin-to-physical conversion uses RadarSettings.range_resolution Bin-to-physical conversion uses self._waveform (WaveformConfig) to keep
and velocity_resolution (should be calibrated to actual waveform). live and replay units aligned. Override via set_waveform() if needed.
""" """
targets: list[RadarTarget] = [] targets: list[RadarTarget] = []
@@ -180,8 +184,11 @@ class RadarDataWorker(QThread):
# Extract detections from FPGA CFAR flags # Extract detections from FPGA CFAR flags
det_indices = np.argwhere(frame.detections > 0) det_indices = np.argwhere(frame.detections > 0)
r_res = self._settings.range_resolution r_res = self._waveform.range_resolution_m
v_res = self._settings.velocity_resolution v_res = self._waveform.velocity_resolution_mps
n_doppler = (frame.detections.shape[1] if frame.detections.ndim == 2
else self._waveform.n_doppler_bins)
doppler_center = n_doppler // 2
for idx in det_indices: for idx in det_indices:
rbin, dbin = idx rbin, dbin = idx
@@ -190,8 +197,9 @@ class RadarDataWorker(QThread):
# Convert bin indices to physical units # Convert bin indices to physical units
range_m = float(rbin) * r_res range_m = float(rbin) * r_res
# Doppler: centre bin (16) = 0 m/s; positive bins = approaching # Doppler: centre bin = 0 m/s; positive bins = approaching.
velocity_ms = float(dbin - 16) * v_res # Derived from frame shape — mirrors processing.py:520.
velocity_ms = float(dbin - doppler_center) * v_res
# Apply pitch correction if GPS data available # Apply pitch correction if GPS data available
raw_elev = 0.0 # FPGA doesn't send elevation per-detection raw_elev = 0.0 # FPGA doesn't send elevation per-detection
@@ -108,12 +108,23 @@ class ConcatWidth:
def parse_python_opcodes(filepath: Path | None = None) -> dict[int, OpcodeEntry]: def parse_python_opcodes(filepath: Path | None = None) -> dict[int, OpcodeEntry]:
"""Parse the Opcode enum from radar_protocol.py. """Parse the Opcode enum from radar_protocol.py.
Returns {opcode_value: OpcodeEntry}. Returns {opcode_value: OpcodeEntry}, excluding MCU_ONLY_OPCODES.
MCU-only opcodes have no FPGA case statement and must not appear in
the bidirectional Python/Verilog contract check.
""" """
if filepath is None: if filepath is None:
filepath = GUI_DIR / "radar_protocol.py" filepath = GUI_DIR / "radar_protocol.py"
text = filepath.read_text() text = filepath.read_text()
# Extract MCU_ONLY_OPCODES set so we can exclude those values below.
mcu_only: set[int] = set()
m_set = re.search(r'MCU_ONLY_OPCODES[^=]*=\s*frozenset\(\{([^}]*)\}\)', text)
if m_set:
for tok in m_set.group(1).split(','):
tok = tok.strip()
if tok.startswith(('0x', '0X')):
mcu_only.add(int(tok, 16))
# Find the Opcode class body # Find the Opcode class body
match = re.search(r'class Opcode\b.*?(?=\nclass |\Z)', text, re.DOTALL) match = re.search(r'class Opcode\b.*?(?=\nclass |\Z)', text, re.DOTALL)
if not match: if not match:
@@ -123,7 +134,8 @@ def parse_python_opcodes(filepath: Path | None = None) -> dict[int, OpcodeEntry]
for m in re.finditer(r'(\w+)\s*=\s*(0x[0-9a-fA-F]+)', match.group()): for m in re.finditer(r'(\w+)\s*=\s*(0x[0-9a-fA-F]+)', match.group()):
name = m.group(1) name = m.group(1)
value = int(m.group(2), 16) value = int(m.group(2), 16)
opcodes[value] = OpcodeEntry(name=name, value=value) if value not in mcu_only:
opcodes[value] = OpcodeEntry(name=name, value=value)
return opcodes return opcodes
+2 -3
View File
@@ -7,7 +7,6 @@
[![Frequency: 10.5GHz](https://img.shields.io/badge/Frequency-10.5GHz-blue)](https://github.com/NawfalMotii79/PLFM_RADAR) [![Frequency: 10.5GHz](https://img.shields.io/badge/Frequency-10.5GHz-blue)](https://github.com/NawfalMotii79/PLFM_RADAR)
[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/NawfalMotii79/PLFM_RADAR/pulls) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://github.com/NawfalMotii79/PLFM_RADAR/pulls)
![AERIS-10 Radar System](https://raw.githubusercontent.com/NawfalMotii79/PLFM_RADAR/main/8_Utils/3fb1dabf-2c6d-4b5d-b471-48bc461ce914.jpg)
AERIS-10 is an open-source, low-cost 10.5 GHz phased array radar system featuring Pulse Linear Frequency Modulated (LFM) modulation. Available in two versions (3km and 20km range), it's designed for researchers, drone developers, and serious SDR enthusiasts who want to explore and experiment with phased array radar technology. AERIS-10 is an open-source, low-cost 10.5 GHz phased array radar system featuring Pulse Linear Frequency Modulated (LFM) modulation. Available in two versions (3km and 20km range), it's designed for researchers, drone developers, and serious SDR enthusiasts who want to explore and experiment with phased array radar technology.
@@ -47,7 +46,7 @@ The AERIS-10 main sub-systems are:
- **Main Board** containing: - **Main Board** containing:
- **DAC** - Generates the RADAR Chirps - **DAC** - Generates the RADAR Chirps
- **2x Microwave Mixers (LT5552)** - For up-conversion and IF-down-conversion - **2x Microwave Mixers (LTC5552)** - For up-conversion and IF-down-conversion
- **4x 4-Channel Phase Shifters (ADAR1000)** - For RX and TX chain beamforming - **4x 4-Channel Phase Shifters (ADAR1000)** - For RX and TX chain beamforming
- **16x Front End Chips (ADTR1107)** - Used for both Low Noise Amplifying (RX) and Power Amplifying (TX) stages - **16x Front End Chips (ADTR1107)** - Used for both Low Noise Amplifying (RX) and Power Amplifying (TX) stages
- **XC7A50T FPGA** - Handles RADAR Signal Processing on the upstream FTG256 board: - **XC7A50T FPGA** - Handles RADAR Signal Processing on the upstream FTG256 board:
@@ -92,7 +91,7 @@ The AERIS-10 main sub-systems are:
### Processing Pipeline ### Processing Pipeline
1. **Waveform Generation** - DAC creates LFM chirps 1. **Waveform Generation** - DAC creates LFM chirps
2. **Up/Down Conversion** - LT5552 mixers handle frequency translation 2. **Up/Down Conversion** - LTC5552 mixers handle frequency translation
3. **Beam Steering** - ADAR1000 phase shifters control 16 elements 3. **Beam Steering** - ADAR1000 phase shifters control 16 elements
4. **Signal Processing (FPGA)**: 4. **Signal Processing (FPGA)**:
- Raw ADC data capture - Raw ADC data capture