pm-kit: jog mode = direction-only, accelerate to a smooth max speed

The joystick has no useful fine speed control, so jog now treats it as direction
only and runs the motor at STEPPER_MAX_RATE, reached via a trapezoidal accel ramp
(STEPPER_ACCEL from STEPPER_JOG_START) so it doesn't stall trying to start at top
speed; reversing decelerates through zero then accelerates the other way. Default
top rate set to a realistic 600 half-steps/s for the 28BYJ-48; tune via jog.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-05 21:57:46 -05:00
parent 5f9e9dfad7
commit 19d646a873
2 changed files with 41 additions and 36 deletions

View file

@ -112,13 +112,16 @@ The Kit can drive a **physical metronome pendulum**: a 4-input unipolar stepper
drives the screen graphic exactly and the motor.
- `STEPPER_STEPS_PER_REV` — your motor's halfsteps per full turn (28BYJ48 halfstep ≈ 4096); maps
degrees → steps.
- `STEPPER_MAX_RATE` — fastest halfsteps/sec the motor can follow; the swing **autoshrinks** (rather than
desync) when a beat is too short to sweep the full arc. At wide swings/fast tempi the screen still shows
the full angle while the arm does what it physically can.
- **Jog / test mode** (hold **A + B** at boot): the joystick spins the motor **L = CCW, R = CW** (speed by
how far you push), with an onscreen direction needle + RGB LED and a **live step counter + rate readout**.
Push to full deflection and note the **peak rate** where the motor begins to stall, then set
`STEPPER_MAX_RATE` just below it. Powercycle (no buttons) to exit.
- `STEPPER_MAX_RATE` — top halfsteps/sec the motor sustains smoothly. **Jog mode spins at this rate**, and
the pendulum **autoshrinks** its arc (rather than desync) when a beat is too short to sweep the full angle.
- `STEPPER_ACCEL` — ramp (halfsteps/sec²) used to reach top speed without stalling; lower it if the motor
stalls/buzzes when starting.
- `STEPPER_JOG_START` — jog kickoff rate from rest (keep at or below the motor's pullin rate).
- **Jog / test mode** (hold **A + B** at boot): the joystick sets **direction only****L = CCW, R = CW** — and
the motor **accelerates to `STEPPER_MAX_RATE`** (reversing decelerates through zero first), with an onscreen
needle + RGB LED and a **live step counter + rate readout**. *Tuning:* raise `STEPPER_MAX_RATE` until the
motor starts skipping, then back off; if it stalls *starting*, lower `STEPPER_ACCEL` / `STEPPER_JOG_START`.
Powercycle (no buttons) to exit.
## programs.json

View file

@ -71,7 +71,9 @@ STEPPER_ENABLED = True # set False if no motor is wired (the pins just sta
PEND_SWING_DEG = 120 # total swing arc, end-to-end, in degrees - drives BOTH the screen graphic and the arm
STEPPER_STEPS_PER_REV = 4096 # your motor's half-steps per full 360 turn (28BYJ-48 half-step ~4096); maps deg -> steps
STEPPER_ARC = round(STEPPER_STEPS_PER_REV * PEND_SWING_DEG / 360.0) # half-steps for one end-to-end swing
STEPPER_MAX_RATE = 900 # max half-steps/sec the motor can follow; auto-shrinks the arc at fast tempi
STEPPER_MAX_RATE = 600 # top half-steps/sec the motor sustains smoothly (jog spins here; tune via jog mode)
STEPPER_ACCEL = 1800 # half-steps/sec^2 ramp so it reaches top speed without stalling (lower if it stalls)
STEPPER_JOG_START = 150 # jog kickoff half-steps/sec from rest (keep <= the motor's pull-in rate)
# ----- pins (fixed by the EP-0172 board) -----
P_SCK, P_MOSI, P_CS, P_DC, P_RST = board.GP2, board.GP3, board.GP5, board.GP6, board.GP7
@ -1138,7 +1140,7 @@ class App:
self.g_overlay.append(rect(0, 40, WIDTH, HEIGHT - 40, C_BG)) # cover the normal UI
for s, fnt, col, yy in (("STEPPER JOG TEST", FONT_M, C_CYAN, 64),
("Joystick L = CCW R = CW", FONT_S, C_TXT, 96),
("push further = faster", FONT_S, C_DIM, 114),
("spins at max speed (ramped)", FONT_S, C_DIM, 114),
("power-cycle (no buttons) to exit", FONT_S, C_DIM, 132)):
tg, w, h = make_text(s, fnt, col, C_BG); tg.x = 12; tg.y = yy; self.g_overlay.append(tg)
self.g_overlay.append(rect(PEND_PX - 26, PEND_PY + 4, 52, 4, C_PANEL)) # stand
@ -1157,37 +1159,37 @@ class App:
show_stats(0, 0, 0)
self.display.refresh()
time.sleep(0.1); center = self.jx.value
total = 0; win = 0; peak = 0; cw = True; dt = 1.0; lastdir = None
now = time.monotonic(); last = now; tsample = now; tjoy = now
while True: # no per-iteration sleep: tight step timing in this mode
now = time.monotonic()
if now - tjoy >= 0.004: # poll the joystick ~250x/s, OFF the step hot-path
tjoy = now
dx = self.jx.value - center; mag = abs(dx)
if mag > JOY_DEADZONE:
cw = dx > 0
frac = (mag - JOY_DEADZONE) / (32768 - JOY_DEADZONE)
if frac > 1.0: frac = 1.0
dt = 0.02 + (0.0006 - 0.02) * frac # commanded up to ~1600 steps/s (find the motor's real wall)
if cw != lastdir: # direction changed -> LED + needle (rare, so refresh here)
lastdir = cw
if cw: self.led.set(0, 150, 0)
else: self.led.set(0, 0, 255)
ang = PEND_THETA if cw else -PEND_THETA
bx = PEND_PX + int(PEND_LEN * math.sin(ang)); by = PEND_PY - int(PEND_LEN * math.cos(ang))
def set_needle(d): # d = +1 CW (green), -1 CCW (blue), 0 centre (off)
if d > 0: self.led.set(0, 150, 0); a = PEND_THETA
elif d < 0: self.led.set(0, 0, 255); a = -PEND_THETA
else: self.led.set(0, 0, 0); a = 0.0
bx = PEND_PX + int(PEND_LEN * math.sin(a)); by = PEND_PY - int(PEND_LEN * math.cos(a))
bob.x = bx; bob.y = by
arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (bx, by)]
self.display.refresh()
elif lastdir is not None: # back to center -> stop, release, recentre needle
lastdir = None; dt = 1.0
# Joystick = DIRECTION only (no fine speed). Spin at STEPPER_MAX_RATE, reached via an
# acceleration ramp (STEPPER_ACCEL) so the motor doesn't stall trying to start at top speed;
# reversing decelerates through zero, then accelerates the other way.
spin = 0; cur = 0.0; total = 0; win = 0; peak = 0
now = time.monotonic(); last = now; tsample = now; tjoy = now
while True: # no per-iteration sleep: tight step timing in this mode
now = time.monotonic()
if now - tjoy >= 0.004: # control update (joystick + accel), off the step hot-path
cdt = now - tjoy; tjoy = now
dx = self.jx.value - center
want = (1 if dx > 0 else -1) if abs(dx) > JOY_DEADZONE else 0
if spin == 0 and want != 0: # start from rest at the safe pull-in rate
spin = want; cur = float(STEPPER_JOG_START); set_needle(spin)
target = float(STEPPER_MAX_RATE) if (spin != 0 and want == spin) else 0.0
if cur < target: cur = min(target, cur + STEPPER_ACCEL * cdt) # accelerate
elif cur > target: cur = max(0.0, cur - STEPPER_ACCEL * cdt) # decelerate
if cur <= 0.0 and spin != 0 and want != spin: # finished slowing -> stop, or flip direction
if self.pend is not None and self.pend.ok: self.pend.release()
self.led.set(0, 0, 0)
bob.x = PEND_PX; bob.y = PEND_PY - PEND_LEN
arm.points = [(PEND_PX - 4, PEND_PY), (PEND_PX + 4, PEND_PY), (PEND_PX, PEND_PY - PEND_LEN)]
self.display.refresh()
if lastdir is not None and self.pend is not None and self.pend.ok and now - last >= dt:
self.pend.spin(lastdir); last = now; total += 1; win += 1
if now - tsample >= 1.0: # readout once/s (one tiny refresh, not 3+ hitches/s)
if want == 0: spin = 0; set_needle(0)
else: spin = want; cur = float(STEPPER_JOG_START); set_needle(spin)
if spin != 0 and cur > 0.0 and self.pend is not None and self.pend.ok and now - last >= 1.0 / cur:
self.pend.spin(spin > 0); last = now; total += 1; win += 1
if now - tsample >= 1.0: # step-rate readout, once/s (one tiny refresh)
rate = int(win / (now - tsample))
if rate > peak: peak = rate
show_stats(total, rate, peak); win = 0; tsample = now