PSF and image simulation (wcc_etc.psfsim)

class wcc_etc.psfsim.DetectorPSFContext(npix, pixel_size_um, plate_scale_mas, wavelength_m, diameter_m, fnum, jitter_sigma_mas=0.0, center=None, oversample=11)[source]

Bases: object

Detector + optics parameters a PSF source needs to render onto the grid.

Parameters:
npix: int
pixel_size_um: float
plate_scale_mas: float
wavelength_m: float
diameter_m: float
fnum: float
jitter_sigma_mas: float = 0.0
center: tuple | None = None
oversample: int = 11
wcc_etc.psfsim.normalize_psf(psf)[source]

Clip negatives and normalize a 2D PSF so it sums to 1.

wcc_etc.psfsim.center_crop_or_pad(img, npix, fill=0.0)[source]

Center-crop or zero-pad a 2D array to (npix, npix), preserving the center.

wcc_etc.psfsim.recenter(psf, center)[source]

Sub-pixel shift a grid-centered PSF so its center lands at (cx, cy).

class wcc_etc.psfsim.PSFSource[source]

Bases: object

Base class: render a normalized (sum=1) PSF onto a DetectorPSFContext.

render(ctx)[source]
cache_key()[source]

Hashable key identifying this PSF source’s parameters (not the rendered output). The render also depends on the DetectorPSFContext (wavelength, optics, jitter, npix, oversample), so a render cache must combine this key with the context and/or be invalidated when the simulation state changes — see Simulation._image_render_bundle.

class wcc_etc.psfsim.AiryPSF[source]

Bases: PSFSource

Diffraction-limited Airy PSF rendered on the detector grid (default).

render(ctx)[source]
wcc_etc.psfsim.load_huygens_psf(path, encoding='utf-16')[source]

Load a Zemax Huygens PSF text file into a 2D float array of intensities.

class wcc_etc.psfsim.DefocusPSF(path, src_um_per_pix=4.0, encoding='utf-16')[source]

Bases: _ResampledPSF

A defocused PSF loaded from a Zemax Huygens text file.

cache_key()[source]

Hashable key identifying this PSF source’s parameters (not the rendered output). The render also depends on the DetectorPSFContext (wavelength, optics, jitter, npix, oversample), so a render cache must combine this key with the context and/or be invalidated when the simulation state changes — see Simulation._image_render_bundle.

class wcc_etc.psfsim.CustomPSF(source, src_um_per_pix, encoding='utf-16')[source]

Bases: _ResampledPSF

A custom PSF from an ndarray or a Huygens-format text file (future hook).

wcc_etc.psfsim.saturation_mask_from_image_e(sensor, image_e)[source]

Boolean mask of pixels at/over the ADC full scale or the full well.

Parameters:
  • sensor (Sensor) – Provides gain, adc_max, and (optionally) meta[‘well_depth’].

  • image_e (ndarray) – Per-pixel charge in electrons (per frame for saturation tests).

Returns:

True where (image_e / gain) >= adc_max, OR image_e >= well_depth when a well_depth is configured.

Return type:

ndarray of bool

class wcc_etc.psfsim.SimulatedImage(image_e, image_clean, saturation_mask, gain, bias_level, npix, pixel_scale_mas, psf)[source]

Bases: object

Result of an ImageSimulator.simulate() call.

Parameters:
image_e: ndarray
image_clean: ndarray
saturation_mask: ndarray
gain: float
bias_level: float
npix: int
pixel_scale_mas: float
psf: PSFSource
to_adu()[source]

Electrons -> ADU via gain, plus the bias level.

to_fitsimg()[source]

Wrap the electron image in a FitsImg for photometry/plotting.

plot_image(backend='mpl', **kwargs)[source]

Plot this image (single panel). backend=’mpl’ or ‘bokeh’.

plot_image_row(backend='mpl', **kwargs)[source]

Plot the 3-panel row (PSF+noise, PSF, saturation mask).

plot_radial(backend='mpl', **kwargs)[source]

Plot the azimuthally-averaged radial profile.

plot_encircled_energy(backend='mpl', **kwargs)[source]

Plot the encircled-energy curve (marks the 90% EE radius by default; pass ee_target=None to disable or another fraction to override).

class wcc_etc.psfsim.ImageSimulator(simulation, npix=300, oversample=11)[source]

Bases: object

Render a point source onto a detector grid with noise, driven by an ETC Simulation.

classmethod from_sensor_and_scene(sensor, scene, npix=300, oversample=11)[source]

Build an ImageSimulator from a sensor name (e.g. ‘sony:r’) and a Scene.

classmethod from_sensorfilter(sensorfilter, scene, npix=300, oversample=11)[source]

Build an ImageSimulator from a sensorfilter label (e.g. ‘zwo:r+1’).

Sets _default_psf on the underlying Simulation (accessible via self.sim._default_psf) based on the sensor’s focus_level in sensor_info. Note: simulate() still requires an explicit psf= argument; _default_psf is used by Simulation.get_image_snr and get_image_exptime_for_snr.

simulate(time=None, psf=None, jitter_sigma_mas=None, center=None, add_noise=True, seed=None)[source]

Simulate a detector image for the given exposure time and PSF.

Parameters:
  • time (float or Quantity, optional) – Exposure time (seconds if a bare float). Defaults to the Simulation’s meta[‘time’].

  • psf (PSFSource, optional) – PSF model to render. Defaults to AiryPSF() (diffraction limited).

  • jitter_sigma_mas (float, optional) – Override the telescope jitter (mas). Defaults to the telescope value.

  • center (tuple, optional) – Sub-pixel (cx, cy) center for the PSF. Defaults to the grid center.

  • add_noise (bool, optional) – If True, apply Poisson shot + dark noise and Gaussian read noise. If False, return the noiseless electron image. Default True.

  • seed (int, optional) – Seed for the random generator, for reproducible noise.

Returns:

Holds the electron image, the noiseless image, and a saturation mask (computed from the returned image_e against adc_max / well).

Return type:

SimulatedImage

wcc_etc.psfsim.howell_center(postage_stamp)[source]

Howell centroiding, from Howell’s Handbook of CCD astronomy

INPUT:

postage_stamp - A 2d numpy array to do the centroiding

OUTPUT:

x and y center of the numpy array

NOTES: Many thanks to Thomas Beatty and the MINERVAphot.py pipeline for this method see here: https://github.com/TGBeatty/MINERVAphot/blob/master/MINERVAphot.py

wcc_etc.psfsim.psf_center(image)[source]

Measured center (xc, yc) of a PSF image for radial reducers.

Uses Howell centroiding so radii are measured from the actual light distribution rather than a fixed grid index. This matters because render_detector_psf forces an odd grid (peak on the exact center pixel) and center_crop_or_pad then brings an even target grid back down, landing the centroid on an integer pixel — a full pixel away from n//2 and half a pixel from the geometric center (n-1)/2. It also tracks the centroid of asymmetric defocused (DefocusPSF) bands, which are not guaranteed symmetric about the grid center.

Falls back to the geometric center ((nx-1)/2, (ny-1)/2) for an empty or degenerate image where the centroid is undefined (zero/negative total flux or a non-finite centroid, e.g. a flat field).

wcc_etc.psfsim.apply_jitter(data, jitter_mas, pixel_scale)[source]

Apply jitter to the input data.

INPUT:

data: 2d data jitter_mas: jitter in mas pixel_scale: mas/pixel

wcc_etc.psfsim.tiff_to_fits(tiff_file, output_dir)[source]

Convert a multi-frame TIFF file to a series of FITS files.

wcc_etc.psfsim.create_master_flat_from_fits(directory, output_filename)[source]
wcc_etc.psfsim.apply_nl_scaling(df_nl, data, how='makenonlinear', scale=1)[source]

Apply non-linear scaling to the input data using the provided scaling factors.

Parameters:

df_nl (pd.DataFrame): DataFrame containing ‘mean_value’ and ‘scaling_factor’ columns. data (np.ndarray): Input data array to be scaled. how (str): ‘makenonlinear’ to apply non-linearity, ‘correctnonlinear’ to reverse it.

RETURNS:

data_scaled (np.ndarray): Scaled data array.

EXAMPLE:

NOTES:
  • only applies scaling to positive values; zero or negative values are unchanged.

  • Should only be applied after bias subtraction.

class wcc_etc.psfsim.FitsImgList(data_list, center_list=None, **kwargs)[source]

Bases: object

aperture_photometry(**kwargs)[source]

Perform aperture photometry on all images in the list.

Parameters:

**kwargs (dict) – Keyword arguments to pass to the aperture_photometry method of FitsImg.

Returns:

results_list – List of dictionaries containing photometry results for each image.

Return type:

list of dict

plot_photometry(axes=None)[source]

Plot the photometry results stored in self.df_phot.

class wcc_etc.psfsim.FitsImg(filename=None, data=None, header=None, center=None, dark_current_rate=0.0, exp_time=1.0, read_noise_rms=0.0, imgnumber=0)[source]

Bases: object

crop(x, y, w, h)[source]

Crop to a box centered at (x,y), of size w x h

cropcenter(w, h, points=False)[source]

Returns an image array around the center of an image array.

cropcentroid(w, h)[source]

Crop to a box centered at the centroid, of size w x h

get_centroid(plot_cross=False, ax=None, plot_lines=False)[source]

Find centroid using Howell centroiding.

See phothelp for the method

get_centroid_line_cut(line='X', plot=False, ax=None)[source]

Horizontal (line='X') or vertical (line='Y') cut through the centroid.

INPUT:

line - ‘X’ for the row through the centroid, ‘Y’ for the column plot - draw the cut on ax (or a new axis)

OUTPUT:

cut - 1D array along the requested direction

plot(stretch='hist', cmap='gray', origin='lower', ax=None, colorbar=False, title='', vmin=None, vmax=None, dpi=200)[source]
get_radial_profile(rmax=None, plot=False, z=2.0, return_hwzm=False, ax=None, xcen=None, ycen=None, annulus_width=1, subtract_min=False)[source]

Plot radial profile

aperture_photometry(r_ap=3.0, r_in=6.0, r_out=8.0, gain=1.0, center=None, bkg_sigma_clip=3.0, bkg_maxiters=5, plot=True, ax=None, verbose=True, vmin=None, vmax=None, cmap='viridis', origin='lower', stretch='hist', colorbar=True)[source]

Perform circular aperture photometry on the current self.data image using photutils.

Parameters:
  • r_ap (float) – Aperture radius in pixels.

  • r_in (float) – Inner and outer radii for background annulus in pixels. If None or invalid, the image median is used as a background estimate.

  • r_out (float) – Inner and outer radii for background annulus in pixels. If None or invalid, the image median is used as a background estimate.

  • gain (float) – e/ADU (set to 1.0 if self.data is already in electrons).

  • center (None or (y,x)) – If provided, use this center; otherwise the method will try self.center, then the peak pixel, then image center.

  • bkg_sigma_clip (float) – Sigma for sigma-clipped background estimation in the annulus.

  • bkg_maxiters (int) – Max iterations for sigma clipping.

Returns:

Dictionary containing ap_sum, bkg_mean_per_pix, bkg_std_per_pix, bkg_sum, net_flux, flux_err, snr, ap_area, ann_area, ann_pixels_used, center

Return type:

dict

wcc_etc.psfsim.solve_time_for_snr(snr, A, B, C)[source]

Solve SNR = A*t / sqrt(B*t + C) for the positive root t.

A, B, C may be scalars or broadcastable arrays (A = signal rate, B = variance rate, C = constant read-noise variance). Returns t in the same shape (a float if all inputs are scalar). Entries with A <= 0 return +inf.

wcc_etc.psfsim.aperture_snr_radial(psf_norm, plate_scale_mas, source_e_total, diffuse_per_pix, dark_per_pix, read_noise)[source]

SNR as a function of circular-aperture radius for a rendered PSF.

The PSF (sum=1) sets how much source light falls inside each radius; the per-pixel diffuse (sky+host), dark, and read-noise terms set the background noise that grows with the number of aperture pixels.

Parameters:
  • psf_norm (ndarray) – Normalized (sum=1) PSF on the detector grid.

  • plate_scale_mas (float) – Detector plate scale, mas/pixel (to report radii in mas).

  • source_e_total (float) – Total source electrons (all of the PSF, before aperture clipping).

  • diffuse_per_pix (float) – Per-pixel sky+host and dark-current electrons.

  • dark_per_pix (float) – Per-pixel sky+host and dark-current electrons.

  • read_noise (float) – Read noise (electrons rms per pixel).

Returns:

‘r_mas’, ‘enclosed_fraction’, ‘n_pix’, ‘signal_e’, ‘noise_e’, ‘snr’.

Return type:

dict of ndarrays, sorted by ascending radius

wcc_etc.psfsim.select_aperture(profile, r_aper_mas=None, ee_frac=None, optimize=False)[source]

Index into an aperture_snr_radial profile for the chosen aperture mode.

Precedence: optimize (max SNR) > explicit r_aper_mas > ee_frac. Raises ValueError if no mode is given.

wcc_etc.psfsim.aperture_time_for_snr(psf_norm, plate_scale_mas, source_rate_total, diffuse_rate_per_pix, dark_rate_per_pix, read_noise, n_reads=1, snr=None, r_aper_mas=None, ee_frac=None, optimize=False)[source]

Exposure time (s) to reach snr for a rendered PSF, per aperture mode.

Rates are per second (the time dependence is solved for analytically). The read-noise variance is incurred n_reads times. Aperture precedence matches select_aperture: optimize (fastest radius) > r_aper_mas > ee_frac.

Returns {‘time_s’, ‘snr’, ‘r_aper_mas’, ‘enclosed_fraction’, ‘n_pix’}.

wcc_etc.psfsim.calc_hwzm(x, y, z=20)[source]

Calculates the HWHM at the Z-th maximum for a given dataset, by finding the roots of splines.

INPUTS:

x - x input array y - y input array

OUTPUT:

HWZM The Half Width at Z-th Max of the data

EXAMPLE:

wcc_etc.psfsim.calc_hwhm(x, y)[source]

Calculates the HWHM for a given dataset, by finding the roots of splines.

INPUTS:

x - x input array y - y input array

OUTPUT:

HWZM The Half Width at Z-th Max of the data

EXAMPLE: