# Source code for triangula.chassis

```
from math import cos, sin, degrees, radians, pi
from time import time
from euclid import Vector2, Point2
from numpy import array as np_array
from numpy.linalg import solve as np_solve
__author__ = 'tom'
def test():
chassis = HoloChassis(wheels=[
HoloChassis.OmniWheel(position=Point2(1, 0), angle=0, radius=60),
HoloChassis.OmniWheel(position=Point2(-1, 0), angle=0, radius=60)]
)
print chassis.get_wheel_speeds(Motion(translation=Vector2(0, 0), rotation=0.5))
print chassis.get_wheel_speeds(Motion(translation=Vector2(0, 0), rotation=0.5), origin=Point2(1, 0))
def rotate_point(point, angle, origin=None):
"""
Rotate a Point2 around another Point2
:param euclid.Point2 point:
The point to rotate
:param float angle:
Angle in radians, clockwise rotation
:param euclid.Point2 origin:
Origin of the rotation, defaults to (0,0) if not specified
:return:
A new :class:`euclid.Point2` containing the rotated input point
"""
if origin is None:
origin = Point2(0, 0)
s = sin(-angle)
c = cos(-angle)
return Point2(c * (point.x - origin.x) - s * (point.y - origin.y) + origin.x,
s * (point.x - origin.x) + c * (point.y - origin.y) + origin.y)
def rotate_vector(vector, angle, origin=None):
"""
Rotate a :class:`euclid.Vector2` around a :class:`euclid.Point2`
:param euclid.Vector2 vector:
The vector to rotate
:param float angle:
Angle in radians, clockwise rotation
:param euclid.Point2 origin:
Origin of the rotation, defaults to (0,0) if not specified
:return:
A new :class:`euclid.Point2` containing the rotated input point
"""
if origin is None:
origin = Point2(0, 0)
s = sin(-angle)
c = cos(-angle)
return Vector2(c * (vector.x - origin.x) - s * (vector.y - origin.y) + origin.x,
s * (vector.x - origin.x) + c * (vector.y - origin.y) + origin.y)
def smallest_difference(a, b, max_value=2 * pi):
"""
Given two floats, a and b, and a maximum possible value for both a and b, calculate the smallest delta from a to b.
For example, if a=1.0, b=2.5 and max_value=2.6, this should return -1.1, as subtracting 1.1 from a would result in
-0.1, which will then be transformed to 2.5 after taking its modulus with 2.6. If max_value was 10, it would return
+1.5, as this is the lower magnitude delta needed to go from 1.0 to 2.5. This function is used when calculating the
shortest delta between two pose orientations, for this reason the max_value defaults to 2*pi for use when working
in radians.
If either a or b are less than zero or greater than the maximum value they will be treated as a % max_value or b %
max_value respectively for the purposes of this calculation.
:param float a:
First value (see above)
:param b:
Second value (see above)
:param max_value:
Modulus, defaults to 2*pi if not specified
:return:
A value d such that (a + d) % max_value == b, and abs(d) is minimal (as there would be an infinite number of
possible d that satisfy this relationship).
"""
mod_a = a % max_value
mod_b = b % max_value
if abs(mod_a - mod_b) <= max_value / 2:
return mod_b - mod_a
elif mod_a >= mod_b:
return mod_b + (max_value - mod_a)
else:
return -(mod_a + (max_value - mod_b))
def get_regular_triangular_chassis(wheel_distance, wheel_radius, max_rotations_per_second):
"""
Build a HoloChassis object with three wheels, each identical in size and maximum speed. Each wheel is positioned
at the corner of a regular triangle, and with direction perpendicular to the normal vector at that corner.
:param wheel_distance:
Distance in millimetres between the contact points of each pair of wheels (i.e. the length of each edge of the
regular triangle)
:param wheel_radius:
Wheel radius in millimetres
:param max_rotations_per_second:
Maximum wheel speed in revolutions per second
:return:
An appropriately configured HoloChassis
"""
point = Point2(0, cos(radians(30)) * wheel_distance / 2.0)
vector = Vector2(-2 * pi * wheel_radius, 0)
# Pink
wheel_a = HoloChassis.OmniWheel(
position=point,
vector=vector,
max_speed=max_rotations_per_second)
# Yellow
wheel_b = HoloChassis.OmniWheel(
position=rotate_point(point, pi * 2 / 3),
vector=rotate_vector(vector, pi * 2 / 3),
max_speed=max_rotations_per_second)
# Green
wheel_c = HoloChassis.OmniWheel(
position=rotate_point(point, pi * 4 / 3),
vector=rotate_vector(vector, pi * 4 / 3),
max_speed=max_rotations_per_second)
return HoloChassis(wheels=[wheel_a, wheel_b, wheel_c])
[docs]class WheelSpeeds:
"""
A simple container to hold desired wheel speeds, and to indicate whether any speeds were scaled back due to
impossibly high values.
"""
[docs] def __init__(self, speeds, scaling):
"""
Create a new wheel speeds container
:param speeds:
A sequence of float values, one per wheel, in revolutions per second
:param float scaling:
If a requested translation or rotation was too fast for the chassis to perform, it will return an instance
of this class with the scaling set to a value greater than 1.0. This indicates that it was unable to
provide the requested trajectory but has instead provided the highest magnitude one possible. This parameter
then contains the proportion of the requested trajectory that was possible to provide. For example, if
the motion requested was a translation of 10mm/s in the X axis and a rotation of 10 radians per second, but
on calculation this resulted in excessive wheel speeds which weren't possible, it might be scaled back to
6mm/s on X and 6 radians per second - the motion is proportionately the same just slower, and in this case
the scaling value would be 0.6.
"""
self.speeds = speeds
self.scaling = scaling
def __str__(self):
return 'WheelSpeeds[ speeds={}, scaling={} ]'.format(self.speeds, self.scaling)
[docs]class Motion:
"""
A container to hold the translation and rotation vector representing the robot's motion. This is always expressed
in the robot's coordinate frame, so a translation component of 0,1 always means the robot is heading forwards,
irrespective of the current orientation of the robot (i.e. if the robot was turned 90 degrees in world space this
0,1 motion would be a movement along the X axis in world space, but the Y axis in robot space). The rotation
component of the motion is expressed in radians per second, positive values corresponding to clockwise rotation
when viewed from the direction relative to the plane such that X is positive to the right and Y positive upwards.
"""
[docs] def __init__(self, translation=None, rotation=0):
"""
Constructor
:param euclid.Vector2 translation:
Vector2 representing the translation component in robot coordinate space of the motion. Defaults to
Vector2(0,0)
:param float rotation:
Rotation in radians per second. Defaults to 0.
"""
if translation is not None:
self.translation = translation
else:
self.translation = Vector2(0, 0)
self.rotation = rotation
def __str__(self):
return 'Motion[ x={}, y={}, theta={} (deg={}) ]'.format(self.translation.x, self.translation.y, self.rotation,
degrees(self.rotation))
[docs]class DeadReckoning:
"""
Encapsulates the logic required to track the robot's position in world space using wheel encoders and chassis
kinematics. To update the state of this object you need to call the update_from_counts function - this will
compute the difference in counts for each wheel, and from this derive the rotational speed for each wheel since
the last measurement. The :class:`triangula.chassis.HoloChassis` is then used to convert these speeds into an arc,
with the assumption that wheel speeds were constant during the time interval. This arc is used to update the
:class:`triangula.chassis.Pose` representing the current best estimate of the robot's position.
Because this is in effect integrating over sensor readings, any errors, particularly in the chassis geometry or
dimensions, or in the number of counts per revolution (for example if the gearing isn't quite what you think it is
or there's enough slop in the gearbox that readings can drift) will accumulate over time. To mitigate this, if you
have precise instantaneous information such as a compass reading every few seconds, these readings can be used to
explicitly set the position, orientation, or both of the :class:`triangula.chassis.Pose` tracked by this class.
As there's an implicit assumption that wheel speeds are constant between encoder readings, this class will yield
more accurate results when updated frequently. The exact optimal update frequency will depend on the encoder
resolutions, chassis geometry etc. Some manual tuning may be required.
"""
[docs] def __init__(self, chassis, counts_per_revolution=64 * 19, max_count_value=1 << 15):
"""
Constructor
:param triangula.chassis.HoloChassis chassis:
The :class:`triangula.chassis.HoloChassis` to be used to define kinematics for this DeadReckoning
:param float counts_per_revolution:
The number of counts registered by the wheel encoders per revolution of the wheel. Defaults to 64*19 to
be the 64 count encoder fitted to a 19:1 reduction gearbox.
:param int max_count_value:
The largest value read from the encoders, this is used to determine when we've wrapped around the zero
point, defaults to 1<<16 to reflect that count values are held in the microcontroller module as a uint16_t
"""
self.chassis = chassis
self.counts_per_revolution = counts_per_revolution
self.max_count_value = max_count_value
self.last_encoder_values = None
self.last_reading_time = None
self.pose = None
[docs] def reset(self):
"""
Clear the state of this :class:`triangula.chassis.DeadReckoning`
"""
self.last_encoder_values = None
self.last_reading_time = None
self.pose = None
[docs] def set_position(self, position):
"""
Explicitly set the position of the robot in world coordinates. Overrides the current value tracked by this
instance. Use this when you have better information and want to update the state accordingly.
:param euclid.Point2 position:
The new position to set, as a :class:`euclid.Point2`, coordinates are in mm
"""
self.pose.position = position
return self.pose
[docs] def set_orientation(self, orientation):
"""
Explicitly set the orientation of the robot in world coordinates. Use this to explicitly update the orientation,
for example when you have a sufficiently accurate compass fix that it can be used to eliminate any accumulated
errors built up by the dead reckoning algorithm.
:param float orientation:
The new orientation to set, in radians from the positive Y axis, clockwise rotations being positive. This
value will be normalised to the range 0-2PI
:return:
The current (updated) value of the :class:`triangula.chassis.Pose`
"""
self.pose.orientation = orientation % (2 * pi)
return self.pose
[docs] def update_from_counts(self, counts):
"""
Update the pose from a new set of encoder values
:param counts:
A list of encoder counts, one per wheel
:return:
The updated :class:`triangula.chassis.Pose` object (this is also modified in the internal state of the
DeadReckoning)
"""
reading_time = time()
if self.last_encoder_values is None:
self.last_encoder_values = counts
self.last_reading_time = reading_time
self.pose = Pose(Point2(0, 0), 0)
else:
time_delta = reading_time - self.last_reading_time
wheel_speeds = [smallest_difference(current_reading, last_reading, self.max_count_value) / (
self.counts_per_revolution * time_delta) for last_reading, current_reading
in zip(counts, self.last_encoder_values)]
motion = self.chassis.calculate_motion(speeds=wheel_speeds)
self.pose = self.pose.calculate_pose_change(motion, time_delta)
self.last_encoder_values = counts
self.last_reading_time = reading_time
return self.pose
[docs]class Pose:
"""
A container to hold the position as a Point2 along with orientation in radians, where 0 corresponds to the positive
Y axis (0,1). Orientation is expressed in radians, with positive values indicating a rotation from the positive Y
axis in the clockwise direction, i.e. a rotation of 0 is North, pi/2 East, pi South and 3pi/2 West.
"""
[docs] def __init__(self, position=None, orientation=0):
"""
Constructor
:param euclid.Point2 position:
A Point2 containing the position of the centre of the robot. Defaults to Point2(0,0)
:param float orientation:
Orientation in radians, 0 being the positive Y axis, positive values correspond to clockwise rotations, i.e.
pi/4 is East. This value will be normalised to be between 0 and 2 * pi. Defaults to 0
"""
if position is not None:
self.position = position
else:
self.position = Point2(0, 0)
self.orientation = orientation % (2 * pi)
[docs] def distance_to_pose(self, to_pose):
"""
Return the distance to the other pose position
:param triangula.chassis.Pose to_pose:
The target pose
"""
return abs(self.position - to_pose.position)
[docs] def is_close_to(self, to_pose, max_distance=0.001, max_orientation_difference=radians(1)):
"""
Check whether we're close to the specified pose, defining closeness as both distance on the plane and difference
in orientation.
:param to_pose:
The target pose
:param max_distance:
Maximum distance within which we'll count as being close, defaults to 0.001
:param max_orientation_difference:
Maximum number of radians we can be off the target pose's orientation to count as close, defaults to 1
degree (calculated with ``radians(1)``)
:return:
True if this pose is regarded as close to the other, False otherwise
"""
if self.distance_to_pose(to_pose) > max_distance:
return False
elif abs(smallest_difference(self.orientation, to_pose.orientation)) > max_orientation_difference:
return False
else:
return True
[docs] def translate(self, vector):
"""
Create a new pose, with the same orientation as this one and the specified translation applied to its position.
:param euclid.Vector2 vector:
Vector by which the position of this pose should be translated when creating the new Pose
:return:
Returns the new Pose
"""
return Pose(position=self.position + vector, orientation=self.orientation)
[docs] def pose_to_pose_vector(self, to_pose):
"""
Calculates the Vector2, in robot coordinate space (remember that Pose objects use world coordinates!) that
represents the translation required to move from this Pose to the specified target Pose.
:param triangula.chassis.Pose to_pose:
A target :class:`triangula.chassis.Pose`, the resultant vector in robot space will translate the robot to
the position contained in this pose. Note that this does not take any account of the orientation component
of the to_pose, only the starting one.
:return:
A :class:`euclid.Vector2` containing the translation part, in robot space, of the motion required to move
from this Pose to the target.
"""
return rotate_vector(
vector=Vector2(to_pose.position.x - self.position.x, to_pose.position.y - self.position.y),
angle=-self.orientation)
[docs] def pose_to_pose_motion(self, to_pose, time_seconds):
"""
Calculates a Motion which should be applied to the current Pose to move the robot towards the target, such that
it should hit the target at no less than time_seconds into the future. This function must be called on any Pose
update, i.e. from a dead reckoning module, as it doesn't do any course planning (it would, for example, be
possible to calculate a single constant motion to move in an arc to the target Pose, but this would be rather
inefficient, better to incrementally home in on the target by repeatedly calling this function). To move as
fast as possible to the target, set the time to something implausibly small, then use the chassis functions
to limit the resultant motion to the range possible for the chassis. This would require some kind of motion
limit to avoid skidding and messing up the Pose calculation logic.
:param to_pose:
A target :class:`triangula.chassis.Pose`
:param time_seconds:
A the minimum number of seconds to transition to the target pose.
:return:
A :class:`triangula.chassis.Motion` containing the motion required to attain the target pose in the
specified time. This is highly likely to be impossible, in which case using the chassis functions to
determine the wheel power and extract the scaling factor will give the actual time (ignoring acceleration
limits) to transition to the target.
"""
translation = self.pose_to_pose_vector(to_pose=to_pose)
rotation = smallest_difference(self.orientation, to_pose.orientation)
return Motion(translation=translation / time_seconds, rotation=rotation / time_seconds)
[docs] def calculate_pose_change(self, motion, time_delta):
"""
Given this as the starting Pose, a Motion and a time in seconds, calculate the resultant Pose at the end of the
time interval.
This makes use of the fact that if you travel in a consistent direction while turning at a constant rate you
will describe an arc. By calculating the centre point of this arc we can simply rotate the starting pose around
this centre point. This is considerably simpler than integrating over the motion 3-vector. A special case is
used to avoid division by zero errors when there is no rotation component to the motion.
:param triangula.chassis.Motion motion:
The motion of the robot, assumed to be constant for the duration of the time interval. The motion is
expressed in the robot's coordinate frame, so a translation of (0,1) is always a forward motion,
irrespective of the current orientation.
:param float time_delta:
The time in seconds during which the specified motion should be applied.
:return:
A :class:`triangula.chassis.Pose` which represents resultant pose after applying the supplied motion for the
given time.
"""
# Total delta in orientation angle over the time interval
orientation_delta = motion.rotation * time_delta
# Scaled translation vector rotated into world coordinate space (motion uses robot space)
translation_vector_world = rotate_vector(motion.translation, self.orientation) * time_delta
':type : euclid.Vector2'
if orientation_delta == 0:
# No orientation, trivially add the rotated, scaled, translation vector to the current pose
return self.translate(translation_vector_world)
else:
centre_of_rotation = self.position + translation_vector_world.cross() / orientation_delta
':type : euclid.Point2'
final_position = rotate_point(self.position, angle=orientation_delta, origin=centre_of_rotation)
return Pose(position=final_position, orientation=self.orientation + orientation_delta)
def __str__(self):
return 'Pose[x={}, y={}, orientation={} (deg={})]'.format(self.position.x, self.position.y, self.orientation,
degrees(self.orientation))
[docs]class HoloChassis:
"""
An assembly of wheels at various positions and angles, which can be driven independently to create a holonomic drive
system. A holonomic system is one where number of degrees of freedom in the system is equal to the number of
directly controllable degrees of freedom, so for a chassis intended to move in two dimensions the degrees of freedom
are two axes of translation and one of rotation. For a full holonomic system we therefore need at least three wheels
defined.
"""
[docs] def __init__(self, wheels):
"""
Create a new chassis, specifying a set of wheels.
:param wheels:
A sequence of :class:`triangula.chassis.HoloChassis.OmniWheel` objects defining the wheels for this chassis.
"""
self.wheels = wheels
self._matrix_coefficients = np_array([[wheel.co_x, wheel.co_y, wheel.co_theta] for wheel in self.wheels])
[docs] def calculate_motion(self, speeds):
"""
Invert the motion to speed calculation to obtain the actual linear and angular velocity of the chassis given
a vector of wheel speeds. See http://docs.scipy.org/doc/numpy-1.10.1/reference/generated/numpy.linalg.solve.html
:param speeds:
An array of wheel speeds, expressed as floats with units of radians per second, positive being towards
the wheel vector.
:return:
A :class:`triangula.chassis.Motion` object containing the calculated translation and rotation in the robot's
coordinate space.
"""
motion_array = np_solve(self._matrix_coefficients, np_array(speeds))
return Motion(Vector2(x=float(motion_array[0]),
y=float(motion_array[1])),
rotation=float(motion_array[2]))
[docs] def get_max_translation_speed(self):
"""
Calculate the maximum translation speed, assuming all directions are equivalent and that there is no rotation
component to the motion.
:return:
Maximum speed in millimetres per second as a float
"""
unrealistic_speed = 10000.0
scaling = self.get_wheel_speeds(Motion(translation=Vector2(0, unrealistic_speed), rotation=0)).scaling
return unrealistic_speed * scaling
[docs] def get_max_rotation_speed(self):
"""
Calculate the maximum rotation speed around the origin in radians per second, assuming no translation motion
at the same time.
:return:
Maximum radians per second as a float
"""
unrealistic_speed = 2 * pi * 100
scaling = self.get_wheel_speeds(Motion(translation=Vector2(0, 0), rotation=unrealistic_speed)).scaling
return unrealistic_speed * scaling
[docs] def get_wheel_speeds(self, motion, origin=Point2(x=0, y=0)):
"""
Calculate speeds to drive each wheel in the chassis at to attain the specified rotation / translation 3-vector.
:param triangula.chassis.Motion motion:
Desired motion of the robot chassis
:param euclid.Point2 origin:
Optional, can define the centre of rotation to be something other than 0,0. Units are in millimetres.
Defaults to rotating around x=0, y=0.
:return:
A :class:`triangula.chassis.WheelSpeeds` containing both the target wheel speeds and the scaling, if any,
which was required to bring those speeds into the allowed range for all wheels. This prevents unexpected
motion in cases where only a single wheel is being asked to turn too fast, in such cases all wheel speeds
will be scaled back such that the highest is within the bounds allowed for that particular wheel. This
can accommodate wheels with different top speeds.
"""
def velocity_at(point):
"""
Compute the velocity as a Vector2 at the specified point given the enclosing translation and rotation values
Method: Normalise the vector from the origin to the point, then take the cross of itself to produce a unit
vector with direction that of a rotation around the origin. Scale this by the distance from the origin and
by the rotation in radians per second, then simply add the translation vector.
:param euclid.Point2 point:
Point at which to calculate velocity
:return:
A :class:`euclid.Vector2` representing the velocity at the specified point in mm/s
"""
d = point - origin
return d.cross() * motion.rotation + motion.translation
wheel_speeds = list(wheel.speed(velocity_at(wheel.position)) for wheel in self.wheels)
scale = 1.0
for speed, wheel in zip(wheel_speeds, self.wheels):
if wheel.max_speed is not None and abs(speed) > wheel.max_speed:
wheel_scale = wheel.max_speed / abs(speed)
scale = min(scale, wheel_scale)
return WheelSpeeds(speeds=list(speed * scale for speed in wheel_speeds), scaling=scale)
[docs] class OmniWheel:
"""
Defines a single omni-wheel within a chassis assembly. Omni-wheels are wheels formed from rollers, where the
motion of the roller is perpendicular to the motion of the primary wheel. This is distinct from a mechanum wheel
where the rollers are at an angle (normally around 40-30 degrees) to the primary wheel. Omni-wheels must be
positioned on the chassis with non-parallel unit vectors, mechanum wheels can in some cases be positioned with
all unit vectors parallel.
A wheel has a location relative to the chassis centre and a vector describing the direction of motion of the
wheel when driven with a positive angular velocity. The location is specified in millimetres, and the magnitude
of the wheel vector should be equal to the number of millimetres travelled in a single revolution. This allows
for different sized wheels to be handled within the same chassis.
"""
[docs] def __init__(self, position, max_speed=0, angle=None, radius=None, vector=None):
"""
Create a new omni-wheel object, specifying the position and either a direction vector directly or the angle
in degrees clockwise from the position Y axis along with the radius of the wheel.
:param euclid.Point2 position:
The wheel's contact point with the surface, specified relative to the centre of the
chassis. Units are millimetres.
:param float max_speed:
The maximum number of revolutions per second allowed for this wheel. When calculating the wheel speeds
required for a given trajectory this value is used to scale back all motion if any wheel would have to
move at an impossible speed. If not specified this defaults to None, indicating that no speed limit
should be placed on this wheel.
:param angle:
The angle, specified in radians from the positive Y axis where positive values are clockwise from this
axis when viewed from above, of the direction of travel of the wheel when driven with a positive speed.
If this value is specified then radius must also be specified and dx,dy left as None.
:param radius:
The radius in millimetres of the wheel, measuring from the centre to the contact point with the surface,
this may be hard to determine for some wheels based on their geometry, particularly for wheels with
cylindrical rollers, as the radius will vary. For these cases it may be worth directly measuring the
circumference of the entire assembly and calculating radius rather than measuring directly. This is used
to determine the magnitude of the direction vector. If this is not None then the angle must also be
specified, and dx,dy left as None.
:param euclid.Vector2 vector:
2 dimensional vector defining the translation of the wheel's contact point after a full
revolution of the wheel.
"""
self.position = position
self.max_speed = max_speed
if angle is None and radius is None and vector is not None:
# Specify wheel based on direct vector """
self.vector = vector
elif angle is not None and radius is not None and vector is None:
# Specify based on angle from positive Y axis and radius """
circumference = 2 * pi * radius
self.vector = Vector2(sin(angle) * circumference, cos(angle) * circumference)
else:
raise ValueError('Must specify exactly one of angle and radius or translation vector')
self.vector_magnitude_squared = self.vector.magnitude_squared()
self.co_x = self.vector.x / self.vector_magnitude_squared
self.co_y = self.vector.y / self.vector_magnitude_squared
self.co_theta = (self.vector.x * self.position.y -
self.vector.y * self.position.x) / self.vector_magnitude_squared
[docs] def speed(self, velocity):
"""
Given a velocity at a wheel contact point, calculate the speed in revolutions per second at which the wheel
should be driven.
Method: we want to find the projection of the velocity onto the vector representing the drive of this wheel.
We store the vector representing a single revolution of travel as self.vector, so the projection onto this
would be velocity.dot(self.vector / abs(self.vector)). However, we want revolutions per second, so we must
then divide again by abs(self.vector), leading to
velocity.dot(self.vector / abs(self.vector))/abs(self.vector). Because the definition of the dot product is
the sum of x1*x2, y1*y2, ... any scalar applied to each x, y ... of a single vector can be moved outside
the dot product, so we can simplify as velocity.dot(self.vector) / abs(self.vector)^2. As the magnitude of
the vector is taken by sqrt(x^2+y^2) we can simply express this as (x^2+y^2), held in the convenient
function magnitude_squared(). So our final simplified form is
velocity.dot(self.vector) / self.vector.magnitude_squared(). For efficiency, and because self.vector doesn't
change, we can pre-compute this.
:param euclid.Vector2 velocity:
The velocity at the wheel's contact point with the surface, expressed in mm/s
:return:
Target wheel speed in rotations per second to hit the desired vector at the contact point.
"""
return velocity.dot(self.vector) / self.vector_magnitude_squared
```