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