PTPX 2024

TL;DR - Lattice Boltzmann Methods are still witchcraft


Well, I’ve had my converted 3D printer / pen plotter up and running for most of the year now. It’s been working great, and so for one last challenge before the end of the year, I decided to participate in the annual pen plotter postcard exchange (PTPX). Started by Paul Butler and currently in its fifth season, PTPX is a “generalized secret santa” for plotter enthusiasts. The only challenge now is to figure out what to plot!

I knew that given my artistic inability, coming up with something “original” would be a fool’s errand. Instead I decided to pull inspiration from something I find consistently beautiful; aerodynamics. Whether it’s the underlying mathematics, the fluid motion itself, or the optimal forms created through optimization, aerodynamics is beautiful.

An Album of Fluid Motion by Milton Van Dyke

An Album of Fluid Motion by Milton Van Dyke

I was originally thinking I would just run something in OpenFOAM and be done with it, but that would have been a little too easy. To make it a little harder, I contemplated writing my own 2D solver, but I’ve written enough Navier-Stokes based solvers over the years that it’s no longer something I want to spend my free time doing. After pondering on this for a bit more, I remembered an area of CFD that’s basically witchcraft: Lattice Boltzmann Methods

LBM is strange because instead of resolving the usual bulk properties of a fluid using the continuum assumption it operates at a smaller mesoscopic scale. Down here, physics is described using probability distributions. Back when I was an actual engineer doing actual work I couldn’t have been bothered with a petty subject like “statistics”… Well, jokes on me! I’ve been doing nothing but statistics for the last 10 years now 😭

Anyway, LBM doesn’t require any real understanding of aerodynamics or numerical methods so implementing it was shockingly simple. In fact, it’s just a first-order PDE and the important bits are less than 30 lines of Python:

while err > 1E-6:
    
    # Bhatnagar-Gross-Krook (BKG) collision
    feq = equilibrium_distribution()
    f_post = f - ((f - feq) / tau)
    
    # streaming
    for k in range(0, q):
        f[:,:,k] = np.roll(f_post[:,:,k], c[k], axis=(1, 0))
        
    # bounce-back: top plate
    f[-1, :, 4] = f_post[-1, :, 2]
    f[-1, :, 7] = f_post[-1, :, 5] + 6 * rho[-1, :] * w[7] * cx[7] * uw
    f[-1, :, 8] = f_post[-1, :, 6] + 6 * rho[-1, :] * w[8] * cx[8] * uw
    
    # bounce-back: bottom plate
    f[0, :, 2] = f_post[0, :, 4]
    f[0, :, 5] = f_post[0, :, 7]
    f[0, :, 6] = f_post[0, :, 8]
        
    # bounce-back: left wall
    f[:, 0, 1] = f_post[:, 0, 3]
    f[:, 0, 5] = f_post[:, 0, 7]
    f[:, 0, 8] = f_post[:, 0, 6]

    # bounce-back: right wall
    f[:, -1, 3] = f_post[:, -1, 1]
    f[:, -1, 7] = f_post[:, -1, 5]
    f[:, -1, 6] = f_post[:, -1, 8]

    # compute density
    rho = np.sum(f, 2)
    
    # compute macroscopic velocity
    ux = np.sum(f * cx, 2) / rho
    uy = np.sum(f * cy, 2) / rho
    
    # compute error
    if time_step % 1_000 == 0:
        err = residual()
        
    time_step += 1

At its core, LBM is just a series of streaming and collision steps. It’s a slightly more advanced form of cellular automaton for fluids. Before getting too wild though, I wanted to validate my LBM solver against the “hello world” of CFD problems: the lid driven cavity flow.

Not too shabby. It took about 100k time steps to converge and looks good against Ghia1982. I know it only takes a couple assumptions to recover the Navier-Stokes from LBM but it’s still weird.

With the lid driven cavity flow sanity check out of the way, I swapped some boundary conditions, added an obstacle, and resized the domain to match my 4x6 postcard blanks. The result was another canonical CFD benchmark case:

For the actual postcard design, I wasn’t sure if I wanted to go with vorticity or pull something from the velocity field. There are so many great ways to visualize this! After a little experimenting, I decided on streamlines pulled from several different time steps. Because the output of the streamline integration is just a series of (x, y) coordinate pairs, I could go from simulation results to G-code in a single step.

for i, timestep in enumerate(timesteps, 1):
    with open(f"lbm_layer-{i}.gcode", "w") as f:
        
        # set some initial constraints
        f.write(f';PTPX2024 - LBM - LAYER #{i}\n\n')
        f.write('M203 X50.00 Y50.00 Z10.00     ;set max feedrate (mm/sec)\n')
        f.write('M201 X100.00 Y100.00 Z100.00  ;set max acceleration (mm/sec/sec)\n')
        f.write('M205 X8.00 Y8.00 Z0.40        ;set max jerk (mm/sec)\n\n')
        
        # starting point for current plot
        f.write(f'G0 x200 Y200 Z{Z_START}\n')
        
        # cycle through each streamline
        for _path in [[v[0], v[1]] for v in paths_final if v[2]==timestep]:
            
            # iterate through each point in current streamline
            for j, [x, y] in enumerate(zip(*_path)):
                if j == 0:
                    
                    # move pen above first point
                    f.write(f'G0 X{x:0.3f} Y{y:0.3f} Z{Z_TRAVEL}\n')
                    
                    # move pen down
                    f.write(f'G0 X{x:0.3f} Y{y:0.3f} Z{Z_DRAW}\n')
                    
                else:
                    f.write(f'G0 X{x:0.3f} Y{y:0.3f} Z{Z_DRAW}\n')
            
            # move pen back up
            f.write(f'G0 X{x:0.3f} Y{y:0.3f} Z{Z_TRAVEL}\n')
            
        # move pen up and back to start point
        f.write(f'G0 X{x:0.3f} Y{y:0.3f} Z{Z_START}\n')
        f.write(f'G0 X200 Y200 Z{Z_START}\n')

One thing I’m particularly happy with was the quick release mounting system I built. I swapped the 1/8" glass print bed for a sheet of aluminum of equivalent thickness to which I TIG welded two toggle clamps. Actually, I tried TIG welding them, but the difference in melting temperatures between the aluminum plate and whatever the latches are made of was too much for my beginner welding skills. After a few botched attempts, I cut my losses and used some of the J-B Weld I had left over from my kayak. I’m starting to become a bit of a fan boy for this stuff. I’m by no means an expert in epoxy or adhesives, but it’s nice when something actually delivers relative to expectations. This shouldn’t really be a noteworthy event, but with the enshittification of seemingly everything nowadays (I’m looking at you Shimano) it’s becoming quite an achievement.

3D printer working as a pen plotter with new quickmount system

Throughout the course of printing the postcards, I iterated on a few different designs. These two were my favorites.

3D printer working as a pen plotter

Since this was my first year participating in PTPX, I only signed up to supply 12 postcards. Playing it safe was fine I guess, but next year I’ll definitely be signing up for more!