Home: Planar PCB Radio Filters

*More* Experiments in Open Loop Resonator Bandpass Filters

Now with tunability.

(2025-09-24: new code)

I've done open loop bandpass filters before but my prior design had no way to tune anything. It was just one band per design.

This is my attempt to implement a tunable radio frequency bandpass filter based off a paper I read. It uses planar PCB resonators that are tuned by a "digitally tunable capacitor" IC controlled by an esp8266 microcontroller.

I'm basically implementing the paper, "A UHF Third Order 5-bit Digital Tunable Bandpass Filter Based on Mixed Coupled Open Ring Resonators" by Ming-Ye Fu, Qian-Yin Xiang, Dan Zhang, Deng-Yao Tian, and Quan-Yuan Feng. But I translated the intent of the design from 0.8mm expensive (Er 2.65) PCB material to cheap 1.6mm thick FR4 PCB (Er 4.4) and I'm rolling my own microcontroller setup since theirs is not described.

[comment on this post] Append "/@say/your message here" to the URL in the location bar and hit enter.

[webmention/pingback] Did you respond to this URL? What's your URL?

Open Loop Resonators

In open loop resonators very close to each other the type of coupling, either electric of magnetic, is a function of the location on the loop(s). At resonance the maximum electrical field density is across the gap and the maximum magnetic field density at the opposite side. So for magnetic coupling they are put "back" to "back" and for electric "face" to "face". By turning them so neither the max electric nor max magnetic field densities are close to the other the contribution of electric and magnetic coupling is about the same. The balance can also be altered by displacing them out of alignment. This is called mixed coupling.

In this particular design RF energy takes two paths using the same start.

Digital Cap Tuning

The tunability comes from using some really neat Peregrine Semiconductor IC called the PE64904; a "Digitally Tunable Capacitor" that works from 100-3000 MHz and spans 0.6 to 4.6 pF. This IC is put in the open end of the open ring resonators to tune them. It is controlled over the 3-wire SPI protocol which necessitates a microcontroller of some sort. I've chosen to use the cheap esp8266 series esp12e boards (~$5) because they come with wifi and I already have experience with them.

The commands it takes are very simple. You pull the chip select (CS) line low then send a message containing a hex number from 0 to 31 and return the CS high.

You may be thinking, "Why not use varactors instead? Then you don't need a computer you just need a variable voltage. It's much simpler." And it is in an absolute way. But the complexity of using a digital computer to control things is something I'm used to while designing a variable 0-30 voltage source that's precise and repeatable is not. And most importantly: the guys in the paper did it this way.

Simulation and Design

Below is the design as I implemented it on 1.6mm 4.4 dielectric FR4. For scale, the input port microstrip is 2.5mm wide (~55 Ohm). I compromised on a grid size of 0.125 mm and this led to a sim time of about 5 minutes (i5 3750K processor). The granularity of the grid to get this decent iteration time made directly copying dimensions infeasible even if they hadn't used 0.8 mm 2.65 dielectric F4B-2 substrate.

I ended up spending a bit of time using an online microstrip electrical length/phase calculator to translate the intent of the shapes to my different PCB material. And then a bit of fiddling back and forth with values when it didn't quite work.

The little square pad with a via in it is for the PE64904 footpad ground. The two 0.75mm bits at the throat of the open loop are also to conform to the IC pads. I also tried adding the other IC footprint traces but the distance between them was very small and when I tried approximations with just one it didn't have much effect.

I set all the capacitors to the same variable, Caps, then did a parameter sweep of Caps from 0.6 to 4.6 mm at step size of 0.125 pF which is 32 steps and matches the PE64904 step size of 0.13 pF. Every other step is shown in the scattering parameters plot (0.25 pF step, 16 total).

The results matched the response reported in the paper pretty darn well.

Figuring out what controls what with parameter sweeps.

I ran some parameter sweeps of the various heights and gaps in the design to see what effected what.

Spacing between top capacitor patches

First I tried the spacing between the two capacitors up top. This did not effect the S11/return loss in any significant way. But it did have a significant effect on S21/insertion loss curves. Nominal spacing was 0.5mm and I covered 0.125 to 1 in 0.125 mm steps. With lower spacing (higher coupling) the 'zero' provided by destructive interference along this path shifted down in frequency towards the resonant freq. With higher spacing (lower coupling) the 'zero' moved up in frequency relative to the resonant freq. Over the parameter span the lower stopband insertion loss moved up and down by about 10 dB and the upper stopband by almost 20 dB in parts.

Vertical height of the top capacitor patches

Optimal seems to be about 8.5-9.0 mm based on return loss (S11). The effect is less important on insertion loss (S21) but with increasing cap height (and so capacitance) the lower stopband drops off more slowly after -15dB down. With decreasing cap height the upper stopband pops back up higher after the high side zero but again only when it's already below -25 dB so not that important.

Total height/aspect ratio of the loops and length of coupling lines

Then I tried altering the height of all the open loop resonators. This had a dramatic effect with a cliff in performance change after just a couple mm reduction in height. So resonator height/aspect ratio matters *a* lot. To be clear, the "height" parameter moved all the tops of the loops, the caps, and the coupling lines together. It kept their relative spacings exactly the same.

But maybe I did the scaling wrong and forgot to click some edge vertices or something. That would explain the sudden change if something began overlapping. I'll have to do this sweep again manually.

Resonator 2 side coupling gaps

Resonator 2 Height Offset

The results of shifting resonator 2's offset from the others was not very dramatic. There was little effect so I'm not going to put the plot up here.

Improved tuning range, higher freq

Some not so small tweaks in total height and coupling gaps eventually lead towards a version of the filter that would perform okay at 902-928 MHz. The total effective range is about 400 to 1000 MHz now but with some marginal performance on the high end (ie, -10dB S11 abd -3dB S21 for 915 MHz).

The large surface area shared between the metallic box and the PCB top metallization creates a significant paracitic capacitance. The closer the PCB metallization is to the conductive box ground the lower the impedance. This effect can be seen in the shift of the high frequency behavior of the transmission loss and in the decrease of the impedance of the microstrip ports from ~55 to ~52 Ohm.

Oops. My vias were going to the ceiling.

It turns out I accidentally set a config preference to send vias up by default instead of down a layer or down to ground. That meant in a lot of the filters I designed recently were not completely realistic. So I had to go back and re-make them to tune them for performance with the proper vias and not some wire going 10mm up to the box walls.

Balancing the resonators

It was actually easier to tune the open loop resonators with the vias going to the right place. I made a series of single parameter sweeps tweaking the line widths and gaps to see how the 3 dip peaks make up a single S11 notch moved. The balance of these was an S11 sweep with two resonances at either end of the tunable frequency range. At the high frequency end the contribution of one of the resonances is discarded to make the other two line up better.

The pass band is made asymmetric but this offsets for the increase relative bandwidth of the resonators with higher frequency. Instead only two peaks contribute and it's narrower but kind of shouldery and ugly. At the higher end this makes it so there are gaps between -15 to -15 db S11 passbands per DTC capacitance step.

The tradeoff is between having the small gaps in perfect passband coverage at the high end and the extended frequency range and better S11 the from overlaping only the 2 higher frequency dip peaks at higher frequencies. It'd be nice to get all 3 lined up at both low and high frequency ends but I can't figure that out.

The end result is pretty good, I think. It uses high 1cm box ceilings which I'll have to make myself out of tin rather than use an RF shield can. The final filter has a *simulated* range of 410-1000 MHz -15 db reflection loss and 1.5~3.25 dB insertion loss over that span.

The arrows on the vias now point down.

esp8266 Hardware and Software

esp8266 using nodemcu lua: software for 3-wire SPI control over wifi+tcp socket.

EDIT: THIS IS BAD CODE GO TO HERE INSTEAD

This lua code connects to to a hard coded wifi ssid then once it has an IP it connects to a TCP server running on a hardcoded IP and port. From there it waits for text instructions. On receiving anything in the form of a number from 0 to 31 it converts it to hex and sends it out the configured HSPI port to the digitally tunable capacitor.

Normally GPIO15 (pin D8) is used as a SPI 'Chip Select' line. But this requires adding external resistors in order to not trigger some boot issue. Additionally people on the forums have reported HSPI CS not working with NodeMCU. So since the PE64904 only requires 3 wires I instead disable the HSPI CS (GPIO15/D8) and the HSPI MOSI (GPIO13/D7) by setting their mode back to normal GPIO. Then I use the HSPI MOSI pin as a GPIO and manually pull it low as a chip select command before spi.send() then manually set it high after.

For a server on the computer it connects to I use use netcat like,

nc -l 6005

and then type, say, 15 and hit enter. If it works it responds "ok".

15
ok
-- esp8266 mediated wifi:telnet control of PE64904 SPI based digitally tuned capacitor.

-- Config				esp12e		PE64904
-- -- Default HSPI for CLK and MISO
-- local CLK = 5           	 --> GPIO14 D5 | MISO, PIN 7 (SDA)
-- local MISO = 6            	 --> GPIO12 D6 | SCL,  PIN 5 (SCL)
-- -- Manual CS/SEN for chip select
local CS = 7              	 --> GPIO13 D7 | SEN,  PIN 6 (SEN)
local duration = 3000     --> 3 seconds
local i = 0
local result = 0

-- Turn on HSPI (dev 1) SPI at (80 MHz/8) 10 MHz
spi.setup(1, spi.MASTER, spi.CPOL_LOW, spi.CPHA_LOW, 8, 8)

-- Manual CS Pin (instead of HSPI CS)
gpio.mode(CS, gpio.OUTPUT)
gpio.write(CS, gpio.HIGH)

-- Need to disable HSPI CS D8/GPIO15 from being toggled. 
-- Set pull-up to prevent potential issues.
-- ref: https://nodemcu.readthedocs.io/en/master/en/modules/spi/#spisetup
gpio.mode(8, gpio.INPUT, gpio.PULLUP)

function string.fromhex(str)
	return (str:gsub('..', function (cc)
		return string.char(tonumber(cc, 16))
	end))
end
function string.tohex(str)
	return (str:gsub('.', function (c)
		return string.format('%02X', string.byte(c))
	end))
end

-- Connect to wifi
print(wifi.sta.getip())
wifi.setmode(wifi.STATION)
wifi.sta.config("ssid","password")
print(wifi.sta.getip())

-- Connect to control server
local flagClientTcpConnected=false;
print("Start TCP Client");
tmr.alarm(3, 5000, 1, function()
	if flagClientTcpConnected==false then
		print("Trying to connect to server");
		local conn=net.createConnection(net.TCP, false) 
		conn:connect(6005,"192.168.1.125");
		conn:on("connection",function(c) 
			print("TCPClient:connected to server");
			flagClientTcpConnected = true;
		end) 
		conn:on("disconnection",function(c) 
			flagClientTcpConnected = false;
			conn=nil;
			collectgarbage();
        	end) 
		conn:on("receive", function(conn, m) 
			print("TCPClient:"..m);
			for i = 0,31 do
				if string==i then
					hexval = i.tohex()
					gpio.write(CS, gpio.LOW)      -->Activate the SPI chip
              				tmr.delay(1)                  -->1us Delay
					wrote = spi.send(1,hexval)    -->Write hex value
					gpio.write(CS,gpio.HIGH)      -->De-activate the SPI chip
					print("Started SPI DTC Control");
					conn:send("ok\r\n");
					tmr.delay(100)
				end
			end
		end) 
	end 
end)

For the init.lua file I use one I cribbed from the DoitCar demo from www.doit.am bbs.doit.am. Change "spiviaweb-nogpio15-v1.lua" to whatever matches.

--DoitCar Ctronl Demo
--sta mode
--Created @ 2015/05/14 by Doit Studio
--Modified: null
--http://www.doit.am/
--http://www.smartarduino.com/
--http://szdoit.taobao.com/
--bbs: bbs.doit.am
-- 

print("\n")
print("ESP8266 Started")

local exefile="sta"
local luaFile = {exefile..".lua","spiviaweb-nogpio15-v1.lua"}
for i, f in ipairs(luaFile) do
	if file.open(f) then
      file.close()
      print("Compile File:"..f)
      node.compile(f)
	  print("Remove File:"..f)
      file.remove(f)
	end
 end

if file.open(exefile..".lc") then
	dofile(exefile..".lc")
else
	print(exefile..".lc not exist")
end
exefile=nil;luaFile = nil
collectgarbage()

Years and years and years later...

So, I was having problems on the software side for a long time. It was sending and receiving to the esp but the PE64904 wasn't retuning or doing anything. Because my code sucks and is broken. But now it's the wild future of 2025 and I can just throw my bad code + nodemcu spi/PE64904 docs + esp8266 board quirks explanation at an LLM AI and debug working code into existence. Oviously time to revisit this project and finish what I couldn't alone.

And it's a good thing too. The nodemcu language itself, the esptool for flashing, and pretty much everything changed in the past 7 years that I've been doing nothing. None of the old code or toolchain worked with the new firmwares. Luckily AI helped figuring out why flashing wasn't working properly.

To flash the firmware get a modern (at least 2.x) version of esptool and a firmware with spi module and all the basic bits. You can use the one I had generated at https://nodemcu-build.com/: nodemcu-release-8-modules-2025-09-24-20-40-37-integer.bin if you want.

superkuh@janus:~/tests$ sudo esptool --port /dev/ttyUSB0 write_flash --flash_mode dio 0x00000 ~/blob/nodemcu-release-8-modules-2025-09-24-20-40-37-integer.bin
esptool.py v2.8
Serial port /dev/ttyUSB0
Connecting....
Detecting chip type... ESP8266
Chip is ESP8266EX
Features: WiFi
Crystal is 26MHz
MAC: 5c:cf:7f:0b:96:c5
Enabling default SPI flash mode...
Configuring flash size...
Auto-detected Flash size: 4MB
Erasing flash...
Flash params set to 0x0240
Took 2.63s to erase flash block
Wrote 450560 bytes at 0x00000000 in 50.5 seconds (71.4 kbit/s)...

Leaving...
Hard resetting via RTS pin...

After flashing the very user friendly java program ESPlorer can be used to upload/edit/compile/etc code on the esp8266. Baudrate 115200. Upload (and compile to .lc) the pe64904_controller.lua before the init.lua otherwise it'll get into a loop complaining about it not being there. Make sure to edit the IP address, ssid, and password in the init.lua.

pe64904_controller.lua
-- esp8266 mediated wifi:telnet control of PE64904 SPI based digitally tuned capacitor.
-- Config esp12e PE64904
-- Default HSPI for CLK and MISO
-- local CLK = 5 --> GPIO14 D5 | MISO, PIN 7 (SDA)
-- local MISO = 6 --> GPIO12 D6 | SCL, PIN 5 (SCL)
-- Manual CS/SEN for chip select using GPIO13 (D7) instead of GPIO15 (D8)
-- IMPORTANT HARDWARE LIMITATION: 
-- GPIO15 (D8) is normally HSPI CS, but ESP8266 requires GPIO15 LOW during boot
-- This creates a conflict - GPIO15 must be pulled low for boot, but SPI CS needs to toggle
-- Many NodeMCU users report HSPI CS (GPIO15) not working reliably
-- WORKAROUND: Use GPIO13 (HSPI MOSI) as manual CS, disable automatic HSPI CS
local CS = 7 --> GPIO13 D7 | Manual SEN/CS for PE64904 PIN 6 (SEN)

-- Turn on HSPI (dev 1) SPI at 10 MHz (80 MHz/8)
-- Only using CLK (GPIO14/D5) and MISO (GPIO12/D6) - MOSI not needed for PE64904
spi.setup(1, spi.MASTER, spi.CPOL_LOW, spi.CPHA_LOW, 8, 8)

-- Configure GPIO13 (D7) as manual CS instead of MOSI
gpio.mode(CS, gpio.OUTPUT)
gpio.write(CS, gpio.HIGH)  -- PE64904 SEN starts HIGH

-- Disable automatic HSPI CS on GPIO15 (D8) to prevent conflicts
-- GPIO15 needs pulldown for boot, but we're not using it for CS anyway
gpio.mode(8, gpio.INPUT, gpio.PULLUP)  -- Keep GPIO15 safe but unused

-- FIXED: Defined as a local function, not added to the string table
local function fromhex(str)
    return (str:gsub('..', function (cc)
        return string.char(tonumber(cc, 16))
    end))
end

-- FIXED: Defined as a local function, not added to the string table
local function tohex(str)
    return (str:gsub('.', function (c)
        return string.format('%02X', string.byte(c))
    end))
end

-- This part of the code is redundant because init.lua already connects to WiFi.
-- It's safe to leave it, but it's not actually being used when called from init.lua.
print("Controller script loaded. Last known IP: " .. wifi.sta.getip())

function start_tcp_client()
    local flagClientTcpConnected = false
    print("Start TCP Client")
    
    local timer = tmr.create()
    timer:alarm(5000, tmr.ALARM_AUTO, function()
        if not flagClientTcpConnected then
            print("Trying to connect to server")
            local conn = net.createConnection(net.TCP, false)
            conn:connect(6005, "192.168.1.125")
            
            conn:on("connection", function(c)
                print("TCPClient: connected to server")
                flagClientTcpConnected = true
            end)
            
            conn:on("disconnection", function(c)
                print("TCPClient: disconnected from server")
                flagClientTcpConnected = false
                conn = nil
                collectgarbage()
            end)
            
            conn:on("receive", function(c, payload)
                print("TCPClient received: " .. payload)
                -- Strip whitespace and extract number
                local cleaned = payload:gsub("%s+", "")
                local numval = tonumber(cleaned)
                
                if numval and numval >= 0 and numval <= 31 then
                    local spi_data = numval
                    
                    gpio.write(CS, gpio.LOW)
                    tmr.delay(1)
                    gpio.write(CS, gpio.HIGH)
                    tmr.delay(1)
                    
                    local bytes_sent = spi.send(1, spi_data)
                    
                    tmr.delay(1)
                    gpio.write(CS, gpio.LOW)
                    tmr.delay(1)
                    
                    print("SPI DTC Control sent state: " .. numval .. " (" .. bytes_sent .. " bytes)")
                    c:send("ok\r\n")
                else
                    print("Invalid value received: " .. tostring(payload))
                    c:send("error: value must be 0-31\r\n")
                end
            end)
        end
    end)
end

-- Start the main logic
start_tcp_client()
init.lua
print("Ready to Set up wifi mode")
wifi.setmode(wifi.STATION)

-- Uses the modern table-based syntax
wifi.sta.config({ ssid="basestationame", pwd="password" })
wifi.sta.connect()

local cnt = 0

-- NEW TIMER API: Create a timer object first
local wifi_timer = tmr.create()

-- NEW TIMER API: Call the alarm method on the object
-- tmr.ALARM_AUTO makes it repeat automatically
wifi_timer:alarm(1000, tmr.ALARM_AUTO, function(t) 
    if (wifi.sta.getip() == nil) and (cnt < 20) then 
        print("Trying Connect to Router, Waiting...")
        cnt = cnt + 1 
    else 
        -- NEW TIMER API: unregister the timer to stop it.
        -- 't' is the timer object that is passed to the callback
        t:unregister()

        if (cnt < 20) then 
            print("Config done, IP is "..wifi.sta.getip())
        else 
            print("Wifi setup time more than 20s, Please verify config. Then re-download the file.")
        end
        cnt = nil
        collectgarbage()
        dofile("pe64904_controller.lc")
    end 
end)

spi over cat5

"The common method to wire shielded cables is to ground only the source end of the shield to avoid ground loops."

"If you do send single ended SPI over twisted pair cable, select one conductor of each twisted pair and make it a ground conductor. This way each signal line will have a pretty much equal electrical environment and you'll at least utilize the twisted pair to reject very high frequency magnetic interference. "


Generic AI generated Tunable RF Filter Docs, etc etc

Overview

This system allows remote control of a PE64904 Digitally Tunable Capacitor via WiFi using an ESP8266 NodeMCU board. The capacitor can be tuned to 32 different states (0-31) for RF filter applications.

System Architecture

Computer -> TCP/IP -> ESP8266 -> SPI -> PE64904 -> RF Filter Tuning

Computer Side (Control Station)

Setup

  1. Start TCP Server: Open terminal and run nc -lvp 6005

Sending Commands

  1. Send Tuning Commands: Type a number and press Enter

Expected Responses

  1. Success Response: ok
  2. Error Response: error: value must be 0-31

ESP8266 Program Flow

1. Boot Sequence

Power On -> init.lua loads -> Compile pe64904_controller.lua -> Execute

What Happens:

2. Hardware Initialization

SPI Setup -> GPIO Configuration -> WiFi Connection

Hardware Setup:

WiFi Connection:

3. TCP Client Connection

WiFi Ready -> Connect to Server -> Wait for Commands

Network Setup:

4. Command Processing Loop

When a command arrives:

Step A: Command Validation

Receive TCP Data -> Strip Whitespace -> Parse Number -> Validate Range

Validation Logic:

Step B: SPI Transaction (Valid Commands Only)

Prepare SPI Data -> Execute PE64904 Protocol -> Send Confirmation

PE64904 SPI Protocol Sequence:

  1. Setup: SEN = LOW (idle state)
  2. Initiate: SEN = HIGH (start transaction)
  3. Data: Send 8-bit value via SPI (upper 3 bits zero, lower 5 bits = state)
  4. Activate: SEN = LOW (falling edge latches data into PE64904)
  5. Timing: All transitions include proper setup/hold delays

Step C: Response Generation

SPI Success -> Log State -> Send "ok" -> Wait for Next Command
SPI/Validation Failure -> Send Error -> Wait for Next Command

5. Continuous Operation

The system runs in an infinite loop:


Example Session

Computer Terminal:

$ (! 2004)-> nc -lvp 6005
Listening on [0.0.0.0] (family 0, port 6005)
Connection from [192.168.1.123] port 6005 [tcp/x11-5] accepted (family 2, sport 14967)
15
ok
99
error: value must be 0-31

ESP8266 Serial Output:

ESP8266 PE64904 DTC Controller Started
Connected with IP: 192.168.1.123
Start TCP Client
TCPClient: connected to server
TCPClient received: 15
SPI DTC Control sent state: 15 (1 bytes)
TCPClient received: 99
Invalid value received: 99

RF Filter Result:


Error Handling

Network Issues:

Command Issues:

Hardware Issues:

The system is designed for robust, continuous operation suitable for remote RF filter tuning applications.