Structural changes to prysm in v0.20

This is the final post in a series which discusses the design of prysm. You may find the first here.

This reflective period on the design of prysm has been spurned by its use for multi-plane diffraction, analyzing and designing a wavefront sensing system embedded within a coronagraph. I have already said much about the design over that time. This post will summarize the changes, and provide examples of their implementation in terms of API.

Remove x and y data

Most or all prysm classes contain three arrays in principle: the data (.data) as well as the spatial grid (.x and .y). x and y are almost never needed, but the sample spacing is. x and y will be removed as data and replaced with computed properties. More accurately, x and y will be drawn from GridCache on demand. This will reduce computation time of constructors and reduce memory usage. The change looks like: def __init__(self, ... x=None, y=None) => def __init__(self, ... dx=1).

More aggressive caching will be done, for example the angular spectrum function will access the grid cache instead of computing arrays inline.

Removing work from __init__ funcs, emphasis on class method factories

Much of the churn of prysm and latent complexity stems from writing constructor functions which suit its original use case - evaluate some polynomials over a particular grid, filling the array. Some botches were grafted on later to allow any data to be placed in instead, but this created a “use X or use Y, but not both” problem in the constructors. Carrying the simplciity (only know the Pupil() syntax, for example) has actually made things more complicated. v0.20 will make a hard break: turn all constructors into “dummy constructors” which simply assign fields of the class doing no work. Factory functions will be created to suit the old use case. This changes:

class Pupil(OpticalPhase):
    """Pupil of an optical system."""
    def __init__(self, samples=128, dia=1, labels=None, xy_unit=None, z_unit=None, wavelength=None,
                 phase_mask='circle', transmission='circle', x=None, y=None, phase=None):

# to...
class Pupil(OpticalPhase):
    """Pupil of an optical system."""
    def __init__(self, phase, amplitude, wavelength, xy_unit=None, z_unit=None, labels=None):

    def to_be_named(cls, samples, dia, wavelength, xy_unit, z_unit, phase_mask, transmission):
        # do work
        return cls(phase, amplitude, wavelength, xy_unit, z_unit, labels)

On pupil particularly, you likely will never use the to_be_named function, but the various Zernike classes will receive similar changes.

Less object oriented

Prysm’s object hierarchy is too deep; there are too many user types, which reduces the approachability of the library if its “magic” does not click for you. Duplicate or overlapped types will be removed. For example, all of the Zernike classes can be consolidated into one with a simple “name” or “type” field. Similarly, I struggle to see the need for the OpticalPhase class. The PSF class will likely also be removed, and its methods added to Wavefront with an unexposed flag for isIntensity to prevent quantities like encircled energy from being calculated on complex fields.

FFT alignment everywhere

Today, large tracts of prysm work on an “inter-sample centered” grid. That is, an even sized array contains no zero sample. This is the source of some minor accurate loss, ~1/N^2 in intensity, due to differing assumptions made by different parts of the code. Unifying everything under an FFT convention will recover accuracy lost by off-by-half sample algorithmic errors.

Easy bypass of library’s grids

Today, it is difficult to bypass the grids prysm “likes” or “naturally” uses while benefiting from the caches. For example, evaluate Zernike polynomials centered at an arbitrary point in an array, with a user-provided normalization radius. New functions or methods will be written to accomodate this use case.

“X” aligned Zernikes

One of the less well understood facets of prysm and most frequent bug report emails is that prysm Zernikes are rotated ‘wrong.’ Prysm’s Zernike polynomials are defined with “Y” as the zero azimuth, instead of “X”. This matches conventions of optical design software, and some metrology software. More recently, metrology software has tended to “X” as the zero axis, adn it seems most prysm users are not optical designers. This change is severely breaking, perhaps more so than FFT alignment, since it invalidates naive comparison between 0.19 and 0.20 at more than a “1/2n error” level.

[Re]move plotting methods

At the moment, types expose .slices().plot() and .plot2d(). A messy labels system was injected into the type system to support this. This will be refactored out into the existing plotting module. The syntax will look something like plot2d(your_pupil, labels=DEFAULT_PUPIL_LABELS). The labels logic itself will be reduced to format strings, as well.


v0.20 will take a long time to write as a one-man band. It is likely that it will not be released until around this time in 2021. A beta release will likely be made, and feedback solicited on the design before comitting. Version 1.0 would follow in 2022.

Brandon Dube
Brandon Dube
Optical Engineer