Source code for obstools.dfocus

# -*- coding: utf-8 -*-
#
#  This file is part of LDTObserverTools.
#
#   This Source Code Form is subject to the terms of the Mozilla Public
#   License, v. 2.0. If a copy of the MPL was not distributed with this
#   file, You can obtain one at http://mozilla.org/MPL/2.0/.
#
#  Created on 01-Feb-2021
#  Modified: 08-Jan-2025
#
#  @author: tbowers

"""DeVeny Collimator Focus Calculator Module

LDTObserverTools contains python ports of various LDT Observer Tools

Lowell Discovery Telescope (Lowell Observatory: Flagstaff, AZ)
https://lowell.edu

This file contains the dfocus routine for computing the required collimator
focus for the DeVeny Spectrograph based on a focus sequence completed by the
DeVeny LOUI.

.. include:: ../include/links.rst
"""

# Built-In Libraries
import argparse
import dataclasses
import os
import pathlib
import sys
import warnings

# 3rd-Party Libraries
import astropy.nddata
import ccdproc
from matplotlib.backends.backend_pdf import PdfPages
import matplotlib.pyplot as plt
import numpy as np
import scipy.signal
import tqdm

# Local Libraries
from obstools import deveny_grangle
from obstools import utils


# Dataclass definitions ======================================================#
[docs] @dataclasses.dataclass class FocusParams: """Focus Parameters DataClass Attributes ---------- mid_file : :obj:`~pathlib.Path` The filename of the middle file in the focus sequence nominal : :obj:`float` Nominal linewidth in pixels based on slit width and grating demagnification start : :obj:`float` Starting focus value in the focus sweep end : :obj:`float` Ending focus value in the focus sweep delta : :obj:`float` Step size between focus values in the sweep mnttemp : :obj:`float` Mount temperature from the middle image of the sweep binning : :obj:`str` Binning scheme of the CCD plot_title : :obj:`str` The plot title for ... opt_title : :obj:`str` The plot title for ... """ mid_file: pathlib.Path start: float end: float delta: float plot_title: str opt_title: str mnttemp: float nominal: float = 2.7 binning: str = "1x1"
[docs] @dataclasses.dataclass class LineInfo: """Extracted Line Information DataClass Attributes ---------- spec_1d : :obj:`~numpy.ndarray` Extracted 1D spectrum along the ``trace`` trace : :obj:`~numpy.ndarray` Pixel locations of the extracted ``spec_1d`` centers : :obj:`~numpy.ndarray` Centroid location (along the ``trace``) of each identified line fwhm : :obj:`~numpy.ndarray` FWHM (in pixles) of each identified line """ spec_1d: np.ndarray trace: np.ndarray centers: np.ndarray fwhm: np.ndarray
[docs] @dataclasses.dataclass class FocusCurves: """Focus Curves DataClass Attributes ---------- min_cf_idx_value : :obj:`~numpy.ndarray` Best fit focus values optimal_cf_idx_value : :obj:`~numpy.ndarray` Best fit linewidths min_linewidth : :obj:`~numpy.ndarray` Minimum linewidths foc_fits : :obj:`~numpy.ndarray` Fit parameters (for plotting) """ min_focus_values: np.ndarray optimal_focus_values: np.ndarray min_linewidths: np.ndarray fit_pars: np.ndarray
[docs] @dataclasses.dataclass class PlotParams: """Plotting Parameters DataClass Attributes ---------- pdf : :obj:`~matplotlib.backends.backend_pdf.PdfPages` The PDF object into which to place plots path : :obj:`~pathlib.Path` The path in which to find the ``deveny_focus.*`` files docfig : :obj:`bool` Make example figures for online documentation? """ pdf: PdfPages path: pathlib.Path docfig: bool
# User-facing Function =======================================================#
[docs] def dfocus( path: pathlib.Path, flog: str = "last", thresh: float = 100.0, launch_preview: bool = True, docfig: bool = False, ): """Find the optimal DeVeny collimator focus value This is the user-facing ``dfocus`` function that calls all of the various following subroutines. This function operates identically to the original IDL routine ``dfocus.pro``, but with the additional options of debugging and whether to launch Preview.app to show the PDF plots generated. Parameters ---------- path : :obj:`~pathlib.Path` The path in which to find the ``deveny_focus.*`` files. flog : :obj:`str`, optional Focus log to process. If unspecified, process the last sequence in the directory. (Default: 'last') flog must be of form: ``deveny_focus.YYYYMMDD.HHMMSS`` thresh : :obj:`float`, optional Line intensity threshold above background for detection (Default: 100.0) launch_preview : :obj:`bool`, optional Display the plots by launching Preview (Default: True) docfig : :obj:`bool`, optional Make example figures for online documentation? (Default: False) """ # Make a pretty title for the output of the routine n_cols = (os.get_terminal_size()).columns print("=" * n_cols) print(" DeVeny Collimator Focus Calculator") try: # Get the Image File Collection for this focus run and initialize a # dictionary to hold information from the headers focus_icl, focus_id = parse_focus_log(path, flog) focus_pars = parse_focus_headers(focus_icl) except utils.ObstoolsError as err: # If errors, print error message and exit print(f"\n** {err} **\n") sys.exit(1) # Process the middle image to get line centers, arrays, trace print(f"\n Processing center focus image {focus_pars.mid_file}...") mid_ccd = astropy.nddata.CCDData.read(focus_pars.mid_file) try: mid_lines = get_lines_from_ccd(mid_ccd, thresh) except utils.ObstoolsError as err: # If errors, print error message and exit print(f"\n** {err} **\n") sys.exit(1) # Run through files, showing a progress bar print("\n Processing arc images...") prog_bar = tqdm.tqdm( total=len(focus_icl.files), unit="frame", unit_scale=False, colour="yellow" ) line_width_array = [] # Loop over the CCDs for ccd in focus_icl.ccds(): this_lines = get_lines_from_ccd( ccd, thresh, trace=mid_lines.trace, verbose=False ) # Empirical shifts in line location due to off-axis paraboloid # collimator mirror line_dx = -4.0 * (ccd.header["COLLFOC"] - mid_ccd.header["COLLFOC"]) # Keep only the lines from this image that match the reference image line_widths = [] for cen in mid_lines.centers: # Find lines within 3 pix of the (adjusted) reference line idx = np.abs((cen + line_dx) - this_lines.centers) < 3.0 # If there's something there wider than 2 pix, use it... else NaN width = this_lines.fwhm[idx][0] if np.sum(idx) else np.nan line_widths.append(width if width > 2.0 else np.nan) # Append these linewidths to the larger array for processing line_width_array.append(np.array(line_widths)) prog_bar.update(1) # Close the progress bar, end of loop prog_bar.close() line_width_array = np.array(line_width_array) print( f"\n Median value of all linewidths: {np.nanmedian(line_width_array):.2f} pix" ) # Fit the focus curve: focus_curves = fit_focus_curves(line_width_array, focus_pars) # Convert the returned indices into actual COLLFOC values, find medians med_opt_focus = np.real(np.nanmedian(focus_curves.optimal_focus_values)) print("=" * n_cols) if focus_pars.binning != "1x1": print(f"*** CCD is operating in binning {focus_pars.binning} (col x row)") print(f"*** Recommended (Median) Optimal Focus Position: {med_opt_focus:.2f} mm") print(f"*** Note: Current Mount Temperature is: {focus_pars.mnttemp:.1f}ÂșC") # =========================================================================# # Make the multipage PDF plot with PdfPages(pdf_fn := path / f"pyfocus.{focus_id}.pdf") as pdf: plot_pars = PlotParams(pdf=pdf, path=path, docfig=docfig) # The plot shown in the IDL0 window: Plot of the found lines # Find the line centers in the image plot_lines( mid_lines.spec_1d, find_lines(mid_lines.spec_1d, thresh, verbose=False)[0], focus_pars=focus_pars, plot_pars=plot_pars, ) # The plot shown in the IDL2 window: Plot of best-fit fwid vs centers plot_optimal_focus( focus_pars, mid_lines.centers, focus_curves.optimal_focus_values, med_opt_focus, plot_pars=plot_pars, ) # The plot shown in the IDL1 window: Focus curves for each identified line plot_focus_curves( mid_lines.centers, line_width_array, focus_curves, focus_pars=focus_pars, plot_pars=plot_pars, ) # Print the location of the plots print(f"\n Plots have been saved to: {pdf_fn.name}\n") # Try to open with Apple's Preview App... if can't, oh well. if launch_preview: try: os.system(f"/usr/bin/open -a Preview {pdf_fn}") except OSError as err: print(f"Cannot open Preview.app\n{err}")
# Helper Functions (Chronological) ===========================================#
[docs] def parse_focus_log( path: pathlib.Path, flog: str ) -> tuple[ccdproc.ImageFileCollection, str]: """Parse the focus log file produced by the DeVeny LOUI The DeVeny focus log file consists of filename, collimator focus, and other relevant information:: : Image File Name ColFoc Grating GrTilt SltWth Filter LampCal MntTmp 20230613.0026.fits 7.50 600/4900 27.04 1.20 Clear (C) Cd,Ar,Hg 9.10 20230613.0027.fits 8.00 600/4900 27.04 1.20 Clear (C) Cd,Ar,Hg 9.10 20230613.0028.fits 8.50 600/4900 27.04 1.20 Clear (C) Cd,Ar,Hg 9.10 20230613.0029.fits 9.00 600/4900 27.04 1.20 Clear (C) Cd,Ar,Hg 9.10 20230613.0030.fits 9.50 600/4900 27.04 1.20 Clear (C) Cd,Ar,Hg 9.10 20230613.0031.fits 10.00 600/4900 27.04 1.20 Clear (C) Cd,Ar,Hg 9.10 20230613.0032.fits 10.50 600/4900 27.04 1.20 Clear (C) Cd,Ar,Hg 9.10 This function parses out the filenames of the focus images for this run, largely discarding the remaining information in the focus log file. Parameters ---------- path : :obj:`~pathlib.Path` The path to the current working directory flog : :obj:`str` Identifier for the focus log to be processed Returns ------- :obj:`~ccdproc.ImageFileCollection` The Image File Collection containing the information about focus images requested :obj:`str` The Focus ID to be used for creating the PDF chart outputs """ # Just to be sure... path = path.resolve() # Get the correct flog if flog.lower() == "last": focfiles = sorted(path.glob("deveny_focus*")) try: flog = pathlib.Path(focfiles[-1]) except IndexError as err: # In the event of no focus files, raise exception raise utils.ObstoolsError( "No successful focus run completed in this directory" ) from err else: flog = path / flog if not flog.is_file(): raise utils.ObstoolsError("Specified focus run not in this directory") files = [] with open(flog, "r", encoding="utf8") as file_object: # Discard file header file_object.readline() # Read in the remainder of the file, grabbing just the filenames for line in file_object: files.append(path.parent / line.strip().split()[0]) # Return the Image File Collection return ccdproc.ImageFileCollection(filenames=files), flog.name[-15:]
[docs] def parse_focus_headers(focus_icl: ccdproc.ImageFileCollection) -> FocusParams: """Parse focus headers values into a dictionary Create a dictionary of values (mainly from the header) that can be used by subsequent routines. Parameters ---------- focus_icl : :obj:`~ccdproc.ImageFileCollection` The Image File Collection containing the focus files for this run Returns ------- :class:`FocusParams` DataClass containing the focus sweep parameters """ # Pull the spectrograph setup from the first focus file: row = focus_icl.summary[0] slitasec = row["slitasec"] grating = row["grating"] grangle = row["grangle"] lampcal = row["lampcal"] mnttemp = row["mnttemp"] binning = row["ccdsum"].replace(" ", "x") # Compute the nominal line width nominal_lw = 2.94 * slitasec * deveny_grangle.deveny_amag(grangle) # Pull the collimator focus values from the first and last files focus_0 = row["collfoc"] focus_1 = focus_icl.summary["collfoc"][-1] # Find the delta between focus values delta_focus = (focus_1 - focus_0) / (len(focus_icl.files) - 1) if delta_focus == 0: raise utils.ObstoolsError("No change in focus over this set of images") # Examine the middle image mid_file = pathlib.Path(focus_icl.files[len(focus_icl.files) // 2]) find_lines_title = ( f"{mid_file.name} Grating: {grating} GRANGLE: " + f"{grangle:.2f} Lamps: {lampcal}" ) optimal_focus_title = ( f"Grating: {grating} Slit width: {slitasec:.2f} arcsec" + f" Binning: {binning} Nominal line width: " + f"{nominal_lw:.2f} pixels" ) return FocusParams( mid_file=mid_file, nominal=nominal_lw, start=focus_0, end=focus_1, delta=delta_focus, plot_title=find_lines_title, opt_title=optimal_focus_title, mnttemp=mnttemp, binning=binning, )
[docs] def get_lines_from_ccd( ccd: astropy.nddata.CCDData, thresh: float = 20.0, window: int = 11, trace: np.ndarray = None, verbose: bool = True, ) -> LineInfo: """Extract the lines from the CCD image Since these steps are repeated often enough, pull them out into a separate function. Parameters ---------- ccd : :obj:`~astropy.nddata.CCDData` The CCD object returned from :meth:`~ccdproc.ImageFileCollection.ccds` thresh : :obj:`float`, optional Threshold above which to identify lines (Default: 20 DN above bkgd) window : :obj:`int`, optional Window over which to average the spectrum (Default: 11 pixels) Returns ------- :class:`LineInfo` Contains the relevant information about the lines found in the frame """ # Process the input CCDData object spec_2d = ccdproc.trim_image(ccd, ccd.header["TRIMSEC"]).data # If no trace provided, produce one trace = centered_trace(spec_2d.data) if trace is None else trace # Extract the spectrum spec_1d = extract_spectrum(spec_2d, trace, window=window, thresh=thresh) # Find the lines in the extracted spectrum centers, fwhm = find_lines(spec_1d, thresh=thresh, verbose=verbose) # Return the DataClass return LineInfo(trace=trace, spec_1d=spec_1d, centers=centers, fwhm=fwhm)
[docs] def centered_trace(spec2d: np.ndarray) -> np.ndarray: """Construct a simple trace down the middle of the image _extended_summary_ Parameters ---------- spec2d : :obj:`~numpy.ndarray` The 2D image for which to create the trace Returns ------- :obj:`~numpy.ndarray` The desired trace """ # Build the trace for spectrum extraction n_y, n_x = spec2d.shape return np.full(n_x, n_y / 2, dtype=float).reshape((1, n_x))
[docs] def extract_spectrum( spectrum: np.ndarray, traces: np.ndarray, window: int, thresh: float = 20.0, verbose: bool = False, ) -> np.ndarray: """Object spectral extraction routine Extract spectra by averaging over the specified window and background subtract Parameters ---------- spectrum : :obj:`~numpy.ndarray` The trimmed spectral image traces : :obj:`~numpy.ndarray` The trace(s) along which to extract spectra window : :obj:`int` Window over which to average the spectrum thresh : :obj:`float`, optional Threshold above which to identify lines [Default: 20 DN above bkgd] verbose : :obj:`bool`, optional Produce verbose output? [Default: False] Returns ------- :obj:`~numpy.ndarray` Background-subtracted extracted spectrum """ # Spec out the shape, and create an empty array to hold the output spectra norders, n_x = traces.shape if norders != 1: raise utils.ObstoolsError("Cannot deal with multiple traces") extracted_spectrum = np.empty(n_x, dtype=float) # Set extraction window size half_window = int(window) // 2 # Because of python indexing, we need to "+1" the upper limit in order # to get the full wsize elements for the average trace = traces[0, :].astype(int) # Do this as a loop since a trace may not be a simple row through the image for i in range(n_x): extracted_spectrum[i] = np.average( spectrum[trace[i] - half_window : trace[i] + half_window + 1, i] ) # Find background from median value of the image: bkgd = np.median(extracted_spectrum) if verbose: print( f" Background level: {bkgd:.1f}" + f" Detection threshold level: {bkgd+thresh:.1f}" ) # Return the background-subtracted spectrum return extracted_spectrum - bkgd
[docs] def find_lines( spectrum: np.ndarray, thresh: float = 20.0, minsep: int = 11, verbose: bool = True, ) -> tuple[np.ndarray, np.ndarray]: """Automatically find and centroid lines in a 1-row image Uses :func:`scipy.signal.find_peaks` for this task Parameters ---------- image : :obj:`~numpy.ndarray` Extracted 1D spectrum thresh : :obj:`float`, optional Threshold above which to indentify lines [Default: 20 DN above bkgd] minsep : :obj:`int`, optional Minimum line separation for identification [Default: 11 pixels] verbose : :obj:`bool`, optional Produce verbose output? [Default: False] Returns ------- centers : :obj:`~numpy.ndarray` Line centers (pixel #) fwhm : :obj:`~numpy.ndarray` The computed FWHM for each peak """ # Use scipy to find peaks & widths -- no more janky IDL-based junk centers, _ = scipy.signal.find_peaks(spectrum, height=thresh, distance=minsep) fwhm = (scipy.signal.peak_widths(spectrum, centers))[0] # Print a statement, if desired if verbose: print(f" Number of lines found: {len(centers)}") return centers, fwhm
[docs] def fit_focus_curves( width_array: np.ndarray, focus_pars: FocusParams, fit_order: int = 2, debug: bool = False, ) -> FocusCurves: """Fit line focus curves [extended_summary] Parameters ---------- width_array : :obj:`~numpy.ndarray` Array of FWHM for all lines as a function of COLLFOC focus_pars : :class:`FocusParams` Focus parameters DataClass from :func:`parse_focus_headers` fit_order : :obj:`int`, optional Polynomial order of the focus fit (Default: 2 = Quadratic) debug : :obj:`bool`, optional Print debug statements (Default: False) Returns ------- :class:`FocusCurves` The focus curve object. """ # Get the number of focus frames, and number of found lines n_frames, n_centers = width_array.shape # Create the various arrays needed (full of NaN to begin with) collfoc_vals = focus_pars.start + np.arange(n_frames) * focus_pars.delta min_linewidth = np.full((n_centers,), np.nan, dtype=float) min_collfoc = np.full((n_centers,), np.nan, dtype=float) optimal_collfoc = np.full((n_centers,), np.nan, dtype=float) foc_fits = np.full((n_centers, fit_order + 1), np.nan, dtype=float) # Loop through lines to find the best focus for each one for i in range(n_centers): # Data are the FWHM for this line at different COLLFOC widths_this_line = width_array[:, i] # Find unphysically large or small FWHM (or NaN) -- set to np.nan bad_idx = ( (widths_this_line < 1.0) | (widths_this_line > 15.0) | ~np.isfinite(widths_this_line) ) widths_this_line[bad_idx] = np.nan # If more than 1/3 of the FHWM are bad for this line, skip and go on if np.sum(bad_idx) > n_frames / 3: continue # Do a polynomial fit (norder) to the FWHM vs COLLFOC index # fit = np.polyfit(cf_idx_coarse, fwhms_of_this_line, norder) fit = utils.good_poly(collfoc_vals, widths_this_line, fit_order, 2.0) foc_fits[i] = fit if debug: print(f"In fit_focus_curves(): fit = {fit}") # If good_poly() returns zeros, skip and go on # If quadratic fit is concave DOWN, skip and go on if all(value == 0 for value in fit) or fit[2] < 0: continue # Evaluate the curve minumum as root of derivative poly = np.polynomial.Polynomial(fit) min_collfoc[i] = poly.deriv(1).roots()[0] min_linewidth[i] = poly(min_collfoc[i]) # Compute the nominal focus position as the larger of the two points # where the polymonial function crosses fnom fnom_curve = np.polynomial.Polynomial( [fit[0] - focus_pars.nominal, fit[1], fit[2]] ) fnom_roots = fnom_curve.roots() if debug: print(f"FNOM Roots: {fnom_roots}") optimal_collfoc[i] = np.max(np.real(fnom_roots)) # After looping, return the items as numpy arrays return FocusCurves( min_focus_values=min_collfoc, optimal_focus_values=optimal_collfoc, min_linewidths=min_linewidth, fit_pars=foc_fits, )
# Plotting Routines ==========================================================#
[docs] def plot_lines( spectrum: np.ndarray, centers: np.ndarray, focus_pars: FocusParams = None, plot_pars: PlotParams = None, ): """Plot centroid lines in a 1-row image Parameters ---------- spectrum : :obj:`~numpy.ndarray` Extracted spectrum centers : :obj:`~numpy.ndarray` The line centers, as reported by :func:`find_lines` focus_pars : :class:`FocusParams`, optional DataClass containing needed variables for plot plot_pars : :class:`PlotParams`, optional DataClass containing needed parameters for plotting """ # Set up the plot environment _, axis = plt.subplots() tsz = 8 # Plot the spectrum, mark the peaks, and label them axis.plot(np.arange(len(spectrum)), spectrum) axis.set_ylim(0, (yrange := 1.2 * max(spectrum))) axis.plot(centers, spectrum[centers.astype(int)] + 0.02 * yrange, "k*") for cen in centers: axis.text( cen, spectrum[int(np.round(cen))] + 0.03 * yrange, f"{cen:.0f}", fontsize=tsz, ) # Make pretty & Save axis.set_title(focus_pars.plot_title, fontsize=tsz * 1.2) axis.set_xlabel("CCD Column", fontsize=tsz) axis.set_ylabel("I (DN)", fontsize=tsz) axis.set_xlim(0, len(spectrum) + 2) axis.tick_params("both", labelsize=tsz, direction="in", top=True, right=True) plt.tight_layout() if plot_pars.pdf is None: plt.show() else: plot_pars.pdf.savefig() if plot_pars.docfig: for ext in ["png", "pdf", "svg"]: plt.savefig(plot_pars.path / f"pyfocus.page1_example.{ext}") plt.close()
[docs] def plot_optimal_focus( focus: FocusParams, centers: np.ndarray, optimal_focus_values: np.ndarray, med_opt_focus: float, plot_pars: PlotParams, debug: bool = False, ): """Make the Optimal Focus Plot (IDL2 Window) [extended_summary] Parameters ---------- focus : :class:`FocusParams` Dataclass of the various focus-related quantities centers : :obj:`~numpy.ndarray` Array of the centers of each line optimal_focus_values : :obj:`~numpy.ndarray` Array of the optimal focus values for each line med_opt_focus : :obj:`float` Median optimal focus value debug : :obj:`bool`, optional Print debug statements (Default: False) plot_pars : :class:`PlotParams` DataClass containing needed parameters for plotting """ if debug: print("=" * 20) print(centers.dtype, optimal_focus_values.dtype, type(med_opt_focus)) _, axis = plt.subplots() tsz = 8 axis.plot(centers, optimal_focus_values, ".") axis.set_xlim(0, 2050) axis.set_ylim(focus.start - focus.delta, focus.end + focus.delta) axis.set_title( "Optimal focus position vs. line position, median = " + f"{med_opt_focus:.2f} mm " + f"(Mount Temp: {focus.mnttemp:.1f}$^\\circ$C)", fontsize=tsz * 1.2, ) axis.hlines( med_opt_focus, 0, 1, transform=axis.get_yaxis_transform(), color="magenta", ls="--", ) axis.set_xlabel(f"CCD Column\n{focus.opt_title}", fontsize=tsz) axis.set_ylabel("Optimal Focus (mm)", fontsize=tsz) axis.grid(which="both", color="#c0c0c0", linestyle="-", linewidth=0.5) axis.tick_params("both", labelsize=tsz, direction="in", top=True, right=True) plt.tight_layout() if plot_pars.pdf is None: plt.show() else: plot_pars.pdf.savefig() if plot_pars.docfig: for ext in ["png", "pdf", "svg"]: plt.savefig(plot_pars.path / f"pyfocus.page2_example.{ext}") plt.close()
[docs] def plot_focus_curves( centers: np.ndarray, line_width_array: np.ndarray, focus_curves: FocusCurves, focus_pars: FocusParams, plot_pars: PlotParams, ): """Make the big plot of all the focus curves (IDL1 Window) [extended_summary] Parameters ---------- centers : :obj:`~numpy.ndarray` List of line centers from find_lines() line_width_array : :obj:`~numpy.ndarray` Array of line widths from each COLLFOC setting for each line focus_curves : :class:`FocusCurves` The set of outputs from the focus curve fitting focus_pars : :obj:`FocusParams`, optional DataClass containing needed variables for plot plot_pars : :class:`PlotParams` DataClass containing needed parameters for plotting """ # Warning Filter -- Matplotlib doesn't like going from masked --> NaN warnings.simplefilter("ignore", UserWarning) # Set up variables n_frames, n_lines = line_width_array.shape focus_idx = np.arange(n_frames) focus_x = focus_idx * focus_pars.delta + focus_pars.start # Set the plotting array ncols = 6 nrows = n_lines // ncols + 1 fig, axes = plt.subplots(ncols=ncols, nrows=nrows, figsize=(8.5, 11)) tsz = 6 # type size for i, axis in enumerate(axes.flatten()): if i < n_lines: # Plot the points and the polynomial fit axis.plot(focus_x, line_width_array[:, i], "kD", fillstyle="none") poly = np.polynomial.Polynomial(focus_curves.fit_pars[i, :]) axis.plot(focus_x, poly(focus_x), "g-") # Plot vertical lines to indicate minimum and optimal focus axis.vlines( focus_curves.min_focus_values[i], 0, focus_curves.min_linewidths[i], color="r", ls="-", ) axis.vlines( focus_curves.optimal_focus_values[i], 0, focus_pars.nominal, color="b", ls="-", ) # Plot parameters to make pretty # ax.set_ylim(0, np.nanmax(line_width_array[:,i])) axis.set_ylim(0, 7.9) axis.set_xlim( np.min(focus_x) - focus_pars.delta, np.max(focus_x) + focus_pars.delta ) axis.set_xlabel("Collimator Position (mm)", fontsize=tsz) axis.set_ylabel("FWHM (pix)", fontsize=tsz) axis.set_title( f"LC: {centers[i]:.0f} Fnom: {focus_pars.nominal:.2f} pixels", fontsize=tsz, ) axis.tick_params( "both", labelsize=tsz, direction="in", top=True, right=True ) axis.grid(which="both", color="#c0c0c0", linestyle="-", linewidth=0.5) else: # Clear any extra positions if there aren't enough lines fig.delaxes(axis) plt.tight_layout() if plot_pars.pdf is None: plt.show() else: plot_pars.pdf.savefig() if plot_pars.docfig: for ext in ["png", "pdf", "svg"]: plt.savefig(plot_pars.path / f"pyfocus.page3_example.{ext}") plt.close()
# Extra Routines =============================================================#
[docs] def find_lines_in_spectrum( filename: str | pathlib.Path, thresh: float = 100.0 ) -> np.ndarray: """Find the line centers in a spectrum This function is not directly utilized in ``dfocus``, but rather is included as a wrapper for several functions that can be used by other programs. Given the filename of an arc-lamp spectrum, this function returns a list of the line centers found in the image. Parameters ---------- filename : :obj:`str` or :obj:`~pathlib.Path` Filename of the arc frame to find lines in thresh : :obj:`float`, optional Line intensity threshold above background for detection [Default: 100] Returns ------- :obj:`~numpy.ndarray` List of line centers found in the image """ # Get the trimmed image ccd = astropy.nddata.CCDData.read(filename) spectrum = ccdproc.trim_image(ccd, ccd.header["TRIMSEC"]).data # Build the trace for spectrum extraction n_y, n_x = spectrum.shape traces = np.full(n_x, n_y / 2, dtype=float).reshape((1, n_x)) spectra = extract_spectrum(spectrum, traces, window=11) # Find the lines! centers, _ = find_lines(spectra, thresh=thresh) return centers
# Command Line Script Infrastructure (borrowed from PypeIt) ==================#
[docs] class DFocus(utils.ScriptBase): """Script class for ``dfocus`` tool Script structure borrowed from :class:`pypeit.scripts.scriptbase.ScriptBase`. """
[docs] @classmethod def get_parser( cls, description: str = None, width: int = None, formatter: argparse.HelpFormatter = argparse.ArgumentDefaultsHelpFormatter, ): """Construct the command-line argument parser. Parameters ---------- description : :obj:`str`, optional A short description of the purpose of the script. width : :obj:`int`, optional Restrict the width of the formatted help output to be no longer than this number of characters, if possible given the help formatter. If None, the width is the same as the terminal width. formatter : :obj:`~argparse.HelpFormatter` Class used to format the help output. Returns ------- :obj:`~argparse.ArgumentParser` Command-line interpreter. """ parser = super().get_parser( description="DeVeny Collimator Focus Calculator", width=width ) parser.add_argument( "--flog", action="store", type=str, help="focus log to use", default="last", ) parser.add_argument( "--thresh", action="store", type=float, help="threshold for line detection", default=100.0, ) parser.add_argument( "--nodisplay", action="store_true", help="DO NOT launch Preview.app to display plots", ) # Produce multiple graphics outputs for the documentation -- HIDDEN parser.add_argument("-g", action="store_true", help=argparse.SUPPRESS) return parser
[docs] @staticmethod def main(args): """Main Driver Simple function that calls the primary function. """ # Giddy Up! dfocus( pathlib.Path(".").resolve(), flog=args.flog, thresh=args.thresh, launch_preview=not args.nodisplay, docfig=args.g, )