Skip to content

Commit 814e2c3

Browse files
committed
Add HardwareCFORobustnessTest (digital NCO) + HardwareRFLOCfoTest (ADRV9002 LO offset)
HardwareCFORobustnessTest -- requires V5_cfo_inj BOOT.BIN. Sweeps the cfo_phase_inc AXI4-Lite register at x"118" which drives an 8-bit-index LUT NCO + complex multiplier on the Tx->Rx digital loopback path. Frequency offset = cfo_phase_inc * Fs / 2^16, Fs = 15.36 MHz. Tests: testPassingCFOSweep 9 CFO values 0, +-10k, +-50k, +-100k, +-200k Hz (inside +-240 kHz stated design range); assert BER<1% and bits>=1M in 3s window testBeyondRangeCharacterization 5 out-of-range CFO points (300k, 500k, 1 MHz, -300k, -500k); informational testCFORecoveryAfterLockLoss pull to 1.5 MHz CFO -> stall; restore zero -> assert re-lock + +pkts > 5k/2s HardwareRFLOCfoTest -- *real-RF* CFO via independent Tx and Rx LO setting on the ADRV9002 (CenterFrequencyChannel0). Tx/Rx are streamed via the adi.ADRV9002.Tx/Rx system objects (axi-adrv9002-{tx,rx}-lpc IPs are included in the V3+ FPGA, alongside the BIST IP). TestClassSetup gates on coherent Tx->Rx signal presence (BW > 1 MHz, peak/median > 10 dB); without an RF cable between TX1/RX1 the test cleanly skips with a "connect TX1 to RX1 via RF cable" diagnostic. When connected, sweeps LO offsets 0, +-50k, +-100k, +200k Hz, asserts BER<1% via demodPlutoCapture. Both classes share a common pattern: assumeXXX gate to detect unavailable hardware, parameterised over scenario points, and consistent BER<1% threshold. The digital NCO test characterises the FPGA-Rx tolerance under deterministic CFO; the LO-offset test characterises the full end-to-end RF chain. V5_cfo_inj BOOT.BIN is currently building.
1 parent 75c5bf8 commit 814e2c3

3 files changed

Lines changed: 357 additions & 1 deletion

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
classdef HardwareCFORobustnessTest < matlab.unittest.TestCase
2+
%HardwareCFORobustnessTest Hardware-in-the-loop sweep of the
3+
% cfo_phase_inc AXI4-Lite register (x"118") on the V5_cfo_inj
4+
% BOOT.BIN. cfo_phase_inc is a 16-bit unsigned value applied to a
5+
% phase accumulator that drives an 8-bit-index LUT-based NCO. The
6+
% NCO output (cos + j*sin) complex-multiplies the Tx samples before
7+
% the Receiver, injecting a frequency offset of approximately
8+
% f_offset = cfo_phase_inc * Fs / 2^16, Fs = 15.36 MHz
9+
% = cfo_phase_inc * 234.375 Hz.
10+
% Negative CFO is achieved via two's-complement wrap: write
11+
% 65536 - |value| for negative frequencies.
12+
%
13+
% Frequency -> register value reference:
14+
% 0 Hz -> 0
15+
% +10 kHz -> 43 -10 kHz -> 65493 (0xFFD5)
16+
% +50 kHz -> 213 -50 kHz -> 65323 (0xFF2B)
17+
% +100 kHz -> 427 -100 kHz -> 65109 (0xFE55)
18+
% +200 kHz -> 853 -200 kHz -> 64683 (0xFCAB)
19+
% +240 kHz -> 1024 -240 kHz -> 64512 (0xFC00)
20+
% +300 kHz -> 1280 -300 kHz -> 64256 (0xFB00)
21+
% +500 kHz -> 2133 -500 kHz -> 63403 (0xF7AB)
22+
%
23+
% Tests assert BER < 1% within the stated +-240 kHz design range
24+
% (carrier-sync acquisition window) and characterise (information-only)
25+
% behaviour beyond that range. Also a recovery test: pull CFO to a
26+
% value the link can't lock, restore zero, assert re-lock + decode.
27+
%
28+
% cfo_phase_inc is write-only at the AXI level (HDL Coder default
29+
% for IP wrapper inputs). Detection of V5 via behavioural probe:
30+
% write a CFO value clearly outside lock range -> assert BIST stalls;
31+
% then write 0 and verify recovery.
32+
%
33+
% Run: runtests('HardwareCFORobustnessTest')
34+
35+
properties (Constant)
36+
CfoAddr = '0x9D000118';
37+
Fs = 15.36e6;
38+
PhaseAccumBits = 16;
39+
DataBitsPerPacket = 2240;
40+
BerThreshold = 0.01;
41+
SshTimeoutSec = 5;
42+
end
43+
44+
properties (TestParameter)
45+
% Frequencies (in Hz) inside the QPSK Rx carrier-sync acquisition
46+
% range, where the link should decode at BER < 1%.
47+
passingCFOHz = struct( ...
48+
'zero', 0, ...
49+
'pos10k', 1e4, ...
50+
'neg10k', -1e4, ...
51+
'pos50k', 5e4, ...
52+
'neg50k', -5e4, ...
53+
'pos100k', 1e5, ...
54+
'neg100k', -1e5, ...
55+
'pos200k', 2e5, ...
56+
'neg200k', -2e5);
57+
58+
% Frequencies outside the stated design range -- behaviour
59+
% is informational. We just verify the FPGA doesn't crash (the
60+
% BIST counters keep responding to AXI reads even if no decode).
61+
characterizeCFOHz = struct( ...
62+
'pos300k', 3e5, ...
63+
'pos500k', 5e5, ...
64+
'pos1MHz', 1e6, ...
65+
'neg300k', -3e5, ...
66+
'neg500k', -5e5);
67+
end
68+
69+
methods (Static)
70+
function val = hzToReg(hzOffset)
71+
% Convert frequency in Hz to the 16-bit cfo_phase_inc value
72+
% (two's-complement for negatives).
73+
Fs_local = 15.36e6;
74+
inc = round(hzOffset / Fs_local * 2^16); % can be +/-
75+
val = mod(inc, 2^16); % wrap to 0..65535
76+
end
77+
78+
function snapWait(testCase, secs)
79+
S0 = BistRegisters.readAll(testCase.SshTimeoutSec); %#ok<NASGU>
80+
end
81+
end
82+
83+
methods (TestClassSetup)
84+
function checkBoardReachableAndV5(testCase)
85+
[rc,~] = BistRegisters.sshExec('true', testCase.SshTimeoutSec);
86+
testCase.assumeEqual(rc, 0, ...
87+
'Jupiter at 10.0.0.146 not reachable -- skipping HW CFO suite');
88+
89+
% Behavioural probe for V5: write a large CFO well outside the
90+
% lock range (e.g. 1.5 MHz) and assert BIST packet rate falls
91+
% near zero. (Pure V3 / V4 BOOT.BINs ignore writes to 0x118 and
92+
% keep decoding -- so they FAIL this probe.)
93+
largeCfoReg = HardwareCFORobustnessTest.hzToReg(1.5e6);
94+
BistRegisters.write(testCase.CfoAddr, largeCfoReg, testCase.SshTimeoutSec);
95+
BistRegisters.write(BistRegisters.RstCsAddr, 1, testCase.SshTimeoutSec);
96+
pause(0.05);
97+
BistRegisters.write(BistRegisters.RstCsAddr, 0, testCase.SshTimeoutSec);
98+
pause(2);
99+
S0 = BistRegisters.readAll(testCase.SshTimeoutSec);
100+
pause(1.5);
101+
S1 = BistRegisters.readAll(testCase.SshTimeoutSec);
102+
dpHi = S1.packets - S0.packets;
103+
% Restore zero CFO + re-acquire
104+
BistRegisters.write(testCase.CfoAddr, 0, testCase.SshTimeoutSec);
105+
BistRegisters.write(BistRegisters.RstCsAddr, 1, testCase.SshTimeoutSec);
106+
pause(0.05);
107+
BistRegisters.write(BistRegisters.RstCsAddr, 0, testCase.SshTimeoutSec);
108+
pause(2);
109+
testCase.assumeLessThan(dpHi, 500, ...
110+
sprintf(['1.5 MHz CFO did not stall the link (dp=%d in 1.5s); ' ...
111+
'deployed BOOT.BIN does not appear to be V5_cfo_inj'], dpHi));
112+
end
113+
114+
function leaveZeroOnExit(testCase)
115+
testCase.addTeardown(@() ...
116+
BistRegisters.write(testCase.CfoAddr, 0, testCase.SshTimeoutSec));
117+
end
118+
end
119+
120+
methods (Test, TestTags = {'Hardware'})
121+
122+
% --- inside-range: must decode at BER < 1% ---
123+
function testPassingCFOSweep(testCase, passingCFOHz)
124+
reg = HardwareCFORobustnessTest.hzToReg(passingCFOHz);
125+
BistRegisters.write(testCase.CfoAddr, reg, testCase.SshTimeoutSec);
126+
BistRegisters.write(BistRegisters.RstCsAddr, 1, testCase.SshTimeoutSec);
127+
pause(0.05);
128+
BistRegisters.write(BistRegisters.RstCsAddr, 0, testCase.SshTimeoutSec);
129+
pause(2); % carrier-sync + AGC settle
130+
S0 = BistRegisters.readAll(testCase.SshTimeoutSec);
131+
pause(3);
132+
S1 = BistRegisters.readAll(testCase.SshTimeoutSec);
133+
dp = S1.packets - S0.packets;
134+
de = S1.bit_errors - S0.bit_errors;
135+
bits = dp * testCase.DataBitsPerPacket;
136+
ber = de / max(1, bits);
137+
fprintf('cfo=%+.0f Hz (reg=0x%04X): +pkts=%d bits=%d BER=%.4f%%\n', ...
138+
passingCFOHz, reg, dp, bits, 100*ber);
139+
testCase.assertGreaterThanOrEqual(bits, 1e6, ...
140+
sprintf('cfo=%+.0f Hz: only %d bits in 3 s -- link stalled', passingCFOHz, bits));
141+
testCase.verifyLessThan(ber, testCase.BerThreshold);
142+
end
143+
144+
% --- characterize beyond design range: report behaviour, but only
145+
% assert that the FPGA continues to respond (BIST register
146+
% reads still work, so the design hasn't crashed) ---
147+
function testBeyondRangeCharacterization(testCase, characterizeCFOHz)
148+
reg = HardwareCFORobustnessTest.hzToReg(characterizeCFOHz);
149+
BistRegisters.write(testCase.CfoAddr, reg, testCase.SshTimeoutSec);
150+
BistRegisters.write(BistRegisters.RstCsAddr, 1, testCase.SshTimeoutSec);
151+
pause(0.05);
152+
BistRegisters.write(BistRegisters.RstCsAddr, 0, testCase.SshTimeoutSec);
153+
pause(2);
154+
S0 = BistRegisters.readAll(testCase.SshTimeoutSec);
155+
pause(2);
156+
S1 = BistRegisters.readAll(testCase.SshTimeoutSec);
157+
% We can only assert that the AXI reads return sensible
158+
% (different) values -- the FPGA must still be alive.
159+
testCase.verifyFalse(isnan(S1.packets), ...
160+
sprintf('AXI read failed after writing cfo=%.0f Hz', characterizeCFOHz));
161+
dp = S1.packets - S0.packets;
162+
de = S1.bit_errors - S0.bit_errors;
163+
if dp > 0
164+
ber = de / (dp * testCase.DataBitsPerPacket);
165+
fprintf('cfo=%+.0f Hz (out-of-range): +pkts=%d BER=%.4f%% (info)\n', ...
166+
characterizeCFOHz, dp, 100*ber);
167+
else
168+
fprintf('cfo=%+.0f Hz (out-of-range): link stalled (+pkts=0) (info)\n', characterizeCFOHz);
169+
end
170+
end
171+
172+
% --- recovery: pull CFO to a value the link can't lock, then
173+
% restore zero, assert recovery ---
174+
function testCFORecoveryAfterLockLoss(testCase)
175+
% First write a CFO that clearly exceeds lock range
176+
reg = HardwareCFORobustnessTest.hzToReg(1.5e6);
177+
BistRegisters.write(testCase.CfoAddr, reg, testCase.SshTimeoutSec);
178+
BistRegisters.write(BistRegisters.RstCsAddr, 1, testCase.SshTimeoutSec);
179+
pause(0.05);
180+
BistRegisters.write(BistRegisters.RstCsAddr, 0, testCase.SshTimeoutSec);
181+
pause(2);
182+
S0 = BistRegisters.readAll(testCase.SshTimeoutSec);
183+
pause(1.5);
184+
S1 = BistRegisters.readAll(testCase.SshTimeoutSec);
185+
stallDp = S1.packets - S0.packets;
186+
% Restore zero CFO + rstCS for re-acquisition
187+
BistRegisters.write(testCase.CfoAddr, 0, testCase.SshTimeoutSec);
188+
BistRegisters.write(BistRegisters.RstCsAddr, 1, testCase.SshTimeoutSec);
189+
pause(0.05);
190+
BistRegisters.write(BistRegisters.RstCsAddr, 0, testCase.SshTimeoutSec);
191+
pause(2);
192+
S2 = BistRegisters.readAll(testCase.SshTimeoutSec);
193+
pause(2);
194+
S3 = BistRegisters.readAll(testCase.SshTimeoutSec);
195+
recoveredDp = S3.packets - S2.packets;
196+
fprintf('lock-lost stall=+%d/1.5s ; recovery=+%d/2s\n', stallDp, recoveredDp);
197+
testCase.verifyLessThan(stallDp, 500, ...
198+
'expected link to fail to lock at 1.5 MHz CFO');
199+
testCase.verifyGreaterThan(recoveredDp, 5000, ...
200+
'expected link to recover after restoring zero CFO + rstCS');
201+
end
202+
end
203+
end
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
classdef HardwareRFLOCfoTest < matlab.unittest.TestCase
2+
%HardwareRFLOCfoTest Real-RF CFO test by adjusting the ADRV9002 Tx
3+
% and Rx LO frequencies independently. Streams a MATLAB-generated
4+
% QPSK waveform out the FPGA DAC (via adi.ADRV9002.Tx), the signal
5+
% propagates through the ADRV9002 analog chain (TX LO mixer ->
6+
% amplifier -> external RF loopback cable -> Rx amplifier ->
7+
% RX LO mixer -> ADC), and is captured (adi.ADRV9002.Rx). The
8+
% delta between Tx and Rx LOs is a real RF carrier-frequency
9+
% offset; the offline receiver decodes the capture and BER is
10+
% asserted.
11+
%
12+
% This is complementary to HardwareCFORobustnessTest (which
13+
% characterises the FPGA-Rx tolerance via a digital NCO injection
14+
% in the BIST design): this one characterises the *end-to-end
15+
% real-RF link*, exercising the analog Tx/Rx chain plus the same
16+
% offline demodulator the FPGA Rx is algorithmically equivalent
17+
% to.
18+
%
19+
% Hardware setup required:
20+
% * A streaming-capable FPGA on Jupiter (any of V3/V4/V5 BOOT.BIN
21+
% works -- they all include axi-adrv9002-{tx,rx}-lpc IPs).
22+
% * **An RF cable (or attenuator) from TX1 port to RX1 port.**
23+
% Without it the captured Rx sees only noise and the test
24+
% will skip with a "no coherent signal" assumption.
25+
%
26+
% Run: runtests('HardwareRFLOCfoTest')
27+
28+
properties (Constant)
29+
URI = 'ip:10.0.0.146';
30+
BaseLO = 2.4e9; % Tx LO
31+
Fs_iio = 30.72e6; % ADRV9002 baseband rate from profile
32+
NSamples = 2^17; % capture length
33+
BerThreshold = 0.01;
34+
end
35+
36+
properties (TestParameter)
37+
% LO offset (Hz) applied to Rx vs Tx. The QPSK Rx algorithm's
38+
% stated tolerance is +-240 kHz; we sweep within that range.
39+
loOffsetHz = struct( ...
40+
'zero', 0, ...
41+
'pos50k', 5e4, ...
42+
'neg50k', -5e4, ...
43+
'pos100k', 1e5, ...
44+
'neg100k', -1e5, ...
45+
'pos200k', 2e5);
46+
end
47+
48+
properties (TestClassSetup)
49+
% Persistent Tx/Rx objects to avoid hammering libiio handshakes
50+
end
51+
52+
properties
53+
Tx
54+
Rx
55+
TxWaveform
56+
end
57+
58+
methods (TestClassSetup)
59+
function setup(testCase)
60+
% Probe the board first; skip if unreachable
61+
[rc,~] = BistRegisters.sshExec('true', 5);
62+
testCase.assumeEqual(rc, 0, ...
63+
'Jupiter at 10.0.0.146 not reachable -- skipping RF LO CFO suite');
64+
65+
% Make +adi package visible (system objects live in repo root)
66+
import matlab.unittest.fixtures.PathFixture
67+
here = fileparts(mfilename('fullpath'));
68+
repoRoot = fullfile(here, '..', '..', '..');
69+
testCase.applyFixture(PathFixture(repoRoot));
70+
% Skip if the toolbox isn't actually available in the search path
71+
testCase.assumeTrue(~isempty(which('adi.ADRV9002.Tx')), ...
72+
'adi.ADRV9002.Tx not on path -- repo +adi package not visible');
73+
74+
o=rng; testCase.addTeardown(@() rng(o)); rng(7,'twister');
75+
76+
% Generate a QPSK Tx waveform at Fs_iio (ADRV9002 baseband
77+
% rate). Use QPSKRxChainTest.genTxWaveform (8 sps @ Rsym=1.92M
78+
% = 15.36 MHz), then resample to Fs_iio.
79+
txMat = QPSKRxChainTest.genTxWaveform(40); % ~40 frames
80+
[p,q] = rat(testCase.Fs_iio / 15.36e6);
81+
txRs = resample(txMat, p, q);
82+
% Scale to near full-scale int16 (signed 16-bit DAC)
83+
txRs = txRs / max(abs(txRs)) * (2^14);
84+
testCase.TxWaveform = complex(txRs);
85+
86+
% Tx setup: cyclic transmission so the buffer repeats forever
87+
testCase.Tx = adi.ADRV9002.Tx('uri', testCase.URI);
88+
testCase.Tx.CenterFrequencyChannel0 = testCase.BaseLO;
89+
testCase.Tx.EnableCyclicBuffers = true;
90+
91+
% Rx setup
92+
testCase.Rx = adi.ADRV9002.Rx('uri', testCase.URI);
93+
testCase.Rx.CenterFrequencyChannel0 = testCase.BaseLO;
94+
testCase.Rx.SamplesPerFrame = testCase.NSamples;
95+
testCase.Rx.kernelBuffersCount = 4;
96+
97+
% Push initial Tx and prime Rx so calibration settles
98+
testCase.Tx(testCase.TxWaveform);
99+
for k=1:4, [~] = testCase.Rx(); end
100+
101+
% --- Coherent-signal sanity: with LO_rx = LO_tx, the Rx
102+
% capture should have BW > ~1 MHz (QPSK bandwidth) and
103+
% SNR clearly above noise. If it doesn't, there is no
104+
% Tx -> Rx coupling (RF cable missing).
105+
y = double(testCase.Rx());
106+
seg = y - mean(y); Y = abs(fftshift(fft(seg.*hann(numel(seg))))).^2;
107+
ff = (-numel(seg)/2:numel(seg)/2-1)/numel(seg)*testCase.Fs_iio;
108+
cs = cumsum(Y) / max(sum(Y), eps);
109+
i95 = find(cs>=0.95,1); i05 = find(cs>=0.05,1);
110+
if isempty(i95) || isempty(i05)
111+
bw = 0;
112+
else
113+
bw = ff(i95) - ff(i05);
114+
end
115+
peakToMed = 10*log10(max(Y) / max(median(Y), eps));
116+
fprintf('LO baseline: BW=%.3f MHz, peak/median=%.1f dB\n', bw/1e6, peakToMed);
117+
testCase.assumeGreaterThan(bw, 1e6, sprintf(...
118+
'no coherent Tx->Rx signal (BW=%.2f MHz < 1 MHz); connect TX1 to RX1 via RF cable', bw/1e6));
119+
testCase.assumeGreaterThan(peakToMed, 10, sprintf(...
120+
'no coherent Tx->Rx signal (peak/median=%.1f dB)', peakToMed));
121+
end
122+
123+
function teardownRadios(testCase)
124+
testCase.addTeardown(@() releaseAll(testCase));
125+
end
126+
end
127+
128+
methods (Test, TestTags = {'Hardware'})
129+
function testLoOffsetCFO(testCase, loOffsetHz)
130+
% Set Rx LO with offset, prime, capture, decode, BER assert
131+
testCase.Rx.CenterFrequencyChannel0 = testCase.BaseLO + loOffsetHz;
132+
for k=1:4, [~] = testCase.Rx(); end
133+
y = testCase.Rx();
134+
iq = double(y(:));
135+
136+
[ber, nframes, evm, info] = demodPlutoCapture(iq, testCase.Fs_iio);
137+
fprintf('LO offset=%+.0f Hz: %s\n', loOffsetHz, info);
138+
testCase.verifyGreaterThan(nframes, 5, ...
139+
sprintf('LO offset=%+.0f Hz: only %d frames decoded -- Rx lost lock', loOffsetHz, nframes));
140+
testCase.verifyLessThan(evm, 0.35, ...
141+
sprintf('LO offset=%+.0f Hz: EVM=%.2f too high', loOffsetHz, evm));
142+
testCase.verifyLessThan(ber, testCase.BerThreshold, ...
143+
sprintf('LO offset=%+.0f Hz: BER %.3f%% >= %.0f%%', loOffsetHz, 100*ber, 100*testCase.BerThreshold));
144+
end
145+
end
146+
end
147+
148+
function releaseAll(tc)
149+
try, release(tc.Tx); catch, end
150+
try, release(tc.Rx); catch, end
151+
end

trx_examples/targeting/QPSKTxRxHDLExample/buildfile.m

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
"BandwidthVariantSimTest.m"; ...
2727
"HardwareLinkRobustnessTest.m"; ...
2828
"HardwareGainRobustnessTest.m"; ...
29-
"OfflineLinkScenarioSweepTest.m"];
29+
"OfflineLinkScenarioSweepTest.m"; ...
30+
"HardwareCFORobustnessTest.m"; ...
31+
"HardwareRFLOCfoTest.m"];
3032

3133
plan("test") = matlab.buildtool.tasks.TestTask(testFiles, ...
3234
TestResults = "test-results/junit.xml", ...

0 commit comments

Comments
 (0)