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.
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.
Throughout the course of printing the postcards, I iterated on a few different designs. These two were my favorites.
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!