Source code for noctua.templates.focus2

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# System modules
from time import sleep

# Third-party modules
# import gnuplotlib as gp # No longer needed
import numpy as np
from astropy.io import fits
from astropy.time import Time

# Other templates
from ..config.constants import pixscale, temp_fits  # Corrected relative import
from ..devices import cam, foc  # Corrected relative import
from ..utils.analysis import fit_star  # Corrected relative import
from ..utils.logger import log  # Corrected relative import
from ..utils.structure import foc_path  # Corrected relative import
from .basetemplate import BaseTemplate  # Corrected relative import
from .box import Template as Box  # Corrected relative import


[docs] def simple_ascii_plot( x_values, y_values, x_label="X", y_label="Y", title="Plot", width=50, height=15): """ Generates a simple ASCII plot for the terminal. Scales y_values to fit within the height. """ if not len(x_values) or not len( y_values) or len(x_values) != len(y_values): log.warning("ASCII Plot: Invalid or empty data for plotting.") return "" plot_str = [] plot_str.append(f"{title:^{width}}") plot_str.append("") # Empty line min_y, max_y = min(y_values), max(y_values) y_range = max_y - min_y if y_range == 0: # Avoid division by zero if all y values are the same y_range = 1.0 # Create the plot grid (transposed for easier filling) grid = [[' ' for _ in range(width)] for _ in range(height)] # Y-axis labels and scale y_axis_label_width = 8 # max width for y-axis numbers plot_width_effective = width - y_axis_label_width - 1 # -1 for the axis itself # Determine x positions (scaled to fit plot_width_effective) # This simple version assumes x_values are somewhat evenly spaced or just for labeling # For true x-axis scaling, it's more complex for ASCII. # We will place points at somewhat regular intervals across the width. num_points = len(x_values) if num_points == 0: return "" # Map y_values to plot height scaled_y = [int(((y - min_y) / y_range) * (height - 1)) for y in y_values] # Create a simple bar-like representation for each point # This is a very basic representation. # We will map each x_value to a column if possible, or group them. # For this simple plot, we'll iterate through y_values and represent them as bars # at distinct x positions if possible. # Let's make a bar chart where each x_value has its own "bar" # The width of each bar will be plot_width_effective / num_points bar_segment_width = max(1, plot_width_effective // num_points) for i in range(height): row_str = "" # Y-axis label y_val_at_row = max_y - \ (i * y_range / (height - 1 if height > 1 else 1)) row_str += f"{y_val_at_row:<{y_axis_label_width}.2f}|" for j in range(num_points): # Check if the current row corresponds to the bar height for this point # scaled_y is 0 at min_y, height-1 at max_y. # Grid is top-down, so we need to invert. if (height - 1 - scaled_y[j]) <= i: row_str += "*" * bar_segment_width else: row_str += " " * bar_segment_width plot_str.append(row_str.rstrip()) # X-axis (very simplified) plot_str.append( "-" * y_axis_label_width + "+" + "-" * (plot_width_effective)) # X-axis labels (approximate positions) # This is tricky to align perfectly in ASCII. # We'll just show min and max x for simplicity or label a few points. if num_points > 0: x_labels_line = " " * (y_axis_label_width + 1) if num_points == 1: x_labels_line += f"{x_values[0]:^{plot_width_effective}.1f}" elif num_points > 1: first_x_label = f"{x_values[0]:.1f}" last_x_label = f"{x_values[-1]:.1f}" middle_space = plot_width_effective - \ len(first_x_label) - len(last_x_label) if middle_space < 0: middle_space = 0 x_labels_line += first_x_label + " " * middle_space + last_x_label plot_str.append(x_labels_line) plot_str.append(f"{x_label:^{width}}") plot_str.append("") plot_str.append(f"{y_label} vs {x_label}") return "\n".join(plot_str)
[docs] class Template(BaseTemplate): def __init__(self): super().__init__() self.name = "focus" self.description = "Fit boxed images to get optimal focus"
[docs] def content(self, params): ######################## ##### Params check ##### ######################## try: objname = params.get("object") or "test" filt = params["filter"] binning = params["binning"] exptime = params["exptime"] center = params.get("center") or cam.center width = params.get("width") or 400 // binning height = params.get("height") or 400 // binning repeat = params.get("repeat") or 10 step = params.get("step") or 10 except KeyError as e: msg = f"Parameter {e} not found" log.error(msg) self.error.append(msg) return # for Box fixed = { "objname": objname, "repeat": 1, "center": center, "width": width, "height": height, } params.update(fixed) ####################################### ##### Preparing to the focus loop ##### ####################################### # Getting initial focus original_foc = foc.position if original_foc is None: log.error("Could not get initial focus position. Aborting focus.") self.error.append("Initial focus position is None.") return log.info(f"Initial focus position: {original_foc} µm") # starting from half before # Corrected: (repeat-1) steps for 'repeat' points total_length_focus_scan = step * (repeat - 1) start_focus = original_foc - total_length_focus_scan / 2 log.info( f"Starting focus scan from { start_focus:.0f} µm to { start_focus + total_length_focus_scan:.0f} µm in {repeat} steps of {step} µm.") # Move to the start of the focus scan foc.position = int(round(start_focus)) # Add a small delay for the focuser to settle if it's a physical device sleep(1.0) # Adjust as needed, or check foc.is_moving if available # Preparing empty arrays for values m2_arr = np.array([], dtype=float) # Use float for focus positions fwhm_arr = np.array([], dtype=float) self.output = [] # Preparing the focus file path now_time = Time.now().isot # Ensure foc_path is from ..utils.structure file_path = foc_path(now_time) # Initial comment about variables comment1 = f"Focus procedure of {now_time} for object '{objname}' with filter '{filt}'" comment2 = f"Step Focus[µm] FWHM[arcsec] Peak[ADU] BG[ADU] X[px] Y[px]" # Write the initial comment if the file doesn't exist with open(file_path, "a+") as file: log.info(f"Using focus data file: {file_path}") file.seek(0) if not file.read(1): file.write(f"# {comment1}\n") file.write(f"# {comment2}\n") # Instanciating a Box template box_template = Box() for rep_idx in range(repeat): current_focus_target = int(round(start_focus + rep_idx * step)) log.info( f"Focus step { rep_idx + 1}/{repeat} :: Target M2 Focus: {current_focus_target} µm") # Set focus position for this step foc.position = current_focus_target # Check if focuser is moving (if available) or sleep # This is important to ensure the image is taken at the correct # focus if hasattr(foc, 'is_moving'): while foc.is_moving: log.debug("Waiting for focuser to settle...") sleep(0.2) # Short sleep while checking if self.check_pause_or_abort(): return else: sleep(1.0) # Generic delay if is_moving is not available # Get actual focus position after movement actual_m2_pos = foc.position if actual_m2_pos is None: log.warning( f"Could not read M2 position at step { rep_idx + 1}. Skipping this point.") continue m2_arr = np.append(m2_arr, actual_m2_pos) ################################ ##### Taking a boxed image ##### ################################ # Update exptime and filter in params for the box template, if they # can change params["exptime"] = exptime params["filter"] = filt params["binning"] = binning box_template.run(params) # saves temp.fits boxed image if self.check_pause_or_abort(): return try: data = fits.getdata(temp_fits) except FileNotFoundError: log.error( f"Focus: {temp_fits} not found after box exposure. Skipping point.") # Add NaN for missing FWHM fwhm_arr = np.append(fwhm_arr, np.nan) continue ############################### ##### Fitting a 2d Moffat ##### ############################### current_fwhm_arcsec = np.nan # Default to NaN peak_adu, bg_adu, fit_x, fit_y = np.nan, np.nan, np.nan, np.nan try: # Estimate background from corners if possible, or use a fixed guess # For simplicity, using a fixed guess for now background_estimation = np.median( data) # A slightly better guess fitted = fit_star( data, model="moffat", background_estimation=background_estimation) if fitted and hasattr( fitted, 'fwhm') and hasattr( fitted, 'xc'): current_fwhm_arcsec = fitted.fwhm * pixscale * binning # FWHM in arcsec peak_adu = fitted.peak bg_adu = fitted.background fit_x = fitted.xc fit_y = fitted.yc log.info( f" -> Fit: FWHM={ current_fwhm_arcsec:.2f}\", Peak={ peak_adu:.0f}, BG={ bg_adu:.0f}, X={ fit_x:.1f}, Y={ fit_y:.1f}") else: log.warning( " -> Fit failed or did not return expected attributes.") except Exception as e: log.warning( f" -> Star fitting error at focus {actual_m2_pos} µm: {e}") fwhm_arr = np.append(fwhm_arr, current_fwhm_arcsec) self.output = { # Update self.output for potential real-time display/API "focus_run": { "m2_positions": m2_arr.tolist(), # Replace NaN for JSON "fwhm_arcsec": np.nan_to_num(fwhm_arr, nan=-1.0).tolist(), "current_point": { "m2": actual_m2_pos, "fwhm": current_fwhm_arcsec if not np.isnan(current_fwhm_arcsec) else -1.0, "peak": peak_adu if not np.isnan(peak_adu) else -1.0, "background": bg_adu if not np.isnan(bg_adu) else -1.0, "fit_x_px": fit_x if not np.isnan(fit_x) else -1.0, "fit_y_px": fit_y if not np.isnan(fit_y) else -1.0, } } } with open(file_path, "a") as file: line_data = f"{ rep_idx + 1:<4d} { actual_m2_pos:<10.1f} { current_fwhm_arcsec:<10.2f} { peak_adu:<9.0f} { bg_adu:<7.0f} { fit_x:<7.1f} { fit_y:<7.1f}" file.write(line_data + "\n") if self.check_pause_or_abort(): return ############################## ##### Showing the result ##### ############################## log.info("Focus scan complete. Results:") for i in range(len(m2_arr)): log.info(f" Focus: {m2_arr[i]:.1f} µm, FWHM: {fwhm_arr[i]:.2f}\"") # Generate and print ASCII plot if len(m2_arr) > 1 and len(fwhm_arr) > 1: # Filter out NaN values for plotting and finding minimum valid_indices = ~np.isnan(fwhm_arr) plot_m2 = m2_arr[valid_indices] plot_fwhm = fwhm_arr[valid_indices] if len(plot_m2) > 0: ascii_chart = simple_ascii_plot( plot_m2, plot_fwhm, x_label="Focus M2 (µm)", y_label="FWHM (arcsec)", title=f"Focus Curve - {objname} ({filt})", width=80, height=20 ) log.info("\n" + ascii_chart) # Find best focus if len(plot_fwhm) > 0: min_fwhm_idx = np.argmin(plot_fwhm) best_focus_val = plot_m2[min_fwhm_idx] best_fwhm_val = plot_fwhm[min_fwhm_idx] log.info( f"Optimal focus found at { best_focus_val:.1f} µm with FWHM { best_fwhm_val:.2f}\".") log.info( f"Moving M2 to optimal focus: { best_focus_val:.0f} µm.") foc.position = int(round(best_focus_val)) else: log.warning( "No valid FWHM data points to determine optimal focus. Restoring original.") foc.position = original_foc else: log.warning( "No valid data points to plot or determine optimal focus. Restoring original.") foc.position = original_foc else: log.warning( "Not enough data points for a plot or to determine optimal focus. Restoring original focus.") foc.position = original_foc log.info( f"Focus procedure finished. M2 position set to: { foc.position} µm.")