import functools
import math
from typing import Any, Generic, Literal, Protocol, TypeVar
from matplotlib.animation import FuncAnimation
from matplotlib.axes import Axes
from matplotlib.lines import Line2D
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.projections.polar import PolarAxes
from numpy.typing import NDArray
= NDArray[np.float64]
FloatArray = TypeVar("FloatArraySequenceHeight", bound=int)
FloatArraySequenceHeight = TypeVar("FloatArraySequenceWidth", bound=int)
FloatArraySequenceWidth = np.ndarray[
FloatArraySequence tuple[FloatArraySequenceHeight, FloatArraySequenceWidth], np.dtype[np.float64]
]= Literal["series", "sequence"]
Kind
class SeriesGenerator(Protocol):
def __call__(self, theta: FloatArray, index: int) -> FloatArray: ...
class SeriesPlotter(Generic[FloatArraySequenceHeight, FloatArraySequenceWidth]):
"""
:ivar width: Precision of sequence (number of terms). The second parameter of the generic should match this.
:ivar height: Number of points in the input space. The first parameter of the generic should match this.
"""
int
height: int
width:
func: SeriesGenerator
theta: FloatArray
sequence: FloatArraySequence[FloatArraySequenceHeight, FloatArraySequenceWidth]
series: FloatArraySequence[FloatArraySequenceHeight, FloatArraySequenceWidth]
def __init__(
self,
func: SeriesGenerator,*,
height: FloatArraySequenceHeight,
width: FloatArraySequenceWidth,| None = None,
theta: FloatArray
):
self.func = func
self.height = height
self.width = width
self.theta = theta or np.linspace(0, 2 * math.pi, self.height)
self.sequence = self.create_series_sequence()
self.series = self.create_series()
def create_series_sequence(
self,
-> FloatArraySequence[FloatArraySequenceHeight, FloatArraySequenceWidth]:
) """
Given a series whose members are the sequence `a_n`, return
`{a_k: where k is positive and less than 'series_length'}`
`create_series` can be used to sum these elements together.
"""
return np.array([self.func(self.theta, index) for index in range(self.width)])
def create_series(
self,
-> FloatArraySequence[FloatArraySequenceHeight, FloatArraySequenceWidth]:
) return np.array([sum(self.sequence[: k + 1]) for k in range(0, self.width)])
def create_subplots(
self,
int,
rows: int,
cols: *,
= "series",
kind: Kind dict[str, Any] = {"projection": "polar"},
subplot_kw:
):"""Put series or sequences into (polar) subplots."""
= self.sequence if kind == "sequence" else self.series
coords = plt.subplots(rows, cols, subplot_kw=subplot_kw)
fig, axs = (
positions // cols, k % cols) for k in range(rows * cols) if k < self.width
(k, k
)
ax: PolarAxesfor k, row, column in positions:
= axs[row][column]
ax self.theta, coords[k], linewidth=0.8)
ax.plot(
return fig
def frame_animation_rect(
self, axes: Axes, line: Line2D, frame: int, *, coords
-> tuple:
) """Animated a single frame."""
= coords[frame]
frame_f
line.set_ydata(frame_f)f"n = {frame + 1}")
line.set_label(=[line])
axes.legend(handles
return (line,) # if callback is None else callback(line)
def create_animation_rect(self, kind: Kind = "series"):
"""
Create an animation of the series or sequence in increasing precision
in rectilinear coordinates.
"""
= self.series if kind == "series" else self.sequence
coords = list(range(self.width))
frames
axs: Axes= plt.subplots(1, 1)
fig, axs "Sawtooth Wave Foureir Series")
axs.set_title(
= axs.plot(self.theta, self.series[0])
(line,) "n = 1")
line.set_label(=[line])
axs.legend(handles
= functools.partial(self.frame_animation_rect, axs, line, coords=coords)
fn return FuncAnimation(fig, fn, frames=frames, interval=333)
def create_animation(self):
"""Plot series or sequence members in polar coordinates."""
...
About NumPY Type Hints
numpy, python, fourier series, polar coordinates, quarto, neoim, multen, pandas, data science
I wrote this up while learning how to use molten-nvim. I kept on writing and eventually found more that I wanted to write about type hints in numpy
because good type hints make writing code in python
a pleasure and not a chore.
If you are curious about using using neovim
for data-science and python
notebooks, checkout the neovim
gallery and repository here on the site:
About Type Hints for Numpy
Arrays
What I have ascertained so far is:
- PEP 646 proposed
TypeVarTuple
and was accepted inpython3.11
. This makes it possible to parametrize a type using an arbitrary number of parameters. - PEP 646 is supported by
mypy
. - PR #17719 on numpy adds shape parametrization to
np.ndarray
. - Type hints for
NDArray
are still not complete or practical, see this comment. - Type hints for
NDArray
are not going be checked bypyright
, which is the type checker I like to run in myquarto
notebooks.
With that out of the way, I would like to say that the type hints in the following code are mostly for my own experimentation and are possibly incorrect. The objective is to have SeriesPlotter
have correct type hints for the attributes sequence
and series
, which should include the size of the numpy
arrays.
To do this, SeriesPlotter
is a generic of two parameters - the first specifies the number of points in the input space and the second specifies the number of elements in the series.
An Example Using The Type Hints
The objective of the code in Figure 1 is to plot some graphs of series using normal and polar coordinates (via matplotlib
) with type hints.
numpy
array in terms of height and width. This requires that every instance of SeriesPlotter
specifies the height (number of points in the domain) and width (number of sequence members computed) to specify the shape of the numpy
array stored in the series
and sequence
attributes. For example, the code code in Figure 2 will generate a \(1000 X 8\) array - the first \(8\) sequence members will be computed on a \(1000\) point domain.
Maple Leaf Like Cardioid
class LeafyPlotter(SeriesPlotter[FloatArraySequenceHeight, FloatArraySequenceWidth]):
@staticmethod
def fn(theta: FloatArray, index: int) -> FloatArray:
return np.cos(theta * (3**index)) / (index + 1)
def frame_animation_rect(
self, axes: Axes, line: Line2D, frame: int, *, coords
-> tuple:
) = max(
bound abs(min(coords[frame])),
abs(max(coords[frame])),
)-bound, bound)
axes.set_ylim(
return super().frame_animation_rect(axes, line, frame, coords=coords)
def __init__(
self,
*,
height: FloatArraySequenceHeight,
width: FloatArraySequenceWidth,| None = None
theta: FloatArray
):super().__init__(self.fn, height=height, width=width, theta=theta)
= LeafyPlotter[Literal[10000], Literal[8]](height=10000, width=8)
leafy_plotter = leafy_plotter.create_subplots(2, 2, kind="series")
leafy_figure "./leafy.svg", format="svg")
leafy_figure.savefig(
plt.close()
= leafy_plotter.create_subplots(2, 2, kind="sequence")
leafy_figure_sequence "./leafy-sequence.svg")
leafy_figure_sequence.savefig(
plt.close()
= leafy_plotter.create_animation_rect()
leafy_animation "leafy-rectilinear.gif", writer="pillow")
leafy_animation.save(
plt.close()
= leafy_plotter.create_animation_rect(kind="sequence")
leafy_animation "leafy-rectilinear-sequence.gif", writer="pillow")
leafy_animation.save( plt.close()


The Sawtooth Cardioid
Next I wanted to plot a sawtooth wave using a Fourier series,
class SawtoothPlotter(SeriesPlotter[FloatArraySequenceHeight, FloatArraySequenceWidth]):
@staticmethod
def fn(theta: FloatArray, index: int) -> FloatArray:
"""Computes the term `a_{index}` of the Fourier series for the sawtooth wave."""
if index < 0:
raise ValueError
+= 1
index = (1 if index % 2 else -1) / index
constant *= 2 / np.pi
constant
return constant * np.sin(theta * index)
def frame_animation_rect(
self, axes: Axes, line: Line2D, frame: int, *, coords
-> tuple:
) -1.2, 1.2)
axes.set_ylim(return super().frame_animation_rect(axes, line, frame, coords=coords)
def __init__(
self,
*,
height: FloatArraySequenceHeight,
width: FloatArraySequenceWidth,| None = None
theta: FloatArray
):super().__init__(self.fn, height=height, width=width, theta=theta)
= SawtoothPlotter[Literal[1440], Literal[32]](height=1440, width=32)
sawtooth_plotter = sawtooth_plotter.create_animation_rect("series")
sawtooth_animation "sawtooth.gif")
sawtooth_animation.save( plt.close()
MovieWriter ffmpeg unavailable; using Pillow instead.

Molten in Action
