Created
January 1, 2025 11:54
-
-
Save hardillb/97944fd506314a1ad6b933a3566a8805 to your computer and use it in GitHub Desktop.
SparkFun Servo Hat Brachiograph
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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