Est. read time: 3 minutes | Posted: March 24, 2026 by John Gentile


Put simply, LLMs have gotten crazy good recently, going beyond typical software coding tasks, to even tackling HDL design. As those with experience in digital design know, this domain is not as simple to “code” as software: you’re really trying to coerce a chain of- often proprietary- EDA tools to map a desired function to a series of wires and hardware-specific primitives. Nonetheless, and even with much less open-source/high-quality training data, recent models have made leaps and bounds progress.

As an example, we want to design a canonical Numerically Controlled Oscillator for use in a DSP FPGA design. The following HDL was generated with Claude Opus 4.6 with the below simple prompt and no other project nor system context:

Create a simple Numerically Controlled Oscillator (NCO) for use in an FPGA in modern/idiomatic SystemVerilog

from IPython.display import Markdown as md
with open('./FPGA_NCO_files/nco.sv', 'r') as f:
    f_contents = f.read()

md(f"\n```verilog\n{f_contents}\n```")
// =============================================================================
// Numerically Controlled Oscillator (NCO)
// =============================================================================
//
// Architecture:
//   Phase Accumulator (N bits) → Truncated Phase (L bits) → Quarter-Wave LUT → Output
//
//   f_out = (FCW * f_clk) / 2^N
//   Phase resolution:  2π / 2^N
//   Spurious floor:    ~-6.02 * L dBc (truncation spurs before dithering)
//
// Quarter-wave symmetry exploits sin() symmetry to store only [0, π/2),
// reducing LUT depth from 2^L to 2^(L-2) entries.
//
// Parameters:
//   PHASE_ACC_W  – Phase accumulator width N (frequency resolution)
//   LUT_ADDR_W   – Truncated phase / LUT address width L (spur performance)
//   OUTPUT_W     – Output sample width (SNR ≈ 6.02*OUTPUT_W dB)
//   DITHER_EN    – Add 1-bit LFSR dither to truncated phase to whiten spurs
// =============================================================================

module nco #(
    parameter int PHASE_ACC_W = 32,
    parameter int LUT_ADDR_W  = 12,
    parameter int OUTPUT_W    = 16,
    parameter bit DITHER_EN   = 1'b1
) (
    input  logic                    clk,
    input  logic                    rst_n,

    // Control
    input  logic [PHASE_ACC_W-1:0]  fcw,          // Frequency control word
    input  logic [PHASE_ACC_W-1:0]  phase_offset, // Phase modulation input

    // Output — signed two's complement
    output logic signed [OUTPUT_W-1:0] sin_out,
    output logic signed [OUTPUT_W-1:0] cos_out,
    output logic                       valid
);

    // -------------------------------------------------------------------------
    // Derived parameters
    // -------------------------------------------------------------------------
    localparam int QUARTER_DEPTH = 2 ** (LUT_ADDR_W - 2); // π/2 worth of entries
    localparam int DISCARD_BITS  = PHASE_ACC_W - LUT_ADDR_W;

    // -------------------------------------------------------------------------
    // Phase accumulator
    // -------------------------------------------------------------------------
    logic [PHASE_ACC_W-1:0] phase_acc;

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            phase_acc <= '0;
        else
            phase_acc <= phase_acc + fcw; // free-running, wraps modulo 2^N
    end

    // -------------------------------------------------------------------------
    // Optional truncation dither (LFSR-based)
    //   Adds pseudorandom noise into discarded LSBs to whiten truncation spurs.
    //   Galois LFSR, maximal-length for 16-bit: x^16 + x^14 + x^13 + x^11 + 1
    // -------------------------------------------------------------------------
    logic [15:0] lfsr;

    generate
        if (DITHER_EN) begin : gen_dither
            always_ff @(posedge clk or negedge rst_n) begin
                if (!rst_n)
                    lfsr <= 16'hACE1; // nonzero seed
                else
                    lfsr <= {lfsr[14:0], lfsr[15] ^ lfsr[13] ^ lfsr[12] ^ lfsr[10]};
            end
        end else begin : gen_no_dither
            assign lfsr = '0;
        end
    endgenerate

    // -------------------------------------------------------------------------
    // Phase truncation + offset + dither → LUT address
    // -------------------------------------------------------------------------
    logic [PHASE_ACC_W-1:0] phase_total;
    logic [LUT_ADDR_W-1:0]  phase_trunc;

    assign phase_total = phase_acc + phase_offset;

    // Add dither into the rounding region then truncate
    always_comb begin
        if (DITHER_EN && DISCARD_BITS > 0) begin
            automatic logic [PHASE_ACC_W-1:0] dithered;
            dithered    = phase_total + {{LUT_ADDR_W{1'b0}}, lfsr[DISCARD_BITS-1:0]};
            phase_trunc = dithered[PHASE_ACC_W-1 -: LUT_ADDR_W];
        end else begin
            phase_trunc = phase_total[PHASE_ACC_W-1 -: LUT_ADDR_W];
        end
    end

    // -------------------------------------------------------------------------
    // Quarter-wave LUT with symmetry decomposition
    //
    //   quadrant[1:0] = phase_trunc MSBs
    //   Q0 (00): sin(θ)        =  LUT[ addr          ]
    //   Q1 (01): sin(π-θ)      =  LUT[ ~addr         ]   (mirror)
    //   Q2 (10): sin(π+θ)      = -LUT[ addr          ]   (negate)
    //   Q3 (11): sin(2π-θ)     = -LUT[ ~addr         ]   (mirror+negate)
    //
    //   cos(θ) = sin(θ + π/2), so feed (phase_trunc + 2^(L-2)) into same logic.
    // -------------------------------------------------------------------------
    logic [1:0]               sin_quad, cos_quad;
    logic [LUT_ADDR_W-3:0]    sin_addr, cos_addr;
    logic                     sin_neg,  cos_neg;
    logic                     sin_mirror, cos_mirror;

    // Cosine phase = sin phase + π/2 (add 1 to quadrant)
    logic [LUT_ADDR_W-1:0] cos_phase;
    assign cos_phase = phase_trunc + LUT_ADDR_W'(QUARTER_DEPTH); // +2^(L-2)

    assign sin_quad   = phase_trunc[LUT_ADDR_W-1 -: 2];
    assign cos_quad   = cos_phase  [LUT_ADDR_W-1 -: 2];

    assign sin_mirror = sin_quad[0];
    assign cos_mirror = cos_quad[0];
    assign sin_neg    = sin_quad[1];
    assign cos_neg    = cos_quad[1];

    assign sin_addr = sin_mirror ? ~phase_trunc[LUT_ADDR_W-3:0]
                                 :  phase_trunc[LUT_ADDR_W-3:0];
    assign cos_addr = cos_mirror ? ~cos_phase[LUT_ADDR_W-3:0]
                                 :  cos_phase[LUT_ADDR_W-3:0];

    // -------------------------------------------------------------------------
    // LUT storage — inferred as block RAM (single-port, registered output)
    // Stores unsigned magnitude: round(2^(OUTPUT_W-1) - 1) * sin(2π·k / 4·DEPTH)
    // for k in [0, QUARTER_DEPTH).
    // -------------------------------------------------------------------------
    logic [OUTPUT_W-2:0] sin_lut [QUARTER_DEPTH]; // unsigned magnitude
    logic [OUTPUT_W-2:0] cos_lut [QUARTER_DEPTH];

    initial begin
        for (int k = 0; k < QUARTER_DEPTH; k++) begin
            sin_lut[k] = OUTPUT_W'($rtoi(
                $floor(((2.0 ** (OUTPUT_W - 1)) - 1.0)
                       * $sin(2.0 * 3.14159265358979323846 * real'(k) / real'(4 * QUARTER_DEPTH))
                       + 0.5)
            ));
            cos_lut[k] = sin_lut[k]; // same data, separate copy for dual-port inference
        end
    end

    // -------------------------------------------------------------------------
    // Pipeline stage 1: LUT read (registered for BRAM inference)
    // -------------------------------------------------------------------------
    logic [OUTPUT_W-2:0] sin_mag_r, cos_mag_r;
    logic                sin_neg_r, cos_neg_r;

    always_ff @(posedge clk) begin
        sin_mag_r <= sin_lut[sin_addr];
        cos_mag_r <= cos_lut[cos_addr];
        sin_neg_r <= sin_neg;
        cos_neg_r <= cos_neg;
    end

    // -------------------------------------------------------------------------
    // Pipeline stage 2: sign application → two's complement output
    // -------------------------------------------------------------------------
    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n) begin
            sin_out <= '0;
            cos_out <= '0;
        end else begin
            sin_out <= sin_neg_r ? -signed'({1'b0, sin_mag_r})
                                 :  signed'({1'b0, sin_mag_r});
            cos_out <= cos_neg_r ? -signed'({1'b0, cos_mag_r})
                                 :  signed'({1'b0, cos_mag_r});
        end
    end

    // -------------------------------------------------------------------------
    // Valid strobe — accounts for 2-cycle pipeline latency
    // -------------------------------------------------------------------------
    logic [1:0] pipe_valid;

    always_ff @(posedge clk or negedge rst_n) begin
        if (!rst_n)
            pipe_valid <= '0;
        else
            pipe_valid <= {pipe_valid[0], 1'b1};
    end

    assign valid = pipe_valid[1];

    // -------------------------------------------------------------------------
    // Assertions (simulation only)
    // -------------------------------------------------------------------------
    // synthesis translate_off
    initial begin
        assert (LUT_ADDR_W >= 4)
            else $fatal(1, "LUT_ADDR_W must be >= 4 for quarter-wave decomposition");
        assert (PHASE_ACC_W >= LUT_ADDR_W)
            else $fatal(1, "PHASE_ACC_W must be >= LUT_ADDR_W");
        assert (OUTPUT_W >= 2)
            else $fatal(1, "OUTPUT_W must be >= 2 for signed representation");
    end
    // synthesis translate_on

endmodule

^ this looks pretty good for one-shot output! I mean, even with “simple” in the prompt, it went beyond the basic “phase accumulator -> LUT” design and right to implementing:

  • A quarter-wave LUT to save on space with built-in sin\sin value generation within the code (no external memory loading required)!
  • Built-in LFSR for phase dithering to increase SFDR.
  • Assertions to check for proper instantiation and usage.

But now we have to verify the design- let’s switch to Claude Code to give the context of our nco.sv HDL file and let it generate a testbench and Makefile for use by Verilator:

@nco.sv is a simple Numerically Controlled Oscillator in SystemVerilog. We want to write a simple unit test to verify basic output- write a simple SystemVerilog testbench as well as Makefile targeting Verilator simulator (Verilator is already installed on the system).

Claude goes right to work making the following testbench, but not without using it’s Bash tool first to find the current Verilator version installed ($$ verilator --version) to make sure the testbench matches supported conventions (brilliant!):

with open('./FPGA_NCO_files/nco_tb.sv', 'r') as f:
    f_contents = f.read()

md(f"\n```verilog\n{f_contents}\n```")
// =============================================================================
// NCO Testbench
// =============================================================================
// Basic verification:
//   1. Reset behavior — outputs zero, valid deasserted
//   2. Valid strobe asserts after 2-cycle pipeline latency
//   3. DC check — FCW=0 produces constant output
//   4. Sine/cosine quadrature — cos leads sin by ~90°
//   5. Output bounded within signed range
// =============================================================================

module nco_tb;

    // Use small parameters for fast simulation
    localparam int PHASE_ACC_W = 16;
    localparam int LUT_ADDR_W  = 8;
    localparam int OUTPUT_W    = 12;

    logic                          clk;
    logic                          rst_n;
    logic [PHASE_ACC_W-1:0]       fcw;
    logic [PHASE_ACC_W-1:0]       phase_offset;
    logic signed [OUTPUT_W-1:0]   sin_out;
    logic signed [OUTPUT_W-1:0]   cos_out;
    logic                          valid;

    nco #(
        .PHASE_ACC_W (PHASE_ACC_W),
        .LUT_ADDR_W  (LUT_ADDR_W),
        .OUTPUT_W    (OUTPUT_W),
        .DITHER_EN   (1'b0)         // disable dither for deterministic checks
    ) dut (
        .clk          (clk),
        .rst_n        (rst_n),
        .fcw          (fcw),
        .phase_offset (phase_offset),
        .sin_out      (sin_out),
        .cos_out      (cos_out),
        .valid        (valid)
    );

    // Clock: 10 ns period
    initial clk = 0;
    always #5 clk = ~clk;

    // Max positive value the LUT can produce
    localparam int MAX_MAG = (2 ** (OUTPUT_W - 1)) - 1;

    int errors = 0;

    // Helper: run N clock cycles
    task automatic tick(int n = 1);
        repeat (n) @(posedge clk);
    endtask

    // -------------------------------------------------------------------------
    // Main test sequence
    // -------------------------------------------------------------------------
    initial begin
        $dumpfile("nco_tb.vcd");
        $dumpvars(0, nco_tb);

        // ---- Init ----
        rst_n        = 0;
        fcw          = '0;
        phase_offset = '0;
        tick(4);

        // ---- Test 1: Reset behaviour ----
        $display("[TEST 1] Reset behavior");
        if (sin_out !== '0 || cos_out !== '0) begin
            $display("  FAIL: outputs not zero in reset (sin=%0d cos=%0d)", sin_out, cos_out);
            errors++;
        end
        if (valid !== 1'b0) begin
            $display("  FAIL: valid asserted during reset");
            errors++;
        end

        // ---- Release reset ----
        @(posedge clk) rst_n <= 1;

        // ---- Test 2: Valid strobe latency ----
        $display("[TEST 2] Valid strobe latency");
        tick(1);
        if (valid !== 1'b0) begin
            $display("  FAIL: valid high too early (cycle 1)");
            errors++;
        end
        tick(1);  // 2 cycles after reset release
        if (valid !== 1'b1) begin
            $display("  FAIL: valid not asserted after 2 pipeline stages");
            errors++;
        end

        // ---- Test 3: FCW=0 → constant output ----
        $display("[TEST 3] FCW=0 constant output");
        fcw = '0;
        tick(10);
        begin
            logic signed [OUTPUT_W-1:0] s0, c0, s1, c1;
            s0 = sin_out; c0 = cos_out;
            tick(5);
            s1 = sin_out; c1 = cos_out;
            if (s0 !== s1 || c0 !== c1) begin
                $display("  FAIL: output changed with FCW=0");
                errors++;
            end
        end

        // ---- Test 4: Run a tone — check outputs stay bounded ----
        // FCW = 2^(N-4) gives f_out = f_clk/16 — completes one full cycle in 16 samples
        $display("[TEST 4] Bounded output with active tone");
        fcw = 16'(1 << (PHASE_ACC_W - 4));
        tick(4); // flush pipeline
        begin
            int bounded_ok = 1;
            for (int i = 0; i < 64; i++) begin
                tick(1);
                if (sin_out > MAX_MAG || sin_out < -MAX_MAG ||
                    cos_out > MAX_MAG || cos_out < -MAX_MAG) begin
                    $display("  FAIL: output out of range at sample %0d (sin=%0d cos=%0d)",
                             i, sin_out, cos_out);
                    bounded_ok = 0;
                    errors++;
                end
            end
            if (bounded_ok) $display("  PASS: all samples within [-%0d, %0d]", MAX_MAG, MAX_MAG);
        end

        // ---- Test 5: Quadrature relationship ----
        // With a slow tone, sample near zero-crossing of sin where cos should be near peak
        $display("[TEST 5] Quadrature check (cos leads sin by ~90 deg)");
        fcw = 16'(1 << (PHASE_ACC_W - 4)); // same tone
        rst_n = 0;
        tick(4);
        @(posedge clk) rst_n <= 1;
        tick(3); // pipeline flush — phase_acc ≈ 0 at this point
        // Near phase=0: sin≈0, cos≈+peak
        begin
            if (!(cos_out > sin_out)) begin
                $display("  FAIL: at phase~0 expected cos > sin (sin=%0d cos=%0d)", sin_out, cos_out);
                errors++;
            end else begin
                $display("  PASS: cos > sin near phase 0 (sin=%0d cos=%0d)", sin_out, cos_out);
            end
        end

        // ---- Summary ----
        tick(2);
        if (errors == 0)
            $display("\n=== ALL TESTS PASSED ===");
        else
            $display("\n=== %0d ERROR(S) ===", errors);

        $finish;
    end

endmodule

Now Claude knows it needs to generate a Makefile to launch the simulation and check outputs:

with open('./FPGA_NCO_files/Makefile', 'r') as f:
    f_contents = f.read()

md(f"\n```makefile\n{f_contents}\n```")
# NCO Verilator testbench
# Usage:
#   make        — build and run the testbench
#   make wave   — open waveform in GTKWave (if installed)
#   make clean  — remove generated files

TOP      := nco_tb
SOURCES  := nco.sv nco_tb.sv
OBJ_DIR  := obj_dir

.PHONY: all run wave clean

all: run

# Verilator compile: SystemVerilog, enable timing, trace (VCD)
$(OBJ_DIR)/V$(TOP): $(SOURCES)
	verilator --binary --timing -j 0 --trace \
		-Wno-WIDTHEXPAND -Wno-WIDTHTRUNC -Wno-INITIALDLY -Wno-IMPLICITSTATIC \
		--top-module $(TOP) $(SOURCES)

run: $(OBJ_DIR)/V$(TOP)
	./$(OBJ_DIR)/V$(TOP)

wave: run
	gtkwave $(TOP).vcd &

clean:
	rm -rf $(OBJ_DIR) $(TOP).vcd

It then runs the Makefile, sees an error related to IMPLICITSTATIC lifetime, adjusts the Makefile to ignore that flag in the one variable, and re-runs to show a successul testbench execution:

- V e r i l a t i o n   R e p o r t: Verilator 5.046 2026-02-28 rev vUNKNOWN-built20260228
- Verilator: Built from 0.061 MB sources in 3 modules, into 0.093 MB in 9 C++ files needing 0.000 MB
- Verilator: Walltime 2.420 s (elab=0.006, cvt=0.011, bld=2.392); cpu 0.021 s on 12 threads; allocated 11.875 MB
./obj_dir/Vnco_tb
[TEST 1] Reset behavior
[TEST 2] Valid strobe latency
[TEST 3] FCW=0 constant output
[TEST 4] Bounded output with active tone
  PASS: all samples within [-2047, 2047]
[TEST 5] Quadrature check (cos leads sin by ~90 deg)
  PASS: cos > sin near phase 0 (sin=783 cos=1871)

=== ALL TESTS PASSED ===
- nco_tb.sv:156: Verilog $finish

Let’s go one step further- let’s have Claude use it’s Python skills to generate a cocotb testbench to numerically verify the NCO output:

Now that we have a basic unit testbench in @nco_tb.sv let’s create a cocotb Python verification file that we can run to plot the output spectrum of @nco.sv and measure the SFDR.

This gives us another Makefile to launch the cocotb sim and then the following Python testbench:

with open('./FPGA_NCO_files/test_nco.py', 'r') as f:
    f_contents = f.read()

md(f"\n```python\n{f_contents}\n```")
"""
cocotb testbench for NCO – captures output samples, plots the spectrum, and measures SFDR.

Run with:
    make -f Makefile.cocotb

Requires: cocotb, cocotb-test, numpy, matplotlib
Simulator: Icarus Verilog (iverilog) or Verilator with cocotb support
"""

import cocotb
from cocotb.clock import Clock
from cocotb.triggers import RisingEdge, ClockCycles
import numpy as np
import matplotlib.pyplot as plt


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def signed_val(signal, width):
    """Convert an unsigned cocotb signal value to signed Python int."""
    val = signal.value.integer
    if val >= (1 << (width - 1)):
        val -= 1 << width
    return val


async def reset_dut(dut, cycles=4):
    """Assert reset for *cycles* clock edges, then release."""
    dut.rst_n.value = 0
    dut.fcw.value = 0
    dut.phase_offset.value = 0
    await ClockCycles(dut.clk, cycles)
    dut.rst_n.value = 1
    await ClockCycles(dut.clk, 2)  # wait for pipeline to fill


# ---------------------------------------------------------------------------
# Parameters (must match the cocotb runner / Makefile generics)
# ---------------------------------------------------------------------------
PHASE_ACC_W = 16
LUT_ADDR_W = 8
OUTPUT_W = 12


# ---------------------------------------------------------------------------
# Test: Capture tone, plot spectrum, measure SFDR
# ---------------------------------------------------------------------------


@cocotb.test()
async def test_nco_spectrum(dut):
    """Run the NCO at a known tone, capture samples, compute FFT, and report SFDR."""

    clock = Clock(dut.clk, 10, units="ns")  # 100 MHz
    cocotb.start_soon(clock.start())

    await reset_dut(dut)

    # --- Choose FCW for a bin-centered tone ---
    # N_samples must be a power of 2 for a clean FFT.
    N = 4096
    # Pick a tone that lands exactly on an FFT bin to avoid spectral leakage:
    #   bin_index * 2^PHASE_ACC_W / N  =  FCW
    bin_index = 107  # prime-ish, away from DC and Nyquist
    fcw_val = int(bin_index * (2**PHASE_ACC_W) / N)
    dut.fcw.value = fcw_val

    f_clk = 100e6
    f_tone = fcw_val * f_clk / 2**PHASE_ACC_W
    dut._log.info(
        f"FCW = {fcw_val}  →  f_tone = {f_tone / 1e6:.4f} MHz  (bin {bin_index})"
    )

    # Let the pipeline settle after changing FCW
    await ClockCycles(dut.clk, 4)

    # --- Collect samples ---
    sin_samples = np.zeros(N, dtype=np.float64)
    cos_samples = np.zeros(N, dtype=np.float64)

    for i in range(N):
        await RisingEdge(dut.clk)
        sin_samples[i] = signed_val(dut.sin_out, OUTPUT_W)
        cos_samples[i] = signed_val(dut.cos_out, OUTPUT_W)

    # --- Compute power spectrum (dBFS) ---
    window = np.blackman(N)
    sin_windowed = sin_samples * window

    spectrum = np.fft.rfft(sin_windowed)
    mag = np.abs(spectrum) / (np.sum(window) / 2)  # normalize
    mag_db = 20 * np.log10(mag + 1e-20)  # dBFS (relative to full-scale sine)

    # Normalize so the fundamental is 0 dBFS
    fund_bin = np.argmax(mag_db)
    mag_db -= mag_db[fund_bin]

    freqs_mhz = np.fft.rfftfreq(N, d=1.0 / f_clk) / 1e6

    # --- Measure SFDR ---
    # Exclude DC (bin 0) and a ±3-bin window around the fundamental
    spur_mask = np.ones(len(mag_db), dtype=bool)
    spur_mask[0] = False  # ignore DC
    guard = 3
    lo = max(1, fund_bin - guard)
    hi = min(len(mag_db) - 1, fund_bin + guard)
    spur_mask[lo : hi + 1] = False

    spur_peak_bin = np.argmax(mag_db[spur_mask])
    # Map back to absolute bin index
    spur_abs_bin = np.arange(len(mag_db))[spur_mask][spur_peak_bin]
    sfdr = mag_db[fund_bin] - mag_db[spur_abs_bin]

    dut._log.info(f"Fundamental bin: {fund_bin}  ({freqs_mhz[fund_bin]:.3f} MHz)")
    dut._log.info(
        f"Largest spur bin: {spur_abs_bin}  ({freqs_mhz[spur_abs_bin]:.3f} MHz)"
    )
    dut._log.info(f"SFDR = {sfdr:.1f} dBc")

    # --- Plot ---
    fig, axes = plt.subplots(2, 1, figsize=(10, 7), constrained_layout=True)

    # Time-domain (first 128 samples)
    t_us = np.arange(128) * (1 / f_clk) * 1e6
    axes[0].plot(t_us, sin_samples[:128], label="sin", linewidth=0.8)
    axes[0].plot(t_us, cos_samples[:128], label="cos", linewidth=0.8, alpha=0.7)
    axes[0].set_xlabel("Time (µs)")
    axes[0].set_ylabel("Amplitude (LSB)")
    axes[0].set_title("NCO Time-Domain Output (first 128 samples)")
    axes[0].legend()
    axes[0].grid(True, alpha=0.3)

    # Spectrum
    axes[1].plot(freqs_mhz, mag_db, linewidth=0.6)
    axes[1].axhline(
        -sfdr, color="r", linestyle="--", linewidth=0.8, label=f"SFDR = {sfdr:.1f} dBc"
    )
    axes[1].set_xlabel("Frequency (MHz)")
    axes[1].set_ylabel("Magnitude (dBc)")
    axes[1].set_title(
        f"NCO Output Spectrum  |  f_tone = {f_tone / 1e6:.4f} MHz  |  SFDR = {sfdr:.1f} dBc"
    )
    axes[1].set_ylim([-120, 5])
    axes[1].legend()
    axes[1].grid(True, alpha=0.3)

    fig.savefig("nco_spectrum.png", dpi=150)
    dut._log.info("Saved plot to nco_spectrum.png")
    plt.close(fig)

    # --- Pass/fail ---
    # With 8-bit LUT address (quarter-wave), theoretical spur floor ~ -6*8 = -48 dBc.
    # Require at least 40 dBc as a sanity check.
    MIN_SFDR = 40.0
    assert sfdr >= MIN_SFDR, f"SFDR {sfdr:.1f} dBc below threshold {MIN_SFDR} dBc"
    dut._log.info("PASS")

This gives the following plot and SFDR measurement output: SFDR plot

Summary

In the end, this isn’t a perfect solution- there’s some quirks in the dither logic and we need to pass through a tool like Vivado’s synthesis flow to know if we can map this to a target FPGA- but this is a massive and quick start to a DSP FPGA design that we can iterate on further. And again, this was done with no system or other context- the true power of LLMs in this domain, and moreover agentic tools like Claude Code, is:

  • Feeding the rest of your codebase and documentation as context (especially useful in complex DSP projects where we have documentation like waveform specs and ICDs, as well as modeling code, like Matlab).
  • Specifying HDL testbench coverage, coding-style, linting, etc. requirements as part of system context (like in Claude Code’s CLAUDE.md instruction file)- LLMs are particularly useful at the mundane tasks of documenting blocks, adding tests, formatting, etc.
  • Hooking up other tools, like Vivado tcl steps, as Model Context Protocol (MCP) tools available to be called by the LLM.