diff --git a/+adi/+AD9084/AD9084_WALKTHROUGH.txt b/+adi/+AD9084/AD9084_WALKTHROUGH.txt new file mode 100644 index 00000000..6c066618 --- /dev/null +++ b/+adi/+AD9084/AD9084_WALKTHROUGH.txt @@ -0,0 +1,291 @@ +================================================================================ +AD9084 Filter Support — Walkthrough & Findings +================================================================================ + +OVERVIEW +-------- +This contribution adds PFIR and CFIR filter class support to the AD9084 driver, +along with gain calibration tools, real-time spectrum analysis, and hardware +tests. It enables users to design, load, compare, and characterize digital +filters on the AD9084 MxFE platform. + +New files added to +adi/+AD9084/: + - PFilt.m PFIR filter class (design, quantize, write, response) + - CFIR.m CFIR filter class (design, quantize, write, response) + - FIRcoeff.m Tap quantization to Q15 hex (used by both classes) + - writeDisabledFilter.m Generates disabled/all-pass reference filter files + - filter_demo.m End-to-end walkthrough script + - filter_compare.m Before/after comparison with theoretical overlay + - plotting_fft.m Real-time FFT plotting function + - pfir_gain_lut_study.m PFIR gain characterization → saves gain_lut.m + - cfir_gain_lut_study.m CFIR gain characterization → saves cfir_gain_lut.m + - pfir_gain_calibration.m Find normalization anchor (max tap output) + - pfir_sweep_study.m Tap value sweep and linearity analysis + + +Modified files: + - Base.m, Rx.m, Tx.m Added EnablePFIRs/EnableCFIRs properties, NCO bugfix + - test/AD9084HWTests.m Added filter loading and attenuation tests + + +PREREQUISITES +------------- + - MATLAB R2023b or later (Signal Processing Toolbox for fdesign) + - AD9084 evaluation board with IIO firmware + - Network connectivity to the board (default IP: 192.168.2.1) + - libiio MATLAB bindings (included in ToolboxCommon submodule) + + +SETUP +----- +1. Open MATLAB +2. Navigate to the repo root: + cd('C:\Dev\HSCT_Fork') +3. Add the repo to the MATLAB path: + addpath(genpath(pwd)) +4. Set your board IP in whichever script you run (see Configuration sections) + + +SIGNAL CHAIN (critical for understanding filter behavior) +--------------------------------------------------------- +The AD9084 Rx digital signal chain order is: + + ADC (full rate 20 GHz) + → PFIR (operates at full ADC rate) + → CDDC (MainNCO mixing + coarse decimation) + → FDDC (ChannelNCO mixing + fine decimation) + → CFIR (operates at decimated rate 2.5 GHz) + → Output to DMA + +Key implications: + - PFIR sees the signal at its absolute RF frequency in the ADC's Nyquist zone. + A tone at RF = MainNCO + ChannelNCO + DDS offset appears at that absolute + frequency in the PFIR domain. + - CFIR sees the signal at baseband AFTER all NCO mixing. + - If you do not set the channel NCO to something other than 0Hz there will be a + offset of 100 MHz. + - The "observable window" in the PFIR's 20 GHz domain is centered at + (MainNCO + ChannelNCO), spanning ±Fs_decimated/2 around that center. + + +SCRIPT-BY-SCRIPT GUIDE +======================= + +filter_demo.m +------------- +Purpose: Complete walkthrough — design filters, load them to hardware, capture + spectra, and compare measured vs theoretical response. + +Configuration (top of file): + uri = 'ip:192.168.2.1'; % Board IP + pfirCompare = 0/1; % Enable PFIR before/after comparison + cfirCompare = 0/1; % Enable CFIR before/after comparison + pfirAllPass = 0/1; % 1=load all-pass PFIR, 0=load designed filter + cfirAllPass = 0/1; % 1=load all-pass CFIR, 0=load designed filter + txMode = 'dds'/'noise'/etc. % TX excitation mode + +How to run: + 1. Set uri to your board IP + 2. Set switches (e.g., cfirCompare=1 to see CFIR comparison) + 3. Run the script section-by-section (Ctrl+Enter per section) + +Expected output: + - Filter design creates LPF, BPF, HPF taps using fdesign.arbmag + - Theoretical response plotted via .response() method + - Live FFT via plotting_fft(rx) at the end + - If pfirCompare/cfirCompare=1: comparison figure with before/after spectra + and measured vs theoretical delta overlay + +filter_compare.m +---------------- +Purpose: Called by filter_demo.m (or standalone). Captures spectrum with filter + active, swaps to all-pass/disabled reference, captures again, overlays + theoretical response. + +Usage: + adi.AD9084.filter_compare(rx, filterObj, 'pfir', 'pfir_auto.txt') + adi.AD9084.filter_compare(rx, filterObj, 'cfir', 'cfir_auto.txt') + +Output: 2-subplot figure + - Top: before (reference) vs after (filter active) spectra + - Bottom: measured delta vs theoretical .response() shape + +The theoretical response for PFIR accounts for NCO offset — it crops the full +20 GHz response to the observable window and shifts to baseband for overlay. + +PFilt.m +------- +Purpose: PFIR filter class. Encapsulates tap storage, mode inference, gain + settings, file output, and frequency response computation. + +Key methods: + pf = adi.AD9084.PFilt(taps, 'mode','real_n2', 'gain',"18", 'scalar_gain',"63") + pf.write('pfir_auto.txt') % Write filter file for hardware + pf.response(20e9) % Plot theoretical response (no output args) + [H, f] = pf.response(20e9) % Return complex response vector + [H, f] = pf.response(20e9, useLUT=true) % Apply gain LUT correction + +Parameters written to filter file (affect hardware behavior): + - mode: real_n2, real_n4, half_complex, matrix, disabled + - gain: 0 to 24 dB in 6 dB steps (shift gain) + - scalar_gain: 0-63 (multiplier = N/64) + +CFIR.m +------ +Purpose: CFIR filter class. Same pattern as PFilt but for the channelizer FIR. + +Key methods: + cf = adi.AD9084.CFIR(taps, 'gain',"12", 'complex_scalar',[32767 0]) + cf.write('cfir_auto.txt') + cf.response(2.5e9, useLUT=true) + +Parameters: + - gain: -18 to +12 dB in 6 dB steps + - complex_scalar: [real, imag] pair (normalized by 32767) + - sparse_mode: 0=normal (16 taps), 1=sparse (16 non-zero in 128 positions) + +FIRcoeff.m +---------- +Purpose: Quantizes floating-point taps to Q15 hex for hardware register loading. + - Scales by 2^15 (full scale = 32767) + - Clamps to int16 range [-32768, 32767] + - Returns hex_I and hex_Q columns (duplicated for real taps) + +pfir_gain_lut_study.m (previously: gain_study.m) +-------------------------------------------------- +Purpose: Characterize PFIR shift gain and scalar gain stages. Sweeps each gain + setting, measures actual hardware output, and saves a lookup table. + +Configuration: + uri = 'ip:192.168.2.1'; + SAVE_RESULTS = true; + +Output: Saves to +adi/+AD9084/gain_study_results/run_NNN/ + - gain_lut.m MATLAB function returning LUT struct + - Figures (shift gain sweep, scalar sweep, fine sweep) + +The LUT is consumed by PFilt.response(Fs, useLUT=true) to correct the +theoretical response for actual hardware gain behavior. + +cfir_gain_lut_study.m (previously: cfir_gain_study.m) +------------------------------------------------------ +Purpose: Same as above but for CFIR gain stages. + +Output: Saves to +adi/+AD9084/cfir_gain_study_results/run_NNN/ + - cfir_gain_lut.m MATLAB function returning LUT struct + +pfir_gain_calibration.m +----------------------- +Purpose: Find the normalization anchor — the tap value that produces maximum + hardware output. Used to understand the relationship between tap + coefficient and actual gain. + +Phases: + Phase 0: Reference level with PFIR disabled + Phase 1: Sweep tap positions (which position is loudest?) + Phase 2: Sweep tap values at best position (what value saturates?) + Phase 3: Statistical validation (repeated measurements for confidence) + +Configuration: + URI = 'ip:192.168.2.1'; + DIAG_ONLY = 0; % 0=full calibration, 1=spectrum check only + RUN_SWEEP = 0; % 0=skip Phase 2 sweep, 1=run it + DIAG_TAP_POS = 8; % Middle tap position for diagnostics + +pfir_sweep_study.m +------------------ +Purpose: Detailed tap value sweep — tests linearity, finds clipping point, + checks register overflow behavior. + + +plotting_fft.m +-------------- +Purpose: Real-time FFT plotting function. Takes an rx object and continuously + captures + plots the spectrum until the figure is closed. + +Usage: + plotting_fft(rx) % rx must already be primed (rx() called once) + + +AD9084HWTests.m (in test/) +-------------------------- +Purpose: Hardware test suite verifying: + - Basic RX streaming + - DDS tone transmission and reception + - Two-channel operation + - PFIR filter loading (all-pass) + - CFIR filter loading (all-pass) + - PFIR attenuation of stopband tones (≥6 dB threshold) + - CFIR attenuation of stopband tones (≥6 dB threshold) + +Running tests: + results = runtests('test/AD9084HWTests') + +Configuration: + Edit line 4: uri = 'ip:192.168.2.1'; + + +KEY FINDINGS +============ + +1. CFIR bypass mode is unreliable on hardware - no output produced + Setting bypass=1 in the CFIR header does not reliably bypass the filter. + Workaround: use an all-pass filter (center tap = 1.0, all others = 0) to + achieve effective bypass. + +3. FIRcoeff quantization is Q15 (2^15 scaling) + A tap value of 1.0 maps to hardware value 32767 (int16 max). This was + previously documented as 2^14 in some comments. + +4. Gain LUT corrects flat gain only, not filter shape + The LUT maps programmed gain settings to actual measured gain (vertical + offset). The filter SHAPE comes from freqz(taps) and is not affected by + the LUT. The LUT matters for absolute level accuracy, not passband ripple. + +5. DDS phase convention + [90000, 0] = positive frequency (I leads Q by 90 degrees) + [0, 90000] = negative frequency (Q leads I by 90 degrees) + Phases are in milli-degrees. + +6. rx.SamplingRate returns post-decimation rate only + There is no IIO property exposing the full ADC clock rate. The PFIR scripts + hardcode 20e9 for the full-rate computation. + +7. Scalar gain behavior + The scalar_gain parameter (0-63) acts as a fractional multiplier of N/64. + scalar_gain=63 is near-unity. scalar_gain=0 is silence. The UG says that + scalar_gain=64 is possible, this is false. + + +KNOWN ISSUES +============ + +1. CFIR bypass mode switch in filter text file doesn't work reliably +(I have not gotten it to work) — use all-pass instead. + +2. You need to set ChannelNCOFrequencies to something other than 0Hz, if not + there will be a 100 MHz offset in the tone. + + +WORKFLOW: Running a Full Filter Characterization +================================================ + +Step 1: Generate gain LUTs (one-time per board) + >> pfir_gain_lut_study % takes ~5-10 minutes + >> cfir_gain_lut_study % takes ~5-10 minutes + +Step 2: Design and load filters + >> filter_demo % with pfirAllPass=0, cfirAllPass=0 + +Step 3: Compare measured vs theoretical + >> Set pfirCompare=1 or cfirCompare=1 in filter_demo + >> Run the comparison section + +Step 4: Use .response() with LUT for accurate predictions + >> [H, f] = pf.response(20e9, useLUT=true); + >> [H, f] = cf.response(2.5e9, useLUT=true); + + +================================================================================ +End of walkthrough +================================================================================ diff --git a/+adi/+AD9084/CFIR.m b/+adi/+AD9084/CFIR.m new file mode 100644 index 00000000..0e16479e --- /dev/null +++ b/+adi/+AD9084/CFIR.m @@ -0,0 +1,238 @@ +classdef CFIR + % AD9084CFIR + % - Encapsulates CFIR-specific catalogs, defaults, validation, and file output. + % - Constructor accepts taps and (optional) params as Name-Value pairs. + % - Always outputs HEX coefficients by calling FIRcoeff + % - Header lines follow CFIR format, e.g.: + % dest: rx cfir_all profile_2 datapath_all + % gain: 0 + % complex_scalar: 32767 0 + % enable: 1 profile_2 + % selection_mode: direct_regmap + % coeff_transfer: 0 + % bypass: 0 + + properties + taps (:,1) double + + %profile Profile Number + % Profile index for the CFIR block. Used to auto-build + % 'dest' and 'enable' header tokens (e.g., 'profile_2'). + profile (1,1) double = 2 + + %gain Shift Gain (dB) + % Programmable gain block at the CFIR output. Ranges from + % -18 dB to +12 dB in 6 dB steps. + gain (1,1) string {mustBeMember(gain, ["-18","-12","-6","0","6","12"])} = "0" + + %complex_scalar Complex Scalar Multiplier + % Complex scalar applied after filtering. Normalized by 32767. + % 32767+0i = unity real, 0+32767i = 90 degree rotation. + % Real and imaginary parts must be integers in [-32768, 32767]. + complex_scalar (1,1) double = 32767+0i + + %dest Destination + % Header destination string for the filter file. + % If empty, auto-built from profile (e.g., 'rx cfir_all profile_2 datapath_all'). + dest (1,1) string = "" + + %enable Enable String + % Header enable string. If empty, auto-built from profile + % (e.g., '1 profile_2'). + enable (1,1) string = "" + + %selection_mode Profile Selection Mode + % Controls how CFIR profiles are switched. + % Options: 'direct_regmap', 'direct_gpio', 'trig_regmap', + % 'trig_gpio', 'trig_auto' + selection_mode (1,1) string {mustBeMember(selection_mode, ["direct_regmap","direct_gpio","trig_regmap","trig_gpio","trig_auto"])} = "direct_regmap" + + %coeff_transfer Coefficient Transfer + % Trigger coefficient transfer to hardware. '0' or '1'. + coeff_transfer (1,1) string {mustBeMember(coeff_transfer, ["0","1"])} = "0" + + %bypass Bypass + % Bypass the CFIR filter block. '0' = filter active, '1' = bypassed. + bypass (1,1) string {mustBeMember(bypass, ["0","1"])} = "0" + + %sparse_mode Sparse Mode + % Enable 128-tap sparse CFIR (16 non-zero taps selectable + % anywhere in the impulse response). + % 0 = normal mode (max 16 taps), 1 = sparse mode (max 128 taps). + sparse_mode (1,1) double {mustBeMember(sparse_mode, [0 1])} = 0 + end + + methods + function obj = CFIR(taps, options) + arguments + taps (:,1) double + + options.profile (1,1) double = 2 + options.gain (1,1) string {mustBeMember(options.gain, ["-18","-12","-6","0","6","12"])} = "0" + options.complex_scalar (1,1) double = 32767+0i + options.dest (1,1) string = "" + options.enable (1,1) string = "" + options.selection_mode (1,1) string {mustBeMember(options.selection_mode, ["direct_regmap","direct_gpio","trig_regmap","trig_gpio","trig_auto"])} = "direct_regmap" + options.coeff_transfer (1,1) string {mustBeMember(options.coeff_transfer, ["0","1"])} = "0" + options.bypass (1,1) string {mustBeMember(options.bypass, ["0","1"])} = "0" + options.sparse_mode (1,1) double {mustBeMember(options.sparse_mode,[0 1])} = 0 + end + + obj.taps = taps(:); + + % Validate tap count + isSparse = (options.sparse_mode == 1); + maxTaps = 128 * isSparse + 16 * ~isSparse; + if numel(obj.taps) > maxTaps + modeLabels = ["normal","sparse"]; + error('CFIR: %d taps provided but max is %d (%s mode).', ... + numel(obj.taps), maxTaps, modeLabels(isSparse + 1)); + end + + % Validate complex_scalar components + cs_r = real(options.complex_scalar); + cs_i = imag(options.complex_scalar); + if any([cs_r cs_i] < -32768) || any([cs_r cs_i] > 32767) || ... + cs_r ~= floor(cs_r) || cs_i ~= floor(cs_i) + error("CFIR: 'complex_scalar' real and imag parts must be integers in [-32768, 32767]. Got %g%+gi.", cs_r, cs_i); + end + + % Assign properties + obj.profile = options.profile; + obj.gain = options.gain; + obj.complex_scalar = options.complex_scalar; + obj.dest = options.dest; + obj.enable = options.enable; + obj.selection_mode = options.selection_mode; + obj.coeff_transfer = options.coeff_transfer; + obj.bypass = options.bypass; + obj.sparse_mode = options.sparse_mode; + + % Fill in profile-dependent header fields if empty + obj = obj.finalizeHeaderTokens(); + end + + function outfile = write(obj, outfile) + arguments + obj + outfile (1,1) string + end + + [hexI, hexQ] = FIRcoeff(obj.taps); + headerLines = obj.previewHeader(); + + fid = fopen(outfile, 'w'); + if fid < 0, error('Cannot open file for writing: %s', outfile); end + cleaner = onCleanup(@() fclose(fid)); + + for i = 1:numel(headerLines) + fprintf(fid, '%s\n', headerLines(i)); + end + + for i = 1:size(hexI, 1) + fprintf(fid, '0x%s 0x%s\n', hexI(i,:), hexQ(i,:)); + end + end + + function lines = previewHeader(obj) + lines = [ + "dest: " + obj.dest + "gain: " + obj.gain + "complex_scalar: " + sprintf('%g %g', real(obj.complex_scalar), imag(obj.complex_scalar)) + "enable: " + obj.enable + "selection_mode: " + obj.selection_mode + "coeff_transfer: " + obj.coeff_transfer + "bypass: " + obj.bypass + "sparse_mode: " + string(obj.sparse_mode) + ]; + end + + function [H, f] = response(obj, Fs, options) + % [H, f] = cf.response(Fs) % nominal (no LUT) + % [H, f] = cf.response(Fs, useLUT=true) % auto-find latest LUT + % [H, f] = cf.response(Fs, lutFile="path/to/cfir_gain_lut.m") + % cf.response(Fs) % no output args -> plots + arguments + obj + Fs (1,1) double + options.N (1,1) double = 1024 + options.useLUT (1,1) logical = false + options.lutFile (1,1) string = "" + end + + taps_q = round(obj.taps * 2^15) / 2^15; + + f_vec = linspace(-Fs/2, Fs/2, options.N).'; + [H_fir, ~] = freqz(taps_q, 1, f_vec, Fs); + + if options.useLUT || options.lutFile ~= "" + lut = obj.loadLUT(options.lutFile); + else + lut = struct(); + end + + if ~isempty(fieldnames(lut)) && isfield(lut, 'shift_gain_sweep') + programmed_gain = str2double(obj.gain); + shift_gain_dB = interp1( ... + lut.shift_gain_sweep.shift_gain_values_dB, ... + lut.shift_gain_sweep.gain_dB, ... + programmed_gain, 'linear', 'extrap'); + else + shift_gain_dB = str2double(obj.gain); + end + + cs = obj.complex_scalar / 32767; + + H = H_fir * cs * 10^(shift_gain_dB/20); + f = f_vec; + + if nargout == 0 + H_dBFS = 20*log10(abs(H) / max(abs(H)) + eps); + figure('Name', 'CFIR Hardware Response'); + plot(f/1e6, H_dBFS, 'b-', 'LineWidth', 1.2); + hold on; grid on; + xlabel('Frequency (MHz)'); ylabel('Magnitude (dBFS)'); + title(sprintf('CFIR Response (gain=%s dB, scalar=%g%+gi)', ... + obj.gain, real(obj.complex_scalar), imag(obj.complex_scalar))); + clear H f; + end + end + end + + methods (Access=private) + function lut = loadLUT(~, lutFile) + if lutFile ~= "" + [~, fname] = fileparts(lutFile); + addpath(fileparts(lutFile)); + lut = feval(fname); + return; + end + cfirRoot = fullfile(fileparts(mfilename('fullpath')), ... + '..', '..', '..', 'MATLAB', 'cfir_gain_study_results'); + if exist(cfirRoot, 'dir') + runs = dir(fullfile(cfirRoot, 'run_*')); + for k = numel(runs):-1:1 + candidate = fullfile(runs(k).folder, runs(k).name, 'cfir_gain_lut.m'); + if exist(candidate, 'file') + [~, fname] = fileparts(candidate); + addpath(fileparts(candidate)); + lut = feval(fname); + return; + end + end + end + lut = struct(); + end + + function obj = finalizeHeaderTokens(obj) + profTok = sprintf('profile_%d', obj.profile); + + if strlength(obj.dest) == 0 + obj.dest = "rx cfir_all " + profTok + " datapath_all"; + end + if strlength(obj.enable) == 0 + obj.enable = "1 " + profTok; + end + end + end +end diff --git a/+adi/+AD9084/CHANGES.md b/+adi/+AD9084/CHANGES.md new file mode 100644 index 00000000..2c131afc --- /dev/null +++ b/+adi/+AD9084/CHANGES.md @@ -0,0 +1,398 @@ +# AD9084 Toolbox Change Log + +Changes made to the HSCT Repo AD9084 class files relative to their original state. + +--- + +## Tx.m + +### Change 0 — Class was originally referencing AD9081 throughout +Every reference to `AD9084` in the current `Tx.m` originally said `AD9081`. This +affected the class inheritance, constructor super call, comments, devName strings, +and any other identifier containing the part number. The file was essentially a +copy of the AD9081 Tx class that had not yet been updated to AD9084. + +Affected locations (now AD9084, originally AD9081): +- Line 1: `classdef Tx < adi.AD9084.Base` +- Line ~2: comment `adi.AD9084.Tx Transmit data from the AD9084...` +- Line ~3: comment `The adi.AD9084.Tx System object...` +- Line ~4: comment `complex data from the AD9084` +- Line ~6: comment `tx = adi.AD9084.Tx;` +- Line ~7: comment `tx = adi.AD9084.Tx('uri',...)` +- Line ~9: hyperlink text and URL containing `AD9084` +- Line ~61: `devName = 'axi-ad9084-tx-hpc'` +- Line ~69: `obj = obj@adi.AD9084.Base(varargin{:})` + +--- + + +### Change 1 — Constructor: override `phyDevName` +**Constructor body, first line after super call** +- Before: (not present) +- After: `obj.phyDevName = 'axi-ad9084-tx-hpc';` +- Reason: `Base.m` hardcodes `phyDevName = 'axi-ad9084-rx-hpc'` as the default. + Without this override, `setupInit` fetches the RX device handle and tries to + write TX-only attributes to it, causing attribute write failures. + The override is done in the constructor body (not as a new property declaration) + to avoid a MATLAB "property already defined in superclass" error. + +--- + +### Change 2 — `ChannelNCOGainScales` disabled: AD9081 artifact, not supported on AD9084 + +`ChannelNCOGainScales` and all associated code were an artifact of the AD9081 +class from which this file was derived. The `channel_nco_gain_scale` IIO +attribute does not exist on the AD9084, so all references have been commented +out rather than removed, in case they are needed for reference: + +- **Property declaration** (in `properties` block): + ```matlab + % ChannelNCOGainScales = [1,1,1,1]; + ``` +- **Constructor initialization**: + ```matlab + % obj.ChannelNCOGainScales = ones(1,obj.num_fine_attr_channels); + ``` +- **Property setter** (`set.ChannelNCOGainScales`): + ```matlab + % function set.ChannelNCOGainScales(obj, value) + % obj.CheckAndUpdateHWFloat(value,'ChannelNCOGainScales',... + % 'channel_nco_gain_scale', obj.combinedDev, false); + % obj.ChannelNCOGainScales = value; + % end + ``` +- **`setupInit` bulk write**: + ```matlab + % obj.CheckAndUpdateHWFloat(obj.ChannelNCOGainScales,... + % 'ChannelNCOGainScales','channel_nco_gain_scale', ... + % combinedDev, false); + ``` + +--- + +### Change 3 — `setupInit`: IIO device routing and `isOutput` flag +**`setupInit` method** +- Before: All attribute writes used `obj.phyDev` (= `axi-ad9084-tx-hpc`) with + `isOutput = true`. +- After: All attribute writes use `combinedDev` (= `axi-ad9084-rx-hpc`) with + `isOutput = false`. +- Reason (two separate issues): + + **Issue A — Wrong device:** + All NCO/gain/enable channel attributes (`out_voltage*_channel_nco_*` etc.) live + on the combined `axi-ad9084-rx-hpc` IIO device, which hosts BOTH `in_voltage*` + (RX) and `out_voltage*` (TX) sysfs channel attributes. The `axi-ad9084-tx-hpc` + device is the DMA/DDS transport layer only and does not expose these attributes. + + **Issue B — Inverted `isOutput` flag:** + The `iio_device_find_channel` wrapper in this MATLAB libiio binding has INVERTED + `isOutput` logic (marked `%FIXME` in `+adi/+common/Attribute.m`): + - Passing `true` → finds INPUT channels (`in_voltage*`) + - Passing `false` → finds OUTPUT channels (`out_voltage*`) + Since `channel_nco_gain_scale` only exists on TX output channels, passing `true` + was always targeting the wrong channel and causing the attribute write to fail. + NCO frequency/phase attributes exist on both in/out channels so those failures + were masked until `channel_nco_gain_scale` was reached. + +--- + +### Change 4 — PFIR and CFIR support added +Mirrors the same additions made to `Rx.m` (see Rx.m section below). + +**Properties added:** +```matlab +% PFIR +EnablePFIRs = false; % (Nontunable, Logical) +PFIRFilenames = ''; % (Nontunable) +% CFIR +EnableCFIRs = false; % (Nontunable, Logical) +CFIRFilenames = ''; % (Nontunable) +``` + +**Set-method validators added:** +- `set.EnablePFIRs` — validates logical input +- `set.PFIRFilenames` — stores filename, calls `writePFIRFile()` if already connected +- `set.EnableCFIRs` — validates logical input +- `set.CFIRFilenames` — stores filename, calls `writeCFIRFile()` if already connected + +**Protected methods added:** +- `writePFIRFile()` — reads `PFIRFilenames` and writes contents to the `pfilt_config` + device attribute over libiio +- `writeCFIRFile()` — reads `CFIRFilenames` and writes contents to the `cfir_config` + device attribute over libiio + +**`setupInit` extended:** +```matlab +if obj.EnablePFIRs + obj.writePFIRFile(); +end +if obj.EnableCFIRs + obj.writeCFIRFile(); +end +``` +Added before the DDS block so filters are programmed at connection time when `tx()` +is first called. + +--- + +### Change 5 — NCO property setters: route to correct IIO device at runtime + +**All 6 NCO property setters** + +Change 3 fixed `setupInit` to write NCO attributes to the correct device +(`axi-ad9084-rx-hpc`) at connection time. However, the runtime property setters +— called when the user changes an NCO property *after* the object is already +connected — still targeted `obj.phyDev` (`axi-ad9084-tx-hpc`) with +`isOutput = true`. This meant any post-setup NCO update would silently write to +the wrong device. + +**New property added:** +```matlab +properties (Nontunable, Hidden) + ... + combinedDev % axi-ad9084-rx-hpc (NCO/PHY attrs for both RX and TX) +end +``` + +**`setupInit` now persists the handle:** +```matlab +combinedDev = getDev(obj, 'axi-ad9084-rx-hpc'); +obj.combinedDev = combinedDev; +obj.phyDev = getDev(obj, obj.phyDevName); % tx-hpc (DDS/DMA) +``` + +**All active NCO setters updated:** +- `set.ChannelNCOFrequencies` +- `set.MainNCOFrequencies` +- `set.ChannelNCOPhases` +- `set.MainNCOPhases` +- `set.NCOEnables` + +Note: `set.ChannelNCOGainScales` was also updated during this change but has +since been commented out entirely — see Change 2. + +Each changed from: +```matlab +obj.CheckAndUpdateHW(value, ..., obj.phyDev, true); +``` +To: +```matlab +obj.CheckAndUpdateHW(value, ..., obj.combinedDev, false); +``` + +This ensures runtime NCO updates are consistent with `setupInit` — targeting +`axi-ad9084-rx-hpc` with the correct inverted `isOutput` flag. + +**Device handle summary:** + +| Handle | IIO Device | Used for | +|-------------------|-----------------------|-----------------------------------------| +| `obj.phyDev` | `axi-ad9084-tx-hpc` | DDS tone control, TX DMA | +| `obj.combinedDev` | `axi-ad9084-rx-hpc` | NCO freq/phase/gain/enable (TX and RX) | + +--- + +### Change 6 — `num_dds_channels` corrected from 32 to 16 + +**Hidden property `num_dds_channels`** + +- Before: `num_dds_channels = 32` +- After: `num_dds_channels = 16` +- Reason: The constructor derives DDS array sizes and channel name lists from + this value (`l = num_dds_channels/2` → `DDSFrequencies = zeros(2,l)`). + With 32, `DDSUpdate` iterated `altvoltage0`–`altvoltage31`. The AD9084 FPGA + DDS core only exposes 16 DDS channels (`altvoltage0`–`altvoltage15`), one + pair of tones per TX I/Q channel (4 channels × 2 tones × 2 I/Q = 16). + This caused a hard error at `altvoltage16`: + ``` + Error using matlabshared.libiio.base/cstatusid + Channel: altvoltage16 not found. + ``` + The value 32 was inherited from the AD9081 class, which has 8 TX data + channels rather than 4. + +--- + +## Rx.m (new) vs Rx1.m (original) + +`Rx1.m` is the original unmodified Rx class. `Rx.m` is the updated version used by +`filter_demo.m`. The filename `Rx` takes precedence in MATLAB's package resolution, +so `adi.AD9084.Rx` will always resolve to `Rx.m`. + +### Difference 1 — CFIR properties added +**Present in Rx.m, absent in Rx1.m** +```matlab +properties (Nontunable, Logical) + EnableCFIRs = false; +end +properties (Nontunable) + CFIRFilenames = ''; +end +``` +- Reason: Adds user-facing controls to enable the CFIR filter and specify the + coefficient file path, matching the existing PFIR pattern. + +### Difference 2 — CFIR set-method validators added +**Present in Rx.m, absent in Rx1.m** +```matlab +function set.EnableCFIRs(obj, value) ... end +function set.CFIRFilenames(obj, value) ... end +``` +- Reason: Validates inputs and triggers `writeCFIRFile()` immediately if already + connected to hardware (same pattern as `set.PFIRFilenames`). + +### Difference 3 — `writeFilterFile` renamed to `writePFIRFile` +**Rx1.m:** `function writeFilterFile(obj)` +**Rx.m:** `function writePFIRFile(obj)` +- Reason: Renamed for clarity to distinguish it from the new `writeCFIRFile`. + The `set.PFIRFilenames` setter was updated to call `obj.writePFIRFile()` accordingly. + +### Difference 4 — `writeCFIRFile` method added +**Present in Rx.m, absent in Rx1.m** +```matlab +function writeCFIRFile(obj) + % reads CFIRFilenames and writes contents to 'cfir_config' device attribute +end +``` +- Reason: Sends the CFIR coefficient file to the hardware over libiio using the + `cfir_config` sysfs attribute, same mechanism as PFIR uses `pfilt_config`. + +### Difference 5 — `setupInit` CFIR block added +**Present in Rx.m, absent in Rx1.m** +```matlab +if obj.EnableCFIRs + obj.writeCFIRFile(); +end +``` +- Reason: Ensures the CFIR filter is programmed to hardware at connection time + (i.e. when `rx()` is first called), after the PFIR block. + +--- + +## filter_demo.m + +Demonstrates a complete AD9084 RX/TX configuration workflow with filter design and application. The script: + +1. **Creates FIR filters** — Designs three example filters (Low-Pass, Band-Pass, High-Pass) using `fir1()` with configurable tap counts and cutoff frequencies +2. **Visualizes filters** — Optionally displays filter magnitude/phase responses using `fvtool` (toggled via `filtView` switch) +3. **Instantiates filter classes** — Uses the new `adi.AD9084.PFilt` and `adi.AD9084.CFIR` classes to wrap coefficients and generate configuration files (`pfir_auto.txt`, `cfir_auto.txt`) +4. **Creates RX object** — Configures an AD9084 receiver with CFIR filter enabled, NCO tuning, and sample frame settings +5. **Creates TX object** — Configures an AD9084 transmitter with DDS tone generation, NCO settings, and gain scaling + + +--- + +## PFilt.m + +### Change 1 — Corrected `gain` and `scalar_gain` valid ranges per AD9084 UG + +**`buildCatalogs` in `PFilt.m`** + +**`gain` (shift gain):** +- Before: `{'0','6','12','18','24','-24','-18','-12'}` (included unsupported negative values) +- After: `{'0','6','12','18','24'}` +- Reason: Per the AD9084 User Guide, the shift gain block supports 0dB to 24dB in + 6dB steps only. Negative dB values are not valid on this hardware. + +**`scalar_gain`:** +- Before: `{'0','6','12','18','24','-24','-18','-12'}` (incorrect — was copied from gain) +- After: `{'0','1','2', ..., '64'}` (integers 0–64, generated via `arrayfun(@num2str, 0:64, ...)`) +- Reason: Per the AD9084 UG, the scalar gain is a 6-bit unsigned integer representing + a fractional multiplier N/64. Value 0 = silence (0/64), value 64 = unity (64/64 = 1). + NOTE: Maximum scalar gain (64) and maximum shift gain (24dB) cannot be used + simultaneously. Maximum achievable combined gain is (63/64) × 24dB. + +Comments were also added to `buildCatalogs` citing the AD9084 UG. + +### Change 2 — Fixed `real_data_mode_en` default and `real_n4` auto-inference + +**Bug 1 — `real_data_mode_en` default mismatch (`defaultParams`)** +- Before: `'real_data_mode_en', 0` +- After: `'real_data_mode_en', 1` +- Reason: The constructor argument default was already `= 1`, and all working + pfir_auto.txt files show `real_data_mode_en: 1`. The `defaultParams` value of `0` + was inconsistent and would produce incorrect filter files when params were + rebuilt from defaults. + +**Bug 2 — Auto mode inference used `real_n2` for 17–32 tap filters** +- Before: both the 9–16 and 17–32 tap branches set `toks = ["real_n2","real_n2"]` +- After: the 17–32 tap branch now sets `toks = ["real_n4","real_n4"]` +- Reason: `real_n2` has N=16 max taps; a filter with 17–32 taps would pass + auto-inference then immediately fail tap-length validation. `real_n4` (N=32) + is the correct mode for that range. + +**Cleanup — Dead code removed from `finalizeHeaderTokens`** +- `profTok` was computed but never used (the dest was always hardcoded to + `"rx pfilt_all bank_0"`). Removed the dead `prof`/`profTok` logic and added + a comment clarifying that PFIR dest does not use profile tokens (unlike CFIR). + +--- + +## CFIR.m + +### Change 1 — Corrected `gain` valid range per AD9084 UG + +**`buildCatalogs` in `CFIR.m`** + +- Before: `{'0','6','12','18','24','-24','-18','-12'}` +- After: `{'-18','-12','-6','0','6','12'}` +- Reason: Per the AD9084 UG (Table 112 / CFIR section): "a gain adjustment block can + be used to adjust the gain between -18 to +12 dB in 6 dB steps." The previous range + was incorrect (copied from an unrelated source). + +### Change 2 — Added `sparse_mode` parameter + +**Constructor `options`, `defaultParams`, and `previewHeader`/`write`** + +The AD9084 UG describes two CFIR operation modes: +- **Normal mode**: 16-tap complex FIR filter (`sparse_mode = 0`, default) +- **Sparse mode**: Up to 128 taps with only 16 non-zero taps, selectable anywhere + in the impulse response (`sparse_mode = 1`). Useful for compensating long cable + echoes without increasing non-zero tap count. + +Added `sparse_mode` as a new boolean constructor parameter (0 or 1): +```matlab +cf = adi.AD9084.CFIR(taps, 'sparse_mode', 1); % enable sparse mode +``` + +The field is written to the filter config file header as `sparse_mode: 0` or +`sparse_mode: 1`. + +NOTE: `selection_mode: direct_regmap` is retained and is unrelated to CFIR sparse +mode — it controls NCO channel selection hopping (per UG: Direct SPI/HSCI profile +select). The two fields are independent. + +### Change 3 — All five `selection_mode` options added + +`buildCatalogs` previously only allowed `{'direct_regmap'}`. All five modes from the +AD9084 UG (`adi_apollo_cfir_profile_sel_mode_set` enum) are now valid: +- `direct_regmap` — Immediate hop via SPI write (default) +- `direct_gpio` — Immediate hop on GPIO edge +- `trig_regmap` — Scheduled hop via SPI, fires on next trigger +- `trig_gpio` — Scheduled hop via GPIO, fires on next trigger +- `trig_auto` — Automatic increment/decrement through profiles on trigger + +### Change 4 — Tap count validation added + +Constructor now validates `numel(taps)` before any other processing: +- Normal mode (`sparse_mode = 0`): max 16 taps +- Sparse mode (`sparse_mode = 1`): max 128 taps + +An `error()` is raised immediately with a clear message if the count is exceeded. + +### Change 5 — `complex_scalar` range validation added + +`validateAll` now checks that both components of `complex_scalar` are integers in +`[-32768, 32767]` (16-bit signed), per the UG definition of `scalar_i`/`scalar_q`. +Previously any numeric pair would pass through silently. + +### Change 6 — Removed dead `ingestParams` method + +The `ingestParams` private method was never called anywhere in the class. Removed. + +--- + +## Base.m + +No changes made. + diff --git a/+adi/+AD9084/PFilt.m b/+adi/+AD9084/PFilt.m new file mode 100644 index 00000000..1930da95 --- /dev/null +++ b/+adi/+AD9084/PFilt.m @@ -0,0 +1,347 @@ +classdef PFilt + % AD9084PFIR + % - Encapsulates PFIR-specific catalogs, defaults, validation, and file output. + % - Constructor accepts taps and Name-Value pairs (editor will suggest names). + % - Infers PFIR mode(s) from taps length if 'mode' is empty. + % - Always outputs HEX coefficients by calling FIRcoeff + + properties + taps (:,1) double + + %mode PFIR Filter Mode + % Determines the PFIR operating mode and max taps per path. + % Options: 'matrix', 'half_complex', 'real_n2', 'real_n4', 'disabled' + % If empty, mode is inferred from tap length. + mode (1,1) string = "" + + %gain Shift Gain (dB) + % Programmable gain block at the PFIR output. Ranges from + % 0 dB to 24 dB in 6 dB steps. Applied after FIR filtering. + gain (1,1) string {mustBeMember(gain, ["0","6","12","18","24"])} = "0" + + %scalar_gain Scalar Gain + % 6-bit unsigned integer (0 to 63). Represents a fractional + % multiplier of N/64. 0 = silence, 63 = unity. Max scalar + % gain (63) and max shift gain (24 dB) cannot be used together. + scalar_gain (1,1) string = "0" + + %dest Destination + % Header destination string for the filter file. + % If empty, auto-built as 'rx pfilt_all bank_0'. + dest (1,1) string = "" + + %hc_delay Half-Complex Delay + % Delay setting for half-complex mode. Options: '0','1','2','3' + hc_delay (1,1) string {mustBeMember(hc_delay, ["0","1","2","3"])} = "0" + + %mode_switch_en Mode Switch Enable + % Enable dynamic mode switching between PFIR profiles. 0 or 1. + mode_switch_en (1,1) double {mustBeMember(mode_switch_en, [0 1])} = 0 + + %mode_switch_add_en Mode Switch Add Enable + % Enable additive mode switching. 0 or 1. + mode_switch_add_en (1,1) double {mustBeMember(mode_switch_add_en, [0 1])} = 0 + + %real_data_mode_en Real Data Mode Enable + % When 1, PFIR operates on real data. When 0, complex data. Default 1. + real_data_mode_en (1,1) double {mustBeMember(real_data_mode_en, [0 1])} = 1 + + %quad_mode_en Quadrature Mode Enable + % Enable quadrature (I/Q correction) mode. 0 or 1. + quad_mode_en (1,1) double {mustBeMember(quad_mode_en, [0 1])} = 0 + + %repeatCount Repeat Count + % Number of times to replicate gain/scalar_gain values in the + % header when a scalar value is provided. Matches path count. + repeatCount (1,1) double = 4 + + %profile Profile Number + % Profile index used to auto-build the 'dest' header token. + profile (1,1) double = 2 + + modeTokens (1,2) string + end + + properties (SetAccess=private) + TapLength (1,1) double + end + + properties (Access=private) + validOptions struct + modeDefaults struct + end + + methods + function obj = PFilt(taps, options) + arguments + taps (:,1) double + + options.mode (1,1) string = "" + options.gain (1,1) string {mustBeMember(options.gain, ["0","6","12","18","24"])} = "0" + options.scalar_gain (1,1) string = "0" + options.dest (1,1) string = "" + options.hc_delay (1,1) string {mustBeMember(options.hc_delay, ["0","1","2","3"])} = "0" + options.mode_switch_en (1,1) double {mustBeMember(options.mode_switch_en, [0 1])} = 0 + options.mode_switch_add_en (1,1) double {mustBeMember(options.mode_switch_add_en, [0 1])} = 0 + options.real_data_mode_en (1,1) double {mustBeMember(options.real_data_mode_en,[0 1])} = 1 + options.quad_mode_en (1,1) double {mustBeMember(options.quad_mode_en, [0 1])} = 0 + options.repeatCount (1,1) double = 4 + options.profile (1,1) double = 2 + end + + obj.taps = taps(:); + + % Build catalogs (PFIR) + [obj.validOptions, obj.modeDefaults] = obj.buildCatalogs(); + + % Assign properties + obj.mode = options.mode; + obj.gain = options.gain; + obj.scalar_gain = options.scalar_gain; + obj.dest = options.dest; + obj.hc_delay = options.hc_delay; + obj.mode_switch_en = options.mode_switch_en; + obj.mode_switch_add_en = options.mode_switch_add_en; + obj.real_data_mode_en = options.real_data_mode_en; + obj.quad_mode_en = options.quad_mode_en; + obj.repeatCount = options.repeatCount; + obj.profile = options.profile; + + % Fill in dest if empty + obj = obj.finalizeHeaderTokens(); + + % Infer / finalize mode tokens, ensure tap-length fit + [obj.modeTokens, obj.mode, obj.TapLength] = ... + obj.inferModesAndTapLength(obj.taps, obj.mode); + + % Validate scalar_gain range + obj.validateScalarGain(); + end + + function outfile = write(obj, outfile) + arguments + obj + outfile (1,1) string + end + + [hexI, ~] = FIRcoeff(obj.taps); + + headerLines = obj.buildHeaderLines(); + + fid = fopen(outfile, 'w'); + if fid < 0, error('Cannot open file for writing: %s', outfile); end + cleaner = onCleanup(@() fclose(fid)); + + for i = 1:numel(headerLines) + fprintf(fid, '%s\n', headerLines(i)); + end + + for i = 1:size(hexI, 1) + fprintf(fid, '0x%s\n', hexI(i,:)); + end + end + + function lines = previewHeader(obj) + lines = obj.buildHeaderLines(); + end + + function [H, f] = response(obj, Fs, options) + % [H, f] = pf.response(Fs) % nominal (no LUT) + % [H, f] = pf.response(Fs, useLUT=true) % auto-find latest LUT + % [H, f] = pf.response(Fs, lutFile="path/to/gain_lut.m") + % pf.response(Fs) % no output args -> plots + arguments + obj + Fs (1,1) double + options.N (1,1) double = 1024 + options.useLUT (1,1) logical = false + options.lutFile (1,1) string = "" + end + + taps_q = round(obj.taps * 2^15) / 2^15; + + f_vec = linspace(-Fs/2, Fs/2, options.N).'; + [H_fir, ~] = freqz(taps_q, 1, f_vec, Fs); + + if options.useLUT || options.lutFile ~= "" + lut = obj.loadLUT(options.lutFile); + else + lut = struct(); + end + + if ~isempty(fieldnames(lut)) && isfield(lut, 'shift_gain_sweep') + programmed_gain = str2double(obj.gain); + shift_gain_dB = interp1( ... + lut.shift_gain_sweep.shift_gain_values_dB, ... + lut.shift_gain_sweep.gain_dB, ... + programmed_gain, 'linear', 'extrap'); + else + shift_gain_dB = str2double(obj.gain); + end + + if ~isempty(fieldnames(lut)) && isfield(lut, 'scalar_sweep') + programmed_scalar = str2double(obj.scalar_gain); + scalar_gain_dB = interp1( ... + lut.scalar_sweep.scalar_values, ... + lut.scalar_sweep.gain_dB, ... + programmed_scalar, 'linear', 'extrap'); + else + programmed_scalar = str2double(obj.scalar_gain); + scalar_gain_dB = 20*log10(programmed_scalar / 64 + eps); + end + + total_gain_linear = 10^(shift_gain_dB/20) * 10^(scalar_gain_dB/20); + H = H_fir * total_gain_linear; + f = f_vec; + + if nargout == 0 + H_dBFS = 20*log10(abs(H) / max(abs(H)) + eps); + figure('Name', 'PFilt Hardware Response'); + plot(f/1e9, H_dBFS, 'b-', 'LineWidth', 1.2); + hold on; grid on; + xlabel('Frequency (GHz)'); ylabel('Magnitude (dBFS)'); + title(sprintf('PFilt Response (gain=%s dB, scalar=%s)', ... + obj.gain, obj.scalar_gain)); + clear H f; + end + end + end + + methods (Access=private) + function lut = loadLUT(~, lutFile) + if lutFile ~= "" + [~, fname] = fileparts(lutFile); + addpath(fileparts(lutFile)); + lut = feval(fname); + return; + end + resultsRoot = fullfile(fileparts(mfilename('fullpath')), ... + '..', '..', '..', 'MATLAB', 'gain_study_results'); + if exist(resultsRoot, 'dir') + runs = dir(fullfile(resultsRoot, 'run_*')); + for k = numel(runs):-1:1 + candidate = fullfile(runs(k).folder, runs(k).name, 'gain_lut.m'); + if exist(candidate, 'file') + [~, fname] = fileparts(candidate); + addpath(fileparts(candidate)); + lut = feval(fname); + return; + end + end + end + lut = struct(); + end + + function [validOptions, modeDefaults] = buildCatalogs(~) + validOptions.pfir.modes = {'matrix','half_complex','real_n2','real_n4','disabled'}; + validOptions.pfir.scalar_gain = arrayfun(@num2str, 0:64, 'UniformOutput', false); + + modeDefaults.pfir.matrix = struct('N', 16); + modeDefaults.pfir.half_complex = struct('N', 16); + modeDefaults.pfir.real_n2 = struct('N', 16); + modeDefaults.pfir.real_n4 = struct('N', 32); + modeDefaults.pfir.disabled = struct('N', 16); + end + + function obj = finalizeHeaderTokens(obj) + if strlength(obj.dest) == 0 + obj.dest = "rx pfilt_all bank_0"; + end + end + + function [modeTokens, modeStr, N_eff] = inferModesAndTapLength(obj, taps, modeStr) + toks = obj.tokenizeModes(modeStr); + + if numel(toks)==0 + L = numel(taps); + if L <= 8 + toks = ["matrix","matrix"]; + elseif L <= 16 + toks = ["real_n2","real_n2"]; + elseif L <= 32 + toks = ["real_n4","real_n4"]; + else + error('Tap length %d exceeds PFIR maximum supported by defaults (N<=32).', L); + end + modeStr = strjoin(toks," "); + elseif numel(toks)==1 + toks = [toks, toks]; + modeStr = strjoin(toks," "); + elseif numel(toks)~=2 + error("Provide zero, one, or two mode tokens (one per path). Got: %s", strjoin(toks," ")); + end + + % Validate mode tokens + typeModes = string(obj.validOptions.pfir.modes); + if ~all(ismember(toks, typeModes)) + bad = toks(~ismember(toks, typeModes)); + error("Invalid PFIR mode token(s): %s. Allowed: %s", strjoin(bad,", "), strjoin(typeModes,", ")); + end + + try + N1 = obj.modeDefaults.pfir.(toks(1)).N; + N2 = obj.modeDefaults.pfir.(toks(2)).N; + catch + error("PFIR mode defaults not defined for token(s): %s", strjoin(toks," ")); + end + + N_eff = min([N1, N2]); + if numel(taps) > N_eff + error("Max possible taps across both PFIR paths is %d (path1 N=%d, path2 N=%d).", N_eff, N1, N2); + end + + modeTokens = toks; + end + + function validateScalarGain(obj) + allowed = string(obj.validOptions.pfir.scalar_gain); + toks = split(strtrim(obj.scalar_gain)); + toks = toks(toks ~= ""); + if ~all(ismember(toks, allowed)) + error("Invalid scalar_gain '%s'. Must be integer 0-64.", obj.scalar_gain); + end + end + + function lines = buildHeaderLines(obj) + rep = obj.repeatCount; + lines = [ + "mode: " + obj.normalizeList(obj.mode, []) + "gain: " + obj.normalizeList(obj.gain, rep) + "scalar_gain: " + obj.normalizeList(obj.scalar_gain, rep) + "dest: " + obj.normalizeList(obj.dest, []) + "hc_delay: " + string(obj.hc_delay) + "mode_switch_en: " + string(obj.mode_switch_en) + "mode_switch_add_en: " + string(obj.mode_switch_add_en) + "real_data_mode_en: " + string(obj.real_data_mode_en) + "quad_mode_en: " + string(obj.quad_mode_en) + ]; + end + + function s = normalizeList(~, val, repeatCount) + if nargin < 3, repeatCount = []; end + + txt = strtrim(string(val)); + if contains(txt, " ") + s = txt; + return; + end + list = txt; + + if ~isempty(repeatCount) && numel(list) == 1 + rep = double(repeatCount); + if isnan(rep) || rep < 1 + rep = 2; + end + list = repmat(list, 1, rep); + end + + s = strjoin(list, " "); + end + + function tokens = tokenizeModes(~, mode) + tokens = split(strtrim(string(mode))); + tokens = tokens(tokens ~= ""); + tokens = lower(tokens); + end + end +end diff --git a/+adi/+AD9084/Rx.m b/+adi/+AD9084/Rx.m index 9f8a3a3f..e00abc5c 100755 --- a/+adi/+AD9084/Rx.m +++ b/+adi/+AD9084/Rx.m @@ -17,7 +17,7 @@ % connected to hardware SamplingRate end - + properties %ChannelNCOFrequencies Channel NCO Frequencies % Frequency of NCO in fine decimators in receive path. Property @@ -53,6 +53,10 @@ JESD204FSMControl = '1'; end + % ======================= + % PFIR SUPPORT (existing) + % ======================= + properties (Nontunable, Logical) %EnablePFIRs Enable PFIRs % Enable use of PFIR/PFILT filters @@ -65,7 +69,17 @@ % cell array of strings. Files are loading in order PFIRFilenames = ''; end - + % ======================= + % CFIR SUPPORT (added) + % ======================= + properties (Nontunable, Logical) + EnableCFIRs = false; + end + + properties (Nontunable) + CFIRFilenames = ''; + end + properties (Hidden, Nontunable, Access = protected) isOutput = false; end @@ -184,16 +198,31 @@ function set.PFIRFilenames(obj, value) obj.PFIRFilenames = value; if obj.EnablePFIRs && obj.ConnectedToDevice - writeFilterFile(obj); + obj.writePFIRFile(); end end + + + % Enable CFIR + function set.EnableCFIRs(obj,value) + validateattributes(value,{'logical'},{}); + obj.EnableCFIRs = value; + end + % CFIR Filenames + function set.CFIRFilenames(obj,value) + obj.CFIRFilenames = value; + if obj.EnableCFIRs && obj.ConnectedToDevice + obj.writeCFIRFile(); + end + end + end %% API Functions methods (Hidden, Access = protected) - function writeFilterFile(obj) - % Read in filter files and write them sequentially into the + function writePFIRFile(obj) + % Read in pfir files and write them sequentially into the % attribute fir_data_files = obj.PFIRFilenames; if ~iscell(fir_data_files) @@ -206,7 +235,25 @@ function writeFilterFile(obj) error('Filter file %s does not exist',filename); end fir_data_str = fileread(filename); - obj.setDeviceAttributeRAW('filter_fir_config',fir_data_str); + obj.setDeviceAttributeRAW('pfilt_config',fir_data_str); + end + end + + function writeCFIRFile(obj) + % Read in pfir files and write them sequentially into the + % attribute + fir_data_files = obj.CFIRFilenames; + if ~iscell(fir_data_files) + fir_data_files = {fir_data_files}; + end + + for fir_data_file = fir_data_files + filename = fir_data_file{:}; + if ~exist(filename,'file') + error('Filter file %s does not exist',filename); + end + fir_data_str = fileread(filename); + obj.setDeviceAttributeRAW('cfir_config',fir_data_str); end end @@ -236,14 +283,21 @@ function setupInit(obj) obj.CheckAndUpdateHW(obj.MainNCOPhases,... 'MainNCOPhases','main_nco_phase', ... obj.iioDev); - %% + %% Program FIR Filters + % Program PFIR if obj.EnablePFIRs - obj.writeFilterFile(); + obj.writePFIRFile(); + end + + % Program CFIR + if obj.EnableCFIRs + obj.writeCFIRFile(); end %% obj.setAttributeRAW('voltage0_i','test_mode',obj.TestMode,... false,obj.iioDev); + end end diff --git a/+adi/+AD9084/Tx.m b/+adi/+AD9084/Tx.m index dd6a69a0..b74aef3e 100755 --- a/+adi/+AD9084/Tx.m +++ b/+adi/+AD9084/Tx.m @@ -1,12 +1,12 @@ -classdef Tx < adi.AD9081.Base & adi.common.Tx - % adi.AD9081.Tx Transmit data from the AD9081 development board - % The adi.AD9081.Tx System object is a signal sink that can tranmsit - % complex data from the AD9081. +classdef Tx < adi.AD9084.Base & adi.common.Tx + % adi.AD90084.Tx Transmit data from the AD90084 development board + % The adi.AD90084.Tx System object is a signal sink that can tranmsit + % complex data from the AD90084. % - % tx = adi.AD9081.Tx; - % tx = adi.AD9081.Tx('uri','ip:192.168.2.1'); + % tx = adi.AD90084.Tx; + % tx = adi.AD90084.Tx('uri','ip:192.168.2.1'); % - % AD9081 Datasheet + % AD9084 Datasheet properties %ChannelNCOFrequencies Channel NCO Frequencies @@ -33,13 +33,45 @@ % Frequency of NCO in fine decimators in transmit path. Property % must be a [1,N] vector where each value is the frequency of an % NCO in hertz. - ChannelNCOGainScales = [0,0,0,0]; + % ChannelNCOGainScales = [1,1,1,1]; %NCOEnables NCO Enables % Vector of logicals which enabled individual NCOs in channel % interpolators NCOEnables = [false,false,false,false]; end - + + % ======================= + % PFIR SUPPORT + % ======================= + properties (Nontunable, Logical) + %EnablePFIRs Enable PFIRs + % Enable use of PFIR/PFILT filters on transmit path + EnablePFIRs = false; + end + + properties (Nontunable) + %PFIRFilenames PFIR File names + % Path(s) to PFIR/PFILT filter file(s). Input can be a string or + % cell array of strings. Files are loaded in order + PFIRFilenames = ''; + end + + % ======================= + % CFIR SUPPORT + % ======================= + properties (Nontunable, Logical) + %EnableCFIRs Enable CFIRs + % Enable use of CFIR filters on transmit path + EnableCFIRs = false; + end + + properties (Nontunable) + %CFIRFilenames CFIR File names + % Path(s) to CFIR filter file(s). Input can be a string or + % cell array of strings. Files are loaded in order + CFIRFilenames = ''; + end + properties (Hidden, Nontunable, Access = protected) isOutput = true; end @@ -57,16 +89,18 @@ num_data_channels = 4; num_coarse_attr_channels = 4; num_fine_attr_channels = 4; - num_dds_channels = 32; - devName = 'axi-ad9081-tx-hpc'; - phyDev + num_dds_channels = 16; + devName = 'axi-ad9084-tx-hpc'; + phyDev % axi-ad9084-tx-hpc (DDS/DMA) + combinedDev % axi-ad9084-rx-hpc (NCO/PHY attrs for both RX and TX) end methods %% Constructor function obj = Tx(varargin) coder.allowpcode('plain'); - obj = obj@adi.AD9081.Base(varargin{:}); + obj = obj@adi.AD9084.Base(varargin{:}); + obj.phyDevName = 'axi-ad9084-tx-hpc'; obj.channel_names = {}; for k = 0:(obj.num_data_channels-1) obj.channel_names = [obj.channel_names(:)', ... @@ -88,55 +122,101 @@ obj.MainNCOFrequencies = zeros(1,obj.num_coarse_attr_channels); obj.ChannelNCOPhases = zeros(1,obj.num_fine_attr_channels); obj.MainNCOPhases = zeros(1,obj.num_coarse_attr_channels); - obj.ChannelNCOGainScales = zeros(1,obj.num_fine_attr_channels); obj.NCOEnables = zeros(1,obj.num_fine_attr_channels) > 0; end % Check ChannelNCOFrequencies function set.ChannelNCOFrequencies(obj, value) obj.CheckAndUpdateHW(value,'ChannelNCOFrequencies',... - 'channel_nco_frequency', obj.phyDev, true); %#ok<*MCSUP> + 'channel_nco_frequency', obj.combinedDev, false); %#ok<*MCSUP> obj.ChannelNCOFrequencies = value; end %% % Check MainNCOFrequencies function set.MainNCOFrequencies(obj, value) obj.CheckAndUpdateHW(value,'MainNCOFrequencies',... - 'main_nco_frequency', obj.phyDev, true); + 'main_nco_frequency', obj.combinedDev, false); obj.MainNCOFrequencies = value; end %% % Check ChannelNCOPhases function set.ChannelNCOPhases(obj, value) obj.CheckAndUpdateHW(value,'ChannelNCOPhases',... - 'channel_nco_phase', obj.phyDev, true); + 'channel_nco_phase', obj.combinedDev, false); obj.ChannelNCOPhases = value; end %% % Check MainNCOPhases function set.MainNCOPhases(obj, value) obj.CheckAndUpdateHW(value,'MainNCOPhases',... - 'main_nco_phase', obj.phyDev, true); + 'main_nco_phase', obj.combinedDev, false); obj.MainNCOPhases = value; end %% - % Check ChannelNCOGainScales - function set.ChannelNCOGainScales(obj, value) - obj.CheckAndUpdateHWFloat(value,'ChannelNCOGainScales',... - 'channel_nco_gain_scale', obj.phyDev, true); - obj.ChannelNCOGainScales = value; - end - %% % Check NCOEnables function set.NCOEnables(obj, value) obj.CheckAndUpdateHWBool(value,'NCOEnables',... - 'en', obj.phyDev, true); + 'en', obj.combinedDev, false); obj.NCOEnables = value; end + % Check EnablePFIRs + function set.EnablePFIRs(obj, value) + validateattributes(value, {'logical'}, {}, '', 'EnablePFIRs'); + obj.EnablePFIRs = value; + end + % Check PFIRFilenames + function set.PFIRFilenames(obj, value) + obj.PFIRFilenames = value; + if obj.EnablePFIRs && obj.ConnectedToDevice + obj.writePFIRFile(); + end + end + % Check EnableCFIRs + function set.EnableCFIRs(obj, value) + validateattributes(value, {'logical'}, {}); + obj.EnableCFIRs = value; + end + % Check CFIRFilenames + function set.CFIRFilenames(obj, value) + obj.CFIRFilenames = value; + if obj.EnableCFIRs && obj.ConnectedToDevice + obj.writeCFIRFile(); + end + end end %% API Functions methods (Hidden, Access = protected) - + + function writePFIRFile(obj) + fir_data_files = obj.PFIRFilenames; + if ~iscell(fir_data_files) + fir_data_files = {fir_data_files}; + end + for fir_data_file = fir_data_files + filename = fir_data_file{:}; + if ~exist(filename,'file') + error('Filter file %s does not exist', filename); + end + fir_data_str = fileread(filename); + obj.setDeviceAttributeRAW('pfilt_config', fir_data_str); + end + end + + function writeCFIRFile(obj) + fir_data_files = obj.CFIRFilenames; + if ~iscell(fir_data_files) + fir_data_files = {fir_data_files}; + end + for fir_data_file = fir_data_files + filename = fir_data_file{:}; + if ~exist(filename,'file') + error('Filter file %s does not exist', filename); + end + fir_data_str = fileread(filename); + obj.setDeviceAttributeRAW('cfir_config', fir_data_str); + end + end + function setupInit(obj) % Write all attributes to device once connected through set % methods @@ -147,33 +227,44 @@ function setupInit(obj) % Enable TX DMA offload % obj.setDebugAttributeBool('pl_ddr_fifo_enable', 1, getDev(obj, obj.devName)); - % Set main PHY - obj.phyDev = getDev(obj, obj.phyDevName); + % NCO frequency/phase/gain/enable attributes all live on the + % combined PHY device (axi-ad9084-rx-hpc), which hosts both + % in_voltage* (RX) and out_voltage* (TX) channel attributes. + % NOTE: iio_device_find_channel has inverted isOutput logic in + % this binding (see %FIXME in Attribute.m), so pass false to + % target output (TX) channels. + combinedDev = getDev(obj, 'axi-ad9084-rx-hpc'); + obj.combinedDev = combinedDev; + obj.phyDev = getDev(obj, obj.phyDevName); % tx-hpc (DDS/DMA) %% obj.CheckAndUpdateHW(obj.ChannelNCOFrequencies,... 'ChannelNCOFrequencies','channel_nco_frequency', ... - obj.phyDev, true); + combinedDev, false); %% obj.CheckAndUpdateHW(obj.MainNCOFrequencies,... 'MainNCOFrequencies','main_nco_frequency', ... - obj.phyDev, true); + combinedDev, false); %% obj.CheckAndUpdateHW(obj.ChannelNCOPhases,... 'ChannelNCOPhases','channel_nco_phase', ... - obj.phyDev, true); + combinedDev, false); %% obj.CheckAndUpdateHW(obj.MainNCOPhases,... 'MainNCOPhases','main_nco_phase', ... - obj.phyDev, true); - %% - obj.CheckAndUpdateHWFloat(obj.ChannelNCOGainScales,... - 'ChannelNCOGainScales','channel_nco_gain_scale', ... - obj.phyDev, true); + combinedDev, false); + %% obj.CheckAndUpdateHWBool(obj.NCOEnables,... 'NCOEnables','en', ... - obj.phyDev, true); + combinedDev, false); + %% Program FIR Filters + if obj.EnablePFIRs + obj.writePFIRFile(); + end + if obj.EnableCFIRs + obj.writeCFIRFile(); + end %% DDS obj.ToggleDDS(strcmp(obj.DataSource,'DDS')); if strcmp(obj.DataSource,'DDS') diff --git a/+adi/+AD9084/filter_compare.m b/+adi/+AD9084/filter_compare.m new file mode 100644 index 00000000..90706df8 --- /dev/null +++ b/+adi/+AD9084/filter_compare.m @@ -0,0 +1,106 @@ +function filter_compare(rx, filterObj, filterType, filterFile) +% filter_compare Before/after comparison with theoretical overlay. +% +% filter_compare(rx, filterObj, 'pfir', 'pfir_auto.txt') +% filter_compare(rx, filterObj, 'cfir', 'cfir_auto.txt') +% +% rx - AD9084.Rx object (must already be primed/streaming) +% filterObj - PFilt or CFIR object used for .response() overlay +% filterType - 'pfir' or 'cfir' +% filterFile - filename of the active filter to restore after reference + +arguments + rx + filterObj + filterType (1,1) string {mustBeMember(filterType, ["pfir","cfir"])} + filterFile (1,1) string +end + +Fs_rx = double(rx.SamplingRate); +NFFT = rx.SamplesPerFrame; +window = hann(NFFT, 'periodic'); +cg = sum(window) / 2; +f_axis = linspace(-Fs_rx/2, Fs_rx/2, NFFT) / 1e6; + +% --- Capture WITH filter active (current state) --- +for k = 1:5, data = rx(); end +x_filt = double(data(1:NFFT, 1)) / 32768; +X_filt = fftshift(fft(x_filt .* window, NFFT)); +mag_filt = 20*log10(abs(X_filt) / cg + eps); + +% --- Load reference and capture --- +if filterType == "pfir" + adi.AD9084.writeDisabledFilter('pfir_disabled_ref.txt', 'pfir'); + release(rx); + rx.PFIRFilenames = 'pfir_disabled_ref.txt'; + rx(); + refLabel = 'Disabled'; +else + ap_taps_ref = zeros(16, 1); ap_taps_ref(ceil(16/2)) = 1.0; + cf_ap = adi.AD9084.CFIR(ap_taps_ref, 'gain', "0", 'complex_scalar', 32767+0i); + cf_ap.write('cfir_allpass_ref.txt'); + release(rx); + rx.CFIRFilenames = 'cfir_allpass_ref.txt'; + rx(); + refLabel = 'All-Pass'; +end + +for k = 1:5, data = rx(); end +x_ref = double(data(1:NFFT, 1)) / 32768; +X_ref = fftshift(fft(x_ref .* window, NFFT)); +mag_ref = 20*log10(abs(X_ref) / cg + eps); + +% --- Restore original filter --- +release(rx); +if filterType == "pfir" + rx.PFIRFilenames = filterFile; +else + rx.CFIRFilenames = filterFile; +end +rx(); + +% --- Theoretical response --- +% PFIR operates at full ADC rate (20 GHz), CFIR at decimated rate. +% The observable window is centered at (MainNCO + ChannelNCO) in the +% PFIR's 20 GHz domain, not at DC. +if filterType == "pfir" + Fs_filter = 20e9; + nco_center = rx.MainNCOFrequencies(1) + rx.ChannelNCOFrequencies(1); +else + Fs_filter = Fs_rx; + nco_center = 0; +end +N_theory = 8192; +[H, f] = filterObj.response(Fs_filter, N=N_theory); +mag_theory = 20*log10(abs(H) / max(abs(H)) + eps); + +% Crop theoretical to the observable window centered at NCO offset +obs_lo = nco_center - Fs_rx/2; +obs_hi = nco_center + Fs_rx/2; +obs_mask = (f >= obs_lo) & (f <= obs_hi); +f_obs = f(obs_mask) - nco_center; % shift to baseband for overlay +mag_theory_obs = mag_theory(obs_mask); + +% --- Plot --- +typeUpper = upper(filterType); +measured_delta = mag_filt - mag_ref; + +figure('Name', sprintf('%s Before/After Comparison', typeUpper), ... + 'Position', [100 100 1000 550]); + +subplot(2,1,1); +plot(f_axis, mag_ref, 'k-', 'LineWidth', 0.8); hold on; +plot(f_axis, mag_filt, 'b-', 'LineWidth', 1.0); +legend(refLabel, sprintf('With %s', typeUpper)); grid on; +xlabel('Frequency (MHz)'); ylabel('Magnitude (dBFS)'); +title(sprintf('Spectrum: Before vs After %s', typeUpper)); + +subplot(2,1,2); +plot(f_axis, measured_delta, 'r-', 'LineWidth', 0.8); hold on; +plot(f_obs/1e6, mag_theory_obs + max(measured_delta) - max(mag_theory_obs), 'm--', 'LineWidth', 1.2); +grid on; yline(0, 'k--'); +legend('Measured \Delta', 'Theoretical (.response)'); +xlabel('Frequency (MHz)'); ylabel('\Delta (dB)'); +title(sprintf('%s: Measured vs Theoretical (observable BW)', typeUpper)); + +end diff --git a/+adi/+AD9084/writeDisabledFilter.m b/+adi/+AD9084/writeDisabledFilter.m new file mode 100644 index 00000000..a5a539fa --- /dev/null +++ b/+adi/+AD9084/writeDisabledFilter.m @@ -0,0 +1,71 @@ +function writeDisabledFilter(filename, filter_type) +% writeDisabledFilter Write a filter config file that bypasses/disables a filter. +% +% writeDisabledFilter(filename, filter_type) +% +% filename : path to the output .txt file +% filter_type : 'pfir' — writes mode:disabled disabled (PFIR bypass) +% 'cfir' — writes bypass:1 with identity coefficients (CFIR bypass) +% +% PFIR: The AD9084 driver skips coefficient loading entirely when both +% I and Q FIR modes are set to 'disabled'. Used as a 0 dB reference. +% +% CFIR: Sets bypass:1 so the CFIR block is passed through unfiltered. +% +% Example: +% writeDisabledFilter('pfir_off.txt', 'pfir') +% writeDisabledFilter('cfir_off.txt', 'cfir') + + if nargin < 2, filter_type = 'pfir'; end + + if strcmpi(filter_type, 'pfir') + lines = { + 'mode: disabled disabled' + 'gain: 0 0 0 0' + 'scalar_gain: 0 0 0 0' + 'dest: rx pfilt_all bank_0' + 'hc_delay: 0' + 'mode_switch_en: 0' + 'mode_switch_add_en: 0' + 'real_data_mode_en: 1' + 'quad_mode_en: 0' + }; + elseif strcmpi(filter_type, 'cfir') + % bypass:1 instructs the driver to route data around the CFIR block. + % Coefficients are included but irrelevant when bypass is active. + zeroTap = '0x0000'; + unityTap = '0x4000'; % Q14 unity for centre tap + nTaps = 16; + midTap = ceil(nTaps / 2); + coeffLines = cell(nTaps, 1); + for k = 1:nTaps + if k == midTap + coeffLines{k} = sprintf('%s %s', unityTap, unityTap); + else + coeffLines{k} = sprintf('%s %s', zeroTap, zeroTap); + end + end + lines = [ + {'dest: rx cfir_all profile_2 datapath_all'} + {'gain: 0'} + {'complex_scalar: 32767 0'} + {'enable: 1 profile_2'} + {'selection_mode: direct_regmap'} + {'coeff_transfer: 0'} + {'bypass: 0'} + {'sparse_mode: 0'} + coeffLines + ]; + else + error('writeDisabledFilter: unknown filter_type "%s". Use ''pfir'' or ''cfir''.', filter_type); + end + + fid = fopen(filename, 'w'); + if fid < 0 + error('writeDisabledFilter: cannot write file: %s', filename); + end + for k = 1:numel(lines) + fprintf(fid, '%s\n', lines{k}); + end + fclose(fid); +end diff --git a/.gitignore b/.gitignore index 02778418..679ccc51 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,22 @@ **/slprj/** AD9361_Filter_Wizard/*TestFiltWiz*.m AD9361_Filter_Wizard/.previous_ip_addr + +# MATLAB auto-save and generated artifacts +*.asv +*.mat + +# AD9084 generated filter/calibration files ++adi/+AD9084/pfir_auto.txt ++adi/+AD9084/cfir_auto.txt ++adi/+AD9084/gain_study_*.txt ++adi/+AD9084/pfir_cal_*.txt ++adi/+AD9084/FIRcoeff.txt ++adi/+AD9084/pfilt_taps.txt ++adi/+AD9084/Rx.txt + +# Gain study results (generated measurement data) ++adi/+AD9084/gain_study_results/ ++adi/+AD9084/cfir_gain_study_results/ +hsx_examples/streaming/gain_study_results/ +hsx_examples/streaming/cfir_gain_study_results/ diff --git a/hsx_examples/streaming/cfir_gain_lut_study.m b/hsx_examples/streaming/cfir_gain_lut_study.m new file mode 100644 index 00000000..9c15db45 --- /dev/null +++ b/hsx_examples/streaming/cfir_gain_lut_study.m @@ -0,0 +1,307 @@ +%% cfir_gain_lut_study.m — Characterize CFIR gain stages and save gain LUT + +clear; clc; + +%% Configuration +uri = 'ip:192.168.2.1'; +N_TAPS = 16; +TAP_POS = ceil(N_TAPS / 2); +NFFT = 4096; +TONE_FREQ_HZ = 10e6; +TAP_FLOAT_FIXED = 2^12; + +% --- Toggles --- +DO_TAP_SWEEP = 1; +DO_TAP_SWEEP_FINE = 1; +DO_SHIFT_GAIN_SWEEP = 1; + +% --- Results output --- +SAVE_RESULTS = true; +RESULTS_ROOT = fullfile(fileparts(mfilename('fullpath')), 'cfir_gain_study_results'); + +% --- Tap sweep settings --- +SWEEP_N_PTS = 30; +SWEEP_N_MEAS = 50; +SWEEP_TAP_VALS = logspace(log10(0.1), log10(2^15), SWEEP_N_PTS); + +% --- Fine tap sweep --- +SWEEP_FINE_N_PTS = 60; +SWEEP_FINE_N_MEAS = 50; +SWEEP_FINE_TAP_LO = 0.3; +SWEEP_FINE_TAP_HI = 2.5; + +% --- Shift gain sweep settings --- +SHIFT_GAIN_N_MEAS = 50; + +%% Set up results folder +if SAVE_RESULTS + if ~exist(RESULTS_ROOT, 'dir'), mkdir(RESULTS_ROOT); end + existing = dir(fullfile(RESULTS_ROOT, 'run_*')); + run_num = numel(existing) + 1; + RUN_DIR = fullfile(RESULTS_ROOT, sprintf('run_%03d', run_num)); + mkdir(RUN_DIR); + fprintf('Results will be saved to: %s\n', RUN_DIR); +end + +%% Write reference CFIR all-pass filter file +% All-pass = unity center tap, gain=0, complex_scalar=32767+0i +allpass_taps = zeros(N_TAPS, 1); +allpass_taps(TAP_POS) = 1.0; +cf_ref = adi.AD9084.CFIR(allpass_taps, 'gain', "0", 'complex_scalar', 32767+0i); +cf_ref.write(fullfile(RUN_DIR, 'cfir_gain_study_allpass.txt')); + +% Disable PFIR so it doesn't color the measurement +adi.AD9084.writeDisabledFilter(fullfile(RUN_DIR, 'cfir_gain_study_pfir_off.txt'), 'pfir'); + +%% Configure TX (identical to PFIR gain_study) +tx = adi.AD9084.Tx('uri', uri); + +tx.EnabledChannels = 1; +tx.SamplesPerFrame = 16384; +tx.DataSource = 'DDS'; +tx.MainNCOFrequencies = [1e9 0 0 0]; +tx.ChannelNCOFrequencies = [100e6 0 0 0]; +tx.MainNCOPhases = [0 0 0 0]; +tx.ChannelNCOPhases = [0 0 0 0]; +tx.NCOEnables = [true false false false]; +tx.DDSFrequencies = [TONE_FREQ_HZ, TONE_FREQ_HZ; 0, 0]; +tx.DDSScales = [.5, .5; 0, 0]; +tx.DDSPhases = [0, 90000; 0, 0]; +tx(); + +%% Configure RX — start with CFIR all-pass (reference) +rx = adi.AD9084.Rx('uri', uri); + +rx.EnabledChannels = 1; +rx.SamplesPerFrame = 16384; +rx.EnablePFIRs = true; +rx.PFIRFilenames = fullfile(RUN_DIR, 'cfir_gain_study_pfir_off.txt'); +rx.EnableCFIRs = true; +rx.CFIRFilenames = fullfile(RUN_DIR, 'cfir_gain_study_allpass.txt'); +rx.MainNCOFrequencies = [1e9 0 0 0]; +rx.ChannelNCOFrequencies = [100e6 0 0 0]; +rx.TestMode = 'off'; +rx(); + +%% Capture all-pass reference snapshot +window = hann(NFFT, 'periodic'); +cg = sum(window) / 2; +Fs = double(rx.SamplingRate); + +data = rx(); +x = double(data(1:NFFT, 1)) / 32768; +X = fftshift(fft(x .* window, NFFT)); +ref_mag = 20*log10(abs(X) / cg + eps); +ref_peak = max(ref_mag); +f_bins = linspace(-Fs/2, Fs/2, NFFT) / 1e6; + +fprintf('CFIR all-pass reference peak: %.2f dBFS\n', ref_peak); + +%% Tap sweep +if DO_TAP_SWEEP + fprintf('\nCFIR Tap sweep: %d values from %.2f to %.0f ...\n', ... + SWEEP_N_PTS, SWEEP_TAP_VALS(1), SWEEP_TAP_VALS(end)); + sweep_gains = nan(SWEEP_N_PTS, 1); + sweep_stds = nan(SWEEP_N_PTS, 1); + + for si = 1:SWEEP_N_PTS + tv = SWEEP_TAP_VALS(si); + t = zeros(N_TAPS, 1); t(TAP_POS) = tv; + cf_sw = adi.AD9084.CFIR(t, 'gain', "0", 'complex_scalar', 32767+0i); + cf_sw.write(fullfile(RUN_DIR, 'cfir_gain_study_sweep_tmp.txt')); + + release(rx); + rx.CFIRFilenames = fullfile(RUN_DIR, 'cfir_gain_study_sweep_tmp.txt'); + rx(); + + peaks = nan(SWEEP_N_MEAS, 1); + for m = 1:SWEEP_N_MEAS + d = rx(); + xm = double(d(1:NFFT,1)) / 32768; + Xm = fftshift(fft(xm .* window, NFFT)); + peaks(m) = max(20*log10(abs(Xm) / cg + eps)); + end + sweep_gains(si) = mean(peaks) - ref_peak; + sweep_stds(si) = std(peaks); + fprintf(' tap=%.4f gain=%+.2f dB std=%.3f dB\n', tv, sweep_gains(si), sweep_stds(si)); + end + + fig_sweep = figure('Name', 'CFIR Gain Study - Tap Sweep', 'NumberTitle', 'off', 'Position', [800 350 750 420]); + errorbar(SWEEP_TAP_VALS, sweep_gains, sweep_stds, 'b.-', 'LineWidth', 1.2, 'MarkerSize', 12, 'CapSize', 4); + set(gca, 'XScale', 'log'); + xlabel('tap\_float (log scale)'); ylabel('\Deltapeak re: all-pass (dB)'); + title('CFIR Gain vs tap\_float (middle tap)'); + grid on; + xline(1.0, 'r--', 'tap=1.0', 'LineWidth', 1.2, 'LabelVerticalAlignment', 'bottom'); + drawnow; + if SAVE_RESULTS + saveFig(RUN_DIR, 'cfir_tap_sweep', fig_sweep); + fprintf('CFIR tap sweep figure saved.\n'); + end +end + +%% Fine tap sweep +if DO_TAP_SWEEP_FINE + if DO_TAP_SWEEP + gain_range = max(sweep_gains) - min(sweep_gains); + lo_thresh = min(sweep_gains) + 0.10 * gain_range; + hi_thresh = min(sweep_gains) + 0.90 * gain_range; + lo_idx = find(sweep_gains >= lo_thresh, 1, 'first'); + hi_idx = find(sweep_gains >= hi_thresh, 1, 'first'); + if ~isempty(lo_idx) && ~isempty(hi_idx) && hi_idx > lo_idx + lo_idx = max(1, lo_idx - 1); + hi_idx = min(SWEEP_N_PTS, hi_idx + 2); + SWEEP_FINE_TAP_LO = SWEEP_TAP_VALS(lo_idx); + SWEEP_FINE_TAP_HI = SWEEP_TAP_VALS(hi_idx); + fprintf('\n Auto-detected inflection region: [%.3f, %.3f]\n', ... + SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI); + end + end + + fine_tap_vals = linspace(SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI, SWEEP_FINE_N_PTS); + fine_gains = nan(SWEEP_FINE_N_PTS, 1); + fine_stds = nan(SWEEP_FINE_N_PTS, 1); + + fprintf('\nCFIR Fine tap sweep: %.3f to %.3f (%d pts, %d meas each) ...\n', ... + SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI, SWEEP_FINE_N_PTS, SWEEP_FINE_N_MEAS); + + for si = 1:SWEEP_FINE_N_PTS + tv = fine_tap_vals(si); + t = zeros(N_TAPS, 1); t(TAP_POS) = tv; + cf_fn = adi.AD9084.CFIR(t, 'gain', "0", 'complex_scalar', 32767+0i); + cf_fn.write(fullfile(RUN_DIR, 'cfir_gain_study_fine_tmp.txt')); + + release(rx); + rx.CFIRFilenames = fullfile(RUN_DIR, 'cfir_gain_study_fine_tmp.txt'); + rx(); + + peaks_fn = nan(SWEEP_FINE_N_MEAS, 1); + for m = 1:SWEEP_FINE_N_MEAS + d = rx(); + xm = double(d(1:NFFT,1)) / 32768; + Xm = fftshift(fft(xm .* window, NFFT)); + peaks_fn(m) = max(20*log10(abs(Xm) / cg + eps)); + end + fine_gains(si) = mean(peaks_fn) - ref_peak; + fine_stds(si) = std(peaks_fn); + fprintf(' tap=%.4f gain=%+.2f dB std=%.3f dB\n', tv, fine_gains(si), fine_stds(si)); + end + + fig_fine = figure('Name', 'CFIR Gain Study - Fine Tap Sweep', 'NumberTitle', 'off', 'Position', [800 350 750 420]); + errorbar(fine_tap_vals, fine_gains, fine_stds, 'b.-', 'LineWidth', 1.2, 'MarkerSize', 10, 'CapSize', 4); + hold on; + xline(1.0, 'r:', 'tap=1.0', 'LineWidth', 1.0, 'LabelVerticalAlignment', 'bottom'); + xlabel('tap\_float (linear)'); ylabel('\Deltapeak re: all-pass (dB)'); + title(sprintf('CFIR Gain vs tap\\_float [%.2f – %.2f]', ... + SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI)); + grid on; + drawnow; + + if SAVE_RESULTS + saveFig(RUN_DIR, 'cfir_tap_sweep_fine', fig_fine); + fprintf('CFIR fine sweep figure saved.\n'); + end +end + +%% Shift gain sweep +if DO_SHIFT_GAIN_SWEEP + shift_gain_vals = [-18, -12, -6, 0, 6, 12]; + n_shift = numel(shift_gain_vals); + shift_gains = nan(n_shift, 1); + shift_stds = nan(n_shift, 1); + + fprintf('\nCFIR Shift gain sweep: [-18, -12, -6, 0, 6, 12] dB (%d values, %d meas each) ...\n', ... + n_shift, SHIFT_GAIN_N_MEAS); + + taps_shg = zeros(N_TAPS, 1); taps_shg(TAP_POS) = TAP_FLOAT_FIXED; + + for si = 1:n_shift + sg_dB = shift_gain_vals(si); + cf_shg = adi.AD9084.CFIR(taps_shg, 'gain', string(sg_dB), 'complex_scalar', 32767+0i); + cf_shg.write(fullfile(RUN_DIR, 'cfir_gain_study_shift_tmp.txt')); + + release(rx); + rx.CFIRFilenames = fullfile(RUN_DIR, 'cfir_gain_study_shift_tmp.txt'); + rx(); + + peaks_shg = nan(SHIFT_GAIN_N_MEAS, 1); + for m = 1:SHIFT_GAIN_N_MEAS + d = rx(); + xm = double(d(1:NFFT,1)) / 32768; + Xm = fftshift(fft(xm .* window, NFFT)); + peaks_shg(m) = max(20*log10(abs(Xm) / cg + eps)); + end + shift_gains(si) = mean(peaks_shg) - ref_peak; + shift_stds(si) = std(peaks_shg); + fprintf(' shift_gain=%3d dB measured=%+.3f dB std=%.3f dB\n', ... + sg_dB, shift_gains(si), shift_stds(si)); + end + + fig_shift = figure('Name', 'CFIR Gain Study - Shift Gain Sweep', 'NumberTitle', 'off', 'Position', [800 100 750 420]); + errorbar(shift_gain_vals, shift_gains, shift_stds, 'b.-', 'LineWidth', 1.2, 'MarkerSize', 10, 'CapSize', 3); + hold on; + plot(shift_gain_vals, shift_gain_vals, 'r--', 'LineWidth', 1.2); + xlabel('Programmed Shift Gain (dB)'); ylabel('Measured \Deltapeak (dB)'); + title(sprintf('CFIR: Measured vs Programmed Shift Gain (tap\\_float=%.0f)', TAP_FLOAT_FIXED)); + legend('Measured', 'Ideal (1:1)', 'Location', 'NorthWest'); + grid on; + drawnow; + + if SAVE_RESULTS + saveFig(RUN_DIR, 'cfir_shift_gain_sweep', fig_shift); + fprintf('CFIR shift gain sweep figure saved.\n'); + end +end + +%% Save CFIR gain lookup table +if SAVE_RESULTS + lut_path = fullfile(RUN_DIR, 'cfir_gain_lut.m'); + fid = fopen(lut_path, 'w'); + + fprintf(fid, 'function lut = cfir_gain_lut()\n'); + fprintf(fid, '%%%% CFIR Gain lookup table generated by cfir_gain_study.m\n'); + fprintf(fid, '%%%% Timestamp: %s\n', datestr(now, 'yyyy-mm-dd HH:MM:SS')); + fprintf(fid, '%%%% Board URI: %s\n\n', uri); + fprintf(fid, 'lut.board_uri = ''%s'';\n', uri); + fprintf(fid, 'lut.tone_hz = %g;\n', TONE_FREQ_HZ); + fprintf(fid, 'lut.adc_sample_rate_hz = %g;\n', Fs); + fprintf(fid, 'lut.filter_block = ''cfir'';\n\n'); + + if DO_TAP_SWEEP + fprintf(fid, '%%%% Tap sweep (gain vs tap_float)\n'); + fprintf(fid, 'lut.tap_sweep.tap_values = %s;\n', mat2str(SWEEP_TAP_VALS, 6)); + fprintf(fid, 'lut.tap_sweep.gain_dB = %s;\n', mat2str(sweep_gains(:).', 6)); + fprintf(fid, 'lut.tap_sweep.std_dB = %s;\n', mat2str(sweep_stds(:).', 6)); + fprintf(fid, 'lut.tap_sweep.config.n_taps = %d;\n', N_TAPS); + fprintf(fid, 'lut.tap_sweep.config.tap_pos = %d;\n', TAP_POS); + fprintf(fid, 'lut.tap_sweep.config.shift_gain_dB = 0;\n'); + fprintf(fid, 'lut.tap_sweep.config.complex_scalar = [32767 0];\n'); + fprintf(fid, 'lut.tap_sweep.config.n_meas = %d;\n\n', SWEEP_N_MEAS); + end + + if DO_SHIFT_GAIN_SWEEP + fprintf(fid, '%%%% Shift gain sweep (gain vs shift_gain setting)\n'); + fprintf(fid, 'lut.shift_gain_sweep.shift_gain_values_dB = %s;\n', mat2str(shift_gain_vals)); + fprintf(fid, 'lut.shift_gain_sweep.gain_dB = %s;\n', mat2str(shift_gains(:).', 6)); + fprintf(fid, 'lut.shift_gain_sweep.std_dB = %s;\n', mat2str(shift_stds(:).', 6)); + fprintf(fid, 'lut.shift_gain_sweep.config.tap_float_fixed = %g;\n', TAP_FLOAT_FIXED); + fprintf(fid, 'lut.shift_gain_sweep.config.complex_scalar = [32767 0];\n'); + fprintf(fid, 'lut.shift_gain_sweep.config.n_meas = %d;\n\n', SHIFT_GAIN_N_MEAS); + end + + fprintf(fid, 'end\n'); + fclose(fid); + fprintf('CFIR Gain LUT function saved to: %s\n', lut_path); +end + +fprintf('\nCFIR gain study complete.\n'); + +%% ========================================================= +% Local helpers +% ========================================================= +function saveFig(run_dir, name, fig) + if ~ishandle(fig), return; end + base = fullfile(run_dir, name); + exportgraphics(fig, [base '.png'], 'Resolution', 150); + savefig(fig, [base '.fig']); +end diff --git a/hsx_examples/streaming/filter_demo.m b/hsx_examples/streaming/filter_demo.m new file mode 100644 index 00000000..f091bae3 --- /dev/null +++ b/hsx_examples/streaming/filter_demo.m @@ -0,0 +1,234 @@ + +%% AD9084 Real-Time FFT + +% Walkthrough of creating filters, analyzing the filters, using the filter +% classes in the HSCT toolbox, creating an RX object and looking at the +% filter on the Washington hardware. For ease of use, the filtView switch have been +% added to turn off/on the filter analysis tool (fvtool). + +clear; clc; + +%% Configuration +uri = 'ip:192.168.2.1'; + +%% Switches +filtView = 0; +pfirCompare = 0; % 1 = show before/after PFIR comparison plot +cfirCompare = 1; % 1 = show before/after CFIR comparison plot +pfirAllPass = 1; % 1 = load all-pass instead of designed filter for PFIR +cfirAllPass = 0; % 1 = load all-pass instead of designed filter for CFIR +PFilt_Fs = 20E9; +CFIR_Fs = 2.5E9; + +%% Creating Filters (complex, one-sided using arbmag) +Ntaps = 15; +F_norm = linspace(-1, 1, 501); % normalized frequency grid [-Fs/2, +Fs/2] + +% Low-Pass Filter: passes +0 to +cutoff +amp_LP = double(F_norm < .45 ); +D_LP = fdesign.arbmag('N,F,A', Ntaps, F_norm, amp_LP); +EQ_LP = design(D_LP, 'allfir', SystemObject=true); +LPFtaps = EQ_LP{1,2}.Numerator(:); + +% Band-Pass Filter: passes +f1 to +f2 +amp_BP = double(F_norm > 0.35 & F_norm < 0.7); +D_BP = fdesign.arbmag('N,F,A', Ntaps, F_norm, amp_BP); +EQ_BP = design(D_BP, 'allfir', SystemObject=true); +BPFtaps = EQ_BP{1,2}.Numerator(:); + +% High-Pass Filter: passes +cutoff to +Fs/2 +amp_HP = double(F_norm > 0.45); +D_HP = fdesign.arbmag('N,F,A', Ntaps, F_norm, amp_HP); +EQ_HP = design(D_HP, 'allfir', SystemObject=true); +HPFtaps = EQ_HP{1,2}.Numerator(:); + +%% Viewing Filters +if filtView + h = fvtool(LPFtaps, 1); + h.Fs = 2500e6; + + h2 = fvtool(BPFtaps,1); + h2.Fs = 2500e6; + + h3 = fvtool(HPFtaps,1); + h3.Fs = 2500e6; +end + +%% Using new Filter Classes +% PFilt +if pfirAllPass + ap_taps = zeros(16, 1); ap_taps(ceil(16/2)) = 1.0; + pf = adi.AD9084.PFilt(ap_taps, 'mode', 'real_n2', 'gain', "0", 'scalar_gain', "63"); +else + pf = adi.AD9084.PFilt(LPFtaps, "mode", 'real_n2', 'gain', "18", 'scalar_gain', "63"); +end +pf.write('pfir_auto.txt'); + +% CFIR +if cfirAllPass + ap_taps = zeros(16, 1); ap_taps(ceil(16/2)) = 1.0; + cf = adi.AD9084.CFIR(ap_taps, 'gain', "0", 'complex_scalar', 32767+0i); +else + cf = adi.AD9084.CFIR(BPFtaps, "gain", "12"); +end +cf.write('cfir_auto.txt'); + +%% View theoretical filter response (comment out if not needed) +% pf.response(PFilt_Fs, useLUT=true); +cf.response(CFIR_Fs, useLUT=true); + +%% --- TX mode selection --- +% TX mode: 'noise' = wideband white noise via DMA (flat excitation) +% 'chirp' = linear frequency sweep via DMA (deterministic) +% 'sweep' = stepped DDS tone sweep (real-time, captures per-freq) +% 'xband_sweep' = stepped DDS sweep with NCOs at 10 GHz (X-band) +% 'dds' = single DDS tone +txMode = 'dds'; + +%% --- Create RX object --- +rx = adi.AD9084.Rx('uri', uri); + +% Basic RX configuration +rx.EnabledChannels = 1; +rx.SamplesPerFrame = 16384; + +% Enable PFilt +rx.EnablePFIRs = true; +rx.PFIRFilenames = 'pfir_auto.txt'; + +% Enable CFIR +rx.EnableCFIRs = true; +% rx.CFIRFilenames = 'sparse_test.txt'; +rx.CFIRFilenames = 'cfir_auto.txt'; + + % Tune NCOs (set to 10 GHz for xband_sweep, 0 otherwise) + if strcmp(txMode, 'xband_sweep') + rx.MainNCOFrequencies = [10e9 0 0 0]; + else + rx.MainNCOFrequencies = [1e9 0 0 0]; + end + + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + +% Turn test mode off +rx.TestMode = 'off'; + + +%% --- Create TX object --- +tx = adi.AD9084.Tx('uri', uri); +tx.EnabledChannels = 1; +tx.SamplesPerFrame = 16384; + +% Tune NCOs +tx.MainNCOFrequencies = [1e9 0 0 0]; +tx.ChannelNCOFrequencies = [100e6 0 0 0]; +tx.MainNCOPhases = [0 0 0 0]; +tx.ChannelNCOPhases = [0 0 0 0]; +tx.NCOEnables = [true false false false]; + + +switch txMode + case 'noise' + % Wideband complex noise — excites all frequencies for filter observation + tx.DataSource = 'DMA'; + tx.EnableCyclicBuffers = true; + N = 16384; + noise = complex(randn(N,1), randn(N,1)); + noise = int16((2^14) * noise / max(abs(noise))); + tx(noise); + + case 'chirp' + % Linear chirp — sweeps from -BW/2 to +BW/2 in one DMA buffer + tx.DataSource = 'DMA'; + tx.EnableCyclicBuffers = true; + Fs_tx = 2.5e9; + N = 16384; + t = (0:N-1).' / Fs_tx; + BW = Fs_tx * 0.8; % sweep 80% of Nyquist + chirpSig = exp(1i * pi * BW * (t - t(end)/2).^2 / t(end)); + tx(int16(2^14 * chirpSig)); + + case 'sweep' + % Stepped DDS sweep — configure DDS, sweep happens after RX prime + tx.DataSource = 'DDS'; + tx.DDSFrequencies = [10e6, 10e6; 0, 0]; + tx.DDSScales = [0.5, 0.5; 0, 0]; + tx.DDSPhases = [0, 90000; 0, 0]; + tx(); + + case 'xband_sweep' + % X-band swept DDS — NCO at 10 GHz, DDS sweeps baseband offsets + tx.MainNCOFrequencies = [10e9 0 0 0]; + tx.DataSource = 'DDS'; + tx.DDSFrequencies = [10e6, 10e6; 0, 0]; + tx.DDSScales = [0.5, 0.5; 0, 0]; + tx.DDSPhases = [0, 90000; 0, 0]; + tx(); + + case 'dds' + % Single DDS tone + tx.DataSource = 'DDS'; + toneFreq = 650e6; + tx.DDSFrequencies = [toneFreq, toneFreq; 0, 0]; + tx.DDSScales = [0.8, 0.8; 0, 0]; + tx.DDSPhases = [90000, 0; 0, 0]; + tx(); +end + + +%% --- Prime RX (this is CRITICAL) --- +% This call will cause an error if Rx is not configured +fprintf('Priming RX...\n'); +data = rx(); +fprintf('RX streaming.\n'); + + +%% --- Stepped DDS sweep (runs in 'sweep' or 'xband_sweep' mode) --- +if strcmp(txMode, 'sweep') || strcmp(txMode, 'xband_sweep') + Fs_rx = double(rx.SamplingRate); + sweepFreqs = linspace(10e6, Fs_rx/2 * 0.9, 50); + sweepPower = nan(size(sweepFreqs)); + NFFT = rx.SamplesPerFrame; + + if strcmp(txMode, 'xband_sweep') + ncoFreq = 10e9; + rfFreqs = ncoFreq + sweepFreqs; + plotLabel = sprintf('CFIR Response — X-band Sweep (NCO = %.0f GHz)', ncoFreq/1e9); + else + ncoFreq = 0; + rfFreqs = sweepFreqs; + plotLabel = 'CFIR Response — Baseband Sweep'; + end + + figure('Name', plotLabel); + for si = 1:numel(sweepFreqs) + f = sweepFreqs(si); + tx.DDSFrequencies = [f, f; 0, 0]; + pause(0.05); + for k = 1:5, data = rx(); end + X = fftshift(fft(double(data(:,1)), NFFT)); + sweepPower(si) = max(20*log10(abs(X)/NFFT + eps)); + fprintf(' RF=%.1f MHz DDS=%.1f MHz -> %.1f dB\n', ... + rfFreqs(si)/1e6, f/1e6, sweepPower(si)); + end + + plot(rfFreqs/1e9, sweepPower, 'b.-', 'LineWidth', 1.2); + xlabel('RF Frequency (GHz)'); ylabel('Peak Power (dB)'); + title(plotLabel); grid on; + fprintf('Sweep complete.\n'); +end + +%% --- Before/After PFIR comparison --- +if pfirCompare + adi.AD9084.filter_compare(rx, pf, 'pfir', 'pfir_auto.txt'); +end + +%% --- Before/After CFIR comparison --- +if cfirCompare + adi.AD9084.filter_compare(rx, cf, 'cfir', 'cfir_auto.txt'); +end + +%% --- Start real-time FFT plotting --- +plotting_fft(rx); + + diff --git a/hsx_examples/streaming/pfir_gain_lut_study.m b/hsx_examples/streaming/pfir_gain_lut_study.m new file mode 100644 index 00000000..b9710dcb --- /dev/null +++ b/hsx_examples/streaming/pfir_gain_lut_study.m @@ -0,0 +1,540 @@ +%% pfir_gain_lut_study.m — Characterize PFIR gain stages and save gain LUT + + +clear; clc; + +%% Configuration +uri = 'ip:192.168.2.1'; +N_TAPS = 16; +TAP_POS = ceil(N_TAPS / 2); % middle tap +NFFT = 4096; +N_STAT = 500; +TONE_FREQ_HZ = 10e6; +TAP_FLOAT_FIXED = 2^12; % <-- fixed single-tap value used for filtered vs unfiltered comparison + +% --- Toggles --- +DO_TAP_SWEEP = 0; % run gain vs tap_float sweep before live stream +DO_TAP_SWEEP_FINE = 0; % run fine tap sweep zoomed around the inflection region +DO_SCALAR_SWEEP = 0; % run gain vs scalar_gain (0-64) sweep before live stream +DO_SHIFT_GAIN_SWEEP = 1; % run gain vs shift_gain (0/6/12/18/24 dB) sweep +SHOW_SPECTRA = 0; % Figure 1: live spectrum +SHOW_STATS = 0; % Figure 2: scatter + errorbar +SHOW_HIST = false; % Figure 3: histogram + +% --- Results output --- +SAVE_RESULTS = true; % save figures to MATLAB/gain_study_results/run_NNN/ +RESULTS_ROOT = fullfile(fileparts(mfilename('fullpath')), 'gain_study_results'); + +% --- Tap sweep settings (only used when DO_TAP_SWEEP = true) --- +SWEEP_N_PTS = 30; % number of tap values to test +SWEEP_N_MEAS = 50; % measurements per tap value +% log-spaced from 0.1 to 2^15; reveals saturation knee +SWEEP_TAP_VALS = logspace(log10(0.1), log10(2^15), SWEEP_N_PTS); + +% --- Fine tap sweep (zoomed in around inflection, only when DO_TAP_SWEEP_FINE = true) --- +SWEEP_FINE_N_PTS = 60; % points in fine range (linear spacing) +SWEEP_FINE_N_MEAS = 50; % measurements per fine tap value +% Defaults used when DO_TAP_SWEEP is false (no coarse data to auto-detect from) +SWEEP_FINE_TAP_LO = 0.3; +SWEEP_FINE_TAP_HI = 2.5; + +% --- Scalar gain sweep settings (only used when DO_SCALAR_SWEEP = true) --- +SCALAR_N_MEAS = 50; % measurements per scalar value (0-64, all integers) + +% --- Shift gain sweep settings (only used when DO_SHIFT_GAIN_SWEEP = true) --- +SHIFT_GAIN_N_MEAS = 50; % measurements per shift gain value + +%% Set up results folder for this run +if SAVE_RESULTS + if ~exist(RESULTS_ROOT, 'dir'), mkdir(RESULTS_ROOT); end + existing = dir(fullfile(RESULTS_ROOT, 'run_*')); + run_num = numel(existing) + 1; + RUN_DIR = fullfile(RESULTS_ROOT, sprintf('run_%03d', run_num)); + mkdir(RUN_DIR); + fprintf('Results will be saved to: %s\n', RUN_DIR); +end + +%% Write filter files +adi.AD9084.writeDisabledFilter(fullfile(RUN_DIR, 'gain_study_pfir_off.txt'), 'pfir'); + +% Write CFIR all-pass to ensure no residual CFIR filter colors the measurement +cfir_ap_taps = zeros(16, 1); cfir_ap_taps(ceil(16/2)) = 1.0; +cf_ap = adi.AD9084.CFIR(cfir_ap_taps, 'gain', "0", 'complex_scalar', 32767+0i); +cf_ap.write(fullfile(RUN_DIR, 'gain_study_cfir_allpass.txt')); + +taps = zeros(N_TAPS, 1); +taps(TAP_POS) = TAP_FLOAT_FIXED; +pf = adi.AD9084.PFilt(taps, 'mode', 'real_n2', 'gain', "0", 'scalar_gain', "63"); + +pf.write(fullfile(RUN_DIR, 'gain_study_filter.txt')); + +%% Configure TX +tx = adi.AD9084.Tx('uri', uri); + +tx.EnabledChannels = 1; +tx.SamplesPerFrame = 16384; +tx.DataSource = 'DDS'; +tx.MainNCOFrequencies = [1e9 0 0 0]; +tx.ChannelNCOFrequencies = [100e6 0 0 0]; +tx.MainNCOPhases = [0 0 0 0]; +tx.ChannelNCOPhases = [0 0 0 0]; +tx.NCOEnables = [true false false false]; +tx.DDSFrequencies = [TONE_FREQ_HZ, TONE_FREQ_HZ; 0, 0]; +tx.DDSScales = [.9, .9; 0, 0]; +tx.DDSPhases = [90000, 0; 0, 0]; +tx(); + +%% Configure RX - start with PFIR disabled (reference) +rx = adi.AD9084.Rx('uri', uri); + +rx.EnabledChannels = 1; +rx.SamplesPerFrame = 16384; +rx.EnablePFIRs = true; +rx.PFIRFilenames = fullfile(RUN_DIR, 'gain_study_pfir_off.txt'); +rx.EnableCFIRs = true; +rx.CFIRFilenames = fullfile(RUN_DIR, 'gain_study_cfir_allpass.txt'); +rx.MainNCOFrequencies = [1e9 0 0 0]; +rx.ChannelNCOFrequencies = [100e6 0 0 0]; +rx.TestMode = 'off'; +rx(); + +%% Capture unfiltered reference snapshot +window = hann(NFFT, 'periodic'); +cg = sum(window) / 2; +Fs = double(rx.SamplingRate); + +data = rx(); +x = double(data(1:NFFT, 1)) / 32768; +X = fftshift(fft(x .* window, NFFT)); +ref_mag = 20*log10(abs(X) / cg + eps); +ref_peak = max(ref_mag); +f_bins = linspace(-Fs/2, Fs/2, NFFT) / 1e6; +[~, ref_peak_idx] = max(ref_mag); +ref_peak_freq_MHz = f_bins(ref_peak_idx); + +fprintf('Reference peak: %.2f dBFS\n', ref_peak); + +%% Tap sweep (optional) +if DO_TAP_SWEEP + fprintf('\nTap sweep: %d values from %.2f to %.0f ...\n', ... + SWEEP_N_PTS, SWEEP_TAP_VALS(1), SWEEP_TAP_VALS(end)); + sweep_gains = nan(SWEEP_N_PTS, 1); + sweep_stds = nan(SWEEP_N_PTS, 1); + + for si = 1:SWEEP_N_PTS + tv = SWEEP_TAP_VALS(si); + t = zeros(N_TAPS, 1); t(TAP_POS) = tv; + pf_sw = adi.AD9084.PFilt(t, 'mode', 'real_n2', 'gain', "0", 'scalar_gain', "63"); + pf_sw.write(fullfile(RUN_DIR, 'gain_study_sweep_tmp.txt')); + + release(rx); + rx.PFIRFilenames = fullfile(RUN_DIR, 'gain_study_sweep_tmp.txt'); + rx(); + + peaks = nan(SWEEP_N_MEAS, 1); + for m = 1:SWEEP_N_MEAS + d = rx(); + xm = double(d(1:NFFT,1)) / 32768; + Xm = fftshift(fft(xm .* window, NFFT)); + peaks(m) = max(20*log10(abs(Xm) / cg + eps)); + end + sweep_gains(si) = mean(peaks) - ref_peak; + sweep_stds(si) = std(peaks); + fprintf(' tap=%.4f gain=%+.2f dB std=%.3f dB\n', tv, sweep_gains(si), sweep_stds(si)); + end + + fig_sweep = figure('Name', 'Gain Study - Tap Sweep', 'NumberTitle', 'off', 'Position', [800 350 750 420]); + errorbar(SWEEP_TAP_VALS, sweep_gains, sweep_stds, 'b.-', 'LineWidth', 1.2, 'MarkerSize', 12, 'CapSize', 4); + set(gca, 'XScale', 'log'); + xlabel('tap\_float (log scale)'); ylabel('\Deltapeak re: disabled (dB)'); + title('Gain vs tap\_float (middle tap, real\_n2)'); + grid on; + xline(1.0, 'r--', 'tap=1.0 (full scale)', 'LineWidth', 1.2, 'LabelVerticalAlignment', 'bottom'); + drawnow; + if SAVE_RESULTS + saveFig(RUN_DIR, 'sweep', fig_sweep); + fprintf('Sweep figure saved.\n'); + end +end + +%% Fine tap sweep (optional) — zoomed in around the inflection +if DO_TAP_SWEEP_FINE + % Auto-detect inflection region from coarse sweep if available + if DO_TAP_SWEEP + gain_range = max(sweep_gains) - min(sweep_gains); + lo_thresh = min(sweep_gains) + 0.10 * gain_range; + hi_thresh = min(sweep_gains) + 0.90 * gain_range; + lo_idx = find(sweep_gains >= lo_thresh, 1, 'first'); + hi_idx = find(sweep_gains >= hi_thresh, 1, 'first'); + if ~isempty(lo_idx) && ~isempty(hi_idx) && hi_idx > lo_idx + lo_idx = max(1, lo_idx - 1); + hi_idx = min(SWEEP_N_PTS, hi_idx + 2); + SWEEP_FINE_TAP_LO = SWEEP_TAP_VALS(lo_idx); + SWEEP_FINE_TAP_HI = SWEEP_TAP_VALS(hi_idx); + fprintf('\n Auto-detected inflection region: [%.3f, %.3f]\n', ... + SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI); + end + end + + fine_tap_vals = linspace(SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI, SWEEP_FINE_N_PTS); + fine_gains = nan(SWEEP_FINE_N_PTS, 1); + fine_stds = nan(SWEEP_FINE_N_PTS, 1); + + fprintf('\nFine tap sweep: %.3f to %.3f (%d pts, %d meas each) ...\n', ... + SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI, SWEEP_FINE_N_PTS, SWEEP_FINE_N_MEAS); + + for si = 1:SWEEP_FINE_N_PTS + tv = fine_tap_vals(si); + t = zeros(N_TAPS, 1); t(TAP_POS) = tv; + pf_fn = adi.AD9084.PFilt(t, 'mode', 'real_n2', 'gain', "0", 'scalar_gain', "63"); + pf_fn.write(fullfile(RUN_DIR, 'gain_study_fine_tmp.txt')); + + release(rx); + rx.PFIRFilenames = fullfile(RUN_DIR, 'gain_study_fine_tmp.txt'); + rx(); + + peaks_fn = nan(SWEEP_FINE_N_MEAS, 1); + for m = 1:SWEEP_FINE_N_MEAS + d = rx(); + xm = double(d(1:NFFT,1)) / 32768; + Xm = fftshift(fft(xm .* window, NFFT)); + peaks_fn(m) = max(20*log10(abs(Xm) / cg + eps)); + end + fine_gains(si) = mean(peaks_fn) - ref_peak; + fine_stds(si) = std(peaks_fn); + fprintf(' tap=%.4f gain=%+.2f dB std=%.3f dB\n', tv, fine_gains(si), fine_stds(si)); + end + + fig_fine = figure('Name', 'Gain Study - Fine Tap Sweep', 'NumberTitle', 'off', 'Position', [800 350 750 420]); + errorbar(fine_tap_vals, fine_gains, fine_stds, 'b.-', 'LineWidth', 1.2, 'MarkerSize', 10, 'CapSize', 4); + hold on; + % yline(0, 'r--', '0 dB (all-pass ideal)', 'LineWidth', 1.2, 'LabelVerticalAlignment', 'bottom'); + xline(1.0, 'r:', 'tap=1.0 (2^{15} full scale)', 'LineWidth', 1.0, 'LabelVerticalAlignment', 'bottom'); + xlabel('tap\_float (linear)'); ylabel('\Deltapeak re: disabled (dB)'); + title(sprintf('Gain vs tap\\_float [%.2f – %.2f] (middle tap, real\\_n2)', ... + SWEEP_FINE_TAP_LO, SWEEP_FINE_TAP_HI)); + grid on; + drawnow; + + if SAVE_RESULTS + saveFig(RUN_DIR, 'sweep_fine', fig_fine); + fprintf('Fine sweep figure saved.\n'); + end +end + +%% Scalar gain sweep (optional) — find which scalar_gain gives closest to all-pass +if DO_SCALAR_SWEEP + scalar_vals = 0:64; + n_scalar = numel(scalar_vals); + scalar_gains = nan(n_scalar, 1); + scalar_stds = nan(n_scalar, 1); + + fprintf('\nScalar gain sweep: 0 to 64 (%d values, %d meas each) ...\n', n_scalar, SCALAR_N_MEAS); + + taps_sg = zeros(N_TAPS, 1); taps_sg(TAP_POS) = TAP_FLOAT_FIXED; + + for si = 1:n_scalar + sg = scalar_vals(si); + pf_sg = adi.AD9084.PFilt(taps_sg, 'mode', 'real_n2', 'gain', "0", ... + 'scalar_gain', string(sg)); + pf_sg.write(fullfile(RUN_DIR, 'gain_study_scalar_tmp.txt')); + + release(rx); + rx.PFIRFilenames = fullfile(RUN_DIR, 'gain_study_scalar_tmp.txt'); + rx(); + + peaks_sg = nan(SCALAR_N_MEAS, 1); + for m = 1:SCALAR_N_MEAS + d = rx(); + xm = double(d(1:NFFT,1)) / 32768; + Xm = fftshift(fft(xm .* window, NFFT)); + peaks_sg(m) = max(20*log10(abs(Xm) / cg + eps)); + end + scalar_gains(si) = mean(peaks_sg) - ref_peak; + scalar_stds(si) = std(peaks_sg); + fprintf(' scalar=%2d gain=%+.3f dB std=%.3f dB\n', sg, scalar_gains(si), scalar_stds(si)); + end + + % find the scalar closest to 0 dB delta (all-pass) + [~, best_scalar_idx] = min(abs(scalar_gains)); + best_scalar = scalar_vals(best_scalar_idx); + fprintf('\n Best scalar_gain = %d (delta = %+.3f dB)\n', best_scalar, scalar_gains(best_scalar_idx)); + + fig_scalar = figure('Name', 'Gain Study - Scalar Sweep', 'NumberTitle', 'off', 'Position', [800 100 750 420]); + errorbar(scalar_vals, scalar_gains, scalar_stds, 'b.-', 'LineWidth', 1.2, 'MarkerSize', 10, 'CapSize', 3); + hold on; + yline(0, 'r--', '0 dB (all-pass)', 'LineWidth', 1.2, 'LabelVerticalAlignment', 'bottom'); + xlabel('scalar\_gain'); ylabel('\Deltapeak re: disabled (dB)'); + title(sprintf('Gain vs scalar\\_gain (tap\\_float=%.1f, middle tap) | best = %d', TAP_FLOAT_FIXED, best_scalar)); + grid on; + drawnow; + + if SAVE_RESULTS + saveFig(RUN_DIR, 'scalar_sweep', fig_scalar); + fprintf('Scalar sweep figure saved.\n'); + end +end + +%% Shift gain sweep (optional) — measure actual gain at each shift gain setting +if DO_SHIFT_GAIN_SWEEP + shift_gain_vals = [0, 6, 12, 18, 24]; + n_shift = numel(shift_gain_vals); + shift_gains = nan(n_shift, 1); + shift_stds = nan(n_shift, 1); + + fprintf('\nShift gain sweep: [0, 9, 12, 18, 24] dB (%d values, %d meas each) ...\n', ... + n_shift, SHIFT_GAIN_N_MEAS); + + taps_shg = zeros(N_TAPS, 1); taps_shg(TAP_POS) = TAP_FLOAT_FIXED; + + for si = 1:n_shift + sg_dB = shift_gain_vals(si); + pf_shg = adi.AD9084.PFilt(taps_shg, 'mode', 'real_n2', ... + 'gain', string(sg_dB), 'scalar_gain', "62"); + pf_shg.write(fullfile(RUN_DIR, 'gain_study_shift_tmp.txt')); + + release(rx); + rx.PFIRFilenames = fullfile(RUN_DIR, 'gain_study_shift_tmp.txt'); + rx(); + + peaks_shg = nan(SHIFT_GAIN_N_MEAS, 1); + for m = 1:SHIFT_GAIN_N_MEAS + d = rx(); + xm = double(d(1:NFFT,1)) / 32768; + Xm = fftshift(fft(xm .* window, NFFT)); + peaks_shg(m) = max(20*log10(abs(Xm) / cg + eps)); + end + shift_gains(si) = mean(peaks_shg) - ref_peak; + shift_stds(si) = std(peaks_shg); + fprintf(' shift_gain=%2d dB measured=%+.3f dB std=%.3f dB\n', ... + sg_dB, shift_gains(si), shift_stds(si)); + end + + fig_shift = figure('Name', 'Gain Study - Shift Gain Sweep', 'NumberTitle', 'off', 'Position', [800 100 750 420]); + errorbar(shift_gain_vals, shift_gains, shift_stds, 'b.-', 'LineWidth', 1.2, 'MarkerSize', 10, 'CapSize', 3); + hold on; + plot(shift_gain_vals, shift_gain_vals, 'r--', 'LineWidth', 1.2); + xlabel('Programmed Shift Gain (dB)'); ylabel('Measured \Deltapeak (dB)'); + title(sprintf('Measured vs Programmed Shift Gain (tap\\_float=%.1f, scalar=63)', TAP_FLOAT_FIXED)); + legend('Measured', 'Ideal (1:1)', 'Location', 'NorthWest'); + grid on; + drawnow; + + if SAVE_RESULTS + saveFig(RUN_DIR, 'shift_gain_sweep', fig_shift); + fprintf('Shift gain sweep figure saved.\n'); + end +end + +%% Save gain lookup table as .m function (includes all sweeps that were run) +if SAVE_RESULTS + lut_path = fullfile(RUN_DIR, 'gain_lut.m'); + fid = fopen(lut_path, 'w'); + + fprintf(fid, 'function lut = gain_lut()\n'); + fprintf(fid, '%%%% Gain lookup table generated by gain_study.m\n'); + fprintf(fid, '%%%% Timestamp: %s\n', datestr(now, 'yyyy-mm-dd HH:MM:SS')); + fprintf(fid, '%%%% Board URI: %s\n\n', uri); + fprintf(fid, 'lut.board_uri = ''%s'';\n', uri); + fprintf(fid, 'lut.tone_hz = %g;\n', TONE_FREQ_HZ); + fprintf(fid, 'lut.adc_sample_rate_hz = %g;\n\n', Fs); + + if DO_TAP_SWEEP + fprintf(fid, '%%%% Tap sweep (gain vs tap_float)\n'); + fprintf(fid, 'lut.tap_sweep.tap_values = %s;\n', mat2str(SWEEP_TAP_VALS, 6)); + fprintf(fid, 'lut.tap_sweep.gain_dB = %s;\n', mat2str(sweep_gains(:).', 6)); + fprintf(fid, 'lut.tap_sweep.std_dB = %s;\n', mat2str(sweep_stds(:).', 6)); + fprintf(fid, 'lut.tap_sweep.config.n_taps = %d;\n', N_TAPS); + fprintf(fid, 'lut.tap_sweep.config.tap_pos = %d;\n', TAP_POS); + fprintf(fid, 'lut.tap_sweep.config.scalar_gain = 63;\n'); + fprintf(fid, 'lut.tap_sweep.config.shift_gain_dB = 0;\n'); + fprintf(fid, 'lut.tap_sweep.config.n_meas = %d;\n\n', SWEEP_N_MEAS); + end + + if DO_SCALAR_SWEEP + fprintf(fid, '%%%% Scalar gain sweep (gain vs scalar_gain)\n'); + fprintf(fid, 'lut.scalar_sweep.scalar_values = %s;\n', mat2str(scalar_vals)); + fprintf(fid, 'lut.scalar_sweep.gain_dB = %s;\n', mat2str(scalar_gains(:).', 6)); + fprintf(fid, 'lut.scalar_sweep.std_dB = %s;\n', mat2str(scalar_stds(:).', 6)); + fprintf(fid, 'lut.scalar_sweep.config.tap_float_fixed = %g;\n', TAP_FLOAT_FIXED); + fprintf(fid, 'lut.scalar_sweep.config.shift_gain_dB = 0;\n'); + fprintf(fid, 'lut.scalar_sweep.config.n_meas = %d;\n\n', SCALAR_N_MEAS); + end + + if DO_SHIFT_GAIN_SWEEP + fprintf(fid, '%%%% Shift gain sweep (gain vs shift_gain setting)\n'); + fprintf(fid, 'lut.shift_gain_sweep.shift_gain_values_dB = %s;\n', mat2str(shift_gain_vals)); + fprintf(fid, 'lut.shift_gain_sweep.gain_dB = %s;\n', mat2str(shift_gains(:).', 6)); + fprintf(fid, 'lut.shift_gain_sweep.std_dB = %s;\n', mat2str(shift_stds(:).', 6)); + fprintf(fid, 'lut.shift_gain_sweep.config.tap_float_fixed = %g;\n', TAP_FLOAT_FIXED); + fprintf(fid, 'lut.shift_gain_sweep.config.scalar_gain = 60;\n'); + fprintf(fid, 'lut.shift_gain_sweep.config.n_meas = %d;\n\n', SHIFT_GAIN_N_MEAS); + end + + fprintf(fid, 'end\n'); + fclose(fid); + fprintf('Gain LUT function saved to: %s\n', lut_path); +end + +%% Load single-tap filter (fixed tap = TAP_FLOAT_FIXED for live stream) +release(rx); +rx.PFIRFilenames = fullfile(RUN_DIR, 'gain_study_filter.txt'); +rx(); + +fig1 = []; fig2 = []; fig3 = []; + +%% Figure 1 - Spectra +if SHOW_SPECTRA +fig1 = figure('Name', 'Gain Study - Spectra', 'NumberTitle', 'off', 'Position', [100 350 1100 580]); + +ax1 = subplot(2,1,1); +plot(ax1, f_bins, ref_mag, 'k-', 'LineWidth', 0.8); +xlabel(ax1, 'Frequency (MHz)'); ylabel(ax1, 'Magnitude (dBFS)'); +title(ax1, sprintf('Unfiltered reference | peak = %.2f dBFS', ref_peak)); +grid(ax1, 'on'); ylim(ax1, [-140 5]); + +ax2 = subplot(2,1,2); +h_spec = plot(ax2, f_bins, ref_mag, 'b-', 'LineWidth', 0.8); +hold(ax2, 'on'); +% errorbar fixed at reference peak frequency; y updated as stats accumulate +h_eb_fft = errorbar(ax2, ref_peak_freq_MHz, ref_peak, 0, 'r^', ... + 'MarkerSize', 8, 'LineWidth', 1.5, 'CapSize', 8, 'Visible', 'off'); +xlabel(ax2, 'Frequency (MHz)'); ylabel(ax2, 'Magnitude (dBFS)'); +title(ax2, 'Filtered (live)'); +grid(ax2, 'on'); ylim(ax2, [-140 5]); +end % SHOW_SPECTRA + +%% Figure 2 - Statistics (scatter + errorbar) +if SHOW_STATS +fig2 = figure('Name', 'Gain Study - Peak Statistics', 'NumberTitle', 'off', 'Position', [100 50 900 280]); + +ax3 = subplot(1,2,1); +h_scatter = plot(ax3, NaN, NaN, 'b.', 'MarkerSize', 6); +hold(ax3, 'on'); +h_mean_line = yline(ax3, 0, 'r--', 'LineWidth', 1.2); +xlabel(ax3, 'Measurement #'); ylabel(ax3, '\Deltapeak (dB)'); +title(ax3, sprintf('Peak delta (n = 0 / %d)', N_STAT)); +grid(ax3, 'on'); + +ax4 = subplot(1,2,2); +h_err = errorbar(ax4, 1, 0, 0, 'rs', 'MarkerSize', 10, 'LineWidth', 1.5, 'CapSize', 12); +ylabel(ax4, '\Deltapeak (dB)'); +title(ax4, 'Mean \pm 1\sigma'); +grid(ax4, 'on'); xlim(ax4, [0.5 1.5]); set(ax4, 'XTick', []); +end % SHOW_STATS + +%% Figure 3 - Histogram +if SHOW_HIST +fig3 = figure('Name', 'Gain Study - Delta Distribution', 'NumberTitle', 'off', 'Position', [1050 350 700 420]); +ax5 = axes(fig3); +ylabel(ax5, 'Count'); xlabel(ax5, '\Deltapeak (dB)'); +title(ax5, 'Gain delta distribution (n = 0)'); +grid(ax5, 'on'); +end % SHOW_HIST + +drawnow; + +%% Stream - collect N_STAT measurements, then keep live spectrum going +if SHOW_SPECTRA +fprintf('Streaming - close figure to stop.\n'); +diff_log = []; +filt_peak_log = []; + +while ishandle(h_spec) + data = rx(); + x = double(data(1:NFFT, 1)) / 32768; + X = fftshift(fft(x .* window, NFFT)); + mag = 20*log10(abs(X) / cg + eps); + peak = max(mag); + + set(h_spec, 'YData', mag); + title(ax2, sprintf('Filtered (live) | peak = %.2f dBFS', peak)); + + if numel(diff_log) < N_STAT + diff_log(end+1) = peak - ref_peak; %#ok + filt_peak_log(end+1) = peak; %#ok + n = numel(diff_log); + d_mean = mean(diff_log); + d_std = std(diff_log); + p_mean = mean(filt_peak_log); + p_std = std(filt_peak_log); + + if SHOW_STATS + set(h_scatter, 'XData', 1:n, 'YData', diff_log); + h_mean_line.Value = d_mean; + set(h_err, 'YData', d_mean, 'YNegativeDelta', d_std, 'YPositiveDelta', d_std); + title(ax3, sprintf('Peak delta (n = %d / %d)', n, N_STAT)); + title(ax4, sprintf('Mean \\pm 1\\sigma = %.3f \\pm %.3f dB', d_mean, d_std)); + end + + % FFT errorbar: fixed x at ref peak freq, y = filtered peak mean +/- std + set(h_eb_fft, 'YData', p_mean, 'YNegativeDelta', p_std, 'YPositiveDelta', p_std, 'Visible', 'on'); + + if SHOW_HIST && (mod(n, 25) == 0 || n == N_STAT) + [counts, edges] = histcounts(diff_log, 30); + centers = (edges(1:end-1) + edges(2:end)) / 2; + cla(ax5); + bar(ax5, centers, counts, 1.0, 'FaceColor', [0.3 0.6 0.9], 'EdgeColor', 'none'); + ylabel(ax5, 'Count'); xlabel(ax5, '\Deltapeak (dB)'); + grid(ax5, 'on'); + if d_std > 0 + pad = max(4 * d_std, 0.5); + xlim(ax5, [d_mean - pad, d_mean + pad]); + end + title(ax5, sprintf('Gain delta distribution (n = %d) | std = %.3f dB', n, d_std)); + end + + if n == N_STAT + fprintf('Done - mean: %.4f dB std: %.4f dB\n', d_mean, d_std); + + % Recommended linear scaling to drive mean Δpeak toward 0 dB: + % If mean Δpeak is +X dB (filtered is hotter), scale should be < 1. + scale_lin = 10.^(-d_mean/20); + tap_recommended = TAP_FLOAT_FIXED * scale_lin; + fprintf('Recommended linear scale (to target 0 dB mean): %.6f\n', scale_lin); + fprintf('Suggested TAP_FLOAT_FIXED next run: %.4f (current %.4f)\n', tap_recommended, TAP_FLOAT_FIXED); + + if SAVE_RESULTS + saveRun(RUN_DIR, 'stats', {fig1, fig2, fig3}, ... + {SHOW_SPECTRA, SHOW_STATS, SHOW_HIST}, ... + N_TAPS, TAP_POS, N_STAT, TONE_FREQ_HZ, TAP_FLOAT_FIXED, d_mean, d_std, scale_lin, tap_recommended); + end + end + end + + drawnow limitrate; +end +end % SHOW_SPECTRA + +%% ========================================================= +% Local helpers +% ========================================================= +function saveFig(run_dir, name, fig) + if ~ishandle(fig), return; end + base = fullfile(run_dir, name); + exportgraphics(fig, [base '.png'], 'Resolution', 150); + savefig(fig, [base '.fig']); +end + +function saveRun(run_dir, tag, figs, flags, n_taps, tap_pos, n_stat, tone_hz, tap_fixed, d_mean, d_std, scale_lin, tap_recommended) + names = {'spectra', 'stats', 'histogram'}; + for k = 1:numel(figs) + if flags{k} && ishandle(figs{k}) + saveFig(run_dir, sprintf('%s_%s', tag, names{k}), figs{k}); + end + end + % write a small summary text file + fid = fopen(fullfile(run_dir, 'run_info.txt'), 'w'); + fprintf(fid, 'timestamp : %s\n', datetime('now','Format','yyyy-MM-dd HH:mm:ss')); + fprintf(fid, 'tone_hz : %.0f\n', tone_hz); + fprintf(fid, 'tap_float_fixed : %.4f\n', tap_fixed); + fprintf(fid, 'n_taps : %d\n', n_taps); + fprintf(fid, 'tap_pos : %d\n', tap_pos); + fprintf(fid, 'n_stat : %d\n', n_stat); + fprintf(fid, 'delta_mean : %.4f dB\n', d_mean); + fprintf(fid, 'delta_std : %.4f dB\n', d_std); + fprintf(fid, 'scale_lin_reco : %.8f\n', scale_lin); + fprintf(fid, 'tap_float_reco : %.4f\n', tap_recommended); + fclose(fid); + fprintf('Run saved to: %s\n', run_dir); +end diff --git a/hsx_examples/streaming/pfir_sweep_study.m b/hsx_examples/streaming/pfir_sweep_study.m new file mode 100644 index 00000000..656fe68e --- /dev/null +++ b/hsx_examples/streaming/pfir_sweep_study.m @@ -0,0 +1,142 @@ +function pfir_sweep_study(rx, best_pos, ref_dBFS, N_TAPS, N_SWEEP_LOG, N_SWEEP_LIN, ... + N_FRAMES, NFFT, Fs, TONE_FREQ_HZ, CAL_FILE, PFIR_GAIN, PFIR_SCALAR) +% pfir_sweep_study Sweep tap_float from 0 → 4 at the given tap position and +% plot gain vs. tap value + linearity check. +% +% Called from pfir_gain_calibration.m when RUN_SWEEP = 1. Produces a +% standalone figure — does not modify any workspace variables in the caller. +% +% Inputs: +% rx — configured adi.AD9084.Rx System object +% best_pos — tap position to set non-zero (from Phase 1) +% ref_dBFS — bypass (PFIR disabled) tone power in dBFS +% N_TAPS — PFIR tap count +% N_SWEEP_LOG — number of log-spaced points (0.01 → 0.3) +% N_SWEEP_LIN — number of linear-spaced points (0.3 → 4.0) +% N_FRAMES — FFT frames to average per measurement +% NFFT — FFT size +% Fs — sample rate (Hz) +% TONE_FREQ_HZ — DDS tone frequency (Hz) +% CAL_FILE — filename for temporary filter file +% PFIR_GAIN — gain string passed to PFilt +% PFIR_SCALAR — scalar_gain string passed to PFilt + +fprintf('\n--- Phase 2 (sweep study): tap position %d, ref = %.2f dBFS ---\n', ... + best_pos, ref_dBFS); + +sweep_vals = sort(unique([ ... + logspace(-2, log10(0.3), N_SWEEP_LOG), ... + linspace(0.3, 4.0, N_SWEEP_LIN), ... + ])); +sweep_gains = nan(size(sweep_vals)); + +for k = 1:numel(sweep_vals) + v = sweep_vals(k); + taps = zeros(N_TAPS, 1); + taps(best_pos) = v; + + pf = adi.AD9084.PFilt(taps, 'mode', 'real_n2', ... + 'gain', PFIR_GAIN, 'scalar_gain', PFIR_SCALAR); + pf.write(CAL_FILE); + + release(rx); + rx.PFIRFilenames = CAL_FILE; + rx(); + + sweep_gains(k) = measureTonePower(rx, TONE_FREQ_HZ, Fs, NFFT, N_FRAMES) - ref_dBFS; + fprintf(' tap_float = %.5f (hw = %5d) : %+.2f dB\n', ... + v, round(16384*v), sweep_gains(k)); +end + +[peak_gain, best_val_idx] = max(sweep_gains); +unity_float = sweep_vals(best_val_idx); +unity_hw = round(16384 * unity_float); + +fprintf(' Peak gain : %+.2f dB at tap_float = %.5f (hw = %d)\n', ... + peak_gain, unity_float, unity_hw); + +% Noise floor and signal region masks +gain_min_p2 = min(sweep_gains); +plateau_mask = sweep_gains < (gain_min_p2 + 3); +noise_floor_est = median(sweep_gains(plateau_mask)); +noise_floor_mask = sweep_gains < (noise_floor_est + 3); + +sweep_dBFS = sweep_gains + ref_dBFS; + +% ---- Figure ---- +figure('Name', 'Phase 2 — Tap Value Sweep Study', 'NumberTitle', 'off', ... + 'Position', [200 200 1100 500]); + +% --- Gain vs. tap value --- +subplot(1,2,1); +plot(sweep_vals(~noise_floor_mask), sweep_dBFS(~noise_floor_mask), ... + 'b.-', 'MarkerSize', 12, 'LineWidth', 1.5, 'DisplayName', 'Measurable signal'); +hold on; +if any(noise_floor_mask) + plot(sweep_vals(noise_floor_mask), sweep_dBFS(noise_floor_mask), ... + 'Color', [0.6 0.6 0.6], 'Marker', '.', 'MarkerSize', 10, 'LineStyle', 'none', ... + 'DisplayName', sprintf('Near noise floor (~%.0f dBFS)', noise_floor_est + ref_dBFS)); +end +yline(ref_dBFS, 'r--', sprintf('Bypass = %.1f dBFS', ref_dBFS), ... + 'LineWidth', 1.5, 'LabelHorizontalAlignment', 'left'); +yline(noise_floor_est + ref_dBFS, 'k:', ... + sprintf('Noise ~%.0f dBFS', noise_floor_est + ref_dBFS), ... + 'LineWidth', 1, 'LabelHorizontalAlignment', 'right'); +xline(unity_float, 'g--', ... + sprintf('Peak = %.4f (hw %d, %.1f dBFS)', unity_float, unity_hw, peak_gain + ref_dBFS), ... + 'LineWidth', 1.5, 'LabelVerticalAlignment', 'bottom'); +legend('Location', 'southeast'); +xlabel('tap\_float (Q14 scale)'); +ylabel('Power (dBFS)'); +title(sprintf('Gain vs. Tap Value (pos %d) | Peak tap = %.4f', best_pos, unity_float)); +grid on; + +% --- Linearity check: amplitude ratio vs. tap value --- +subplot(1,2,2); +sig_vals = sweep_vals(~noise_floor_mask); +sig_gains = sweep_gains(~noise_floor_mask); +amp_ratio = sqrt(10.^(sig_gains / 10)); + +[~, anc_k] = min(abs(sig_vals - unity_float)); +amp_at_anchor = amp_ratio(anc_k); +amp_norm = amp_ratio * (unity_float / amp_at_anchor); + +plot(sig_vals, amp_norm, 'b.-', 'MarkerSize', 12, 'LineWidth', 1.5, ... + 'DisplayName', 'Measured (normalised)'); +hold on; +plot([0, max(sig_vals)], [0, max(sig_vals)], 'r--', 'LineWidth', 1.5, ... + 'DisplayName', 'Ideal: amp = tap\_float'); +xline(unity_float, 'g--', sprintf('Peak = %.4f', unity_float), ... + 'LineWidth', 1.5, 'LabelVerticalAlignment', 'bottom', 'HandleVisibility', 'off'); +legend('Location', 'northwest'); +xlabel('tap\_float'); +ylabel('Amplitude ratio (normalised)'); +title('Linearity check: amplitude ratio vs. tap value'); +grid on; + +end % pfir_sweep_study + + +% ========================================================= +% Local helper (mirrors measureTonePower in main script) +% ========================================================= +function [peak_dBFS, pwr_avg, f_bins] = measureTonePower(rx, tone_hz, Fs, nfft, n_frames) + window = hann(nfft, 'periodic'); + cg = sum(window) / 2; + + pwr_sum = zeros(nfft, 1); + for k = 1:n_frames + data = rx(); + x = double(data(1:nfft, 1)) / 32768; + X = fft(x .* window, nfft); + pwr_sum = pwr_sum + abs(X).^2; + end + pwr_avg = pwr_sum / n_frames; + + f_bins = (0:nfft-1).' * Fs / nfft; + + % Use global max — tone is not at TONE_FREQ_HZ in the captured baseband + % due to NCO mixing offsets. Hardcoded bin search finds noise, not signal. + [peak_pwr, ~] = max(pwr_avg); + peak_dBFS = 10*log10(peak_pwr / cg^2 + eps); +end diff --git a/hsx_examples/streaming/plotting_fft.m b/hsx_examples/streaming/plotting_fft.m new file mode 100644 index 00000000..cd8f5b02 --- /dev/null +++ b/hsx_examples/streaming/plotting_fft.m @@ -0,0 +1,51 @@ + +function plotting_fft(rx) +% plotting_fft Real-time FFT plot for AD9084 RX +% +% plotting_fft(rx) +% +% rx : adi.AD9084.Rx object (already configured and streaming) + + %% --- Get sampling rate safely --- + Fs = double(rx.SamplingRate); + assert(~isnan(Fs) && Fs > 0, 'Invalid SamplingRate'); + + fprintf('Sampling rate: %.3f MHz\n', Fs/1e6); + + %% --- FFT parameters --- + NFFT = 4096; + window = hann(NFFT, 'periodic'); + + %% --- Set up plot --- + figure('Name','AD9084 Real-Time FFT'); + h = plot(nan, nan); + grid on; + xlabel('Frequency (MHz)'); + ylim([-140 5]); + ylabel('Magnitude (dBFS)'); + title('AD9084 Real-Time FFT'); + + coherentGain = sum(window) / 2; + + %% --- Streaming loop --- + while isvalid(h) + data = rx(); % BLOCKING until frame arrives + + % Data is assumed to be complex (16384x1 complex double) + x = data(:,1) / 32768; + + % Window + FFT + xw = x(1:NFFT) .* window; + X = fftshift(fft(xw, NFFT)); + + % Magnitude (dBFS) + mag_dBFS = 20*log10((abs(X) / coherentGain) + eps); + + % Frequency axis + f = linspace(-Fs/2, Fs/2, NFFT) / 1e6; + + % Update plot + set(h, 'XData', f, 'YData', mag_dBFS); + drawnow; + end +end diff --git a/test/AD9084HWTests.m b/test/AD9084HWTests.m new file mode 100644 index 00000000..e1296b5e --- /dev/null +++ b/test/AD9084HWTests.m @@ -0,0 +1,532 @@ +classdef AD9084HWTests < HardwareTests + + properties + uri = 'ip:192.168.2.1'; + author = 'ADI'; + end + + methods(TestClassSetup) + function CheckForHardware(~) + disp('Skipping init test'); + end + end + + methods (Static) + function estFrequency(data, fs, saveNoShow, figname) + nSamp = length(data); + FFTRxData = fftshift(10*log10(abs(fft(data)))); + df = fs/nSamp; freqRangeRx = (0:df:fs/2-df).'/1000; + if nargin < 3 + saveNoShow = false; + end + if nargin < 4 + figname = 'freq_plot'; + end + if saveNoShow + f = figure('visible', 'off'); + end + plot(freqRangeRx, FFTRxData(end-length(freqRangeRx)+1:end, :)); + if saveNoShow + saveas(f, figname, 'png') + saveas(f, figname, 'fig') + end + end + + function freq = estFrequencyMax(data, fs, saveNoShow, figname) + % Peak frequency estimation for complex I/Q data. + % Returns the absolute frequency of the strongest bin + % in the full [-Fs/2, Fs/2] spectrum. + nSamp = length(data); + fs = double(fs); + freqRange = linspace(-fs/2, fs/2, nSamp).'; + FFTRxData = fftshift(20*log10(abs(fft(data)) + eps)); + [~, ind] = max(FFTRxData(:,1)); + freq = abs(freqRange(ind)); + if nargin >= 3 && saveNoShow + if nargin < 4 + figname = 'freq_plot'; + end + f = figure('visible', 'off'); + plot(freqRange/1e6, FFTRxData(:,1)); + xlabel('Frequency (MHz)'); ylabel('Magnitude (dB)'); + saveas(f, figname, 'png') + saveas(f, figname, 'fig') + end + end + end + + methods (Test) + + function testAD9084Rx(testCase) + % Test Rx DMA data output + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + [out, valid] = rx(); + testCase.verifyTrue(valid); + testCase.verifyGreaterThan(sum(abs(double(out))), 0); + rx.release(); + end + + function testAD9084DDSFrequencySweep(testCase) + % Diagnostic: sweep DDS frequencies and report measured values + testFreqs = [10e6, 20e6, 45e6, 100e6, 200e6]; + for fi = 1:numel(testFreqs) + toneFreq = testFreqs(fi); + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = 1; + tx.DataSource = 'DDS'; + tx.MainNCOFrequencies = [1e9 0 0 0]; + tx.ChannelNCOFrequencies = [100e6 0 0 0]; + tx.MainNCOPhases = [0 0 0 0]; + tx.ChannelNCOPhases = [0 0 0 0]; + tx.NCOEnables = [true false false false]; + tx.DDSFrequencies = [toneFreq, toneFreq; 0, 0]; + tx.DDSScales = [0.9, 0.9; 0, 0]; + tx.DDSPhases = [0, 90000; 0, 0]; + tx(); + pause(1); + + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + rx.MainNCOFrequencies = [1e9 0 0 0]; + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + for k = 1:10 + [out, ~] = rx(); + end + sr = rx.SamplingRate; + freqEst = testCase.estFrequencyMax(double(out), sr); + fprintf('DDS=%.0f MHz Measured=%.2f MHz Offset=%.2f MHz\n', ... + toneFreq/1e6, freqEst/1e6, (freqEst - toneFreq)/1e6); + rx.release(); + tx.release(); + end + end + + function testAD9084RxWithTxDDS(testCase) + % Test DDS output — single channel + toneFreq = 45e6; + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = 1; + tx.DataSource = 'DDS'; + tx.MainNCOFrequencies = [1e9 0 0 0]; + tx.ChannelNCOFrequencies = [100e6 0 0 0]; + tx.MainNCOPhases = [0 0 0 0]; + tx.ChannelNCOPhases = [0 0 0 0]; + tx.NCOEnables = [true false false false]; + tx.DDSFrequencies = [toneFreq, toneFreq; 0, 0]; + tx.DDSScales = [0.9, 0.9; 0, 0]; + tx.DDSPhases = [0, 90000; 0, 0]; + tx(); + pause(1); + + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + rx.MainNCOFrequencies = [1e9 0 0 0]; + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + valid = false; + for k = 1:10 + [out, valid] = rx(); + end + sr = rx.SamplingRate; + + freqEst = testCase.estFrequencyMax(double(out), sr); + relError = (freqEst - toneFreq) / toneFreq; + fprintf('Expected: %.3f MHz Actual: %.3f MHz RelError: %.5f\n', ... + toneFreq/1e6, freqEst/1e6, relError); + testCase.verifyTrue(valid); + testCase.verifyGreaterThan(sum(abs(double(out))), 0); + testCase.verifyEqual(freqEst, toneFreq, 'RelTol', 0.01, ... + 'Frequency of DDS tone unexpected'); + rx.release(); + tx.release(); + end + + function testAD9084RxWithTxDDSTwoChan(testCase) + % Test DDS output — two channels at different frequencies + toneFreq1 = 45e6; + toneFreq2 = 90e6; + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = [1 2]; + tx.DataSource = 'DDS'; + tx.MainNCOFrequencies = [1e9 1e9 0 0]; + tx.ChannelNCOFrequencies = [100e6 100e6 0 0]; + tx.MainNCOPhases = [0 0 0 0]; + tx.ChannelNCOPhases = [0 0 0 0]; + tx.NCOEnables = [true true false false]; + tx.DDSFrequencies = [toneFreq1, toneFreq1, toneFreq2, toneFreq2; 0, 0, 0, 0]; + tx.DDSScales = [0.9, 0.9, 0.9, 0.9; 0, 0, 0, 0]; + tx.DDSPhases = [0, 90000, 0, 90000; 0, 0, 0, 0]; + tx(); + pause(1); + + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = [1 2]; + rx.MainNCOFrequencies = [1e9 1e9 0 0]; + rx.ChannelNCOFrequencies = [100e6 100e6 0 0]; + valid = false; + for k = 1:10 + [out, valid] = rx(); + end + sr = rx.SamplingRate; + + freqEst1 = testCase.estFrequencyMax(double(out(:,1)), sr); + freqEst2 = testCase.estFrequencyMax(double(out(:,2)), sr); + relError1 = (freqEst1 - toneFreq1) / toneFreq1; + relError2 = (freqEst2 - toneFreq2) / toneFreq2; + fprintf('Ch1 Expected: %.3f MHz Actual: %.3f MHz RelError: %.3f\n', ... + toneFreq1/1e6, freqEst1/1e6, relError1); + fprintf('Ch2 Expected: %.3f MHz Actual: %.3f MHz RelError: %.3f\n', ... + toneFreq2/1e6, freqEst2/1e6, relError2); + testCase.verifyTrue(valid); + testCase.verifyGreaterThan(sum(abs(double(out))), 0); + testCase.verifyEqual(freqEst1, toneFreq1, 'RelTol', 0.01, ... + 'Frequency of DDS tone Ch1 unexpected'); + testCase.verifyEqual(freqEst2, toneFreq2, 'RelTol', 0.01, ... + 'Frequency of DDS tone Ch2 unexpected'); + rx.release(); + tx.release(); + end + + function testAD9084RxWithTxData(testCase) + % Test Tx DMA data output — single channel + rx_probe = adi.AD9084.Rx('uri', testCase.uri); + rx_probe.EnabledChannels = 1; + rx_probe(); + sr = double(rx_probe.SamplingRate); + rx_probe.release(); + + amplitude = 2^15; frequency = sr/6; + swv1 = dsp.SineWave(amplitude, frequency); + swv1.ComplexOutput = true; + swv1.SamplesPerFrame = 2^20; + swv1.SampleRate = sr; + y = swv1(); + + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = 1; + tx.DataSource = 'DMA'; + tx.MainNCOFrequencies = [1e9 0 0 0]; + tx.ChannelNCOFrequencies = [100e6 0 0 0]; + tx.NCOEnables = [true false false false]; + tx.EnableCyclicBuffers = true; + tx(y); + + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + rx.MainNCOFrequencies = [1e9 0 0 0]; + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + for k = 1:10 + [out, valid] = rx(); + end + sr = rx.SamplingRate; + + + freqEst = testCase.estFrequencyMax(double(out), sr); + relError = (freqEst - frequency) / frequency; + fprintf('Expected: %.3f MHz Actual: %.3f MHz RelError: %.3f\n', ... + frequency/1e6, freqEst/1e6, relError); + testCase.verifyTrue(valid); + testCase.verifyGreaterThan(sum(abs(double(out))), 0); + testCase.verifyEqual(freqEst, frequency, 'RelTol', 0.01, ... + 'Frequency of DMA tone unexpected'); + rx.release(); + tx.release(); + end + + function testAD9084RxWithTxDataTwoChan(testCase) + % Test Tx DMA data output — two channels + rx_probe = adi.AD9084.Rx('uri', testCase.uri); + rx_probe.EnabledChannels = 1; + rx_probe(); + sr = double(rx_probe.SamplingRate); + rx_probe.release(); + + amplitude = 2^15; toneFreq1 = sr/5; + swv1 = dsp.SineWave(amplitude, toneFreq1); + swv1.ComplexOutput = true; + swv1.SamplesPerFrame = 2^20; + swv1.SampleRate = sr; + y1 = swv1(); + + amplitude = 2^15; toneFreq2 = sr/8; + swv2 = dsp.SineWave(amplitude, toneFreq2); + swv2.ComplexOutput = true; + swv2.SamplesPerFrame = 2^20; + swv2.SampleRate = sr; + y2 = swv2(); + + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = [1 2]; + tx.DataSource = 'DMA'; + tx.MainNCOFrequencies = [1e9 1e9 0 0]; + tx.ChannelNCOFrequencies = [100e6 100e6 0 0]; + tx.NCOEnables = [true true false false]; + tx.EnableCyclicBuffers = true; + tx([y1, y2]); + + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = [1 2]; + rx.MainNCOFrequencies = [1e9 1e9 0 0]; + rx.ChannelNCOFrequencies = [100e6 100e6 0 0]; + for k = 1:10 + [out, valid] = rx(); + end + sr = rx.SamplingRate; + + + freqEst1 = testCase.estFrequencyMax(double(out(:,1)), sr); + freqEst2 = testCase.estFrequencyMax(double(out(:,2)), sr); + relError1 = (freqEst1 - toneFreq1) / toneFreq1; + relError2 = (freqEst2 - toneFreq2) / toneFreq2; + fprintf('Ch1 Expected: %.3f MHz Actual: %.3f MHz RelError: %.3f\n', ... + toneFreq1/1e6, freqEst1/1e6, relError1); + fprintf('Ch2 Expected: %.3f MHz Actual: %.3f MHz RelError: %.3f\n', ... + toneFreq2/1e6, freqEst2/1e6, relError2); + testCase.verifyTrue(valid); + testCase.verifyGreaterThan(sum(abs(double(out))), 0); + testCase.verifyEqual(freqEst1, toneFreq1, 'RelTol', 0.01, ... + 'Frequency of DMA tone Ch1 unexpected'); + testCase.verifyEqual(freqEst2, toneFreq2, 'RelTol', 0.01, ... + 'Frequency of DMA tone Ch2 unexpected'); + rx.release(); + tx.release(); + end + + function testAD9084RxWithPFIR(testCase) + % Test PFIR filter loading on Rx with DDS loopback + toneFreq = 45e6; + + % Create a unity passthrough PFIR filter file (impulse at center tap) + pfirTaps = zeros(16, 1); + pfirTaps(8) = 1; + pf = adi.AD9084.PFilt(pfirTaps); + pfirFile = [tempname, '.txt']; + cleanFile = onCleanup(@() delete(pfirFile)); + pf.write(pfirFile); + + % Configure Tx DDS + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = 1; + tx.DataSource = 'DDS'; + tx.MainNCOFrequencies = [1e9 0 0 0]; + tx.ChannelNCOFrequencies = [100e6 0 0 0]; + tx.MainNCOPhases = [0 0 0 0]; + tx.ChannelNCOPhases = [0 0 0 0]; + tx.NCOEnables = [true false false false]; + tx.DDSFrequencies = [toneFreq, toneFreq; 0, 0]; + tx.DDSScales = [0.9, 0.9; 0, 0]; + tx.DDSPhases = [0, 90000; 0, 0]; + tx(); + pause(1); + + % Configure Rx with PFIR enabled + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + rx.MainNCOFrequencies = [1e9 0 0 0]; + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + rx.EnablePFIRs = true; + rx.PFIRFilenames = pfirFile; + valid = false; + for k = 1:10 + [out, valid] = rx(); + end + sr = rx.SamplingRate; + + + freqEst = testCase.estFrequencyMax(double(out), sr); + relError = (freqEst - toneFreq) / toneFreq; + fprintf('PFIR Expected: %.3f MHz Actual: %.3f MHz RelError: %.3f\n', ... + toneFreq/1e6, freqEst/1e6, relError); + testCase.verifyTrue(valid); + testCase.verifyGreaterThan(sum(abs(double(out))), 0); + testCase.verifyEqual(freqEst, toneFreq, 'RelTol', 0.01, ... + 'Frequency with PFIR enabled unexpected'); + rx.release(); + tx.release(); + end + + function testAD9084RxWithCFIR(testCase) + % Test CFIR filter loading on Rx with DDS loopback + toneFreq = 45e6; + + % Create a unity passthrough CFIR filter file (impulse at center tap) + cfirTaps = zeros(16, 1); + cfirTaps(8) = 1; + cf = adi.AD9084.CFIR(cfirTaps); + cfirFile = [tempname, '.txt']; + cleanFile = onCleanup(@() delete(cfirFile)); + cf.write(cfirFile); + + % Configure Tx DDS + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = 1; + tx.DataSource = 'DDS'; + tx.MainNCOFrequencies = [1e9 0 0 0]; + tx.ChannelNCOFrequencies = [100e6 0 0 0]; + tx.MainNCOPhases = [0 0 0 0]; + tx.ChannelNCOPhases = [0 0 0 0]; + tx.NCOEnables = [true false false false]; + tx.DDSFrequencies = [toneFreq, toneFreq; 0, 0]; + tx.DDSScales = [0.5, 0.5; 0, 0]; + tx.DDSPhases = [0, 90000; 0, 0]; + tx(); + pause(1); + + % Configure Rx with CFIR enabled + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + rx.MainNCOFrequencies = [1e9 0 0 0]; + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + rx.EnableCFIRs = true; + rx.CFIRFilenames = cfirFile; + valid = false; + for k = 1:10 + [out, valid] = rx(); + end + sr = rx.SamplingRate; + rx.release(); + tx.release(); + + freqEst = testCase.estFrequencyMax(double(out), sr); + relError = (freqEst - toneFreq) / toneFreq; + fprintf('CFIR Expected: %.3f MHz Actual: %.3f MHz RelError: %.3f\n', ... + toneFreq/1e6, freqEst/1e6, relError); + testCase.verifyTrue(valid); + testCase.verifyGreaterThan(sum(abs(double(out))), 0); + testCase.verifyEqual(freqEst, toneFreq, 'RelTol', 0.01, ... + 'Frequency with CFIR enabled unexpected'); + end + + function testAD9084RxPFIRAttenuation(testCase) + % Verify PFIR attenuates a tone in the filter stopband. + % Uses a HP filter (passband > 0.5 norm = 5 GHz at 20 GHz). + % DDS tone at 45 MHz → lands at ~1.145 GHz in the PFIR domain, + % deep in the HP stopband. Compares tone power with all-pass + % vs HP filter and verifies attenuation exceeds threshold. + toneFreq = 45e6; + ATTN_THRESHOLD_DB = 6; + + % Design HP filter: passes above 0.5 normalized (5 GHz at 20 GHz) + Ntaps = 15; + F_norm = linspace(-1, 1, 501); + amp_HP = double(F_norm > 0.5); + D_HP = fdesign.arbmag('N,F,A', Ntaps, F_norm, amp_HP); + EQ_HP = design(D_HP, 'allfir', SystemObject=true); + hpTaps = EQ_HP{1,2}.Numerator(:); + + % Create all-pass and HP filter files + apTaps = zeros(16, 1); apTaps(8) = 1.0; + pfAP = adi.AD9084.PFilt(apTaps, 'mode', 'real_n2', 'gain', "0", 'scalar_gain', "63"); + pfHP = adi.AD9084.PFilt(hpTaps, 'mode', 'real_n2', 'gain', "0", 'scalar_gain', "63"); + apFile = [tempname, '.txt']; pfAP.write(apFile); + hpFile = [tempname, '.txt']; pfHP.write(hpFile); + cleanAP = onCleanup(@() delete(apFile)); + cleanHP = onCleanup(@() delete(hpFile)); + + % Configure TX DDS + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = 1; + tx.DataSource = 'DDS'; + tx.MainNCOFrequencies = [1e9 0 0 0]; + tx.ChannelNCOFrequencies = [100e6 0 0 0]; + tx.MainNCOPhases = [0 0 0 0]; + tx.ChannelNCOPhases = [0 0 0 0]; + tx.NCOEnables = [true false false false]; + tx.DDSFrequencies = [toneFreq, toneFreq; 0, 0]; + tx.DDSScales = [0.9, 0.9; 0, 0]; + tx.DDSPhases = [90000, 0; 0, 0]; + tx(); + pause(1); + + % RX with all-pass PFIR — reference capture + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + rx.MainNCOFrequencies = [1e9 0 0 0]; + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + rx.EnablePFIRs = true; + rx.PFIRFilenames = apFile; + for k = 1:10, out = rx(); end + refPower = max(20*log10(abs(fft(double(out(:,1)))) + eps)); + + % Reload with HP PFIR — filtered capture + release(rx); + rx.PFIRFilenames = hpFile; + for k = 1:10, out = rx(); end + filtPower = max(20*log10(abs(fft(double(out(:,1)))) + eps)); + + attenuation = refPower - filtPower; + fprintf('PFIR Attenuation: %.2f dB (threshold: %d dB)\n', attenuation, ATTN_THRESHOLD_DB); + testCase.verifyGreaterThan(attenuation, ATTN_THRESHOLD_DB, ... + 'PFIR did not attenuate stopband tone sufficiently'); + rx.release(); + tx.release(); + end + + function testAD9084RxCFIRAttenuation(testCase) + % Verify CFIR attenuates a tone in the filter stopband. + % Uses a LP filter (passband < 0.2 norm = 250 MHz at 2.5 GHz). + % DDS tone at 900 MHz → appears at 900 MHz in the CFIR domain, + % well above the LP cutoff. Compares tone power with all-pass + % vs LP filter and verifies attenuation exceeds threshold. + toneFreq = 900e6; + ATTN_THRESHOLD_DB = 6; + + % Design LP filter: passes below 0.2 normalized (250 MHz at 2.5 GHz) + Ntaps = 15; + F_norm = linspace(-1, 1, 501); + amp_LP = double(abs(F_norm) < 0.2); + D_LP = fdesign.arbmag('N,F,A', Ntaps, F_norm, amp_LP); + EQ_LP = design(D_LP, 'allfir', SystemObject=true); + lpTaps = EQ_LP{1,2}.Numerator(:); + + % Create all-pass and LP filter files + apTaps = zeros(16, 1); apTaps(8) = 1.0; + cfAP = adi.AD9084.CFIR(apTaps, 'gain', "0", 'complex_scalar', [32767 0]); + cfLP = adi.AD9084.CFIR(lpTaps, 'gain', "0", 'complex_scalar', [32767 0]); + apFile = [tempname, '.txt']; cfAP.write(apFile); + lpFile = [tempname, '.txt']; cfLP.write(lpFile); + cleanAP = onCleanup(@() delete(apFile)); + cleanLP = onCleanup(@() delete(lpFile)); + + % Configure TX DDS + tx = adi.AD9084.Tx('uri', testCase.uri); + tx.EnabledChannels = 1; + tx.DataSource = 'DDS'; + tx.MainNCOFrequencies = [1e9 0 0 0]; + tx.ChannelNCOFrequencies = [100e6 0 0 0]; + tx.MainNCOPhases = [0 0 0 0]; + tx.ChannelNCOPhases = [0 0 0 0]; + tx.NCOEnables = [true false false false]; + tx.DDSFrequencies = [toneFreq, toneFreq; 0, 0]; + tx.DDSScales = [0.9, 0.9; 0, 0]; + tx.DDSPhases = [90000, 0; 0, 0]; + tx(); + pause(1); + + % RX with all-pass CFIR — reference capture + rx = adi.AD9084.Rx('uri', testCase.uri); + rx.EnabledChannels = 1; + rx.MainNCOFrequencies = [1e9 0 0 0]; + rx.ChannelNCOFrequencies = [100e6 0 0 0]; + rx.EnableCFIRs = true; + rx.CFIRFilenames = apFile; + for k = 1:10, out = rx(); end + refPower = max(20*log10(abs(fft(double(out(:,1)))) + eps)); + + % Reload with LP CFIR — filtered capture + release(rx); + rx.CFIRFilenames = lpFile; + for k = 1:10, out = rx(); end + filtPower = max(20*log10(abs(fft(double(out(:,1)))) + eps)); + + attenuation = refPower - filtPower; + fprintf('CFIR Attenuation: %.2f dB (threshold: %d dB)\n', attenuation, ATTN_THRESHOLD_DB); + testCase.verifyGreaterThan(attenuation, ATTN_THRESHOLD_DB, ... + 'CFIR did not attenuate stopband tone sufficiently'); + rx.release(); + tx.release(); + end + + end + +end diff --git a/test/FIRcoeff.m b/test/FIRcoeff.m new file mode 100644 index 00000000..f6682a50 --- /dev/null +++ b/test/FIRcoeff.m @@ -0,0 +1,25 @@ +function [hex_I, hex_Q] = FIRcoeff(taps) +% FIRcoeff Quantize filter taps to Q15 hex and return I/Q columns. +% [hex_I, hex_Q] = FIRcoeff(taps) +% +% Real taps: hex_I = hex_Q (duplicated) +% Complex taps: hex_I = real part, hex_Q = imaginary part +% +% Quantization: scale by 2^15, clamp to int16 range [-32768, 32767] + + scale = 2^15; + + % Quantize real part + i_scaled = round(scale * real(taps)); + i_scaled = max(min(i_scaled, 32767), -32768); + hex_I = dec2hex(double(typecast(int16(i_scaled), 'uint16')), 4); + + % Quantize imaginary part (zero if taps are real) + if ~isreal(taps) + q_scaled = round(scale * imag(taps)); + q_scaled = max(min(q_scaled, 32767), -32768); + hex_Q = dec2hex(double(typecast(int16(q_scaled), 'uint16')), 4); + else + hex_Q = hex_I; + end +end diff --git a/trx_examples/streaming/pfir_gain_calibration.m b/trx_examples/streaming/pfir_gain_calibration.m new file mode 100644 index 00000000..a2921e75 --- /dev/null +++ b/trx_examples/streaming/pfir_gain_calibration.m @@ -0,0 +1,423 @@ +%% pfir_gain_calibration.m +% +% PURPOSE +% Determines empirically the PFIR tap value that produces the maximum +% (loudest) hardware output, to be used as a normalization anchor. +% +% BACKGROUND +% FIRcoeff.m scales tap values using 2^15 (Q15 format): +% hardware_value = round(2^15 * tap_float) = round(32768 * tap_float) +% Theoretically, a single-tap filter with tap_float = 1.0 (hardware = 32767) +% should produce 0 dB gain. In practice, hardware path loss and ADC noise +% mean the measured peak may be slightly below 0 dB. The maximum achievable +% gain IS the correct normalization anchor — it represents the loudest the +% hardware can produce, which is what we want to normalize to. +% +% METHOD +% Phase 0 - Reference: +% Load a disabled-mode PFIR to measure the raw ADC tone level. +% All gains are reported relative to this. +% +% Phase 1 - Position sweep: +% Test 16 single-tap filters (tap_float = 1.0) to find the tap position +% with the highest response. Confirms uniformity across positions. +% +% Phase 2 - Value sweep: +% At the best position, sweep tap_float across a range to find the +% tap value producing maximum gain (the normalization anchor). +% +% Phase 3 - Statistical validation: +% Load the anchor tap once, then take N_REPEATS independent gain +% measurements with no filter reload between them (fast). Build a +% histogram to characterise measurement variance. The median is the +% final reported anchor. +% +% OUTPUT +% Three-subplot figure: gain vs. tap position, gain vs. tap value, +% histogram of repeated anchor measurements. +% Console summary with final normalization anchor and formula. +% +% USAGE +% Edit the Configuration section below, then run the script. + +clear; clc; + +%% ========================================================= +% Configuration +% ========================================================= +URI = 'ip:192.168.2.1'; % board IP +TONE_FREQ_HZ = 10e6; % DDS tone frequency (Hz) + % With matched TX/RX NCOs the digital + % baseband tone is always at this offset. +N_SAMPLES = 16384; % samples per RX frame +NFFT = 4096; % FFT size for power measurement +N_FRAMES = 4; % RX frames to average per measurement +N_TAPS = 16; % PFIR tap count (real_n2 mode = 16) +N_REPEATS = 500; % Phase 3: repeated unity-tap measurements for histogram +N_FRAMES_STAT = 4; % Phase 3: frames per measurement +N_SWEEP_LOG = 15; % Phase 2: points in log region (0.01 → 0.3) +N_SWEEP_LIN = 50; % Phase 2: points in linear region (0.3 → 2.0) + % Total sweep points ≈ N_SWEEP_LOG + N_SWEEP_LIN + % Each point costs ~1-2 s; 65 pts ≈ 1-2 min for Phase 2. + +% --- Diagnostic-only mode --- +% Set DIAG_ONLY = 1 to skip all sweep phases and just capture + display +% the pre/post-filter diagnostic spectra. A fresh filter file is written +% from PFIR_GAIN, PFIR_SCALAR, DIAG_TAP_POS, and DIAG_TAP_FLOAT each run, +% so you can tweak any of those and immediately see the effect on the spectrum. +% DIAG_TAP_POS : which tap position to set non-zero (1–N_TAPS) +% DIAG_TAP_FLOAT : tap coefficient value (0 < value <= 0.9999) +DIAG_ONLY = 1; % 0 = full calibration run, 1 = spectrum check only +DIAG_TAP_POS = 8; % tap position used for the diagnostic filter +DIAG_TAP_FLOAT = 1; % tap value used for the diagnostic filter + +% --- Phase 2 tap sweep (optional) --- +% Set RUN_SWEEP = 1 to run the full tap value sweep (0 → 4.0) via +% pfir_sweep_study.m. Produces a separate figure showing gain vs. tap value, +% linearity check, and register overflow study. +% When RUN_SWEEP = 0 the sweep is skipped and the main figure shows placeholders +% for the Phase 2 subplots. +RUN_SWEEP = 0; % 0 = skip, 1 = run full tap value sweep + +% Gain settings held fixed during calibration. +% Using the same gain/scalar combination as the production filter (pfir_auto.txt) +% to minimize hardware-mode-switch spurs. +% NOTE: observed behaviour suggests scalar_gain may be an attenuation factor +% (lower value = more signal), which is the inverse of the N/64 interpretation +% in the UG. The absolute level does not affect which tap wins the max() — it +% only needs to be constant across all sweep points. +PFIR_GAIN = "6"; +PFIR_SCALAR = "63"; + +% Temporary files written during calibration (deleted at the end) +DISABLED_FILE = 'pfir_cal_disabled.txt'; +CFIR_BYPASS_FILE = 'pfir_cal_cfir_bypass.txt'; +CAL_FILE = 'pfir_cal_single.txt'; + +%% ========================================================= +% Step 1 — Write "disabled" reference filter file +% ========================================================= +% mode: disabled disabled causes the AD9084 driver to bypass the PFIR +% coefficient loading entirely and set both I and Q FIR paths to disabled. +adi.AD9084.writeDisabledFilter(DISABLED_FILE, 'pfir'); +adi.AD9084.writeDisabledFilter(CFIR_BYPASS_FILE, 'cfir'); + +%% ========================================================= +% Step 2 — Configure TX (DDS tone source) +% ========================================================= +fprintf('Connecting TX...\n'); +tx = adi.AD9084.Tx('uri', URI); +tx.EnabledChannels = 1; +tx.SamplesPerFrame = N_SAMPLES; +tx.DataSource = 'DDS'; +tx.MainNCOFrequencies = [1e9 0 0 0]; +tx.ChannelNCOFrequencies = [0 0 0 0]; +tx.MainNCOPhases = [0 0 0 0]; +tx.ChannelNCOPhases = [0 0 0 0]; +tx.NCOEnables = [true false false false]; +tx.DDSFrequencies = [TONE_FREQ_HZ, TONE_FREQ_HZ; 0, 0]; +tx.DDSScales = [.5, .5; 0, 0]; +tx.DDSPhases = [0, 90000; 0, 0]; % In mili-degrees +tx(); +fprintf('TX streaming tone at %.1f MHz.\n', TONE_FREQ_HZ/1e6); + +%% ========================================================= +% Step 3 — Configure RX with PFIR enabled, disabled file +% ========================================================= +fprintf('Connecting RX...\n'); +rx = adi.AD9084.Rx('uri', URI); +rx.EnabledChannels = 1; +rx.SamplesPerFrame = N_SAMPLES; +rx.EnablePFIRs = true; % stays true throughout +rx.PFIRFilenames = DISABLED_FILE; +rx.EnableCFIRs = true; % push bypass:1 to override any leftover filter_demo state +rx.CFIRFilenames = CFIR_BYPASS_FILE; +rx.MainNCOFrequencies = [1e9 0 0 0]; +rx.ChannelNCOFrequencies = [0 0 0 0]; +rx.TestMode = 'off'; + +fprintf('Priming RX (disabled filter reference)...\n'); +rx(); +Fs = double(rx.SamplingRate); +fprintf('Fs = %.3f MHz\n', Fs/1e6); + +%% ========================================================= +% Phase 0 — Reference level (PFIR disabled) +% ========================================================= +fprintf('\n--- Phase 0: Reference (PFIR disabled) ---\n'); + +[ref_dBFS, ref_pwr_avg, f_bins] = measureTonePower(rx, TONE_FREQ_HZ, Fs, NFFT, N_FRAMES); +fprintf(' Reference level : %.2f dBFS\n', ref_dBFS); + +%% ========================================================= +% Phase 1 — Sweep tap positions (tap_float = 1.0) +% ========================================================= +if ~DIAG_ONLY +gain_by_pos = nan(1, N_TAPS); + +for pos = 1:N_TAPS + taps = zeros(N_TAPS, 1); + taps(pos) = 1.0; % Q14 unity: hardware value = round(16384 * 1.0) = 16384 + + pf = adi.AD9084.PFilt(taps, 'mode', 'real_n2', ... + 'gain', PFIR_GAIN, 'scalar_gain', PFIR_SCALAR); + pf.write(CAL_FILE); + + % Swap filter: unlock -> change file -> re-prime + release(rx); + rx.PFIRFilenames = CAL_FILE; + rx(); + + gain_by_pos(pos) = measureTonePower(rx, TONE_FREQ_HZ, Fs, NFFT, N_FRAMES) - ref_dBFS; + fprintf(' Tap pos %2d/%2d : %+.2f dB\n', pos, N_TAPS, gain_by_pos(pos)); +end + +[max_gain_pos, best_pos] = max(gain_by_pos); +spread_dB = max(gain_by_pos) - min(gain_by_pos); +fprintf('\n Best position : tap %d (%+.2f dB)\n', best_pos, max_gain_pos); +fprintf(' Position spread : %.2f dB (should be small if architecture is uniform)\n', spread_dB); + +%% ========================================================= +% Phase 2 — Optional tap value sweep (see pfir_sweep_study.m) +% ========================================================= +if RUN_SWEEP + pfir_sweep_study(rx, best_pos, ref_dBFS, N_TAPS, N_SWEEP_LOG, N_SWEEP_LIN, ... + N_FRAMES, NFFT, Fs, TONE_FREQ_HZ, CAL_FILE, PFIR_GAIN, PFIR_SCALAR); +end + +% Capture tap=1.0 spectrum for the diagnostic figure regardless of RUN_SWEEP. +% This is the theoretical all-pass: a single delay at unity coefficient. +taps_anc_diag = zeros(N_TAPS, 1); +taps_anc_diag(best_pos) = 1.0; +pf_diag = adi.AD9084.PFilt(taps_anc_diag, 'mode', 'real_n2', ... + 'gain', PFIR_GAIN, 'scalar_gain', PFIR_SCALAR); +pf_diag.write(CAL_FILE); +release(rx); +rx.PFIRFilenames = CAL_FILE; +rx(); +[~, anchor_pwr_avg] = measureTonePower(rx, TONE_FREQ_HZ, Fs, NFFT, N_FRAMES); +diag_filter_label = sprintf('unity tap (1.0) pos %d, gain=%s, scalar=%s', ... + best_pos, PFIR_GAIN, PFIR_SCALAR); + +else % DIAG_ONLY — write a fresh filter from current config and load it + +fprintf('\n--- DIAG_ONLY: Writing diagnostic filter (tap %d = %.5f, gain=%s, scalar=%s) ---\n', ... + DIAG_TAP_POS, DIAG_TAP_FLOAT, PFIR_GAIN, PFIR_SCALAR); +taps_diag = zeros(N_TAPS, 1); +taps_diag(DIAG_TAP_POS) = DIAG_TAP_FLOAT; +pf_diag_only = adi.AD9084.PFilt(taps_diag, 'mode', 'real_n2', ... + 'gain', PFIR_GAIN, 'scalar_gain', PFIR_SCALAR); +pf_diag_only.write(CAL_FILE); +release(rx); +rx.PFIRFilenames = CAL_FILE; +rx(); +[~, anchor_pwr_avg] = measureTonePower(rx, TONE_FREQ_HZ, Fs, NFFT, N_FRAMES); +diag_filter_label = sprintf('tap %d = %.5f, gain=%s, scalar=%s', ... + DIAG_TAP_POS, DIAG_TAP_FLOAT, PFIR_GAIN, PFIR_SCALAR); + +end % DIAG_ONLY + +% Figure 2 — centered two-sided diagnostic spectra (IQ data). +% fftshift centers the spectrum at 0 Hz so the x-axis runs -Fs/2 to +Fs/2 +% (e.g. -1.25 GHz to +1.25 GHz). With TX/RX NCOs cancelling, the 10 MHz +% DDS tone appears at exactly +10 MHz — slightly right of centre. +% Uses 10*log10(pwr_avg) to convert to dB. +f_bins_centered = (-NFFT/2 : NFFT/2-1).' * Fs / NFFT; + +figure('Name', 'PFIR Diagnostic Spectra', 'NumberTitle', 'off', 'Position', [150 150 1100 700]); + +% SNR: find the dominant peak (global max of ref spectrum) as the tone bin, +% then exclude ±50 bins around it for the noise floor estimate. +[~, tone_bin_raw] = max(ref_pwr_avg); +snr_mask = true(NFFT, 1); +snr_mask(max(1, tone_bin_raw-50) : min(NFFT, tone_bin_raw+50)) = false; + +ref_snr_dB = 10*log10(ref_pwr_avg(tone_bin_raw) / median(ref_pwr_avg(snr_mask))); +anc_snr_dB = 10*log10(anchor_pwr_avg(tone_bin_raw) / median(anchor_pwr_avg(snr_mask))); + +% Display spectra in dBFS. pwr_avg = |FFT(x.*window)|^2 / n_frames where +% x is normalized by /32768. Dividing by cg^2 (coherent gain squared) converts +% to true dBFS so a full-scale tone appears at 0 dBFS. +cg_diag = sum(hann(NFFT, 'periodic')) / 2; +ref_shifted = 10*log10(fftshift(ref_pwr_avg) / cg_diag^2); +anc_shifted = 10*log10(fftshift(anchor_pwr_avg) / cg_diag^2); + +subplot(2,1,1); +plot(f_bins_centered, ref_shifted, 'k-', 'LineWidth', 0.8); +xlabel('Frequency (Hz)'); +ylabel('Power (dBFS)'); +title(sprintf('Diagnostic — Spectrum: PFIR disabled (pre-filter reference) | SNR = %.1f dB', ref_snr_dB)); +grid on; + +% Subplot 2: real-time streaming of post-filter spectrum (DIAG_ONLY) or +% single static snapshot (full calibration run). +ax2 = subplot(2,1,2); +h_line = plot(ax2, f_bins_centered, anc_shifted, 'b-', 'LineWidth', 0.8); +xlabel(ax2, 'Frequency (Hz)'); +ylabel(ax2, 'Power (dBFS)'); +title(ax2, sprintf('Diagnostic — Spectrum: %s | SNR = %.1f dB', diag_filter_label, anc_snr_dB)); +grid(ax2, 'on'); +drawnow; + +if DIAG_ONLY + fprintf('\nStreaming post-filter spectrum — close the figure to stop.\n'); + window_rt = hann(NFFT, 'periodic'); + cg_rt = sum(window_rt) / 2; + while ishandle(h_line) + % Capture one averaged frame + pwr_rt = zeros(NFFT, 1); + for k = 1:N_FRAMES + d = rx(); + x = double(d(1:NFFT, 1)) / 32768; + X = fft(x .* window_rt, NFFT); + pwr_rt = pwr_rt + abs(X).^2; + end + pwr_rt = pwr_rt / N_FRAMES; + spec_rt = 10*log10(fftshift(pwr_rt) / cg_rt^2); + + % Update SNR + [maxp, tb] = max(pwr_rt); + sm = true(NFFT,1); + sm(max(1,tb-50):min(NFFT,tb+50)) = false; + snr_rt = 10*log10(pwr_rt(tb) / (median(pwr_rt(sm)) + eps)); + + set(h_line, 'YData', spec_rt); + title(ax2, sprintf('Diagnostic — Spectrum: %s | SNR = %.1f dB', diag_filter_label, snr_rt)); + ylim(ax2, [-120 20]); + drawnow limitrate; + end + if isfile(DISABLED_FILE), delete(DISABLED_FILE); end + if isfile(CFIR_BYPASS_FILE), delete(CFIR_BYPASS_FILE); end + return; +end + +drawnow; + +%% ========================================================= +% Phase 3 — Unity tap repeatability: how close is tap_float=1.0 to 0 dB offset? +% ========================================================= +% Writes a single-tap filter at tap_float=1.0 (theoretical all-pass) and +% measures it N_REPEATS times. The distribution of measured dBFS values +% relative to the bypass reference shows the hardware offset and its stability. +fprintf('\n--- Phase 3: Unity-tap repeatability (%d measurements) ---\n', N_REPEATS); + +taps_unity = zeros(N_TAPS, 1); +taps_unity(best_pos) = 1.0; +pf_unity = adi.AD9084.PFilt(taps_unity, 'mode', 'real_n2', ... + 'gain', PFIR_GAIN, 'scalar_gain', PFIR_SCALAR); +pf_unity.write(CAL_FILE); +release(rx); +rx.PFIRFilenames = CAL_FILE; +rx(); + +unity_meas_dBFS = nan(N_REPEATS, 1); +for r = 1:N_REPEATS + unity_meas_dBFS(r) = measureTonePower(rx, TONE_FREQ_HZ, Fs, NFFT, N_FRAMES_STAT); + fprintf(' Run %2d/%2d : %.2f dBFS (offset = %+.2f dB re bypass)\n', ... + r, N_REPEATS, unity_meas_dBFS(r), unity_meas_dBFS(r) - ref_dBFS); +end + +unity_offset_dB = unity_meas_dBFS - ref_dBFS; % dB re: bypass (0 = perfect all-pass) +offset_mean = mean(unity_offset_dB); +offset_median = median(unity_offset_dB); +offset_std = std(unity_offset_dB); + +fprintf('\n tap_float=1.0 offset from bypass — Mean : %+.3f dB\n', offset_mean); +fprintf(' tap_float=1.0 offset from bypass — Median : %+.3f dB\n', offset_median); +fprintf(' tap_float=1.0 offset from bypass — Std : %.3f dB\n', offset_std); + +%% ========================================================= +% Plots +% ========================================================= +figure('Name', 'PFIR Gain Calibration', 'NumberTitle', 'off', 'Position', [100 100 1100 500]); + +% --- Phase 1: Gain vs. tap position --- +subplot(1,2,1); +bar(1:N_TAPS, gain_by_pos, 'FaceColor', [0.2 0.5 0.8]); +hold on; +yline(0, 'r--', '0 dB ref', 'LineWidth', 1.5, 'LabelHorizontalAlignment', 'left'); +xlabel('Tap Position'); +ylabel('Gain (dB, re: disabled filter)'); +title(sprintf('Phase 1 — Gain vs. Tap Position (tap\\_float = 1.0, gain = %s dB, scalar = %s)', ... + PFIR_GAIN, PFIR_SCALAR)); +grid on; +ylim([min(gain_by_pos)-2, max(gain_by_pos)+2]); + +% --- Phase 3: Histogram of unity-tap offset --- +subplot(1,2,2); +histogram(unity_offset_dB, min(15, N_REPEATS), ... + 'FaceColor', [0.2 0.7 0.4], 'EdgeColor', 'w', 'Normalization', 'count'); +hold on; +xline(offset_median, 'r-', sprintf('Median = %+.3f dB', offset_median), ... + 'LineWidth', 2, 'LabelVerticalAlignment', 'bottom'); +xline(offset_mean, 'b--', sprintf('Mean = %+.3f dB', offset_mean), ... + 'LineWidth', 1.5, 'LabelVerticalAlignment', 'top'); +xline(0, 'k:', '0 dB (ideal)', 'LineWidth', 1, 'LabelVerticalAlignment', 'bottom'); +xlabel('Offset from bypass (dB)'); +ylabel('Count'); +title(sprintf('Phase 3 — Unity tap offset over %d runs | std = %.3f dB', ... + N_REPEATS, offset_std)); +grid on; + +%% ========================================================= +% Summary +% ========================================================= +fprintf('\n========================================================\n'); +fprintf(' PFIR Gain Calibration Summary\n'); +fprintf('========================================================\n'); +fprintf(' Tone : %.1f MHz (digital baseband)\n', TONE_FREQ_HZ/1e6); +fprintf(' gain setting : %s dB\n', PFIR_GAIN); +fprintf(' scalar_gain : %s (N/64 = %.4f; NOTE: hardware may be inverse)\n', PFIR_SCALAR, str2double(PFIR_SCALAR)/64); +fprintf(' PFIR mode : real_n2 (%d taps)\n', N_TAPS); +fprintf('\n Tap position uniformity:\n'); +fprintf(' Best pos : %d (%+.2f dB)\n', best_pos, max_gain_pos); +fprintf(' Spread : %.2f dB across all positions\n', spread_dB); +fprintf('\n Phase 3 — Unity tap (tap_float=1.0) offset from bypass (%d runs):\n', N_REPEATS); +fprintf(' Mean : %+.3f dB\n', offset_mean); +fprintf(' Median : %+.3f dB\n', offset_median); +fprintf(' Std : %.3f dB\n', offset_std); +fprintf(' 95%% CI : [%+.3f, %+.3f] dB\n', offset_median - 2*offset_std, offset_median + 2*offset_std); +fprintf('\n Hardware calibration offset: %+.3f dB\n', offset_mean); +fprintf(' Correction factor (linear) : %.5f\n', 10^(-offset_mean/20)); +fprintf('========================================================\n'); + +%% ========================================================= +% Cleanup +% ========================================================= +if isfile(DISABLED_FILE), delete(DISABLED_FILE); end +if isfile(CFIR_BYPASS_FILE), delete(CFIR_BYPASS_FILE); end +% CAL_FILE is intentionally kept so you can inspect the anchor tap filter +% that was loaded during Phase 2. Open it to verify the coefficients. +fprintf('\n Anchor filter file preserved for inspection: %s\n', CAL_FILE); +fprintf(' (Delete manually when done)\n'); + +% ========================================================= +% Local helper functions +% ========================================================= +function [peak_dBFS, pwr_avg, f_bins] = measureTonePower(rx, tone_hz, Fs, nfft, n_frames) +% measureTonePower Return the peak power at tone_hz in dBFS. +% peak_dBFS = measureTonePower(...) — scalar peak only +% [~, pwr_avg, f_bins] = measureTonePower(...) — also return raw averaged +% power spectrum and frequency axis (Hz). To plot exactly as a manual +% breakpoint would: plot(f_bins, 10*log10(pwr_avg)) +% Averages n_frames FFT periodograms with a Hann window. + + window = hann(nfft, 'periodic'); + cg = sum(window) / 2; % coherent gain normalises amplitude + + pwr_sum = zeros(nfft, 1); + for k = 1:n_frames + data = rx(); + x = double(data(1:nfft, 1)) / 32768; % normalise to FS + X = fft(x .* window, nfft); + pwr_sum = pwr_sum + abs(X).^2; + end + pwr_avg = pwr_sum / n_frames; + + f_bins = (0:nfft-1).' * Fs / nfft; + + % Use global max — tone is not at TONE_FREQ_HZ in the captured baseband + % due to NCO mixing offsets. Hardcoded bin search finds noise, not signal. + [peak_pwr, ~] = max(pwr_avg); + peak_dBFS = 10*log10(peak_pwr / cg^2 + eps); +end