Skip to content

Instantly share code, notes, and snippets.

@hardillb
Created January 1, 2025 11:54
Show Gist options
  • Save hardillb/97944fd506314a1ad6b933a3566a8805 to your computer and use it in GitHub Desktop.
Save hardillb/97944fd506314a1ad6b933a3566a8805 to your computer and use it in GitHub Desktop.
SparkFun Servo Hat Brachiograph
from time import sleep, monotonic
import pi_servo_hat
# from brachiograph import BrachioGraph
# from plotter import Plotter
import tqdm
import math
import json
import readchar
import pprint
import numpy
servoHat = pi_servo_hat.PiServoHat()
class SparkFun():
def __init__(
self,
virtual: bool = False, # a virtual plotter runs in software only
turtle: bool = False, # create a turtle graphics plotter
turtle_coarseness=None, # a factor in degrees representing servo resolution
# ----------------- geometry of the plotter -----------------
bounds: tuple = [-8, 4, 6, 13], # the maximum rectangular drawing area
inner_arm: float = 8, # the lengths of the arms
outer_arm: float = 8,
# ----------------- naive calculation values -----------------
servo_1_parked_pw: int = 1500, # pulse-widths when parked
servo_2_parked_pw: int = 1500,
servo_1_degree_ms: int = -10, # milliseconds pulse-width per degree
servo_2_degree_ms: int = 10, # reversed for the mounting of the shoulder servo
servo_1_parked_angle: int = -90, # the arm angle in the parked position
servo_2_parked_angle: int = 90,
# ----------------- hysteresis -----------------
hysteresis_correction_1: int = 0, # hardware error compensation
hysteresis_correction_2: int = 0,
# ----------------- servo angles and pulse-widths in lists -----------------
servo_1_angle_pws: tuple = [], # pulse-widths for various angles
servo_2_angle_pws: tuple = [],
# ----------------- servo angles and pulse-widths in lists (bi-directional) ------
servo_1_angle_pws_bidi: tuple = [], # bi-directional pulse-widths for various angles
servo_2_angle_pws_bidi: tuple = [],
# ----------------- the pen -----------------
pw_up: int = 1500, # pulse-widths for pen up/down
pw_down: int = 1100,
# ----------------- physical control -----------------
wait: float = None, # default wait time between operations
angular_step: float = None, # default step of the servos in degrees
resolution: float = None, # default resolution of the plotter in cm
):
self.pen = Pen()
self.last_moved = monotonic()
self.virtual = False
self.servo_1_parked_angle = servo_1_parked_angle
self.servo_2_parked_angle = servo_2_parked_angle
self.wait = wait if wait is not None else 0.01
self.angular_step = angular_step
self.angle_1 = servo_1_parked_angle
self.angle_2 = servo_2_parked_angle
self.bounds = bounds
self.servo_2_parked_pw = servo_2_parked_pw
self.servo_2_degree_ms = servo_2_degree_ms
self.servo_2_parked_angle = servo_2_parked_angle
self.hysteresis_correction_2 = hysteresis_correction_2
self.inner_arm = inner_arm
self.outer_arm = outer_arm
self.x = -self.inner_arm
self.y = self.outer_arm
self.angular_step = angular_step or 0.1
self.resolution = resolution or 0.1
self.set_angles(self.servo_1_parked_angle, self.servo_2_parked_angle)
def xy_to_angles(self, x=0, y=0):
"""Return the servo angles required to reach any x/y position."""
hypotenuse = math.sqrt(x**2 + y**2)
if hypotenuse > self.inner_arm + self.outer_arm:
raise Exception(
f"Cannot reach {hypotenuse}; total arm length is {self.inner_arm + self.outer_arm}"
)
hypotenuse_angle = math.asin(x / hypotenuse)
inner_angle = math.acos(
(hypotenuse**2 + self.inner_arm**2 - self.outer_arm**2)
/ (2 * hypotenuse * self.inner_arm)
)
outer_angle = math.acos(
(self.inner_arm**2 + self.outer_arm**2 - hypotenuse**2)
/ (2 * self.inner_arm * self.outer_arm)
)
shoulder_motor_angle = hypotenuse_angle - inner_angle
elbow_motor_angle = math.pi - outer_angle
return (math.degrees(shoulder_motor_angle), math.degrees(elbow_motor_angle))
def angles_to_xy(self, shoulder_motor_angle, elbow_motor_angle):
"""Return the x/y co-ordinates represented by a pair of servo angles."""
elbow_motor_angle = math.radians(elbow_motor_angle)
shoulder_motor_angle = math.radians(shoulder_motor_angle)
hypotenuse = math.sqrt(
(
self.inner_arm**2
+ self.outer_arm**2
- 2
* self.inner_arm
* self.outer_arm
* math.cos(math.pi - elbow_motor_angle)
)
)
base_angle = math.acos(
(hypotenuse**2 + self.inner_arm**2 - self.outer_arm**2)
/ (2 * hypotenuse * self.inner_arm)
)
inner_angle = base_angle + shoulder_motor_angle
x = math.sin(inner_angle) * hypotenuse
y = math.cos(inner_angle) * hypotenuse
return (x, y)
def set_angles(self, angle_1=None, angle_2=None):
# print(angle_1, angle_2)
if angle_1 is not None:
angle_1 = 130 + angle_1
servoHat.move_servo_position(0, angle_1)
if angle_2 is not None:
angle_2 = 132 - angle_2
servoHat.move_servo_position(2, angle_2)
def plot_file(self, filename="", bounds=None, angular_step=None, wait=None, resolution=None):
"""Plots and image encoded as JSON lines in ``filename``. Passes the lines in the supplied
JSON file to ``plot_lines()``.
"""
bounds = bounds or self.bounds
with open(filename, "r") as line_file:
lines = json.load(line_file)
self.plot_lines(lines, bounds, angular_step, wait, resolution, flip=True)
def plot_lines(
self,
lines=[],
bounds=None,
angular_step=None,
wait=None,
resolution=None,
flip=False,
rotate=False,
):
"""Passes each segment of each line in lines to ``draw_line()``"""
bounds = bounds or self.bounds
lines = self.rotate_and_scale_lines(lines=lines, bounds=bounds, flip=True)
for line in tqdm.tqdm(lines, desc="Lines", leave=False):
x, y = line[0]
# only if we are not within 1mm of the start of the line, lift pen and go there
if (round(self.x, 1), round(self.y, 1)) != (round(x, 1), round(y, 1)):
self.xy(x, y, angular_step, wait, resolution)
for point in line[1:]:
x, y = point
self.xy(x, y, angular_step, wait, resolution, draw=True)
self.park()
# ----------------- pattern-drawing methods -----------------
def box(
self, bounds=None, angular_step=None, wait=None, resolution=None, repeat=1, reverse=False
):
"""Draw a box marked out by the ``bounds``."""
bounds = bounds or self.bounds
if not bounds:
return "Box drawing is only possible when the bounds attribute is set."
self.xy(bounds[0], bounds[1], angular_step, wait, resolution)
for r in tqdm.tqdm(tqdm.trange(repeat), desc="Iteration", leave=False):
if not reverse:
self.xy(bounds[2], bounds[1], angular_step, wait, resolution, draw=True)
self.xy(bounds[2], bounds[3], angular_step, wait, resolution, draw=True)
self.xy(bounds[0], bounds[3], angular_step, wait, resolution, draw=True)
self.xy(bounds[0], bounds[1], angular_step, wait, resolution, draw=True)
else:
self.xy(bounds[0], bounds[3], angular_step, wait, resolution, draw=True)
self.xy(bounds[2], bounds[3], angular_step, wait, resolution, draw=True)
self.xy(bounds[2], bounds[1], angular_step, wait, resolution, draw=True)
self.xy(bounds[0], bounds[1], angular_step, wait, resolution, draw=True)
self.park()
def test_pattern(
self,
lines=4,
bounds=None,
angular_step=None,
wait=None,
resolution=None,
repeat=1,
reverse=False,
both=False,
):
self.vertical_lines(lines, bounds, angular_step, wait, resolution, repeat, reverse, both)
self.horizontal_lines(lines, bounds, angular_step, wait, resolution, repeat, reverse, both)
def vertical_lines(
self,
lines=4,
bounds=None,
angular_step=None,
wait=None,
resolution=None,
repeat=1,
reverse=False,
both=False,
):
bounds = bounds or self.bounds
if not bounds:
return "Plotting a test pattern is only possible when the bounds attribute is set."
if not reverse:
top_y = self.top
bottom_y = self.bottom
else:
bottom_y = self.top
top_y = self.bottom
for n in range(repeat):
step = (self.right - self.left) / lines
x = self.left
while x <= self.right:
self.draw_line((x, top_y), (x, bottom_y), angular_step, wait, resolution, both)
x = x + step
self.park()
def horizontal_lines(
self,
lines=4,
bounds=None,
angular_step=None,
wait=None,
resolution=None,
repeat=1,
reverse=False,
both=False,
):
bounds = bounds or self.bounds
if not bounds:
return "Plotting a test pattern is only possible when the bounds attribute is set."
if not reverse:
min_x = self.left
max_x = self.right
else:
max_x = self.left
min_x = self.right
for n in range(repeat):
step = (self.bottom - self.top) / lines
y = self.top
while y >= self.bottom:
self.draw_line((min_x, y), (max_x, y), angular_step, wait, resolution, both)
y = y + step
self.park()
# ----------------- x/y drawing methods -----------------
def draw_line(
self, start=(0, 0), end=(0, 0), angular_step=None, wait=None, resolution=None, both=False
):
"""Draws a line between two points"""
start_x, start_y = start
end_x, end_y = end
self.xy(start_x, start_y, angular_step, wait, resolution)
self.xy(end_x, end_y, angular_step, wait, resolution, draw=True)
if both:
self.xy(start_x, start_y, angular_step, wait, resolution, draw=True)
def xy(self, x=None, y=None, angular_step=None, wait=None, resolution=None, draw=False):
"""Moves the pen to the xy position; optionally draws while doing it. ``None`` for x or y
means that the pen will not be moved in that dimension.
"""
wait = wait if wait is not None else self.wait
resolution = resolution or self.resolution
x = x if x is not None else self.x
y = y if y is not None else self.y
(angle_1, angle_2) = self.xy_to_angles(x, y)
if draw:
# calculate how many steps we need for this move, and the x/y length of each
(x_length, y_length) = (x - self.x, y - self.y)
length = math.sqrt(x_length**2 + y_length**2)
no_of_steps = round(length / resolution) or 1
if no_of_steps < 100:
disable_tqdm = True
else:
disable_tqdm = False
(length_of_step_x, length_of_step_y) = (x_length / no_of_steps, y_length / no_of_steps)
for step in range(no_of_steps):
self.x = self.x + length_of_step_x
self.y = self.y + length_of_step_y
angle_1, angle_2 = self.xy_to_angles(self.x, self.y)
self.move_angles(angle_1, angle_2, angular_step, wait, draw)
else:
self.move_angles(angle_1, angle_2, angular_step, wait, draw)
# ----------------- servo angle drawing methods -----------------
def move_angles(self, angle_1=None, angle_2=None, angular_step=None, wait=None, draw=False):
"""Moves the servo motors to the specified angles step-by-step, calling ``set_angles()`` for
each step. ``None`` for one of the angles means that that servo will not move.
"""
wait = wait if wait is not None else self.wait
angular_step = angular_step or self.angular_step
if draw:
self.pen.down()
else:
self.pen.up()
diff_1 = diff_2 = 0
if angle_1 is not None:
diff_1 = angle_1 - self.angle_1
if angle_2 is not None:
diff_2 = angle_2 - self.angle_2
no_of_steps = int(max(map(abs, (diff_1 / angular_step, diff_2 / angular_step)))) or 1
if no_of_steps < 100:
disable_tqdm = True
else:
disable_tqdm = False
(length_of_step_1, length_of_step_2) = (diff_1 / no_of_steps, diff_2 / no_of_steps)
for step in tqdm.tqdm(
range(no_of_steps), desc="Progress", leave=False, disable=disable_tqdm
):
self.angle_1 = self.angle_1 + length_of_step_1
self.angle_2 = self.angle_2 + length_of_step_2
time_since_last_moved = monotonic() - self.last_moved
# print(time_since_last_moved)
if time_since_last_moved < wait:
sleep(wait - time_since_last_moved)
self.set_angles(self.angle_1, self.angle_2)
self.last_moved = monotonic()
def park(self):
"""Park the plotter."""
if self.virtual:
print("Parking")
self.pen.up()
self.move_angles(self.servo_1_parked_angle, self.servo_2_parked_angle)
def rotate_and_scale_lines(self, lines=[], rotate=False, flip=False, bounds=None):
"""Rotates and scales the lines so that they best fit the available drawing ``bounds``."""
(
rotate,
x_mid_point,
y_mid_point,
box_x_mid_point,
box_y_mid_point,
divider,
) = self.analyse_lines(lines, rotate, bounds)
for line in lines:
for point in line:
if rotate:
point[0], point[1] = point[1], point[0]
x = point[0]
x = x - x_mid_point # shift x values so that they have zero as their mid-point
x = x / divider # scale x values to fit in our box width
if flip ^ rotate: # flip before moving back into drawing pane
x = -x
# shift x values so that they have the box x midpoint as their endpoint
x = x + box_x_mid_point
y = point[1]
y = y - y_mid_point
y = y / divider
y = y + box_y_mid_point
point[0], point[1] = x, y
return lines
def analyse_lines(self, lines=[], rotate=False, bounds=None):
"""
Analyses the co-ordinates in ``lines``, and returns:
* ``rotate``: ``True`` if the image needs to be rotated by 90˚ in order to fit better
* ``x_mid_point``, ``y_mid_point``: mid-points of the image
* ``box_x_mid_point``, ``box_y_mid_point``: mid-points of the ``bounds``
* ``divider``: the value by which we must divide all x and y so that they will fit safely
inside the bounds.
``lines`` is a tuple itself containing a number of tuples, each of which contains a number
of 2-tuples::
[
[
[3, 4], # |
[2, 4], # |
[1, 5], # a single point in a line # | a list of points defining a line
[3, 5], # |
[3, 7], # |
],
[ # all the lines
[...],
[...],
],
[
[...],
[...],
],
]
"""
bounds = bounds or self.bounds
# First, we create a pair of empty sets for all the x and y values in all of the lines of
# the plot data.
x_values_in_lines = set()
y_values_in_lines = set()
# Loop over each line and all the points in each line, to get sets of all the x and y
# values:
for line in lines:
x_values_in_line, y_values_in_line = zip(*line)
x_values_in_lines.update(x_values_in_line)
y_values_in_lines.update(y_values_in_line)
# Identify the minimum and maximum values.
min_x, max_x = min(x_values_in_lines), max(x_values_in_lines)
min_y, max_y = min(y_values_in_lines), max(y_values_in_lines)
# Identify the range they span.
x_range, y_range = max_x - min_x, max_y - min_y
box_x_range, box_y_range = bounds[2] - bounds[0], bounds[3] - bounds[1]
# And their mid-points.
x_mid_point, y_mid_point = (max_x + min_x) / 2, (max_y + min_y) / 2
box_x_mid_point, box_y_mid_point = (bounds[0] + bounds[2]) / 2, (bounds[1] + bounds[3]) / 2
# Get a 'divider' value for each range - the value by which we must divide all x and y so
# that they will fit safely inside the bounds.
# If both image and box are in portrait orientation, or both in landscape, we don't need to
# rotate the plot.
if (x_range >= y_range and box_x_range >= box_y_range) or (
x_range <= y_range and box_x_range <= box_y_range
):
divider = max((x_range / box_x_range), (y_range / box_y_range))
rotate = False
else:
divider = max((x_range / box_y_range), (y_range / box_x_range))
rotate = True
x_mid_point, y_mid_point = y_mid_point, x_mid_point
return (rotate, x_mid_point, y_mid_point, box_x_mid_point, box_y_mid_point, divider)
class Pen:
def __init__(self):
print("setting up pen")
def up(self):
servoHat.move_servo_position(4, 0)
def down(self):
servoHat.move_servo_position(4, 45)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment