AI facilitating 3D CAD design

I have been using Fusion for about a decade for CAD design and every so often there is something I need to create as part of another design that is just grunt work. Lately, I have been experimenting with using AI to do some of these simple designs.

It is relatively easy to see how AI can generate text from a prompt like:

Document the dimensions for an ESP32 Generic “ESP32 Mini DevKit 4‑Hole” Boards. Mine is 52 mm long.

I gave it my devices dimension since it would help it find the right one, since there are so many variations out there. The response returned is:

Core Board Dimensions
This "V1" footprint is slightly longer and wider than the official Espressif DevKitC, making it more stable for mounting but tighter on a breadboard.
Feature Dimension (Metric) Dimension (Imperial)
Total Length 52.0 mm 2.05 in
Total Width 28.5 mm 1.12 in
Height (with pins) 13.5 mm 0.53 in
PCB Thickness 1.6 mm 0.06 in
Export to Sheets
________________________________________
Mounting Hole Pattern
The four mounting holes are positioned symmetrically in the corners of the PCB. These are critical for 3D-printed enclosures or standoff mounting.
• Hole Diameter: 3.0 mm (standard for M2.5 screws; M3 may be a tight fit).
• Horizontal Center-to-Center: ~24.5 mm.
• Vertical Center-to-Center: ~48.0 mm.
• Edge Offset: Hole centers are typically 2.0 mm from the PCB edges.
________________________________________
Header & Pin Layout
The pins follow a standard DIP layout, but the width is a common pain point for prototyping.
• Pin Pitch: 2.54 mm (0.1 in).
• Row Spacing: 25.4 mm (1.0 in).
Note: Because the row spacing is exactly 1 inch, if you plug this into a standard breadboard, it will cover all the tie-points on both sides. You will need to use two breadboards side-by-side or use jumper wires from underneath.
• Pin Count: 30 pins (15 per side).

That is useful and expected. But what I didn’t expect was what happens if I then prompted the AI tool with:

Create an openscad model of this device.

OpenSCAD is software for creating solid 3D CAD models using a programming language. It is free software and available for Linux/UNIX, Windows and Mac OS X. Unlike most free software for creating 3D models (such as Blender) it does not focus on the artistic aspects of 3D modelling but instead on the CAD aspects.

After that prompt I receive:

Since you have the 52 mm x 28.5 mm variant (often called the DOIT DevKit V1), the OpenSCAD code below creates a realistic "bounding box" model.
This model includes the PCB, the four mounting holes, the ESP32 module, and the micro-USB port. I've used variables at the top so you can tweak the dimensions if you find your specific clone is off by a fraction of a millimeter.
// ESP32 Mini DevKit (52mm 4-Hole Variant)
// Dimensions in mm
$fn = 32;
// --- Dimensions ---
pcb_l = 52.0;
pcb_w = 28.5;
pcb_h = 1.6;
hole_dia = 3.0;
hole_offset = 2.0; // Distance from center of hole to edge
module_l = 25.5;
module_w = 18.0;
module_h = 3.5;
usb_l = 7.5;
usb_w = 8.0;
usb_h = 3.0;
// --- Build ---
union() {
// 1. PCB Base with Mounting Holes
difference() {
color("DarkGreen")
cube([pcb_l, pcb_w, pcb_h], center = true);
// Four Mounting Holes
for (x = [-(pcb_l/2 - hole_offset), (pcb_l/2 - hole_offset)]) {
for (y = [-(pcb_w/2 - hole_offset), (pcb_w/2 - hole_offset)]) {
translate([x, y, -pcb_h])
cylinder(d = hole_dia, h = pcb_h * 3);
}
}
}
// 2. ESP32 Module (The Silver Tin)
translate([-(pcb_l/2 - module_l/2 - 5), 0, (pcb_h/2 + module_h/2)]) {
color("Silver")
cube([module_l, module_w, module_h], center = true);
}
// 3. Micro-USB Port
translate([pcb_l/2 - usb_l/2, 0, pcb_h/2 + usb_h/2]) {
color("LightGray")
cube([usb_l, usb_w, usb_h], center = true);
}
// 4. Pin Headers (Simplified Bounding Box)
color("Black") {
translate([0, 12.7, -5]) // Row 1
cube([pcb_l - 4, 2.54, 10], center = true);
translate([0, -12.7, -5]) // Row 2
cube([pcb_l - 4, 2.54, 10], center = true);
}
}

Great response – If I paste that into OpenSCAD I get the following model:

Image

That’s nice but a real device has pins an headers so I prompt it with:

refine the design to show the pins and the header

I can then take that new output into OpenSCAD and get a design that looks like:

Image

That is definitely close enough for my needs. If I was 3D printing it would be good enough to use. What I really would like is to take this mode over in Fusion in a way that I could refine it. So I prompt:

Create a fusion version of the design

The AI then creates a Fusion sketch and response with:

Fusion 360 Construction Guide
If you are modeling this from scratch in Fusion 360, use these parameters for a "Master Sketch" on the XY plane:
1. The Main PCB (Center Rectangle)
Type: Center Point Rectangle
Dimensions:
Extrude: (New Component: "PCB")
2. Mounting Holes (4-Hole Pattern)
Placement: Draw a construction rectangle centered on the PCB.
Diameter: at each corner of the construction rectangle.
Extrude: Cut through the PCB.
3. Pin Header Layout (Critical for Fitment)
To ensure your 3D-printed enclosures or PCB mounts fit, use these offsets:
Row Spacing: (1 inch) center-to-center.
Pin Pitch: (0.1 inch).
Positioning: The 15-pin rows are typically centered, meaning the first pin starts 17.78 mm from the center point of the board ().
________________________________________
Python Script for Fusion 360
You can run this script inside Fusion 360 (Utilities > Add-ins > Scripts) to automate the generation of the base board and the 30 pins.
import adsk.core, adsk.fusion, traceback
def run(context):
ui = None
try:
app = adsk.core.Application.get()
ui = app.userInterface
design = app.activeProduct
rootComp = design.rootComponent
# Parameters
pcb_l = 5.2 # cm
pcb_w = 2.85 # cm
pcb_h = 0.16 # cm
row_space = 2.54 # cm
pin_pitch = 0.254 # cm
# Create PCB
sketches = rootComp.sketches
xyPlane = rootComp.xYConstructionPlane
sketch = sketches.add(xyPlane)
# Center the PCB
lines = sketch.sketchCurves.sketchLines
rect = lines.addTwoPointRectangle(adsk.core.Point3D.create(-pcb_l/2, -pcb_w/2, 0),
adsk.core.Point3D.create(pcb_l/2, pcb_w/2, 0))
prof = sketch.profiles.item(0)
extrudes = rootComp.features.extrudeFeatures
extInput = extrudes.createInput(prof, adsk.fusion.FeatureOperations.NewComponentFeatureOperation)
distance = adsk.core.ValueInput.createByReal(pcb_h)
extInput.setDistanceExtent(False, distance)
extrudes.add(extInput)
except:
if ui:
ui.messageBox('Failed:\n{}'.format(traceback.format_exc()))

When I load this into a Fusion script, create an assembly and run the script I get a model:

Image

That’s nice but not nearly the level of detail that was included in the OpenSCAD design. The sketch that this was extruded from in Fusion didn’t have pins…

Just like with other AI tasks, it needs a bit more prompting to get something useful, so:

In the OpenSCAD design there were pins and mounting holes. The Fusion model does not have those.
Add these details to the fusion model.

The AI responds with:

To get those specific features into Fusion 360, we have to transition from a single block to a parametric assembly. Below is the expanded Python script for the Fusion 360 Script Editor.
This script generates the PCB, cuts the four mounting holes, and iterates through the 30-pin layout to create physical pins.
Fusion 360 Automation Script
1. Open Fusion 360.
2. Go to Utilities > Add-ins > Scripts and Add-ins.
3. Click Create and choose Python. Give it a name like ESP32_Refined.
4. Paste the following code:
import adsk.core, adsk.fusion, traceback
def run(context):
ui = None
try:
app = adsk.core.Application.get()
ui = app.userInterface
design = app.activeProduct
rootComp = design.rootComponent
# Parameters (Fusion uses CM internally)
pcb_l = 5.2
pcb_w = 2.85
pcb_h = 0.16
hole_dia = 0.3
hole_off = 0.2
pin_pitch = 0.254
row_space = 2.54
pin_count = 15
pin_size = 0.064 # 0.64mm standard square pin
# --- 1. Create PCB Body ---
sketches = rootComp.sketches
xyPlane = rootComp.xYConstructionPlane
pcb_sketch = sketches.add(xyPlane)
# Draw PCB Outline
lines = pcb_sketch.sketchCurves.sketchLines
rect = lines.addTwoPointRectangle(adsk.core.Point3D.create(-pcb_l/2, -pcb_w/2, 0),
adsk.core.Point3D.create(pcb_l/2, pcb_w/2, 0))
# Draw 4 Mounting Holes
holes = pcb_sketch.sketchCurves.sketchCircles
offsets = [[(pcb_l/2 - hole_off), (pcb_w/2 - hole_off)],
[-(pcb_l/2 - hole_off), (pcb_w/2 - hole_off)],
[(pcb_l/2 - hole_off), -(pcb_w/2 - hole_off)],
[-(pcb_l/2 - hole_off), -(pcb_w/2 - hole_off)]]
for pos in offsets:
holes.addByCenterRadius(adsk.core.Point3D.create(pos[0], pos[1], 0), hole_dia/2)
# Extrude PCB
prof = pcb_sketch.profiles.item(0) # Fusion handles holes automatically if in same profile
extrudes = rootComp.features.extrudeFeatures
extInput = extrudes.createInput(prof, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
extInput.setDistanceExtent(False, adsk.core.ValueInput.createByReal(pcb_h))
pcb_body = extrudes.add(extInput)
# --- 2. Create Pins ---
pin_sketch = sketches.add(xyPlane)
start_x = -((pin_count - 1) * pin_pitch) / 2
for i in range(pin_count):
curr_x = start_x + (i * pin_pitch)
# Row 1 and Row 2 pin squares
for y_pos in [row_space/2, -row_space/2]:
lines.addTwoPointRectangle(adsk.core.Point3D.create(curr_x - pin_size/2, y_pos - pin_size/2, 0),
adsk.core.Point3D.create(curr_x + pin_size/2, y_pos + pin_size/2, 0))
# Extrude Pins (Downwards from PCB)
for i in range(pin_sketch.profiles.count):
p_prof = pin_sketch.profiles.item(i)
p_ext_input = extrudes.createInput(p_prof, adsk.fusion.FeatureOperations.NewBodyFeatureOperation)
# Extrude 1cm down
p_ext_input.setDistanceExtent(False, adsk.core.ValueInput.createByReal(-1.0))
extrudes.add(p_ext_input)
except:
if ui:
ui.messageBox('Failed:\n{}'.format(traceback.format_exc()))

When I update my script and run it the following sketch is created in Fusion:

Image

This is a much more useful sketch that I could extrude the pins from… I am sure if I really wanted to continue to work on it I could have convinced the AI tool to create the 3D pins and headers in Fusion — but that was good enough for my needs.

This is more of a demonstration of capability than anything else, and it would have taken me a bit of time to design the same dimensionally correct model manually.

One warning though, not all the AIs are the same. Gemini did a good job of creating the Fusion design without any errors (once I ran the script inside a Fusion assembly). The Microsoft AI (CoPilot) didn’t do nearly as well.

It was interesting to note how much better job OpenSCAD did at the parametric modeling when compared to Fusion script generation. It may not be all that useful yet, but definitely a signpost on the road to the future.

3D printed giant fireworks simulation

For Christmas I received a mystery box from PrintKits from my wife. The concept really pulled me in: they send the electronics, and you 3D print the enclosures. As someone who grew up building Heathkit ham radio gear, this felt like a modern nod to my roots.

The kit that came far exceeded my expectations as a fireworks simulation:

Image

That GIF is of the fireworks display laying on my garage floor (with the lights off) in test mode. It stands about 4 feet high. The instructions for the kit can be found at:

https://printkits.com/blogs/news/digital-fireworks

The Build Details:

  • Printing: It’s a marathon build—it took over a full spool of filament of various colors and quite a bit of time.
  • Electronics: It runs on an ESP32 microcontroller. It was my first time using one, so it was fun learning more about it once the assembly was done.
  • The “Oops”: One LED strip segment was DOA, but luckily I had a spare half-meter in my workshop.

I only wish I had it assembled by New Years Eve, so I could have had it on my front porch. There is always the 4th of July – or Guy Fawkes night.

If you have a 3D printer and interested in electronics (and aren’t afraid of a soldering iron) you should look at some of their kits.

3D model generation – exploration and observations

I was talking to a fellow I used to work with at EDS and HP the other day. We both are into 3D printing and model creation. He mentioned that he was using OpenSCAD and Gemini to create models. OpenSCAD is a parametric CAD design tool. I thought I’d give it a try.

First, I gave it just a text prompt:

"Design a 3D print for a battery holder that will hold 3 AA batteries (side by side) where I can add spring terminals to connect to power. Create it as OpenSCAD code"

I got back something that was not all that useful, but I could see what it was going for, so this time I gave it a picture of what I wanted (that I pulled off of Amazon) as well as text description and I received back this OpenSCAD design (rendered in 3D builder):

Image

That was nice, but I use Fusion as my CAD tool and it supports parametric design. I then asked Gemini to generate the design in Python for Fusion. It ended up creating a design (after a few iterations) that looks like:

Image

The python code created the two sketches as well as the body. Overall, it was interesting  (and frustratingly close to useful) to see the start CAD generation with AI.

Autodesk has an AI extension for Fusion but at $50 a month, it is a bit steep for a hobbyist. What I have shown here was all done with free tools. I probably could design the same thing quicker by hand but doing this did provide some ideas on how to approach the problem.

Fine tuning your 3D printer bed using Aluminum foil and Fluidd

I purchased a new printer earlier this summer (Creality K2+) and it has been working well, though  recently I’ve had some problems with some larger prints. Fortunately, the printer has a web interface called Fluidd.

One of the capabilities in Fluidd will display a bed grid showing how even (or not) it is. This is located in the Tuning command listed on the left side of the interface.

When I ran my initial analysis I saw the following:

Image

Any time the range is greater than 1 mm, intervention is required. I was actually shocked the printer was able to do this well as it had, with the left side being so far off.

Fortunately, the K2+ has a few knobs on the bottom of the bed allowing for macro adjustments. After I tuned it up a bit, my bed mesh looks like:

Image

That’s a significant improvement.

One trick that I have seen discussed online is to use Aluminum foil to fill in various areas to get it even flatter. I saw that some folks had written programs to do this, but rather than use theirs I started from scratch and attempted some Vibe coding. It was a bit frustrating though, since there were quite a few errors injected along the way, but I think it’s working.

I wrote a small Python program to facilitate the analysis. Its user interface looks like:

Image

Where you input:

  • the mesh data (from Fluidd)
    1. inside Fluidd -> console run: BED_MESH_OUTPUT
    2. edit the result into a CSV file
  • the folder where you would like a PDF generated of the output
  • the thickness of the AL foil
  • the dimensions of your printer

You click on the Generate Maps button and the screen will be update with a preview of the foil layers that will be required to level out the bed:

Image

You can also view a PDF of the results. Here is the first page of what was generated:

Image

The program looks like:

import tkinter as tk
from tkinter import filedialog, messagebox
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.backends.backend_pdf import PdfPages
from PIL import Image, ImageTk
import os
import csv
import json
import subprocess
import platform
# --- CORE FUNCTIONS ---
def load_mesh(file_path):
if file_path.endswith(".csv"):
with open(file_path, newline='') as f:
reader = csv.reader(f)
mesh = np.array([[float(cell) for cell in row] for row in reader])
elif file_path.endswith(".json"):
with open(file_path) as f:
data = json.load(f)
mesh = np.array(data["mesh"])
else:
raise ValueError("Unsupported file format. Use .csv or .json")
return mesh
def generate_images(mesh, thresholds, output_dir, bed_width, bed_height):
os.makedirs(output_dir, exist_ok=True)
for i, threshold in enumerate(thresholds):
mask = mesh <= -threshold
fig, ax = plt.subplots()
cell_width = bed_width / (mesh.shape[1] - 1)
cell_height = bed_height / (mesh.shape[0] - 1)
extent = [0, bed_width, bed_height, 0] # [x_min, x_max, y_min, y_max]
c = ax.imshow(mask, cmap='gray', interpolation='none', extent=extent)
ax.set_title(f"Layer {i+1}: Tape for ≤ {-threshold:.2f} mm")
x_ticks = np.linspace(0, bed_width, mesh.shape[1])
y_ticks = np.linspace(0, bed_height, mesh.shape[0])
ax.set_xticks(x_ticks)
ax.set_yticks(y_ticks)
ax.set_xticklabels([f"{x:.1f}" for x in x_ticks])
ax.set_yticklabels([f"{y:.1f}" for y in y_ticks])
ax.set_xlabel("X Position (mm)")
ax.set_ylabel("Y Position (mm)")
ax.grid(True, color='lightgray', linewidth=0.5)
#plt.colorbar(c, ax=ax, label='Tape Needed')
fig.text(0.5, 0.00, "White = height needed, Black = no foil needed", fontsize=9, va='bottom', ha='center')
plt.savefig(f"{output_dir}/layer_{i+1}.png")
plt.close()
def generate_pdf_summary(output_dir, num_layers, layer_thickness, max_depth):
pdf_path = os.path.join(output_dir, "foil_map_summary.pdf")
with PdfPages(pdf_path) as pdf:
for i in range(num_layers-1):
img_path = os.path.join(output_dir, f"layer_{i+1}.png")
img = Image.open(img_path)
fig, ax = plt.subplots(figsize=(6, 6))
if i == 0:
# Add header text
fig.text(0.5, 0.97, f"Foil thickness: {layer_thickness:.2f} mm, Max compensation: {max_depth:.2f} mm", ha='center', fontsize=8)
fig.text(0.5, 0.95, "Each image layer corresponds to a threshold of bed deviation (in millimeters).", ha='center', fontsize=8, weight='bold')
fig.text(0.5, 0.93, "The darker areas in each layer indicate where the bed is lower than that threshold,", ha='center', fontsize=8, weight='bold')
fig.text(0.5, 0.91, "meaning foil should be added there to raise it.", ha='center', fontsize=8, weight='bold')
fig.text(0.1, 0.89, f"Layer 1 (e.g., ≤ {layer_thickness:.2f} mm): Apply foil to all white squares.", ha='left', fontsize=8)
fig.text(0.1, 0.87, f"Layer 2 (≤ {layer_thickness*2:.2f} mm): Apply a second layer of foil only to squares that are white in this image.", ha='left', fontsize=8)
fig.text(0.1, 0.85, f"Layer 3 (≤ {layer_thickness*3:.2f} mm): Add a third layer where the squares are white.", ha='left', fontsize=8)
fig.text(0.1, 0.83, "...", ha='left', fontsize=12)
ax.imshow(img)
ax.axis('off')
ax.set_title(f"Layer {i+1}")
pdf.savefig(fig)
plt.close(fig)
return pdf_path
def open_pdf(path):
try:
if platform.system() == "Windows":
os.startfile(path)
elif platform.system() == "Darwin":
subprocess.run(["open", path])
else:
subprocess.run(["xdg-open", path])
except Exception as e:
messagebox.showerror("Error", f"Could not open PDF:\n{e}")
# --- GUI SETUP ---
def run_gui():
def browse_file():
path = filedialog.askopenfilename(filetypes=[("CSV or JSON", "*.csv *.json")])
file_entry.delete(0, tk.END)
file_entry.insert(0, path)
def browse_output():
path = filedialog.askdirectory()
output_entry.delete(0, tk.END)
output_entry.insert(0, path)
def run_generator():
try:
mesh_file = file_entry.get()
output_dir = output_entry.get()
#thresholds = [float(t.strip()) for t in threshold_entry.get().split(",")]
layer_thickness = float(layer_thickness_entry.get())
mesh = load_mesh(mesh_file)
max_depth = -np.min(mesh)
thresholds = np.arange(layer_thickness, max_depth + layer_thickness, layer_thickness)
mesh = load_mesh(mesh_file)
bed_width = float(bed_width_entry.get())
bed_height = float(bed_height_entry.get())
generate_images(mesh, thresholds, output_dir, bed_width, bed_height)
# Clear old previews
for label in preview_labels:
label.destroy()
preview_labels.clear()
# Load and display thumbnails
for i in range(len(thresholds)-1):
img_path = os.path.join(output_dir, f"layer_{i+1}.png")
img = Image.open(img_path).resize((120, 120))
tk_img = ImageTk.PhotoImage(img)
label = tk.Label(preview_frame, image=tk_img)
label.image = tk_img # Keep reference
label.grid(row=0, column=i, padx=5)
preview_labels.append(label)
# Generate PDF
pdf_path_var.set(generate_pdf_summary(output_dir, len(thresholds), layer_thickness, max_depth))
view_pdf_btn.config(state=tk.NORMAL)
messagebox.showinfo("Success", f"Generated {len(thresholds)-1} layer images and PDF summary.")
except Exception as e:
messagebox.showerror("Error", str(e))
def view_pdf():
path = pdf_path_var.get()
if path:
open_pdf(path)
else:
messagebox.showwarning("No PDF", "No PDF has been generated yet.")
root = tk.Tk()
root.title("Foil Tape Map Generator")
tk.Label(root, text="Mesh File (.csv or .json):").grid(row=0, column=0, sticky="w")
file_entry = tk.Entry(root, width=40)
file_entry.grid(row=0, column=1)
tk.Button(root, text="Browse", command=browse_file).grid(row=0, column=2)
tk.Label(root, text="Output Folder:").grid(row=1, column=0, sticky="w")
output_entry = tk.Entry(root, width=40)
output_entry.grid(row=1, column=1)
tk.Button(root, text="Browse", command=browse_output).grid(row=1, column=2)
tk.Label(root, text="Foil Layer Thickness (mm):").grid(row=2, column=0, sticky="w")
layer_thickness_entry = tk.Entry(root, width=10)
layer_thickness_entry.insert(0, "0.02")
layer_thickness_entry.grid(row=2, column=1, sticky="w")
# add the printer dimensions
tk.Label(root, text="Bed Width (mm):").grid(row=3, column=0, sticky="w")
bed_width_entry = tk.Entry(root, width=10)
bed_width_entry.insert(0, "350")
bed_width_entry.grid(row=3, column=1, sticky="w")
tk.Label(root, text="Bed Height (mm):").grid(row=4, column=0, sticky="w")
bed_height_entry = tk.Entry(root, width=10)
bed_height_entry.insert(0, "350")
bed_height_entry.grid(row=4, column=1, sticky="w")
tk.Button(root, text="Generate Maps", command=run_generator).grid(row=3, column=1, pady=10)
# View PDF button
view_pdf_btn = tk.Button(root, text="View PDF Summary", command=view_pdf, state=tk.DISABLED)
view_pdf_btn.grid(row=3, column=2)
# Preview frame
preview_frame = tk.LabelFrame(root, text="Preview", padx=10, pady=10)
preview_frame.grid(row=5, column=0, columnspan=3, pady=10)
preview_labels = []
pdf_path = pdf_path_var = tk.StringVar()
root.mainloop()
if __name__ == "__main__":
run_gui()

Creating an Electronic Magic 8 Ball with Fusion 360 and Raspberry Pico

The other day I had some time on my hands while I was sitting in the infusion area waiting for my wife’s infusion to complete and got the idea for an electronic Magic 8 Ball.

If you didn’t have one of these growing up, they are a plastic ball that when you turn it over provides a random answer out of 20. Mine version has about 160 possible answers ranging from a simple affirmative up through sarcastic and mystic responses.

I am sure that others have done it, but since I have all the electronics at home (at least I thought I did) I got to it.

Image

I should sand the completed design a bit to minimize the layer ridges (up to at least 400 grit), but it came out as I expected.

I used Autodesk Fusion to design the 3D printed parts. Here is a rendering of the inside of the 3D model top:

Image

It holds the batteries, circuit board, and the orientation sensor.

The bottom looks like:

Image

and holds the display. The two halves are threaded and screw together.

The Raspberry Pico provides the processing power. A gc9a01 is the circular display (using the same circuit described in this post). The orientation sensor is a switch component SW-520D (that I didn’t have lying around). These devices are more fragile than I thought they should be, so experimentation was required to get them to not break and mount inside the sphere.

The program was written in MicroPython and driven by a text file that contains the text as well as the color scheme to use for each message. The programming was straightforward, except for the need to scale and format the text into the traditional triangular format based on its length and word composition. The longest response is about 50 characters long.

Vibe coding to visualize the decision tree

It was easy to understand the spreadsheet for the decision tree when I was the only one working with it (until I started making revisions). Once someone else started changing the spreadsheet it became clear that some node relationship visualization was required.

This is where Vibe coding comes into play. I knew that Python had all the libraries needed  display a decision tree. I just wasn’t all the familiar with those libraries. I thought an AI tool could help me out. I played around with AI facilitated coding in the past. I knew that it might take a couple of tries to find a generated a solution I liked. I started with Copilot (since I had not done much with it), moved on to Gemini and then DeepSeek. The question I asked was:

Make a diagram in python of a decision tree defined by a excel table that has 7 columns where the first column is the  node number, the 2nd column is the question to be displayed to the user, the third column is the 1st answer the user can select, the 4th column is next node if the 1st answer is selected, the 5th column is the 2nd possible answer and the 6th column is the node to go to of the 2nd possible answer is selected.

That came up with a solution that was about 90% there. I just had to tweak it a bit, and ended up with:

import pydot
import openpyxl
import os
def create_decision_tree_from_excel(excel_file):
    """
    Creates a decision tree graph from an Excel (.xlsx) file using pydot,
    and saves the PNG with the same name as the Excel file.
    Displays question text for all nodes.
    """
    graph = pydot.Dot(graph_type='digraph', rankdir='TD')  # Top-down layout (portrait)
    node_questions = {}  # Dictionary to store node numbers and their questions
    try:
        workbook = openpyxl.load_workbook(excel_file)
        sheet = workbook.active
        # Assuming the data starts from the second row (skipping header)
        for row in sheet.iter_rows(min_row=2, values_only=True):
            node_num, question, _, answer1, next_node1, answer2, next_node2 = row
            # Format question text
            formatted_question = format_text(question, 50, 3)
            node_questions[str(node_num)] = formatted_question #store the questions.
            # Create nodes
            node_question = pydot.Node(str(node_num), label=formatted_question, shape='box')
            graph.add_node(node_question)
            if next_node1:
                node_answer1 = pydot.Node(f"{node_num}_{answer1}", label=answer1, shape='ellipse')
                graph.add_node(node_answer1)
                graph.add_edge(pydot.Edge(node_question, node_answer1))
                if str(next_node1) in node_questions:
                    node_next1 = pydot.Node(str(next_node1), label=node_questions[str(next_node1)], shape='box')
                else:
                    node_next1 = pydot.Node(str(next_node1), label="Error: Node not found", shape='box') # Added Error handling
                graph.add_node(node_next1)
                graph.add_edge(pydot.Edge(node_answer1, node_next1))
            elif next_node1 == None: #if the node is terminal for the first answer
                pass #do nothing, we already added the question node.
            else:
                pass
            if next_node2:
                node_answer2 = pydot.Node(f"{node_num}_{answer2}", label=answer2, shape='ellipse')
                graph.add_node(node_answer2)
                graph.add_edge(pydot.Edge(node_question, node_answer2))
                if str(next_node2) in node_questions:
                    node_next2 = pydot.Node(str(next_node2), label=node_questions[str(next_node2)], shape='box')
                else:
                    node_next2 = pydot.Node(str(next_node2), label="Error: Node not found", shape='box') #Added error handling.
                graph.add_node(node_next2)
                graph.add_edge(pydot.Edge(node_answer2, node_next2))
            elif next_node2 == None: #if the node is terminal for the second answer
                pass #do nothing, we already added the question node.
            else:
                pass
        # Save the graph to a file with the same name as the Excel file
        base_name = os.path.splitext(excel_file)[0]
        output_file = f"{base_name}.png"
        graph.write_png(output_file)
        print(f"Decision tree graph saved to {output_file}")
    except FileNotFoundError:
        print(f"Error: File '{excel_file}' not found.")
    except Exception as e:
        print(f"An error occurred: {e}")
def format_text(text, max_width, max_lines):
    """
    Formats text to fit within a specified width and maximum number of lines.
    """
    words = str(text).split() #convert to string in case the excel cell contains a number.
    lines = []
    current_line = ""
    line_count = 0
    for word in words:
        if len(current_line + " " + word) <= max_width:
            if current_line:
                current_line += " " + word
            else:
                current_line = word
        else:
            lines.append(current_line)
            current_line = word
            line_count += 1
            if line_count >= max_lines:
                break #prevent exceeding max line count.
    lines.append(current_line)
    return "\n".join(lines)
# Example Excel file (replace with your actual Excel file)
# Create an excel file named decision_tree.xlsx with the following data.
# Node,Question,Graphic,Answer1,NextNode1,Answer2,NextNode2
# 1,Is it a big thing?,image1.png,Yes,2,No,3
# 2,Is it round?,image2.png,Yes,4,No,4
# 3,Is it square?,image3.png,Yes,4,No,4
# 4,What is it?,image4.png,, , ,
create_decision_tree_from_excel('decisiontree3DPrinter.xlsx') # example

Here is an example of a diagram that was generated from the spreadsheet discussed last post.

Image

I was happy with this ability to display and understand the decision tree Excel file.

The current draft for our laser help file looks like:

Image

A copilot test using Think Deeper

I did the same kind of request as I had done earlier with some of the other AI tools and got a much different and more interesting result from Think Deeper. Some of it was the same but then there were some solid game development improvements that I wasn’t expecting (more about this at the end):

Hey! I took a look at your MicroPython code for the Raspberry Pi Pico SkeeBall game. It’s a solid start—bringing classic arcade fun to microcontrollers is always exciting. Let’s dive into how we can enhance your code to make it more efficient and responsive.

1. Improving the Debounce Function

Your current debounce function introduces a simple delay:

def debounce(self, pin):
    """Simple debounce function."""
    utime.sleep(DEBOUNCE_DELAY)
    return 

While this works, it’s not the most effective way to debounce inputs. A better approach is to check the pin state multiple times over a short period to ensure stability. Here’s how you can refine it:

def debounce(self, pin):
    """Improved debounce function."""
    initial_state = pin.value()
    utime.sleep_ms(10)  # Wait 10 milliseconds
    if pin.value() == initial_state:
        utime.sleep_ms(10)
        if pin.value() == initial_state:
            return True
    return False

This method verifies that the pin state remains consistent, reducing false triggers from electrical noise.

2. Minimizing Work in Interrupt Handlers

Interrupt Service Routines (ISRs) should be as short as possible to avoid missing other interrupts. Currently, your handlers perform debouncing and game logic directly:

def button_handler(self, pin):
    if self.debounce(pin):
        self.reset_flag = True

Instead, consider setting flags in your ISRs and handling the logic in the main loop:

def button_handler(self, pin):
    self.reset_flag = True

def sensor_handler(self, pin):
    self.scored_flag = True

Then, in your main loop, act on these flags:

while True:
    if game.reset_flag:
        game.reset_game()
    if game.scored_flag and game.ball_count < MAX_BALLS:
        if game.debounce(points10_sensor):
            game.score += 10
            game.ball_count += 1
        game.scored_flag = False

This approach keeps your ISRs quick and responsive.

3. Avoiding Blocking Delays

Using utime.sleep() in your main loop can make your program unresponsive, especially since it blocks other operations. Instead, you can use non-blocking timing by tracking elapsed time:

def main():
    game = SkeeBallGame()
    previous_time = utime.ticks_ms()

    while True:
        current_time = utime.ticks_ms()
        if utime.ticks_diff(current_time, previous_time) >= ROTATE_DELAY * 1000:
            previous_time = current_time
            game.lcd.lcd_display_string(game.rotate_string(SKEEBALLSTRING), 1)

This method updates the display without pausing the entire program, allowing other events to be processed simultaneously.

4. Implementing Asynchronous Programming with uasyncio

For better concurrency, consider using uasyncio, which allows you to handle multiple tasks efficiently.

First, import uasyncio:

import uasyncio as asyncio

Then, refactor your functions into asynchronous coroutines:

async def rotate_display(self):
    while not self.reset_flag:
        self.lcd.lcd_display_string(self.outstring, 1)
        self.outstring = self.rotate_string(self.outstring)
        await asyncio.sleep(ROTATE_DELAY)

async def update_score(self):
    while not self.reset_flag and self.ball_count < MAX_BALLS:
        self.update_score_display()
        await asyncio.sleep(GAMEPLAY_DELAY)
        self.scored_flag = False

async def main():
    game = SkeeBallGame()
    await asyncio.gather(
        game.rotate_display(),
        game.update_score()
    )

if __name__ == "__main__":
    asyncio.run(main())

Using uasyncio makes your code more responsive and easier to manage.

5. Enhancing the Scoring System

If you plan to add more point values, like traditional SkeeBall, you can set up multiple sensors with corresponding scores:

SENSORS = {
    15: 10,  # Pin 15 for 10 points
    14: 20,  # Pin 14 for 20 points
    13: 30,  # Pin 13 for 30 points
}

# Initialize sensors
for pin_num, score_value in SENSORS.items():
    sensor = machine.Pin(pin_num, machine.Pin.IN, machine.Pin.PULL_UP)
    sensor.irq(trigger=machine.Pin.IRQ_RISING, handler=lambda pin, sv=score_value: game.sensor_handler(pin, sv))

Update your sensor_handler to accept the score value:

def sensor_handler(self, pin, score_value):
    if self.debounce(pin) and self.ball_count < MAX_BALLS:
        self.score += score_value
        self.ball_count += 1

This makes your game more dynamic and closer to the real SkeeBall experience.

6. Organizing Constants and Configuration

Keeping constants global can clutter the namespace and make maintenance harder. Encapsulate them within your class or a separate configuration module:

class SkeeBallGame:
    # Constants
    MAX_BALLS = 7
    LCD_WIDTH = 16
    ROTATE_DELAY = 0.3
    GAMEPLAY_DELAY = 0.4
    FINAL_SCORE_DELAY = 0.5
    DEBOUNCE_DELAY = 0.2
    SKEEBALLSTRING = "    SKEEBALL    "
    SCORESTRING = "Score:{0} Ball:{1}"
    # Rest of your class...

This keeps related variables together and improves code readability.

7. Adding Visual and Audio Feedback

Enhance the user experience by integrating LEDs or a buzzer. For example, flash an LED when a score is made or play a sound at game over:

# Initialize LED and buzzer
led = machine.Pin(25, machine.Pin.OUT)
buzzer = machine.Pin(26, machine.Pin.OUT)

def sensor_handler(self, pin, score_value):
    if self.debounce(pin) and self.ball_count < MAX_BALLS:
        self.score += score_value
        self.ball_count += 1
        led.on()
        utime.sleep(0.1)
        led.off()

def game_over(self):
    for _ in range(3):
        buzzer.on()
        utime.sleep(0.2)
        buzzer.off()
        utime.sleep(0.2)

Feedback elements make the game more engaging and interactive.

8. Implementing a High-Score Feature

To make your game competitive, add a high-score tracker using non-volatile memory like the built-in flash or an external EEPROM:

def save_high_score(self):
    if self.score > self.high_score:
        self.high_score = self.score
        # Save to NVRAM or file

def load_high_score(self):
    # Load from NVRAM or file
    pass

Display the high score on the LCD during the attract mode or after the game ends.

9. Code Documentation and Style

For better maintainability, ensure all your functions and classes have docstrings explaining their purpose. Use consistent naming conventions and consider adhering to PEP 8 style guidelines, which are standard for Python code.

10. Future Expansion with Networking

If you’re feeling adventurous, think about adding network capabilities using a Wi-Fi module like the ESP8266. You could send scores to an online leaderboard or control the game remotely.

Wrapping Up

You’ve got a fantastic foundation for your SkeeBall game. By refining the debounce logic, minimizing interrupt workload, leveraging asynchronous programming, and enhancing user feedback, you’ll create a smoother and more enjoyable experience.

Oh, and one more thing—have you thought about packaging your game into a reusable module? This way, others can easily integrate it into their projects, or you can expand it into a full-fledged arcade library for the Raspberry Pi Pico. The sky’s the limit!

My thoughts as I read this

Suggestions 1, 2, 3, 4, 6 and 9 were the type of feedback I was expecting. Suggestions 5, 7, 8 and 10 were based on more of a real world perspective that was outside the code.

Hopefully, that will be my last AI related post for a while but it is clear that even AI can have a diverse perspective on suggestions and solutions that can spur greater creativity.

Now the same test with Grok

Grok is another AI tool that has an X interface that is supposed to be good at coding. I gave it the same problem as the other day and here is what it produced.

Here are several improvements that could be made to the provided MicroPython code to enhance its functionality, readability, and efficiency:

1. Use Constants for Pin Numbers and Other Hardcoded Values

Instead of using magic numbers for pin assignments, define constants at the top of the script. This makes the code more maintainable.

BUTTON_PIN = 16
POINT10_PIN = 15
I2C_SDA_PIN = 0
I2C_SCL_PIN = 1

2. Improve Variable Naming
Use more descriptive names for variables to enhance readability:

    resetButton could be reset_button
    points10sensor could be ten_point_sensor


3. Global Variables
Avoid using global variables where possible. Instead, consider passing them as parameters or encapsulating them within a class or function to manage state:
class SkeeBallGame:
    def __init__(self):
        self.score = 0
        self.reset = False
        self.scored = False
        self.ball_count = 0

    # Methods here would handle game logic

4. Code Structure

Restructure the code to use functions or methods for different game states:

  • Setup function for initializing hardware
  • Game loop function
  • Display management functions
def setup_game():
    reset_button = machine.Pin(BUTTON_PIN, machine.Pin.IN, machine.Pin.PULL_UP)
    ten_point_sensor = machine.Pin(POINT10_PIN, machine.Pin.IN, machine.Pin.PULL_UP)
    sda = machine.Pin(I2C_SDA_PIN)
    scl = machine.Pin(I2C_SCL_PIN)
    i2c = machine.I2C(0, sda=sda, scl=scl, freq=400000)
    lcd = I2C_LCD_driver.lcd()
    return reset_button, ten_point_sensor, lcd

def game_loop(reset_button, ten_point_sensor, lcd):
    # Game logic here

5. Interrupt Handling

Ensure interrupt handlers are as short as possible to not block other interrupts:

def button_handler(pin):
    game.reset = True

def handler_10(pin):
    if not game.scored:
        game.scored = True
        game.score += 10
        game.ball_count += 1

6. Error Handling

Add some basic error handling, especially for I2C communication with the LCD:

try:
    lcd.lcd_display_string(outstring, 1)
except Exception as e:
    print("Display error:", e)

7. Optimize Loop

The while loop can be optimized by checking conditions once at the start of each iteration:

while True:
    game.reset = False
    game.score = 0
    game.ball_count = 0
    game.scored = False

    # Initial screen setup
    lcd.lcd_display_string(SKEEBALLSTRING, 1)
    lcd.lcd_display_string(" " * 16, 2)

    while not game.reset:
        if game.ball_count < 7 and not game.reset:
            # Game play logic
        else:
            # Display final score

8. Consistency in String Formatting

Ensure consistent use of string formatting methods:

txt = SCORESTRING.format(game.score, game.ball_count)

9. Debounce Implementation

Consider adding a software debounce for the sensors if hardware debounce isn’t sufficient:

def debounce(pin):
    utime.sleep_ms(50)  # Example debounce delay
    return pin.value() == 0

These changes should make the code more modular, maintainable, and potentially more stable. Remember to test thoroughly after implementing changes, especially with hardware interactions.

This was the only tool I tried that only gave me code in fragments until I asked it to give me the code in a single block. I liked the explanation by issue so I could look at what it was ‘thinking’, kind of like a coding partner. I have yet to really dig into the results though to see how effective it is.

Playing with Deepseek for coding

I read an article on Deepseek and its coding capabilities, so I thought I’d give it a shot. I could not believe the level of explanation it went through during its ‘thinking’ process.

Gemini 1.5

Here is an example for a project I am working on — it is a main loop for a desktop Skeeball game I’ve been designing. It has one sensor for the ball return and eight other sensors for scoring. I asked Gemini 1.5:

How would you improve the following MicroPython code:

#imports
import machine
import utime
import I2C_LCD_driver
#constants
BUTTON_GPx = 16
SKEEBALLSTRING = "    SKEEBALL    "
SCORESTRING = "Score:{0} Ball:{1}"
I2C0 = 0 #SDA pin
I2C1 = 1 #SCL pin
POINT10PIN = 15
#variables
resetButton = machine.Pin(BUTTON_GPx, machine.Pin.IN, machine.Pin.PULL_UP)
points10sensor = machine.Pin(POINT10PIN, machine.Pin.IN, machine.Pin.PULL_UP)
sda=machine.Pin(I2C0) 
scl=machine.Pin(I2C1)
i2c=machine.I2C(0,sda=sda, scl=scl, freq=400000)  #display
mylcd = I2C_LCD_driver.lcd()
global score
global reset
global scored
global ballCount
#fucntions
def rotate_string(inputString):
    inputString = inputString[-1] + inputString[0:-1]
    return inputString

def score_string():
    txt = SCORESTRING.format(score,ballCount)
    return(txt)

# interrupt handler
def button_handler(port):
    global reset
    if not reset:
        reset=True
        
def handler_10(port):
    global score
    global scored
    global ballCount 
#print("10 interrupt ", scored)
    if not scored:
        scored = True
        score +=10
        ballCount +=1
    
#main
#setup the interrupt        
resetButton.irq(trigger=machine.Pin.IRQ_RISING, handler=button_handler)
points10sensor.irq(trigger=machine.Pin.IRQ_RISING, handler=handler_10)
while (True):
    reset=False
    score=0
    scored =False
#initialize the screen
    outstring = SKEEBALLSTRING
    mylcd.lcd_display_string(outstring, 1) #print temp on line 1
    mylcd.lcd_display_string(" "*16,2)
    while not(reset): 
        mylcd.lcd_display_string(outstring, 1) #print temp on line 1
        utime.sleep(0.3)
        outstring = rotate_string(outstring)
#set up to play game    
    reset=False
    score = 0
    ballCount=0
    mylcd.lcd_display_string(SKEEBALLSTRING, 1)
    while not(reset) and ballCount<7:
        mylcd.lcd_display_string(score_string(), 2)
        utime.sleep(0.4)
        scored=False #  bounce prevention
    while not(reset):
        mylcd.lcd_display_string("Final score:" + str(score) + "       ", 2)

Here is the explanation it gave me back (in italics):

Here are some ways to improve the provided micropython code:

Reduce String Concatenation: The rotate_string function continuously creates new strings by concatenating the last character to the beginning of the original string. This can be inefficient for long strings. An alternative approach would be to use string slicing and a loop to achieve the same result.

Combine Loops: The code refreshes the display with the current score twice within the game loop. This can be optimized by combining these two loops into one, reducing the number of times the display is updated.

Early Termination: The while not(reset) and ballCount < 7 loop condition can be simplified by using or instead of and. The loop can terminate if either reset is True or ballCount reaches 7.

Variable Naming: Consider using more descriptive variable names, such as game_over instead of reset and total_score instead of score. This can improve code readability.

Here’s the improved code incorporating these suggestions:

#imports
import machine
import utime
import I2C_LCD_driver

#constants
BUTTON_GPx = 16
SKEEBALLSTRING = "    SKEEBALL    "
SCORESTRING = "Score:{0} Ball:{1}"
I2C0 = 0 #SDA pin
I2C1 = 1 #SCL pin
POINT10PIN = 15

#variables
resetButton = machine.Pin(BUTTON_GPx, machine.Pin.IN, machine.Pin.PULL_UP)
points10sensor = machine.Pin(POINT10PIN, machine.Pin.IN, machine.Pin.PULL_UP)
sda=machine.Pin(I2C0) 
scl=machine.Pin(I2C1)
i2c=machine.I2C(0,sda=sda, scl=scl, freq=400000)  #display
mylcd = I2C_LCD_driver.lcd()

def rotate_string(inputString):
  """Rotates a string by one character to the left"""
  return inputString[1:] + inputString[0]

def score_string(score, ball_count):
  """Formats the score and ball count string"""
  return SCORESTRING.format(score, ball_count)

# interrupt handler
def button_handler(port):
  global game_over
  game_over = True

def handler_10(port):
  """Handles 10 point sensor interrupt"""
  global total_score, ball_count
  if not scored:
    scored = True
    total_score += 10
    ball_count += 1

#main
#setup the interrupt
resetButton.irq(trigger=machine.Pin.IRQ_RISING, handler=button_handler)
points10sensor.irq(trigger=machine.Pin.IRQ_RISING, handler=handler_10)

while True:
  game_over = False
  total_score = 0
  ball_count = 0
  scored = False

  #initialize the screen
  mylcd.lcd_display_string(SKEEBALLSTRING, 1) #print temp on line 1
  mylcd.lcd_display_string(" "*16,2)
  while not game_over:
    mylcd.lcd_display_string(SKEEBALLSTRING, 1) #print temp on line 1
    utime.sleep(0.3)
    SKEEBALLSTRING = rotate_string(SKEEBALLSTRING)

  #set up to play game
  game_over = False
  total_score = 0
  ball_count = 0
  mylcd.lcd_display_string(SKEEBALLSTRING, 1)
  while not game_over and ball_count < 7:
    mylcd.lcd_display_string(score_string(total_score, ball_count), 2)
    utime.sleep(0.4)
    scored = False

Gemini 2.0

Since Gemini 2.0 is available in test mode, I asked it the same question:

How would you improve the following MicroPython code: (code removed for the sake of brevity).

The code that came back was quite different:

import machine
import utime
import I2C_LCD_driver

# Constants - Use enums for better readability and maintainability
from enum import Enum
class Pins(Enum):
    BUTTON = 16
    POINT10 = 15
    I2C_SDA = 0
    I2C_SCL = 1

class GameState(Enum):
    START = 1
    PLAYING = 2
    GAME_OVER = 3

# Constants
SKEEBALL_STRING = "   SKEEBALL   "  # Consistent spacing
SCORE_STRING = "Score:{:4d} Ball:{:1d}" # Formatting for consistent display
BALLS_PER_GAME = 7

# Initialize hardware
reset_button = machine.Pin(Pins.BUTTON.value, machine.Pin.IN, machine.Pin.PULL_UP)
points10_sensor = machine.Pin(Pins.POINT10.value, machine.Pin.IN, machine.Pin.PULL_UP)
i2c = machine.I2C(0, sda=machine.Pin(Pins.I2C_SDA.value), scl=machine.Pin(Pins.I2C_SCL.value), freq=400000)
lcd = I2C_LCD_driver.lcd()  # More descriptive name

# Global variables - Minimize use, especially for simple flags
score = 0
ball_count = 0
game_state = GameState.START # Start in the start state
scored = False # Debounce flag

# Functions
def rotate_string(input_string):
    return input_string[-1] + input_string[:-1]

def update_display():
    lcd.lcd_display_string(SKEEBALL_STRING, 1)
    lcd.lcd_display_string(SCORE_STRING.format(score, ball_count), 2)

# Interrupt handlers - Keep them concise
def button_handler(pin):
    global game_state
    if game_state != GameState.GAME_OVER: # Prevent reset during game over
        game_state = GameState.START

def points10_handler(pin):
    global score, ball_count, scored
    if not scored and game_state == GameState.PLAYING: # Only score during playing state
        scored = True
        score += 10
        ball_count += 1

# Main loop
reset_button.irq(trigger=machine.Pin.IRQ_RISING, handler=button_handler)
points10_sensor.irq(trigger=machine.Pin.IRQ_RISING, handler=points10_handler)

while True:
    if game_state == GameState.START:
        score = 0
        ball_count = 0
        scored = False
        out_string = SKEEBALL_STRING
        while game_state == GameState.START:  # Display rotating string until button press
            lcd.lcd_display_string(out_string, 1)
            utime.sleep(0.3)
            out_string = rotate_string(out_string)
            if reset_button.value() == 0: # Check for a reset
                game_state = GameState.PLAYING # Start game

    elif game_state == GameState.PLAYING:
        update_display()
        while game_state == GameState.PLAYING and ball_count < BALLS_PER_GAME:
            update_display()
            utime.sleep(0.4)
            scored = False  # Reset debounce flag
            if reset_button.value() == 0: # Check for a reset
                game_state = GameState.START # Start game

        game_state = GameState.GAME_OVER

    elif game_state == GameState.GAME_OVER:
        lcd.lcd_display_string("Final score:{:4d}".format(score), 2)  # Consistent formatting
        while game_state == GameState.GAME_OVER:
            if reset_button.value() == 0: # Check for a reset
                game_state = GameState.START # Start game


With the explanation:

Key improvements:

  • Enums for Pins and GameState: Makes the code more readable and less prone to errors when managing pin assignments and game states. This makes it easier to change pin assignments later.
  • Constants for Strings and Ball Count: Makes it easier to modify text and the number of balls. Consistent spacing in SKEEBALL_STRING.
  • Improved Formatting: Uses {:4d} and {:1d} in the SCORE_STRING to ensure consistent spacing on the LCD, even with varying scores and ball counts.
  • Descriptive Variable Names: lcd instead of mylcd, ball_count instead of ballCount, etc.
  • Minimized Global Variables: Reduced reliance on global variables, especially for simple flags like reset. The game_state variable is now used to control the flow of the game.
  • Clearer Game Logic: The while True loop and the GameState enum make the game flow much easier to understand. The code is now structured around the game states.
  • Debouncing Improvement: The scored flag is now reset only during the playing state, preventing spurious score increases.
  • Reset Prevention: The reset button is now disabled during the game over state.
  • Concise Interrupt Handlers: Keep interrupt handlers short and efficient. All the game logic is handled in the main loop.
  • Removed Redundant Code: The reset flag inside the main loop was redundant and has been removed. The game state is now used to control the flow.
  • Comments: Added more comments to explain the code’s functionality.

This revised code is more organized, readable, and maintainable. It also addresses potential issues like spurious score increases and makes the game logic much clearer. Using enums and a clear state machine approach significantly improves the structure of the code.

Deepseek

I asked Deepseek the same question:

How would you improve the following MicroPython code: (code removed for the sake of brevity).

The answer I got back took a while to output since it was a dissertation on the code:

Okay, I need to figure out how to improve this MicroPython code. Let me start by reading through the code carefully to understand what it’s doing.

The code is for a Skee-Ball game, I think. It uses a button and a sensor to track the score. The LCD displays the score and some rotating text. There are interrupts for the reset button and the 10-point sensor. The main loop seems to handle the display and game logic.

First, let’s check the imports. They have machine, utime, and I2C_LCD_driver. That looks okay. The constants are defined with uppercase names, which is good practice. BUTTON_GPx is 16, SKEEBALLSTRING with spaces, SCORESTRING for formatting, I2C pins 0 and 1, POINT10PIN is 15. Variables are initialized, like resetButton as an input with pull-up. The points10sensor is also an input with pull-up. Then they set up I2C for the LCD using machine.I2C. The mylcd is initialized with the driver.

Global variables: score, reset, scored, ballCount. Using global variables can be tricky, especially with interrupts. The functions rotate_string shifts the string, and score_string formats the score and ball count.

Interrupt handlers: button_handler sets reset to True. handler_10 increments score and ballCount if not scored. But there’s a comment about bounce prevention, so maybe dealing with switch debounce.

In the main loop, there’s a while True loop. It resets the game variables, initializes the screen, displays the rotating string until reset is pressed. Then another loop where the game runs until reset or 7 balls. Then displays the final score until reset.

Possible issues:

1. **Global Variables in Interrupts:** Using global variables in interrupts can lead to race conditions. Maybe use volatile variables or disable interrupts when accessing them.

2. **Debouncing:** The button and sensor inputs might need debouncing. The current code uses a scored flag to prevent multiple increments, but mechanical switches can generate multiple interrupts.

3. **LCD Updates:** Updating the LCD in the main loop with sleeps might not be efficient. Maybe use a timer for rotating the string to free up the main loop.

4. **Code Structure:** The main loop has nested while loops which might be hard to follow. Maybe structure the code into functions for setup, game loop, etc.

5. **Magic Numbers:** Constants like 16 in rotate_string, 7 balls maximum. Should define these as named constants.

6. **Interrupt Triggers:** Using IRQ_RISING might trigger on noise. Maybe check the pin state in the handler after a delay to confirm.

7. **String Rotation Efficiency:** Rotating the string by slicing each time might not be efficient. Could precompute all rotations or find a better method.

8. **Reset Handling:** The reset logic uses a global flag. How is it reset? The button handler sets reset to True, but in the main loop, after reset, variables are reset again. Maybe need to handle reset more cleanly.

9. **LCD Content Management:** The LCD is updated in multiple places with hardcoded strings. Maybe centralize the display updates.

10. **Error Handling:** No error handling for I2C or LCD communication. Maybe add try-except blocks.

Let me go through each point:

1. **Global Variables:** In MicroPython, using global variables in interrupts is allowed, but they can be modified in both the main loop and the ISR. To prevent race conditions, especially with variables like score and ballCount, it’s better to use atomic operations or disable interrupts briefly when accessing them. However, in this code, the main loop sets scored = False after each iteration, and the ISR checks if not scored before incrementing. That might be okay for preventing multiple counts, but there’s a chance of a race condition if the interrupt happens just as the main loop is setting scored to False. Maybe using a mutex or a critical section, but MicroPython doesn’t support that. Alternatively, use a flag that’s only set in the ISR and cleared in the main loop.

2. **Debouncing:** The current code uses a scored flag to prevent multiple increments. For the 10-point sensor, that’s okay, but the reset button might need hardware debouncing or software debouncing. The button_handler sets reset to True on rising edge. If the button bounces, it could trigger multiple interrupts. Maybe add a debounce delay in the handler or use a timer to ignore subsequent interrupts for a short time.

3. **LCD Updates:** The rotating string is updated every 0.3 seconds in a loop. This blocks the main loop. Using a timer interrupt to update the display would free up the main loop. But in MicroPython, timer callbacks can’t do too much. Alternatively, track the rotation with a counter and update the LCD at intervals without sleeping, using non-blocking delays (checking time elapsed).

4. **Code Structure:** The main loop has multiple nested while loops. Breaking the code into functions (like start_screen(), game_loop(), final_score()) would make it more readable.

5. **Magic Numbers:** The 16 in rotate_string is the length of SKEEBALLSTRING. If the string length changes, this would break. Using len(inputString) instead of hardcoding 16. Also, the maximum ball count is 7; define a constant like MAX_BALLS = 7.

6. **Interrupt Triggers:** The IRQ_RISING trigger might be triggered by noise. Adding a small delay in the interrupt handler and re-checking the pin state could help. For example, in handler_10, wait a few milliseconds and check if the pin is still high before processing.

7. **String Rotation Efficiency:** Each call to rotate_string creates a new string. Precomputing all possible rotated strings and cycling through them might be more efficient, especially if the string is fixed.

8. **Reset Handling:** The code has a while True loop that starts a new game each time. When reset is pressed, it resets variables and goes into the game loop. However, after the game ends (ballCount >=7), it shows the final score until reset. The logic seems okay, but the use of multiple reset flags in different loops could be confusing. Maybe consolidate the reset handling.

9. **LCD Content Management:** The code directly writes to the LCD in multiple places. Creating functions to update specific lines would centralize the code. For example, a function update_score_display(score, ballCount) that formats and displays the string.

10. **Error Handling:** Adding try-except around I2C initialization and LCD operations could prevent crashes from hardware issues.

Other observations:

– The initial setup of the I2C might not be necessary since the I2C_LCD_driver might handle it. But the code initializes the I2C bus manually. Check if the library requires that.

– The variables are declared as global in the main loop. In MicroPython, you need to declare ‘global’ in each function that modifies them. The current code does that in the handlers, but in the main loop, when they assign score=0, etc., they should also declare global. Wait, looking at the code:

In the main loop, after while True: reset=False, score=0, etc. But those variables are declared as global in the functions. However, in the main loop, when assigning to them, they are treated as global. In Python, if you assign to a variable in a function (or main loop), you need to declare it as global. Wait, in MicroPython, the main loop is at module level, so variables declared there are global by default. So maybe it’s okay. But inside the button_handler and handler_10 functions, they declare global variables, which is correct.

– The LCD display is cleared with ” “*16. Maybe use the lcd_clear() function if available.

– The rotate_string function moves the string each time. For example, “ABCD” becomes “DABC”, then “CDAB”, etc. But if SKEEBALLSTRING is 16 characters, after 16 rotations, it loops. However, the current code starts with the original string, then rotates each time. So the first rotation is done after the first sleep. Maybe the initial display is the unrotated string, then after 0.3s it’s rotated once, etc. But the code sets outstring = SKEEBALLSTRING before the loop, then in the loop it displays outstring, sleeps, rotates. So the initial display is the original string, then after each iteration, it’s rotated. That’s correct.

Possible improvements:

1. **Debounce the reset button:** Add a debounce mechanism, either hardware (capacitor) or software (timer in the interrupt handler).

2. **Use constants for magic numbers:** Define MAX_BALLS = 7, STR_LENGTH = 16, DEBOUNCE_DELAY_MS = 50, etc.

3. **Refactor code into functions:** Break the main loop into functions for different states (start screen, game, final score).

4. **Precompute rotated strings:** For the rotating title, precompute all rotations once and cycle through them instead of rotating each time.

5. **Non-blocking delays:** Use timestamps (utime.ticks_ms()) to handle delays without using utime.sleep(), allowing the main loop to handle other tasks.

6. **Improve interrupt handlers:** Add debounce checks, ensure atomic access to shared variables.

7. **Error handling and LCD management:** Add functions to handle LCD updates and error checking.

Let me outline the steps for each improvement.

First, debouncing. For the reset button, the interrupt is triggered on RISING edge. But if the button bounces, it could trigger multiple times. To debounce, in the interrupt handler, record the time of the interrupt and ignore subsequent interrupts for a certain period. However, MicroPython’s interrupt handlers can’t block or use delays. So, perhaps in the handler, set a flag and then in the main loop check the time since the last button press. Alternatively, use a timer to disable the interrupt temporarily.

But for simplicity, maybe in the button_handler, after setting reset=True, disable the interrupt, start a timer to re-enable it after a debounce period. But that’s more complex. Alternatively, in the main loop, when processing the reset, check if the button is still pressed after a delay.

Alternatively, in the handler, note the time and compare in the main loop. For example:

In the button_handler:

global last_button_press

current_time = utime.ticks_ms()

if utime.ticks_diff(current_time, last_button_press) > DEBOUNCE_DELAY_MS:

reset = True

last_button_press = current_time

But in the handler, you can’t use utime.ticks_ms() if the handler is called in an interrupt context. Wait, MicroPython’s IRQ handlers can’t do certain things, like allocate memory or use certain functions. So maybe a software debounce in the main loop.

Alternatively, use a flag in the interrupt and check it in the main loop with a timestamp.

But maybe the current code’s approach is sufficient. Since the reset is a flag that’s set once, and in the main loop, after handling reset, it’s set back to False. But if the button is held down, the interrupt may trigger multiple times. Wait, the button is configured with a pull-up, so when pressed, it goes low. Wait, no: the resetButton is initialized as machine.Pin.IN with machine.Pin.PULL_UP. So normally, the pin is high. When the button is pressed, it connects to ground, so the pin goes low. But the interrupt is triggered on IRQ_RISING, which is when the button is released. Wait, that’s a common approach: button is pulled up, pressed pulls it low, released makes it rise. So the interrupt triggers on release. But bouncing can occur on release as well.

So the interrupt may fire multiple times during release. The current handler sets reset=True once, but if the interrupt is triggered again, it would check if reset is already True and not set it again. However, since in the main loop, after detecting reset=True, it processes and then sets reset=False again. So if the button is pressed again, it would trigger another reset. But during the time the button is released and bouncing, the interrupt might fire multiple times, setting reset=True multiple times. But since the main loop resets it to False after handling, maybe it’s not a problem. However, the initial trigger could cause multiple resets. So adding a debounce would help.

Alternative approach: in the button_handler, record the time of the last interrupt, and only allow the reset to be processed if a certain time has passed. But again, in the handler, we can’t do time checks. So this would need to be handled in the main loop.

In the main loop, when reset is detected, check the time since the last reset. If it’s been more than DEBOUNCE_DELAY_MS, then process the reset.

But the current code’s reset handling is in the outer while True loop. Let me see:

The main loop is:

while True:

reset=False

score=0

… initialize screen

while not reset:

rotate string…

# After reset, enter game loop

reset=False

… game loop

while not reset and ballCount <7:

# After game ends, show final score until reset

while not reset:

display final score…

So when the reset button is pressed (released), it sets reset=True in the button_handler. The first inner loop (rotating title) exits, then the game loop runs until ballCount reaches 7 or reset is pressed again. Then the final score loop runs until reset.

But if the user presses reset during the game, it would exit the game loop and go back to the start. The problem is that during the button press (release), the interrupt may fire multiple times, causing multiple resets. To prevent that, debouncing is needed.

So, perhaps in the button_handler, disable the interrupt immediately, set a timer to re-enable it after a debounce period. For example:

def button_handler(port):

global reset

reset = True

port.irq(handler=None) # disable interrupt

# schedule re-enabling after debounce time

debounce_timer = machine.Timer()

debounce_timer.init(mode=machine.Timer.ONE_SHOT, period=300, callback=lambda t: port.irq(handler=button_handler))

But I’m not sure if this is feasible in MicroPython. Also, using a timer in the interrupt handler may not be allowed due to memory constraints.

Alternatively, track the last reset time in the main loop and ignore resets that are too close.

But the main loop’s structure may not easily allow that. Alternatively, when handling the reset, in the main loop, after detecting reset=True, wait for a debounce period and check if the button is still released.

But that complicates the main loop with sleeps.

This might be a bit tricky. Maybe the current code is okay for a simple project, but to improve robustness, adding a debounce would be better.

Another improvement: the scored flag is set to False after each loop iteration. But if the sensor is triggered again before the loop iterates, it could increment the score again. The current code uses a scored flag to prevent that. But with the sleep(0.4) in the game loop, there’s a window where the scored is False, and the sensor could trigger again. Wait, in the game loop:

while not(reset) and ballCount<7:

mylcd.lcd_display_string(score_string(), 2)

utime.sleep(0.4)

scored=False # bounce prevention

So after displaying the score, it sleeps for 0.4 seconds, then sets scored=False. So during the 0.4 seconds, scored is still True, so the handler_10 can’t increment again. After that, it sets scored=False, allowing another trigger. But if the sensor is triggered during the sleep, the interrupt would fire, but since scored is True, it does nothing. After the sleep, scored is set to False, so the next interrupt can process. But the sleep is blocking, so during that time, the code isn’t processing anything else. So if the sensor is triggered multiple times during the sleep, only the first one would be processed (since scored is set to True), and others are ignored. Then after sleep, scored is reset. This acts as a software debounce with a 0.4-second window. That’s a reasonable approach, but maybe better to have a non-blocking delay to check the time and allow other processing.

Next, the LCD updates. The rotating string is updated every 0.3 seconds. Using a non-blocking approach would allow the main loop to handle other tasks. For example, track the last update time and rotate when the time difference exceeds the interval.

Similarly, in the game loop, instead of sleeping for 0.4 seconds, check the time and only update the LCD periodically. But since the game loop is waiting for either reset or ballCount >=7, the current approach works but blocks with sleep.

Refactoring into functions would make the code cleaner. For example:

def show_start_screen():

# display rotating title until reset

def play_game():

# handle scoring, display score until 7 balls or reset

def show_final_score():

# display final score until reset

Then the main loop would be:

while True:

show_start_screen()

play_game()

show_final_score()

This structure is more readable.

Magic numbers: define MAX_BALLS = 7, DEBOUNCE_DELAY_MS = 300, ROTATE_DELAY_MS = 300, etc.

Precomputing rotated strings: for the title, generate all possible rotations once and cycle through them. For a 16-character string, there are 16 rotations. Store them in a list and iterate through indexes.

For example:

rotated_strings = [SKEEBALLSTRING]

current = SKEEBALLSTRING

for _ in range(len(SKEEBALLSTRING) -1):

current = rotate_string(current)

rotated_strings.append(current)

Then, in the start screen loop, cycle through rotated_strings[index] each time, incrementing index modulo 16.

This is more efficient than rotating each time.

String rotation function: the current rotate_string takes the last character and moves it to the front. So for “ABCD”, it becomes “DABC”. But the initial string is ”    SKEEBALL    ” (with spaces). Precomputing all rotations would save processing time.

Error handling: wrap I2C initialization in a try-except block to catch errors if the LCD is not connected.

Also, check if the I2C_LCD_driver initializes the I2C bus itself. If not, the current code initializes it correctly. If it does, then the manual I2C setup might be redundant.

Putting it all together, here’s how the code could be improved:

1. Add debouncing to the reset button interrupt handler.

2. Use constants for MAX_BALLS, DEBOUNCE_TIME, ROTATE_INTERVAL, etc.

3. Refactor code into functions for different states.

4. Precompute rotated strings for the title.

5. Use non-blocking delays where possible.

6. Add error handling for hardware initialization.

Now, let me draft the improved code with these changes.

Here’s an improved version of the code with the suggested enhancements:

#imports
import machine
import utime
import I2C_LCD_driver

# Constants
BUTTON_GPx = 16
SKEEBALLSTRING = "    SKEEBALL    "
SCORESTRING = "Score:{0} Ball:{1}"
I2C0 = 0  # SDA pin
I2C1 = 1  # SCL pin
POINT10PIN = 15
MAX_BALLS = 7
DEBOUNCE_DELAY_MS = 300
ROTATE_DELAY_MS = 300
SCORE_DELAY_MS = 400

# Global variables
score = 0
reset = False
scored = False
ballCount = 0
last_rotate_time = 0
rotate_index = 0
rotated_strings = []

# Hardware initialization
try:
    resetButton = machine.Pin(BUTTON_GPx, machine.Pin.IN, machine.Pin.PULL_UP)
    points10sensor = machine.Pin(POINT10PIN, machine.Pin.IN, machine.Pin.PULL_UP)
    
    sda = machine.Pin(I2C0)
    scl = machine.Pin(I2C1)
    i2c = machine.I2C(0, sda=sda, scl=scl, freq=400000)
    mylcd = I2C_LCD_driver.lcd()
except Exception as e:
    print("Initialization error:", e)
    machine.reset()

# Precompute rotated strings
if SKEEBALLSTRING:
    current = SKEEBALLSTRING
    rotated_strings.append(current)
    for _ in range(len(SKEEBALLSTRING) - 1):
        current = current[-1] + current[:-1]
        rotated_strings.append(current)

# Interrupt handlers with debouncing
def button_handler(port):
    global reset
    if not reset:
        reset = True
        port.irq(handler=None)  # Disable interrupt during debounce
        debounce_timer = machine.Timer()
        debounce_timer.init(mode=machine.Timer.ONE_SHOT, period=DEBOUNCE_DELAY_MS, 
                           callback=lambda t: port.irq(handler=button_handler))

def handler_10(port):
    global score, scored, ballCount
    if not scored:
        scored = True
        score += 10
        ballCount += 1

# Display functions
def update_score_display():
    mylcd.lcd_display_string(SCORESTRING.format(score, ballCount), 2)

def show_final_score():
    mylcd.lcd_display_string("Final score: {}    ".format(score), 2)

# Game states
def show_start_screen():
    global reset, rotate_index, last_rotate_time
    reset = False
    rotate_index = 0
    mylcd.lcd_clear()
    mylcd.lcd_display_string(SKEEBALLSTRING, 1)
    
    while not reset:
        current_time = utime.ticks_ms()
        if utime.ticks_diff(current_time, last_rotate_time) >= ROTATE_DELAY_MS:
            mylcd.lcd_display_string(rotated_strings[rotate_index], 1)
            rotate_index = (rotate_index + 1) % len(rotated_strings)
            last_rotate_time = current_time
        utime.sleep_ms(10)

def play_game():
    global reset, score, ballCount, scored
    reset = False
    score = 0
    ballCount = 0
    scored = False
    mylcd.lcd_clear()
    mylcd.lcd_display_string(SKEEBALLSTRING, 1)
    
    last_score_update = utime.ticks_ms()
    while not reset and ballCount < MAX_BALLS:
        current_time = utime.ticks_ms()
        if utime.ticks_diff(current_time, last_score_update) >= SCORE_DELAY_MS:
            update_score_display()
            scored = False  # Reset scored flag after delay
            last_score_update = current_time
        utime.sleep_ms(10)

def game_loop():
    resetButton.irq(trigger=machine.Pin.IRQ_RISING, handler=button_handler)
    points10sensor.irq(trigger=machine.Pin.IRQ_RISING, handler=handler_10)
    
    while True:
        show_start_screen()
        play_game()
        
        # Show final score until reset
        while not reset:
            show_final_score()
            utime.sleep_ms(10)

# Main execution
if __name__ == "__main__":
    try:
        game_loop()
    except KeyboardInterrupt:
        mylcd.lcd_clear()
        machine.reset()

Key improvements made:

  1. Debouncing: Added timer-based debouncing for the reset button
  2. Constants: Added named constants for magic numbers
  3. Precomputed Rotations: Rotated strings are precomputed for better performance
  4. Non-blocking Delays: Used timestamp comparisons instead of sleep() where possible
  5. Modular Structure: Split functionality into distinct functions for different game states
  6. Error Handling: Added basic error handling for hardware initialization
  7. LCD Management: Created dedicated display functions and proper clearing
  8. Interrupt Management: Added proper interrupt disabling during debounce periods
  9. Global Variables: Organized global variables more systematically
  10. Efficiency: Reduced blocking calls and improved main loop responsiveness

This version should be more robust, maintainable, and efficient while maintaining the original functionality.

It may take a while to go through all that analysis but it definitely gave me some things to think about on a relatively small piece of code. You might easily be overwhelmed with feedback on any substantial code base. I will definitely use Deepseek as a coding partner. When I asked it to create the code from scratch, it timed out without giving an answer. I have had it respond effectively on other generate from scratch quesitons, but I’ve not tested the answers thoroughly yet.

Create a waveform generator with the Pico

Recently, I purchased a handheld multimeter/Oscilloscope (it was on super sale over the holidays) and wanted a signal generator to use with it. After digging around for a while, I came across this instructables article: Arbitrary Wave Generator With the Raspberry Pi Pico

I had everything I needed already, so I dusted off the soldering iron and got to work. Here is a picture of it running, generating a sine wave:

Image

It was a quick with some tedious soldering, though it could have been much easier if I’d used both 1K and 2K resistors to simplify the circuit (rather than using the 2Ks in parallel).

Here is a copy of the running program:

# Sine waveform generator for Rasberry Pi Pico
# Requires 8-bit R2R DAC on pins 0-7. Works for R=1kOhm
# Achieves 125Msps when running 125MHz clock
# Rolf Oldeman, 13/2/2021. CC BY-NC-SA 4.0 licence
# tested with rp2-pico-20210205-unstable-v1.14-8-g1f800cac3.uf2
from machine import Pin,mem32
from rp2 import PIO, StateMachine, asm_pio
from array import array
from utime import sleep
from math import pi,sin,exp,sqrt,floor
from uctypes import addressof
from random import random

fclock=125000000 #clock frequency of the pico

DMA_BASE=0x50000000
CH0_READ_ADDR  =DMA_BASE+0x000
CH0_WRITE_ADDR =DMA_BASE+0x004
CH0_TRANS_COUNT=DMA_BASE+0x008
CH0_CTRL_TRIG  =DMA_BASE+0x00c
CH0_AL1_CTRL   =DMA_BASE+0x010
CH1_READ_ADDR  =DMA_BASE+0x040
CH1_WRITE_ADDR =DMA_BASE+0x044
CH1_TRANS_COUNT=DMA_BASE+0x048
CH1_CTRL_TRIG  =DMA_BASE+0x04c
CH1_AL1_CTRL   =DMA_BASE+0x050

PIO0_BASE      =0x50200000
PIO0_TXF0      =PIO0_BASE+0x10
PIO0_SM0_CLKDIV=PIO0_BASE+0xc8

#state machine that just pushes bytes to the pins
@asm_pio(out_init=(PIO.OUT_HIGH,PIO.OUT_HIGH,PIO.OUT_HIGH,PIO.OUT_HIGH,PIO.OUT_HIGH,PIO.OUT_HIGH,PIO.OUT_HIGH,PIO.OUT_HIGH),
         out_shiftdir=PIO.SHIFT_RIGHT, autopull=True, pull_thresh=32)
def stream():
    out(pins,8)

sm = StateMachine(0, stream, freq=125000000, out_base=Pin(0))
sm.active(1)

#2-channel chained DMA. channel 0 does the transfer, channel 1 reconfigures
p=array('I',[0]) #global 1-element array
def startDMA(ar,nword):
    #print("start DMA") #debug
    #first disable the DMAs to prevent corruption while writing
    mem32[CH0_AL1_CTRL]=0
    mem32[CH1_AL1_CTRL]=0
    #setup first DMA which does the actual transfer
    mem32[CH0_READ_ADDR]=addressof(ar)
    mem32[CH0_WRITE_ADDR]=PIO0_TXF0
    mem32[CH0_TRANS_COUNT]=nword
    IRQ_QUIET=0x1 #do not generate an interrupt
    TREQ_SEL=0x00 #wait for PIO0_TX0
    CHAIN_TO=1    #start channel 1 when done
    RING_SEL=0
    RING_SIZE=0   #no wrapping
    INCR_WRITE=0  #for write to array
    INCR_READ=1   #for read from array
    DATA_SIZE=2   #32-bit word transfer
    HIGH_PRIORITY=1
    EN=1
    CTRL0=(IRQ_QUIET<<21)|(TREQ_SEL<<15)|(CHAIN_TO<<11)|(RING_SEL<<10)|(RING_SIZE<<9)|(INCR_WRITE<<5)|(INCR_READ<<4)|(DATA_SIZE<<2)|(HIGH_PRIORITY<<1)|(EN<<0)
    mem32[CH0_AL1_CTRL]=CTRL0
    #setup second DMA which reconfigures the first channel
    p[0]=addressof(ar)
    mem32[CH1_READ_ADDR]=addressof(p)
    mem32[CH1_WRITE_ADDR]=CH0_READ_ADDR
    mem32[CH1_TRANS_COUNT]=1
    IRQ_QUIET=0x1 #do not generate an interrupt
    TREQ_SEL=0x3f #no pacing
    CHAIN_TO=0    #start channel 0 when done
    RING_SEL=0
    RING_SIZE=0   #no wrapping
    INCR_WRITE=0  #single write
    INCR_READ=0   #single read
    DATA_SIZE=2   #32-bit word transfer
    HIGH_PRIORITY=1
    EN=1
    CTRL1=(IRQ_QUIET<<21)|(TREQ_SEL<<15)|(CHAIN_TO<<11)|(RING_SEL<<10)|(RING_SIZE<<9)|(INCR_WRITE<<5)|(INCR_READ<<4)|(DATA_SIZE<<2)|(HIGH_PRIORITY<<1)|(EN<<0)
    mem32[CH1_CTRL_TRIG]=CTRL1

def setupwave(buf,f,w):
    #print("setupwave") #debug
    div=fclock/(f*maxnsamp) # required clock division for maximum buffer size
    if div<1.0:  #can't speed up clock, duplicate wave instead
        dup=int(1.0/div)
        nsamp=int((maxnsamp*div*dup+0.5)/4)*4 #force multiple of 4
        clkdiv=1
    else:        #stick with integer clock division only
        clkdiv=int(div)+1
        nsamp=int((maxnsamp*div/clkdiv+0.5)/4)*4 #force multiple of 4
        dup=1

    #fill the buffer
    for isamp in range(nsamp):
        buf[isamp]=max(0,min(255,int(256*eval(w,dup*(isamp+0.5)/nsamp))))

    #set the clock divider
    clkdiv_int=min(clkdiv,65535) 
    clkdiv_frac=0 #fractional clock division results in jitter
    mem32[PIO0_SM0_CLKDIV]=(clkdiv_int<<16)|(clkdiv_frac<<8)
    #start DMA
    startDMA(buf,int(nsamp/4))

#evaluate the content of a wave
def eval(w,x):
    #print("eval x: ",x)
    m,s,p=1.0,0.0,0.0
    if 'phasemod' in w.__dict__:
        p=eval(w.phasemod,x)
    if 'mult' in w.__dict__:
        m=eval(w.mult,x)
    if 'sum' in w.__dict__:
        s=eval(w.sum,x)
    x=x*w.replicate-w.phase-p
    x=x-floor(x)  #reduce x to 0.0-1.0 range
    v=w.func(x,w.pars)
    v=v*w.amplitude*m
    v=v+w.offset+s
    return v

#some common waveforms. combine with sum,mult,phasemod
def sine(x,pars):
    return sin(x*2*pi)

#make buffers for the waveform.
#large buffers give better results but are slower to fill
maxnsamp=1024*4 #must be a multiple of 4. maximum size is 65536
wavbuf={}
wavbuf[0]=bytearray(maxnsamp)
wavbuf[1]=bytearray(maxnsamp)
ibuf=0

#empty class just to attach properties to
class wave:
    pass

wave1=wave()
wave1.amplitude=0.5
wave1.offset=0.5
wave1.phase=0.0
wave1.replicate=1
wave1.func=sine
wave1.pars=[]
freq = 1e5

while True:
#send the wave 
    setupwave(wavbuf[ibuf],freq,wave1); ibuf=(ibuf+1)%2

I simplified the example to just create a sine wave. It seemed to work well.

Since I used a Pico W, I thought about adding Bluetooth controls to change waveform, amplitude and frequency, but that would be a whole other post. Create an interface on the PC or phone would be an exciting next step, but it warrants a dedicated post.

Read the article listed above for more details about the circuit construction and the software to generate other waveforms.