Appendix O: “W, A, S, D” Control Script & Operational Sequence
| Manualcontrolv3.py |
|---|
import argparse import sys import time import pygame from gimbal_lib import GimbalController AZ_MIN, AZ_MAX = -90.0, 90.0 EL_MIN, EL_MAX = -90.0, 90.0 def clamp(v: float, lo: float, hi: float) -> float: return max(lo, min(hi, v)) def print_help(jog_deg_s_az: float, jog_deg_s_el: float, az_acc: float, el_acc: float, az_dec: float, el_dec: float, fps: int, poll_tp_hz: float, dry_run: bool): print("\n=== Manual WASD Gimbal Control (pygame jog, accel/decel ramps) ===") print(f"Update rate: {fps} FPS | TP poll: {poll_tp_hz:.1f} Hz") print(f"Jog speed: AZ={jog_deg_s_az:.2f}°/s, EL={jog_deg_s_el:.2f}°/s") print(f"Accel (AC): AZ={az_acc:.2f}°/s², EL={el_acc:.2f}°/s²") print(f"Decel (DC): AZ={az_dec:.2f}°/s², EL={el_dec:.2f}°/s²") print("A : AZ - (West)") print("D : AZ + (East)") print("W : EL +") print("S : EL -") print("SPACE : STOP (all axes, ramped stop)") print("ESC : Quit") if dry_run: print("DRY RUN: simulates motion (no hardware).") print("=================================================================\n") def parse_tp_xy(resp: str): """ Expected Galil TP response (common): "12345,67890" Returns (x_counts, y_counts) as floats. """ s = resp.strip() parts = [p.strip() for p in s.split(",")] if len(parts) < 2: raise ValueError(f"Unexpected TP format: {resp!r}") return float(parts[0]), float(parts[1]) def main(): ap = argparse.ArgumentParser() ap.add_argument("--conn", default="192.168.1.2 --direct", help="Galil connection string, e.g. '192.168.1.2 --direct'") ap.add_argument("--fps", type=int, default=60, help="pygame loop rate (default 60)") ap.add_argument("--az_speed", type=float, default=4.0, help="AZ jog speed in deg/s while held (default 4.0)") ap.add_argument("--el_speed", type=float, default=4.0, help="EL jog speed in deg/s while held (default 4.0)") ap.add_argument("--poll_tp_hz", type=float, default=10.0, help="How often to refresh TP from controller (Hz). Default 10") # NEW: accel/decel ramps (deg/s^2) ap.add_argument("--az_accel", type=float, default=4.0, help="AZ acceleration in deg/s^2 (default 8.0)") ap.add_argument("--el_accel", type=float, default=4.0, help="EL acceleration in deg/s^2 (default 8.0)") ap.add_argument("--az_decel", type=float, default=6.0, help="AZ deceleration in deg/s^2 (default 12.0)") ap.add_argument("--el_decel", type=float, default=6.0, help="EL deceleration in deg/s^2 (default 12.0)") ap.add_argument("--dry-run", action="store_true", help="Print actions only; do not move hardware (simulates motion).") args = ap.parse_args() fps = int(args.fps) az_deg_s = float(args.az_speed) el_deg_s = float(args.el_speed) poll_tp_hz = float(args.poll_tp_hz) az_acc = float(args.az_accel) el_acc = float(args.el_accel) az_dec = float(args.az_decel) el_dec = float(args.el_decel) print_help(az_deg_s, el_deg_s, az_acc, el_acc, az_dec, el_dec, fps, poll_tp_hz, args.dry_run) g = None # --- pygame init --- pygame.init() screen = pygame.display.set_mode((620, 210)) pygame.display.set_caption("GS5 Manual Jog (WASD) — Live Pos (Ramped)") clock = pygame.time.Clock() font = pygame.font.SysFont(None, 26) last_x_dir = 0 last_y_dir = 0 poll_period = 1.0 / max(0.5, poll_tp_hz) next_poll = 0.0 last_print_t = 0.0 print_period = 0.10 # 10 Hz # Local cached state (deg) curr_az = 0.0 curr_el = 0.0 def galil_safe(cmd: str, quiet=False): """ Send a command but don't let a Galil '?' crash the whole program. Returns response string (or "" on failure). """ nonlocal g try: return g._cmd(cmd, quiet=quiet) if (g is not None) else "" except Exception as e: print(f"\n[GALIL ERROR] {cmd}\n → {e}") return "" try: if args.dry_run: print("[DRY RUN] No hardware will move.") print(f"[DRY RUN] Would connect to: {args.conn}\n") curr_az, curr_el = 0.0, 0.0 cnt_per_deg_az = 4630 cnt_per_deg_el = 4630 x_counts = 0.0 y_counts = 0.0 else: g = GimbalController(args.conn, assume_zero_on_connect=False, streaming=False) print("[OK] Connected. Current state from controller:") print(f" AZ={g.curr_az:.2f}°, EL={g.curr_el:.2f}°\n") # Use the controller's counts/deg from your lib cnt_per_deg_az = g.cnt_az cnt_per_deg_el = g.cnt_el # Stop everything at start (safe) galil_safe("ST", quiet=True) # NEW: set acceleration/deceleration ramps (counts/s^2) az_acc_counts = int(round(az_acc * cnt_per_deg_az)) el_acc_counts = int(round(el_acc * cnt_per_deg_el)) az_dec_counts = int(round(az_dec * cnt_per_deg_az)) el_dec_counts = int(round(el_dec * cnt_per_deg_el)) # Apply to X,Y together (common Galil syntax) galil_safe(f"AC {az_acc_counts},{el_acc_counts}", quiet=True) galil_safe(f"DC {az_dec_counts},{el_dec_counts}", quiet=True) # Read initial TP directly (avoids [SYNC] spam) tp = galil_safe("TP", quiet=True) x_counts, y_counts = parse_tp_xy(tp) if tp else (0.0, 0.0) curr_az = x_counts / cnt_per_deg_az curr_el = y_counts / cnt_per_deg_el start_az, start_el = curr_az, curr_el az_counts_s = int(round(az_deg_s * cnt_per_deg_az)) el_counts_s = int(round(el_deg_s * cnt_per_deg_el)) # (For DRY RUN) used to simulate ramping a bit nicer sim_v_az = 0.0 sim_v_el = 0.0 running = True while running: now = time.time() dt = clock.get_time() / 1000.0 for event in pygame.event.get(): if event.type == pygame.QUIT: running = False elif event.type == pygame.KEYDOWN: if event.key == pygame.K_ESCAPE: running = False elif event.key == pygame.K_SPACE: print("\n[STOP] SPACE pressed.") last_x_dir = 0 last_y_dir = 0 if args.dry_run: sim_v_az = 0.0 sim_v_el = 0.0 else: galil_safe("ST", quiet=True) # Poll TP sometimes (no [SYNC] spam) if (not args.dry_run) and (g is not None) and (now >= next_poll): tp = galil_safe("TP", quiet=True) if tp: try: x_counts, y_counts = parse_tp_xy(tp) curr_az = x_counts / cnt_per_deg_az curr_el = y_counts / cnt_per_deg_el except Exception: pass next_poll = now + poll_period keys = pygame.key.get_pressed() x_dir = (-1 if keys[pygame.K_a] else 0) + (1 if keys[pygame.K_d] else 0) y_dir = (1 if keys[pygame.K_w] else 0) + (-1 if keys[pygame.K_s] else 0) # Soft limits if x_dir > 0 and curr_az >= AZ_MAX: x_dir = 0 if x_dir < 0 and curr_az <= AZ_MIN: x_dir = 0 if y_dir > 0 and curr_el >= EL_MAX: y_dir = 0 if y_dir < 0 and curr_el <= EL_MIN: y_dir = 0 # DRY RUN simulate (with simple accel/decel) if args.dry_run and dt > 0: # desired velocities v_des_az = az_deg_s * x_dir v_des_el = el_deg_s * y_dir # accel limits max_dv_az = az_acc * dt if abs(v_des_az) > abs(sim_v_az) else az_dec * dt max_dv_el = el_acc * dt if abs(v_des_el) > abs(sim_v_el) else el_dec * dt def approach(v, v_des, max_dv): dv = v_des - v if dv > max_dv: dv = max_dv elif dv < -max_dv: dv = -max_dv return v + dv sim_v_az = approach(sim_v_az, v_des_az, max_dv_az) sim_v_el = approach(sim_v_el, v_des_el, max_dv_el) curr_az = clamp(curr_az + sim_v_az * dt, AZ_MIN, AZ_MAX) curr_el = clamp(curr_el + sim_v_el * dt, EL_MIN, EL_MAX) # === Apply jog commands only when direction changes === # NOTE: With AC/DC set, Galil will ramp on BG and ramp on ST. if x_dir != last_x_dir: if x_dir == 0: if args.dry_run: print("\n[X] stop (ramped)") else: galil_safe("STX", quiet=True) # uses DC else: spx = az_counts_s * x_dir if args.dry_run: print(f"\n[X] jog {spx} counts/s (ramped)") else: galil_safe(f"JG {spx},0", quiet=True) galil_safe("BGX", quiet=True) # ramps using AC last_x_dir = x_dir if y_dir != last_y_dir: if y_dir == 0: if args.dry_run: print("\n[Y] stop (ramped)") else: galil_safe("STY", quiet=True) # uses DC else: spy = el_counts_s * y_dir if args.dry_run: print(f"\n[Y] jog {spy} counts/s (ramped)") else: galil_safe(f"JG 0,{spy}", quiet=True) galil_safe("BGY", quiet=True) # ramps using AC last_y_dir = y_dir # Console live position if now - last_print_t >= print_period: d_az = curr_az - start_az d_el = curr_el - start_el print( f"AZ={curr_az:+7.2f}° (Δ{d_az:+7.2f}°) " f"EL={curr_el:+7.2f}° (Δ{d_el:+7.2f}°)", end="\r", flush=True ) last_print_t = now # UI screen.fill((18, 18, 18)) d_az = curr_az - start_az d_el = curr_el - start_el line0 = "W/S=EL A/D=AZ SPACE=STOP (ramped) ESC=QUIT" line1 = f"AZ {curr_az:+.2f}° Δ {d_az:+.2f}° (limits {AZ_MIN:+.0f}..{AZ_MAX:+.0f})" line2 = f"EL {curr_el:+.2f}° Δ {d_el:+.2f}° (limits {EL_MIN:+.0f}..{EL_MAX:+.0f})" line3 = f"Speed: AZ {az_deg_s:.2f}°/s EL {el_deg_s:.2f}°/s TP poll: {poll_tp_hz:.1f} Hz" line4 = f"AC/DC: AZ {az_acc:.1f}/{az_dec:.1f} °/s² EL {el_acc:.1f}/{el_dec:.1f} °/s²" line5 = "Mode: DRY RUN (simulated ramps)" if args.dry_run else "Mode: LIVE (Galil ramps via AC/DC)" t0 = font.render(line0, True, (220, 220, 220)) t1 = font.render(line1, True, (220, 220, 220)) t2 = font.render(line2, True, (220, 220, 220)) t3 = font.render(line3, True, (180, 180, 180)) t4 = font.render(line4, True, (180, 180, 180)) t5 = font.render(line5, True, (160, 160, 160)) screen.blit(t0, (12, 12)) screen.blit(t1, (12, 55)) screen.blit(t2, (12, 85)) screen.blit(t3, (12, 120)) screen.blit(t4, (12, 145)) screen.blit(t5, (12, 170)) pygame.display.flip() clock.tick(fps) except KeyboardInterrupt: print("\n[EXIT] Ctrl+C pressed.") finally: try: if not args.dry_run and g is not None: galil_safe("ST", quiet=True) g.close() finally: pygame.quit() print("\n[EXIT] Closed.") return 0 if __name__ == "__main__": sys.exit(main()) |
###
Main Operational Sequence (W, A, S ,D)
The operation of the manual control script follows the sequence below:
-
Parse user-defined parameters
The script first reads configuration parameters such as connection address, jog speed, acceleration, deceleration, polling rate, and dry-run mode using the argparse module.
(Lines 60–87) -
Initialize graphical interface
The pygame library is initialized to create a graphical window and enable keyboard input detection. The display window is configured to show the live gimbal position and control instructions.
(Lines 104–111) -
Establish controller connection
The script connects to the Galil motion controller using the GimbalController class from gimbal_lib.py. Initial encoder scaling factors and the current gimbal position are retrieved from the controller.
(Lines 150–165) -
Configure acceleration and deceleration ramps
Motion ramp parameters are converted from degrees per second squared to encoder counts per second squared and applied to the controller using the Galil AC and DC commands.
(Lines 170–181) -
Read initial gimbal position
The controller position is queried using the TP command and converted from encoder counts to azimuth and elevation angles.
(Lines 183–191) -
Enter the main control loop
The program enters a continuous loop where keyboard inputs are monitored and motion commands are generated accordingly.
(Lines 202–214) -
Detect keyboard inputs
The script reads the current keyboard state using pygame.key.get_pressed() and determines the desired motion direction for the azimuth and elevation axes.
(Lines 236–242) -
Check motion limits
Before sending commands, the script verifies that the requested movement does not exceed the software motion limits of the gimbal.
(Lines 244–254) -
Send jog commands to the controller
When a direction change is detected, jog commands (JG) are issued to set the motion speed, followed by BGX or BGY to begin motion on the selected axis.
(Lines 283–296) -
Stop motion with ramped deceleration
When the control key is released, the script sends the STX or STY commands to stop the axis using the configured deceleration ramp.
(Lines 270–279) -
Poll encoder position and update display
The controller position is periodically queried using the TP command. The returned encoder counts are converted into azimuth and elevation angles and displayed on both the terminal and the graphical interface.
(Lines 215–230 and 310–343) -
Safe termination of the script
When the user exits the program, the script sends a stop command to the controller, closes the connection, and shuts down the pygame interface safely.
(Lines 354–367)