a Cartesian origin point with
two spinning polar points in a single code loop
Goal:
- I want Cartesian and Polar point objects in both 2D and 3D flavors.
- I want instances of point types to be immutable.
- I want to freely arithmetically mix these two types without ever having to think about their inherent incompatibilities.
- I want iterators that can take a pair of points and produce sequences of steps between them.
Using a tuple as a Cartesian or Polar point just doesn't work very well. In Python, the arithmetic operators (+, -, *, /) don't actually do arithmetic operations on tuple. Instead they act on tuple's traits as a sequence. __add__ is a concatenation operator, while __mul__ is a repetition action. I need tuples to do vector arithmetic instead.
Of course, vector arithmetic is already done properly in numpy. Unfortunately, ndarray is difficult to subclass. All the hand waving and somersaults I'd have to go through with numpy just seemed like too much trouble. Maybe the funtionality I'm looking for already exists somewhere in numpy, but I'm looking for my own adventure.
Arithmetic directly with polar coordinates is nasty buisness. Vector arithmetic doesn't directly work with them. Since I don't want to go there, I decided that a Cartesian universe would be the basis for all arithmetic in this package. Any subclass that doesn't use the cartesian universe must provide conversion both to and from cartesian.
I went ahead and started with tuple as the base class of my vector class. Because a tuple instance is an immutable object, the __init__ constructor is executed too late to give the tuple values. Instead of writing an __init__ constructor method, the __new__ method is the right starting point to subclass a tuple.
This is where I step into the alleged anti-pattern of multiple dispatch. I'm taking the arguments as a sequence of discrete values and making the __new__ method act in different ways based on both the number and types of the input parameters. In otherwords, I'm passing the args tuple directly into the match statement and using its pattern matching magic as a multiple dispatch system. In the first three cases below, they're selected not only by the type of the parameter, but the fact that args has a length of 1. The catch-all default case is for all other cases of length and content of args.
class Vector(tuple):
def __new__(cls, *args):
match args:
case [cls() as an_instance_of_cls]:
# match instances of the calling cls or its derivatives
# this is the identity case
return an_instance_of_cls
case [Vector() as an_instance_of_vector]:
# match any instance of the Vector family not directly in line with cls
# explicitly invoke a conversion - maybe cartesian to polar or vice versa
return cls.as_my_type(an_instance_of_vector)
case [Iterable() as an_iterable]:
# match any old iterable like a generator or numpy array
# create a new instance of this cls
return super().__new__(
cls,
tuple(cls._judge_candidate_value(n) for n in an_iterable),
)
case _:
# discrete values were passed, assume they are to be the coordinate
# values of a new instance of this cls
return super().__new__(
cls, tuple(cls._judge_candidate_value(n) for n in args)
)
(see this code in situ in the vector.py file in the points main directory)
Had this been written with a decorator based system for single/multiple dispatch, there would be a flock of alternate definitions for __new__ and this unmistakably visible heirarchical structure would be hidden within a flat one. One of the most common complaints in C++, where overloading functions by type (single/multiple dispatch) is common, centers around the mysteries why the compiler chose to invoke one method over another. It's hard to see the patterns lurking within the flattened visual structure of the code on a page. Structural Pattern Matching gives me a visual way to understand how it is going to work at a glance.
Now I have to make the Vector class do proper Vector Arithmetic and interoperate with scalars and iterables in a sensible manner:
def _operation(self, the_other, a_dyadic_fn):
# return a new instance with members that result from a_dyadic_fn applied to
# this instance zipped with the_other
match the_other:
case Number() as a_number:
# match scalars
return self.__class__(
*starmap(
a_dyadic_fn, zip_longest(self, (a_number,), fillvalue=a_number)
)
)
case Vector() as a_vector:
# match any instance of the Vector family
the_other_as_my_type = self.as_my_type(a_vector)
return self.__class__(
*starmap(a_dyadic_fn, zip(self, the_other_as_my_type))
)
case Iterable() as an_iterable:
# match any other type of iterable
return self.__class__(*starmap(a_dyadic_fn, zip(self, an_iterable)))
case _:
# no idea how to interpret this value as an operand
raise TypeError(f"{the_other} disallowed")
def __add__(self, the_other):
return self._operation(the_other, add)
# other Vector Arithmetic operations ommited for brevity
(see this code in situ in the vector.py file in the points main directory)
The middle case above, case Vector(), demonstrates a key class method that subclasses ought to override: as_my_type. Vectors instances themselves impose no rules on how its components are interpreted. The first layer of subclasses are required to do so. The CartesianPoint subclass interprets its vector components in the realm of cartesian coordinate systems. The PolarPoint subclass, appropriately, interprets its components in a circular/spherical universe.That's where the as_my_type class method comes in. Its job is to take a value and convert it into a value compatible with the calling class' coordinate world view.
Here's how CartesianPoint does it:
@classmethod
def as_my_type(cls, the_other):
# This function is in charge of converting things into cartesian coordinates.
# The base class Vector has no sense of what its members mean, so members of the
# cartesian branch of the Vector family interpret base Vector instances and other
# Iterables as having cartesian values already.
match the_other:
case cls():
# Match an instance of this cls or its derivatives - identity case
# For example cls may be CartesianPoint but IntPoints would match
# because an IntPoint is a CartesianPoint
return the_other
case Vector():
# match an instance of base class Vector, but of another lineage
# other than CartesianPoint. For example, this ensures that a
# PolarPoint is properly converted to Cartesian.
try:
return the_other.as_cartesian(cls)
except AttributeError:
# Vector itself doesn't know anything about being cartesian, so just
# make a new instance of cls from it
return cls(*the_other)
case Iterable():
# make an instance of cls from any iterable assuming that the
# coordinate values are already cartesian.
return cls(*the_other)
case _:
raise TypeError(
f"No conversion defined for {the_other.__class__} to {cls}"
)
(see this code in situ in the cartesian.py file in the
points main directory)
Contrast that with how PolarPoint does it:
@classmethod
def as_my_type(cls, the_other):
# This function is in charge of converting things into polar coordinates.
# The base class Vector has no sense of what its members mean, so members of the
# polar branch of the Vector family interpret base Vector instances and other
# Iterables as having polar values already.
match the_other:
case cls():
# identity case
return the_other
case CartesianPoint() as a_cartesian_point:
# we know this is the cartesian case, so we must explicitly convert it
return cls.as_polar(a_cartesian_point)
case Iterable() as an_iterator:
# we don't know what this sequence represents.
# To be consistent with the constructor, assume they are
# series of components appropriate for a polar point
return cls(*an_iterator)
case _:
raise TypeError(
f"No conversion defined for {the_other.__class__} to {cls}"
)
(see this code in situ in the polar.py file in the points main directory)
Finally, the PolarPoint needs to know how to do arithmetic by converting to CartesianPoints. That's accomplished simply by overriding the Vector base class arithmetic operators:
def __add__(self, the_other):
return PolarPoint(self.as_cartesian() + the_other)
def __sub__(self, the_other):
return PolarPoint(self.as_cartesian() - the_other)
# the rest of the arithmetic operations ommitted for brevity
(see this code in situ in the polar.py file in the points main directory)
Taken all together, these point classes work together seamlessly. The code for drawing the loops in the image above is gorgeously simple in form (a least until the black code formatter mangled the single loop into something nearly unreadable - seriously, do we really need to maintain compatibility with punch cards in the 21st century?):
def draw_a_looping_spiral(a_canvas):
# the middle of the image
cartesian_middle_point = CartesianPoint(a_canvas.the_image.size) / 2
# beginning and end polar points for two loops around a circle
# while the radius of the loop shrink
larger_rotator_polar_origin = PolarPoint(
cartesian_middle_point * (3.0 / 4.0, 0)
)
larger_rotator_polar_destination = PolarPoint(0, 4.0 * π)
larger_rotator_iter = iter_linearly_between(
larger_rotator_polar_origin, larger_rotator_polar_destination, 2000
)
# beginning and end polar points for fifty loops around a circle
# with the loop radius shrinking with each step
smaller_rotator_polar_origin = PolarPoint(
cartesian_middle_point * (1.0 / 8.0, 0)
)
smaller_rotator_polar_destination = PolarPoint(0, 100.0 * π)
smaller_rotator_iter = iter_linearly_between(
smaller_rotator_polar_origin, smaller_rotator_polar_destination, 2000
)
# create a couple iterators that will produce a sequence of polar points
# that spin in lockstep with each other
for step_counter, (
larger_rotated_polar_point,
smaller_rotated_polar_point,
) in enumerate(
no_consectutive_repeats_iter(
zip(
larger_rotator_iter,
smaller_rotator_iter,
)
)
):
# add the cartesian middle point with the spinning polar points
current_cartesion_point = (
cartesian_middle_point
+ larger_rotated_polar_point
+ smaller_rotated_polar_point
)
# draw the line segment from prevous and current cartesian points
a_canvas.draw_successive_line_segment(current_cartesion_point, step_counter)
(see this code in situ in thespirals.py file in the
points demo directory)
This family of classes plays a huge role in the development and production of my artwork. Using these classes, I prove that my mazes are solvable with exactly one path. Further they underlie the zooms, pans and rotations, as well as the moving solution lines in my animation, The Dead Spider Waltz.
Since these classes are built on the base class of tuple, they are appropriate for use anywhere a tuple is appropriate. This includes indexing numpy ndarray and working with images in the pillow package.
The full working code of from this post is available in my github repo. However, I've not made this an installable package as that would imply that I'm willing to maintain it in perpetuity. Maybe it will inspire someone to make a more serious version that accomplishes the same thing with more more aplomb and efficiency. This implementation may well be full of problems, can't-get-there-from-here situations, or other such troubles. There is no warranty, no guarantee, and the author is absentee.
Please support my artwork, coding and writing on Patreon.
Even a small conribution helps.
Yeah, but is Structural Pattern Matching actually
Pythonic?
After deep introspection, I've come to
the conclusion that it just doesn't matter.