What's My Point?


(see this code in situ in thespirals.pyfile in the points demo directory)
An animation made by simple addition of
a Cartesian origin point with
two spinning polar points in a single code loop
tl;dr: This is a joy-of-programming excursion. I have concluded that Structural Pattern Matching introduced in Python 3.10 is a great asset to the language. I like using it in cases of both single and multiple dispatch instead of functools.singledispatch for the former and if-chains for the latter. Why? Visual locality of reference when reading the code: the match statement gives me an excellent overview of how different inputs affect the execution of the function. Additionally, I can finesse behavior of different inputs and their interactions more succinctly than with either of the aforementioned techniques. By the way, in case you did not know, I am a heretic.

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.

Inheritance Diagram

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.

You can support my
programming and writing
on Patreon or Ko-fi
Retirement is tough.
Even small
contributions help.

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.

The Dead Spider Waltz

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.