Batch Generation

Tools for designing WFE budgets and generating large PSF datasets (e.g. for ML training).


build_wfe_budget

from psfcraft.utils import build_wfe_budget

Converts a radial WFE budget into a flat list used by generate_coefficients.

Each entry budget[i] is repeated i+1 times, reflecting the increasing number of Zernike modes per radial order:

Radial order # modes Typical budget (nm)
0 (piston) 1 0
1 (tip/tilt) 2 100
2 (defocus, astig) 3 50
3 (coma, trefoil) 4 36
4 5 18
from psfcraft.utils import build_wfe_budget

budget = build_wfe_budget([0, 100, 50, 36, 18, 9, 5])
print(budget)
# [0, 100, 100, 50, 50, 50, 36, 36, 36, 36, ...]

build_wfe_budget

build_wfe_budget(radial_budget: list = [0, 100, 50, 36, 18, 9, 5]) -> list

Build a flat WFE budget list from a per-radial-order specification.

Each entry radial_budget[i] is repeated i + 1 times to account for the increasing number of Zernike modes per radial order (order 0 has 1 mode, order 1 has 2 modes, etc.).

Parameters:
  • radial_budget (list[int], default: [0, 100, 50, 36, 18, 9, 5] ) –

    WFE amplitude in nm RMS for each radial order, starting from order 0 (piston). The default [0, 100, 50, 36, 18, 9, 5] covers orders 0–6.

Returns:
  • list

    list[int]: Flat budget list whose total length equals the number of Zernike modes covered by the specification.

Example

build_wfe_budget([0, 100, 50]) [0, 100, 100, 50, 50, 50]

Source code in psfcraft/utils.py
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
def build_wfe_budget(radial_budget: list = [0, 100, 50, 36, 18, 9, 5]) -> list:
    """Build a flat WFE budget list from a per-radial-order specification.

    Each entry ``radial_budget[i]`` is repeated ``i + 1`` times to account for
    the increasing number of Zernike modes per radial order (order 0 has 1
    mode, order 1 has 2 modes, etc.).

    Args:
        radial_budget (list[int]): WFE amplitude in nm RMS for each radial
            order, starting from order 0 (piston).  The default
            ``[0, 100, 50, 36, 18, 9, 5]`` covers orders 0–6.

    Returns:
        list[int]: Flat budget list whose total length equals the number of
            Zernike modes covered by the specification.

    Example:
        >>> build_wfe_budget([0, 100, 50])
        [0, 100, 100, 50, 50, 50]
    """
    WFE_BUDGET = []
    for i, budget in enumerate(radial_budget):
        WFE_BUDGET.extend([budget] * (i + 1))
    return WFE_BUDGET

generate_coefficients

from psfcraft.utils import generate_coefficients

Draws a random Zernike coefficient vector respecting a given WFE budget.

from psfcraft.utils import build_wfe_budget, generate_coefficients
import numpy as np

budget = build_wfe_budget([0, 100, 50, 36, 18, 9, 5])
rng = np.random.default_rng(42)

# Generate one random coefficient set
coefs = generate_coefficients(budget, rng=rng)
print(coefs)  # array in metres RMS, length = len(budget)

generate_coefficients

generate_coefficients(wfe_budget, sec_factor: float = 1.0) -> np.ndarray

Draw a random realisation of Zernike coefficients from a uniform distribution.

Each coefficient is drawn independently from a uniform distribution over [-budget_i * sec_factor, +budget_i * sec_factor] (converted from nm to metres internally).

Parameters:
  • wfe_budget (list[float]) –

    WFE amplitude in nm RMS for each Zernike coefficient. Typically the output of :func:build_wfe_budget.

  • sec_factor (float, default: 1.0 ) –

    Global scaling factor applied to every budget entry before sampling. Useful to reduce the amplitude of secondary-mirror-sensitive modes. Defaults to 1.0.

Returns:
  • ndarray

    numpy.ndarray: Array of random Zernike coefficients in metres, with the same length as wfe_budget.

Example

import numpy as np np.random.seed(0) budget = build_wfe_budget([0, 50, 30]) # 6 coefficients coefs = generate_coefficients(budget) coefs.shape (6,)

Source code in psfcraft/utils.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
def generate_coefficients(wfe_budget, sec_factor: float = 1.0) -> np.ndarray:
    """Draw a random realisation of Zernike coefficients from a uniform distribution.

    Each coefficient is drawn independently from a uniform distribution
    over ``[-budget_i * sec_factor, +budget_i * sec_factor]``
    (converted from nm to metres internally).

    Args:
        wfe_budget (list[float]): WFE amplitude in nm RMS for each Zernike
            coefficient.  Typically the output of :func:`build_wfe_budget`.
        sec_factor (float): Global scaling factor applied to every budget
            entry before sampling.  Useful to reduce the amplitude of
            secondary-mirror-sensitive modes.  Defaults to ``1.0``.

    Returns:
        numpy.ndarray: Array of random Zernike coefficients in **metres**,
            with the same length as ``wfe_budget``.

    Example:
        >>> import numpy as np
        >>> np.random.seed(0)
        >>> budget = build_wfe_budget([0, 50, 30])   # 6 coefficients
        >>> coefs = generate_coefficients(budget)
        >>> coefs.shape
        (6,)
    """
    return np.random.uniform(
        low=-1e-9 * np.array(wfe_budget) * sec_factor,
        high=1e-9 * np.array(wfe_budget) * sec_factor
    )

PSF_Generator

High-level batch generation engine. Handles parallelism, FITS output, and HDF5 dataset writing.

from psfcraft.utils import PSF_Generator

gen = PSF_Generator(
    optics_name="NewtonianTelescope",
    optics_version="1_3",
    size_psf=32,
)
gen.run(n_psfs=1000, output_path="dataset.h5")

PSF_Generator

PSF_Generator(optics_name=OPTICS_NAME_DEFAULT, optics_version=OPTICS_VERSION_DEFAULT, optics_primary_radius=OPTICS_PRIMARY_RADIUS, optics_secondary_radius=OPTICS_SECONDARY_RADIUS, size_psf=SIZE_PSF, N_psfs=N_PSFS, wfe_budget=WFE_BUDGET, source=SOURCE_DEFAULT, detector_oversampling=DETECTOR_OVERSAMPLING, fov_arcsec=FOV_ARCSEC)

This class generates a database of Point Spread Functions (PSFs) for optical systems.

Parameters

optics_name : str, optional Name of the optical system. Defaults to 'NewtonianTelescope'. Options: 'Euclid'. optics_version : str, optional Version of the optical system. Defaults to '0', indicating no struts. size_psf : int, optional Size of the PSFs in pixels. Defaults to 32. N_psfs : int, optional Total number of PSFs to generate for the dataset. Defaults to 1000. wfe_budget : list, optional List of the Wavefront Error (WFE) budget for each Zernike coefficient. source : float or list, optional Wavelength of the monochromatic source in nanometers or a list specifying the type of random spectral source.

Attributes

OPTICS_NAME_DEFAULT : str Default name of the optical system. OPTICS_VERSION_DEFAULT : str Default version of the optical system. ...

Methods

init(optics_name, optics_version, size_psf, N_psfs, wfe_budget, source, ) Initializes the PSF_Generator with specified parameters. optical_system_initiator() Initiates the optical system using psfcraft_core. simulate_single_psf(wfe_budget, source) Simulates a single PSF. simulate_worst_psf() Returns the PSF with maximum coefficients. write_database_hdf5() Writes the generated PSFs to an HDF5 file.

Source code in psfcraft/utils.py
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
def __init__(self, 
            optics_name = OPTICS_NAME_DEFAULT,
            optics_version = OPTICS_VERSION_DEFAULT,
            optics_primary_radius = OPTICS_PRIMARY_RADIUS,
            optics_secondary_radius = OPTICS_SECONDARY_RADIUS,

            size_psf = SIZE_PSF,
            N_psfs = N_PSFS,
            wfe_budget = WFE_BUDGET,

            source = SOURCE_DEFAULT, 

            detector_oversampling = DETECTOR_OVERSAMPLING, # Oversampling factor for the PSFs, default is 6
            fov_arcsec = FOV_ARCSEC,
            ):


    self.optics_name = optics_name
    self.optics_version = optics_version
    self.optics_primary_radius = float(optics_primary_radius)
    self.optics_secondary_radius = float(optics_secondary_radius)

    self.size_psf = int(size_psf)
    self.N_psfs = int(N_psfs)
    self.wfe_budget = wfe_budget # Should be given in nanometers
    self.N_orders = len(wfe_budget) # Number of Zernike coefficients but also the maximum order of Zernike polynomials in Noll's indexation.

    self.source = source

    self.detector_oversampling = detector_oversampling
    self.fov_arcsec = fov_arcsec 

    self.chromatism = 'mono' if type(self.source) == float else 'poly' # If the source is a float, then it is monochromatic, otherwise it is polychromatic.
    if self.chromatism == 'poly':
        try : # Only works with a dictionary source that contains 'temperature', 'filter' and 'sampling'
            self.temperature_star = self.source['temperature']
            self.filter_name = self.source['filter']
            self.sampling_wl = self.source['sampling']
        except KeyError as e:
            raise ValueError(f"Missing key in source dictionary: {e}")
    else : 
        self.source = float(self.source)

    self.path = get_path_data(self.optics_name, self.optics_version)

    self.obj = None
    self.optical_system_initiator()

filename_builder

filename_builder(extension)

Builds the filename of the PSFs, containing all the parameters AND the extension. Should be called inside each method that writes a file.

Source code in psfcraft/utils.py
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
def filename_builder(self, extension):
    """
    Builds the filename of the PSFs, containing all the parameters AND the extension.
    Should be called inside each method that writes a file.
    """
    # Name of the file containing the PSFs
    filename_psfs = self.optics_name + "_v" + self.optics_version # Name of the optical version and the optical system
    filename_psfs += "_N" + str(self.size_psf) # Size of the PSFs, by default 32x32 so N32
    filename_psfs += "_P"+format(self.N_psfs, '.0e').replace('+0','').replace('+','') # The number of PSFs is in a format "1e7" or "1e23" for example.
    filename_psfs += "_J" + str(self.N_orders) # Number of Zernike coefficients
    filename_psfs += "_ChP" if self.chromatism == 'poly' else '_ChM' # Chromatism of the source, either polychromatic or monochromatic

    if self.chromatism == 'poly':
        # Add photometric band information
        filename_psfs += "_Phot" + self.filter_name  # e.g., _PhotY, _PhotJ, _PhotH
        # Add wavelength sampling information
        filename_psfs += "_Dw" + str(int(self.sampling_wl))  # e.g., _Dw10
        # Add blackbody temperature information
        filename_psfs += "_BB" + str(int(self.temperature_star)) + "K"  # e.g., _BB6600K

    else : 
        wavelength_format = str(int(round(self.source * 1e9)))
        filename_psfs += "_W" + wavelength_format + "nm" # Wavelength of the monochromatic source (in nm)

    # Now we add the extension
    filename_psfs += extension
    self.filename_psfs = filename_psfs

get_custom_fov

get_custom_fov()

Pickle-safe method to return custom FOV

Source code in psfcraft/utils.py
334
335
336
def get_custom_fov(self):
    """Pickle-safe method to return custom FOV"""
    return self.fov_arcsec if self.fov_arcsec is not None else self.obj._get_default_fov()

simulate_worst_psf

simulate_worst_psf()

Returns the PSF with all maximum coefficients.

Source code in psfcraft/utils.py
450
451
452
453
454
455
def simulate_worst_psf(self):
    """
    Returns the PSF with all maximum coefficients.
    """
    return self.simulate_single_psf(wfe_coefs=np.array(self.wfe_budget)*1e-9,
                             )[1:]