SITCOMTN-072: Sensitivity Matrix Calculation for AuxTel

  • Chris Suberlak

Latest Revision: 2023-05-11

Author: Chris Suberlak ([@suberlak](https://github.com/lsst-sitcom/sitcomtn-072/issues/new?body=@suberlak))

Software Versions:

  • ts_wep: v6.1.1

  • lsst_distrib: w_2023_19

Introduction

The Sensitivity Matrix is a way of quantifying the relationship between measured wavefront curvature (Zernike coefficients), and the required mechanical correction (mm of hexapod motion). As described in https://tstn-016.lsst.io/ , for auxiliary telescope (auxTel) it concerns the motion of the M2 mirror hexapod in one of three directions: x,y,z. ts_wep quantifies the wavefront using the annular Noll Zernike coefficients terms 4-22, called 4 (defocus), 5-6 (primary astigmatism), 7-8 (primary coma), 9-10 (trefoil), 11 (primary spherical), 12-13 (secondary astigmatism), 14-15 (quadrafoil), 16-17 (secondary coma), 18-19 (secondary trefoil), 20-21 (pentafoil), as illustrated on this image:

Noll Zernikes

Fig.1 Noll Zernike expansion

. For auxTel sensitivity matrix, we only use the defocus (Z4), and primary coma (Z7,Z8), as related to dx,dy,dz hexapod motion (with dz affecting primarily defocus term Z4, dx the coma-x Z7, and dy the coma-y Z8).

\(\begin{bmatrix} \tag{1} dx \\ dy \\ dz \end{bmatrix} = \begin{bmatrix} C_{X} & C_{YX} C_{Y} & C_{ZX} D_{Z} \\ C_{XY} C_{X} & C_{Y} & C_{ZY} D_{Z} \\ C_{XZ} C_{X} & C_{YZ} C_{Y} & D_{Z} \end{bmatrix} \times \begin{bmatrix} Z7 & Z8 & Z4 \end{bmatrix}\)

Due to tilts in the system, some of the cross-terms are non-zero (eg. \(C_{ZY}\) measuring the impact of dy motion on Z4 ). The original sensitivity matrix for auxTel was derived with data from 20200218, using a notebook CWFS Sensitivity Matrix Determination. The resulting sensitivity matrix was included in the latiss_base_align script as

self.matrix_sensitivity = [
    [1.0 / 206.0, 0.0, 0.0],
    [0.0, -1.0 / 206.0, -(109.0 / 206.0) / 4200],
    [0.0, 0.0, 1.0 / 4200.0],
]

In this notebook we show how this matrix can be updated with the auxTel data taken during the March 10th, 2023 run.

Setup

  • access to USDF devl nodes

  • working installation of ts_wep package ( see the following notes on how to install and build the AOS packages)

Imports

[1]:
from lsst.daf.butler import Butler
from lsst.ctrl.mpexec import SimplePipelineExecutor
from lsst.pipe.base import Pipeline
from lsst_efd_client import EfdClient
import os
os.environ['NUMEXPR_MAX_THREADS'] = '8'
import subprocess
import matplotlib as mpl
import matplotlib.pyplot as plt
import numpy as np
from astropy.visualization import ZScaleInterval
from astropy.time import Time, TimeDelta
from astropy.table import Table
from scipy.optimize import curve_fit
from galsim.zernike import zernikeRotMatrix

from numexpr.utils import log as numexpr_logger
from logging import WARN
numexpr_logger.setLevel(WARN)

import pandas as pd
import warnings
# to prevent butler from displaying the username
# with INFO:botocore.credentials
warnings.filterwarnings('ignore')
INFO:numexpr.utils:Note: detected 128 virtual cores but NumExpr set to maximum of 64, check "NUMEXPR_MAX_THREADS" environment variable.
INFO:numexpr.utils:Note: NumExpr detected 128 cores but "NUMEXPR_MAX_THREADS" not set, so enforcing safe limit of 8.
INFO:numexpr.utils:NumExpr defaulting to 8 threads.
[2]:
def line(x,b, m):
    return b + m*x

Data

The observing run on 2023-03-10 included multiple tests, as described in analyzeWepOutput notebook. . In this tech note we only use the subset of sequence numbers pertaining to sensitivity matrix calculation, using as target HD66225 (observations taken first that night, using HD53897 as target, did not include the z-offsets, see Night Log). The sequence numbers 71-211 include the x/y, rx/ry, z, m1 pressure offsets.

The raw auxTel data for 20230310 is in LATISS/raw/all collection in /repo/embargo. The information about exposure times and angles needed to derotate the Zernikes is obtained from the Engineering Facility Database (EFD) via the EFD client.

Find CWFS pairs

We find pairs of defocal exposures used for wavefront estimation by selecting on exposure.observation_type equal to cwfs and querying the record to find sequential poirs that have observation_reason starting with intra and extra, respectively.

[3]:
input_collections = ['LATISS/raw/all', 'LATISS/calib/unbounded']

butler = Butler(
    "/repo/embargo",
    collections=input_collections,
    instrument='LATISS'
)


records = list(
    butler.registry.queryDimensionRecords(
        "exposure",
        where="exposure.observation_type='cwfs' and exposure.day_obs=20230310"
    )
)
records.sort(key=lambda record: (record.day_obs, record.seq_num))

# Loop through and make pairs where 1st exposure is intra and second exposure is extra
# and have same group_id
# Save record information for each pair and sequence numbers
# for easy location later.
pairs = []
seq_nums = []
for record0, record1 in zip(records[:-1], records[1:]):
    if (
        record0.observation_reason.startswith('intra') and
        record1.observation_reason.startswith('extra') and
        record0.group_id == record1.group_id and
        not record0.physical_filter.startswith("empty")
    ):
        pairs.append((record0, record1))
        seq_nums.append(record0.seq_num)

# this only stores the first seqNum in each pair
seq_nums = np.array(seq_nums)
INFO:botocore.credentials:Found credentials in shared credentials file: /sdf/home/s/scichris/.lsst/aws-credentials.ini

Run AOS pipeline

For completeness, the cell below runs the complete pipeline that does the instrument signature removal with isr task , donut detection with generateDonutDirectDetectTask, cutting out of donut stamps with cutOutDonutsScienceSensorTask, wavefront estimation with calcZernikesTask, is contained in the latissWepPipeline.yaml file (it is similar to latissWepPipeline used earlier, except for the image transpose). It can also be run via bps, by running the run_bps_wep_submit.py submission script (included in the github repository of this technote). The code below assumes that the $USER obtained from os.getlogin() is the same as the u/$USER collection to which one has write access.

This runs the full pipeline, i.e. from the raws, does limited ISR (no CR repair).

[4]:
repo_dir = '/sdf/data/rubin/repo/embargo/'
user = os.getlogin()
output_collection = f'u/{user}/latiss_230310_run/wep_full'


butler = Butler(repo_dir)
registry = butler.registry
collections_list = list(registry.queryCollections())

# skip if collection already exists
if output_collection in collections_list:
    print(f'{output_collection} already exists, skipping')

else:
    butlerRW = SimplePipelineExecutor.prep_butler(repo_dir,
                                                  inputs=input_collections,
                                                  output=output_collection)
    path_to_pipeline_yaml = os.path.join(os.getcwd(), 'latissWepPipeline.yaml' )

    # Load pipeline from file
    pipeline = Pipeline.from_uri(path_to_pipeline_yaml)

    # run the pipeline for each CWFS pair...
    for record0, record1 in pairs:
        day_obs = record0.day_obs
        first = record0.seq_num
        second = record1.seq_num

        data_query = f"exposure in ({first}..{second})"
        executor = SimplePipelineExecutor.from_pipeline(pipeline,
                                                        where=data_query,
                                                        butler=butlerRW)
        quanta = executor.run(True)
u/scichris/latiss_230310_run/wep_full already exists, skipping

Obtain the derotation angle from the EFD

[5]:
efd_client = EfdClient('usdf_efd')

spans = []

day_obs = '20230310'
butler = Butler('/sdf/data/rubin/repo/embargo/')
datasetRefs = butler.registry.queryDatasets('raw',collections='LATISS/raw/all',
              where=f"instrument='LATISS' AND exposure.day_obs = {day_obs}").expanded()

records = []
for i, ref in enumerate(datasetRefs):
    record = ref.dataId.records["exposure"]
    exp = record.dataId['exposure']
    records.append(record)
    spans.append(record.timespan)

t1= min(spans)
t2 = max(spans)

end_readout = await efd_client.select_time_series("lsst.sal.ATCamera.logevent_endReadout",
                                          '*', t1.begin.utc, t2.end.utc)

Obtain the time of the end of readout of intra image and extra image in each pair:

[6]:
m = (end_readout['imageNumber'] > 69) * (end_readout['imageNumber'] < 212)
subset = end_readout[m]
intra_times = []
extra_times = []

intra_images = []
extra_images = []

intra_programs = []
extra_programs = []

intra_exptimes = []
extra_exptimes = []


for i in range(len(subset)):
    num = subset['imageNumber'][i]
    values = subset['additionalValues'][i]
    imageName = subset['imageName'][i]
    expTime = subset['requestedExposureTime'][i]

    time = subset.index[i] # time of end readout
    timestamp = ''.join(values.split(':')[1:-6])
    program = values.split(':')[-2]

    if program.startswith('INTRA_AOS_SM_offset'):
        intra_times.append(time)
        intra_images.append(imageName)
        intra_programs.append(program)
        intra_exptimes.append(expTime)

    elif program.startswith('EXTRA_AOS_SM_offset'):
        extra_times.append(time)
        extra_images.append(imageName)
        extra_programs.append(program)
        extra_exptimes.append(expTime)

    if program.startswith('FINAL'):
        zero_time = time
        zero_image = imageName
        zero_program = program

Find the elevation angle, azimuth angle, rotator angle by querying the EFD for this information contained between the beginning of the intra-focal exposure, and the end of the extra-focal exposure. These angles were changing continuously during each intra / extra exposure, and we consider the mean for the derotation angle, since the wavefront is estimated based on the combined information between intra-focal and extra-focal exposures.

[7]:
t1 = Time(zero_time) - TimeDelta(25, format='sec')
t2 = Time(zero_time)
topic = "lsst.sal.ATAOS.logevent_correctionOffsets"
correction_0 = await efd_client.select_time_series(topic,
                                                 ["x","y","z","u","v","w"], t1,t2)

azimuth_list = []
elevation_list = []
rot_pos_list = []
camrot_list = []

for i in range(len(intra_times)):

   # 5 sec before the beginning of exposure
   # all defocal exposures are 20 sec
    t1 = Time(intra_times[i]) - TimeDelta(intra_exptimes[i]-5, format='sec')

    # this is 2 sec before the end of the extra-focal exposure
    t2 = Time(extra_times[i]) - TimeDelta(2., format='sec')


    azel = await efd_client.select_time_series("lsst.sal.ATMCS.mount_AzEl_Encoders",
                          ["elevationCalculatedAngle99", "azimuthCalculatedAngle99"],
                          t1, t2)

    rotator = await efd_client.select_time_series("lsst.sal.ATMCS.mount_Nasmyth_Encoders",
                          ["nasmyth2CalculatedAngle99"], t1, t2)

    camera = await efd_client.select_time_series("lsst.sal.MTRotator.rotation",
                          ["actualPosition"], t1,t2 )

    azimuth_list.append(np.mean(azel['azimuthCalculatedAngle99']))
    elevation_list.append(np.mean(azel['elevationCalculatedAngle99']))
    rot_pos_list.append(np.mean(rotator['nasmyth2CalculatedAngle99']))
    camrot_list.append(np.mean(camera['actualPosition']))

# store the results as astropy table
d = Table(data=[intra_images, extra_images, rot_pos_list, elevation_list,
                azimuth_list, camrot_list],
         names=['intra','extra', 'rot', 'el','az', 'camrot'])
d['angle'] = d['rot'] - d['el']

Plot the example of how the rotator angle and elevation angle change continuously during the intra-focal and subsequent extra-focal exposure:

[8]:
# print in seconds
dt = rotator['nasmyth2CalculatedAngle99'].index - \
     rotator['nasmyth2CalculatedAngle99'].index[0]
dt_sec = np.array(dt.values.astype(float)) / 1e9
rot = np.mean(rotator['nasmyth2CalculatedAngle99'])
plt.plot(dt_sec, rotator['nasmyth2CalculatedAngle99'].values, marker='o')
plt.xlabel('Time since start of intra exposure [sec]')
plt.ylabel('Rotator angle [deg]')
plt.axhline(rot)
[8]:
<matplotlib.lines.Line2D at 0x7fb3d4aefb50>
_images/index_21_1.png
[9]:
dt = azel['elevationCalculatedAngle99'].index - \
     azel['elevationCalculatedAngle99'].index[0]
dt_sec = np.array(dt.values.astype(float)) / 1e9
plt.plot(dt_sec, azel['elevationCalculatedAngle99'].values, marker='o')
plt.xlabel('Time since start of intra exposure [sec]')
plt.ylabel('Elevation angle [deg]')
el = np.mean(azel['elevationCalculatedAngle99'])
plt.axhline(el)
[9]:
<matplotlib.lines.Line2D at 0x7fb3d4aef6a0>
_images/index_22_1.png

Inspect the results

The wavefront was measured with rotation different than 0, and needs to be derotated to the boresight frame.

In general, Zernike terms are either not affected by rotation (eg. defocus term Z4), or they rotate in pairs (e.g., Z7 & Z8). For the pairs, the rotation matrix is

\(\begin{bmatrix} \tag{2} \cos(m*a) & -\sin(m*a) \\ \sin(m*a) & \cos(m*a) \end{bmatrix}\)

where \(a\) is the angle in the coordinate system rotation and \(m\) is the azimuthal order of the particular Zernike pair. For coma, \(m=1\) so that the rotation matrix has \(sin(a)\) and \(cos(a)\). For astigmatism, \(m=2\) so the matrix contains \(sin(2a)\) and \(cos(2a)\). Given the maximum number of Zernike terms to rotate, the GalSim function zernikeRotMatrix calculates the necessary rotation matrix.

Note: the original latiss_base_align code derotates Zernikes using this rotation matrix:

\(\tag{3} R_{784} = \begin{bmatrix} \cos{\theta} & -\sin{\theta} & 0 \\ \sin{\theta} & \cos{\theta} & 0 \\ 0 & 0 & 1 \end{bmatrix}\)

where \(\theta = rot - el\). But Galsim zernikeRotMatrix rotates in the opposite direction. This can be shown with the following - we load an example set of Zernikes resulting from ts_wep fit. We consider only a subset of \([Z7,Z8,Z4]\). We rotate it first using the \(R_{784}(\theta)\) above. Then we rotate with Galsim by \(-\theta\), and show that the resulting rotated Zernikes are the same.

[10]:
# the rotation matrix used in latiss_base_align
matrix_rotation = lambda angle: np.array(
        [
            [np.cos(np.radians(angle)), -np.sin(np.radians(angle)), 0.0],
            [np.sin(np.radians(angle)), np.cos(np.radians(angle)), 0.0],
            [0.0, 0.0, 1.0],
        ]
    )

# load one set of Zernikes
seq_num=71
output_collection = 'u/scichris/latiss_230310_run/wep_no_transpose_'
butler = Butler(
    "/sdf/data/rubin/repo/embargo/",
    collections=[output_collection],
    instrument='LATISS'
)
zk4_22 =  butler.get(
            "zernikeEstimateAvg",
            dataId={'instrument':'LATISS', 'detector':0,
                    'visit':int(f'20230310{seq_num+1:05d}')}
                    )

[11]:
# zernikes from ts_wep  is 4:22 ...
angle = 90

# that is [comaX, comaY, defocus ]
zk784 = [zk4_22[7-4],zk4_22[8-4],zk4_22[4-4]]

# rotate by angle
zk784_rot = np.matmul(zk784 , matrix_rotation(angle))

zk4_8 = zk4_22[ :9-4]
zk1_8 = np.pad(zk4_8, [4,0]) # pad by 4 so it starts at 0,
                            # and array[i] corresponds to zk[i]

# rotate by -angle
zk1_8_rot_galsim = np.matmul(zk1_8, zernikeRotMatrix(8, -np.deg2rad(angle)))

zk784_rot_galsim = zk1_8_rot_galsim[[7,8,4]]
[12]:
zk784_rot_galsim
[12]:
array([-0.32600393,  0.1319935 , -0.07967852])
[13]:
zk784_rot
[13]:
array([-0.32600393,  0.1319935 , -0.07967852])

Thus the two sets of rotated Zernikes are the same if we use for galsim the -angle used in latiss_base_align matrix_rotation

The Noll Zernike polynomials include x,y components of various optical aberrations (“moments” of expansion, eg. astigmatism, coma, trefoil). The total moment consists of x,y components added in quadrature, eg. total astigmatism \(Z{\mathrm{ast}}^{2}= Z_{5}^{2},Z_{6}^{2}\), total coma \(Z_{\mathrm{coma}}^{2} = Z_{7}^{2},Z_{8}^{2}\).

Since the total moment is invariant under rotation, we plot it first as a sanity check to confirm that rotation only shifted power between x,y components. The moments plotted include:

Optical Aberration

x,y components

Primary Astigmatism

Z5, Z6

Primary Coma

Z7, Z8

Primary Trefoil

Z9, Z10

Secondary Astigmatism

Z12, Z13

Secondary Quadrafoil

Z14, Z15

Secondary Coma

Z16, Z17

Secondary Trefoil

Z18, Z19

Pentafoil

Z20, Z21

[14]:
%matplotlib inline

def plot_derot_total(axis = 'x'):
    if axis == 'x':
        dxs = np.linspace(-2, 2, 10)
        intra_seq_nums = np.arange(71, 90, 2)

    elif axis == 'y':
        dxs = np.linspace(-2, 2, 10) # this is dy
        intra_seq_nums = np.arange(91, 110, 2)

    elif axis == 'z':
        dxs = np.linspace(-0.1, 0.1, 10) # this is dz
        intra_seq_nums = np.arange(172, 191, 2)

    zernikes = []
    for seq_num in intra_seq_nums:
        zernikes.append(
            butler.get(
                "zernikeEstimateAvg",
                dataId={'instrument':'LATISS', 'detector':0,
                        'visit':int(f'20230310{seq_num+1:05d}')}
            )
        )
    zernikes = np.array(zernikes)  # this is unrotated 4:22

    # pad with 4 zeros so that array[N]  corresponds to ZkN
    zk1_22_list = []
    for zk4_22 in zernikes:
        zk1_22 = np.pad(zk4_22,[4,0])
        zk1_22_list.append(zk1_22)
    zk1_22_arr = np.array(zk1_22_list)


    zk1_22_rot_list = []
    for pair in range(len(intra_seq_nums)):

        zk1_22_unrot = zk1_22_arr[pair]

        seq_num = intra_seq_nums[pair]
        intra_name = 'AT_O_20230310_'+   str(seq_num).zfill(6)

        # derotate by applying matrix multiplication
        # angle = rot - el
        angle =  d[d['intra'] == intra_name]['angle'].value[0]

        zk1_22rot = np.matmul(zk1_22_unrot,
                              zernikeRotMatrix(22, -np.deg2rad(angle)))
        zk1_22_rot_list.append(zk1_22rot)

    zk1_22_rot_arr = np.array(zk1_22_rot_list)

    titles = {5:'astigmatism', 7:'coma', 9:'trefoil',
                  12:'2nd astigmatism',14:'quadrafoil',16:'2nd coma',
                  18:'2nd trefoil',20:'pentafoil'}

    # rename rotated 4:22 to use the same plotting code
    fig, axes = plt.subplots(nrows=4, ncols=2, figsize=(8,12))
    axes = axes.ravel()
    xdata = dxs

    i = 0
    for j in titles.keys():

        # plot the rotated Zks
        zk_x = zk1_22_rot_arr[:,j]  # eg. zk5
        zk_y = zk1_22_rot_arr[:,j+1]  # eg, zk6
        ydata = np.sqrt(zk_x**2. + zk_y**2.)
        axes[i].scatter(xdata, ydata, marker='d',s=95, label='original')

        # plot the unrotated Zks
        zk_x = zk1_22_arr[:,j]
        zk_y = zk1_22_arr[:,j+1]
        ydata = np.sqrt(zk_x**2. + zk_y**2.)
        axes[i].scatter(xdata, ydata, marker='s', label='rotated')

        axes[i].set_title(f"Z{j,j+1}: {titles[j]}")

        i += 1

    for ax in axes:
        ax.set_ylim(-0.02, 0.36)
        ax.axhline(0, c='k')
    fig.text(0.44,0, f"M2 d{axis} [mm]", fontsize=14)
    fig.text(-0.02
             ,0.4,"WFE [µm]", rotation='vertical',fontsize=14 )
    plt.tight_layout()
    plt.show()

[15]:
output_collection = 'u/scichris/latiss_230310_run/wep_no_transpose_'
butler = Butler(
    "/sdf/data/rubin/repo/embargo/",
    collections=[output_collection],
    instrument='LATISS'
)

Note: in the image below the y-axis is set to the same range to allow easier comparison between total amount of optical distortion in different Zernike modes.

[16]:
for axis in 'xyz':
    plot_derot_total(axis = axis)
_images/index_35_0.png
_images/index_35_1.png
_images/index_35_2.png

Blue diamonds are original (rotated), and orange squares are derotated Zernike values. They are identical because the total coma / astigmatism / trefoil is invariant under rotation.

Plot only a subset of Zernikes relevant to sensitivity matrix calculation (defocus, comaX, comaY)

[17]:

def plot_sens_rot_zk478(butler, axis = 'x', verbose=True, add_angle=0, mult_angle = 1, swap_xy_coma = True, pre = '-'): if axis == 'x': dxs = np.linspace(-2, 2, 10) intra_seq_nums = np.arange(71, 90, 2) elif axis == 'y': dxs = np.linspace(-2, 2, 10) # this is dy intra_seq_nums = np.arange(91, 110, 2) elif axis == 'z': dxs = np.linspace(-0.1, 0.1, 10) # this is dz intra_seq_nums = np.arange(172, 191, 2) # read in the appropriate Zernike coefficients zernikes = [] for seq_num in intra_seq_nums: zernikes.append( butler.get( "zernikeEstimateAvg", dataId={'instrument':'LATISS', 'detector':0, 'visit':int(f'20230310{seq_num+1:05d}')} ) ) zernikes = np.array(zernikes) # first, pad with 4 zeros so that array[N] corresponds to ZkN zk1_22_list = [] for zk4_22 in zernikes: zk1_22 = np.pad(zk4_22,[4,0]) zk1_22_list.append(zk1_22) zk1_22_arr = np.array(zk1_22_list) # now derotate using the EFD angle = el-rot zk1_22_rot_list = [] for pair in range(len(intra_seq_nums)): zk1_22_unrot = zk1_22_arr[pair] if swap_xy_coma : # swap x,y coma ... x_coma = zk1_22_unrot[7] y_coma = zk1_22_unrot[8] zk1_22_unrot[7] = y_coma zk1_22_unrot[8] = x_coma seq_num = intra_seq_nums[pair] intra_name = 'AT_O_20230310_'+ str(seq_num).zfill(6) # derotate using angle = rot-el # = Nasmyth2CalculatedAngle- elevationCalculatedAngkle angle = d[d['intra'] == intra_name]['angle'].value[0] # in the latiss_base_align, the rotation is by # angle + camera_rotation_angle, where # camera_rotation_angle is orientation of the # detector relative to the rotator, # assumed to be within a degree or two zk1_22rot = np.matmul(zk1_22_unrot, zernikeRotMatrix(22, np.deg2rad(add_angle+mult_angle*angle))) zk1_22_rot_list.append(zk1_22rot) zk1_22_rot_arr = np.array(zk1_22_rot_list) # plot the derotated and the rotated Zks fig, axes = plt.subplots(nrows=1, ncols=3, figsize=(10, 4)) axes = axes.ravel() xdata = dxs # rotated if verbose: print('\n Rotated ') col=0 slopes = [] for j in [7,8,4]: # range(4, 23): ydata = 1000*zk1_22_rot_arr[:,j] # use nm axes[col].scatter(dxs, ydata,) # fit straight line x=np.arange(np.min(xdata), np.max(xdata), np.abs(np.max(xdata) - np.min(xdata))/100 ) popt,pcov = curve_fit(line, xdata, ydata) if verbose: print(f'Z{j} as func of dx displacement fit intercept and slope ',popt) axes[col].plot(x,line(x, *popt), label=f'fit slope {np.round(popt[1],1)}nm / mm', alpha=0.8) axes[col].set_ylabel(f'Z{j} [nm]') col += 1 if verbose: print('1/slope=', 1/popt[1]) slopes.append(popt[1]) # The original senM we're comparing to: # self.matrix_sensitivity = [ # [1.0 / 206.0, 0.0, 0.0], # [0.0, -1.0 / 206.0, -(109.0 / 206.0) / 4200], # [0.0, 0.0, 1.0 / 4200.0], # ] xleg = 0 yleg = 1.2 loc_string = 'lower left' mult = int(pre+'1') # we're plotting zk7, zk8 ,zk4 if axis == 'x': # dx slopes # Z7 as a function of dx : Cx ax_idx = 0 # zk7 popt = [+7.20791437, mult*206] axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm') axes[ax_idx].legend(loc=loc_string, bbox_to_anchor=(xleg, yleg )) elif axis == 'y': # dy slopes # z4: defocus as function of dy : Czy ax_idx = 2 popt = [+7.20791437, mult*(-109)] axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm') axes[ax_idx].legend(loc=loc_string,bbox_to_anchor=(xleg, yleg )) # Z8 (comaY) as function of dy : Cy ax_idx = 1 popt = [+7.20791437, mult*(-206)] axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm') axes[ax_idx].legend(loc=loc_string, bbox_to_anchor=(xleg, yleg )) elif axis == 'z': # dz slopes # Z4: defocus as function of dz : Dz ax_idx = 2 popt = [+7.20791437, mult*4200] axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm') axes[ax_idx].legend(loc=[0,1], bbox_to_anchor=(xleg, yleg )) for ax in axes:#[:-1]: ax.set_ylim(-300, 300) ax.axhline(0, c='k') #ax.set_ylabel() ax.set_xlabel(f"M2 d{axis} [mm]") fig.suptitle(f'Rotation by Galsim({add_angle} + {mult_angle}*angle), \ slope= {pre}senM term', fontsize=14) plt.tight_layout() plt.show() return slopes

We can run ts_wep pipeline with a boolean config calcZernikesConfig.transposeImages, which transposes the postISR images before running the Zernike estimation. This is the default with phoSim simulated images. We expect comaX to depend on dx displacement, and comaY on dy. It turns out that without applying the transpose, we get the opposite behavior (i.e. comaX depending on dy, comaY on dx). In the plots below, senM stands for the slope value from the matrix_sensitivity above, eg. \(C_{x} = 1/206.0\) corresponds to a slope of \(206\) nm/mm.

First we show the without the image transpose, coma-X needs to be swapped with coma-Y (swap_xy_coma = True) to yield correct dx and dy dependence:

[18]:
output_collection = 'u/scichris/latiss_230310_run/wep_no_transpose_'
butler = Butler(
    "/sdf/data/rubin/repo/embargo/",
    collections=[output_collection],
    instrument='LATISS'
)

for axis in 'xyz':
    slopes = plot_sens_rot_zk478(butler, axis=axis, add_angle=90,
                                 mult_angle = -1, swap_xy_coma = True)

 Rotated
Z7 as func of dx displacement fit intercept and slope  [  60.87750239 -145.20305461]
1/slope= -0.006886907460044104
Z8 as func of dx displacement fit intercept and slope  [29.55216431 -7.0379821 ]
1/slope= -0.1420861811180882
Z4 as func of dx displacement fit intercept and slope  [-42.70664384  -5.25989789]
1/slope= -0.19011775909558462
_images/index_40_1.png

 Rotated
Z7 as func of dx displacement fit intercept and slope  [54.77759387  1.84962481]
1/slope= 0.5406501858986662
Z8 as func of dx displacement fit intercept and slope  [ 24.6973816  153.65157085]
1/slope= 0.006508231542932624
Z4 as func of dx displacement fit intercept and slope  [-57.22614764  18.17853625]
1/slope= 0.0550099296449157
_images/index_40_3.png

 Rotated
Z7 as func of dx displacement fit intercept and slope  [65.85094081 27.9882209 ]
1/slope= 0.03572931639687421
Z8 as func of dx displacement fit intercept and slope  [-3.22341484 45.20044489]
1/slope= 0.022123676047898166
Z4 as func of dx displacement fit intercept and slope  [  -47.69477378 -3894.91079696]
1/slope= -0.00025674528946318607
_images/index_40_5.png

Then with the image transpose, we get the correct behavior - i.e. there’s a comaX dependence on dx, and comaY dependence on dy, and nothing needs to be swapped (swap_xy_coma=False):

[19]:
output_collection = 'u/scichris/latiss_230310_run/wep_with_transpose_'
butler = Butler(
    "/sdf/data/rubin/repo/embargo/",
    collections=[output_collection],
    instrument='LATISS'
)

slope_dic = {}
for axis in 'xyz':
    slopes = plot_sens_rot_zk478(butler, axis=axis, add_angle=90,
                                 mult_angle = -1, swap_xy_coma=False,
                                 pre='-')
    slope_dic[axis] = slopes

 Rotated
Z7 as func of dx displacement fit intercept and slope  [  60.87750239 -145.2030546 ]
1/slope= -0.006886907460396176
Z8 as func of dx displacement fit intercept and slope  [29.55216427 -7.03798224]
1/slope= -0.14208617835920004
Z4 as func of dx displacement fit intercept and slope  [-42.70664388  -5.25989759]
1/slope= -0.19011776980762635
_images/index_42_1.png

 Rotated
Z7 as func of dx displacement fit intercept and slope  [54.77759383  1.84962474]
1/slope= 0.5406502076992792
Z8 as func of dx displacement fit intercept and slope  [ 24.69738157 153.65157087]
1/slope= 0.0065082315418858745
Z4 as func of dx displacement fit intercept and slope  [-57.22614764  18.17853627]
1/slope= 0.05500992957311213
_images/index_42_3.png

 Rotated
Z7 as func of dx displacement fit intercept and slope  [65.85094081 27.98822459]
1/slope= 0.03572931168827265
Z8 as func of dx displacement fit intercept and slope  [-3.22341477 45.20044742]
1/slope= 0.02212367480814095
Z4 as func of dx displacement fit intercept and slope  [  -47.69477378 -3894.91079784]
1/slope= -0.00025674528940569554
_images/index_42_5.png
[20]:
slope_dic
[20]:
{'x': [-145.20305460042786, -7.037982241115364, -5.259897594064277],
 'y': [1.8496247402835009, 153.65157087054595, 18.17853627081868],
 'z': [27.988224590630104, 45.20044742440462, -3894.910797836887]}

We have measured directly the slopes that form the following Jacobian:

[Z7]   [ dZ7/dx  dZ7/dy  dZ7/dz ]   [dx]
[Z8] = [ dZ8/dx  dZ8/dy  dZ8/dz ] * [dy]  = J * X
[Z4]   [ dZ4/dx  dZ4/dy  dZ4/dz ]   [dz]
[21]:
J = np.array([slope_dic['x'], slope_dic['y'], slope_dic['z']]).T
np.set_printoptions(formatter={'float_kind':'{:.8f}'.format})
J
[21]:
array([[-145.20305460, 1.84962474, 27.98822459],
       [-7.03798224, 153.65157087, 45.20044742],
       [-5.25989759, 18.17853627, -3894.91079784]])

The sensitivity matrix is the inverse of that Jacobian:

[22]:
M = -np.linalg.inv(J)
np.set_printoptions(formatter={'float_kind':'{:.4f}'.format})
M*1000
[22]:
array([[6.8894, -0.0887, 0.0485],
       [0.3179, -6.5034, -0.0732],
       [-0.0078, -0.0302, 0.2563]])

Compare that to the original matrix:

[23]:
M0 = np.array([[1.0 / 206.0,      0.0,                       0.0],
[ 0.0,          -1.0 / 206.0,     -(109.0 / 206.0) / 4200],
[ 0.0,             0.0,                      1.0 / 4200.0]])
M0*1000
[23]:
array([[4.8544, 0.0000, 0.0000],
       [0.0000, -4.8544, -0.1260],
       [0.0000, 0.0000, 0.2381]])

Thus we have that the new sensitivity matrix would be :

[24]:
np.set_printoptions(formatter={'float_kind':'{:.8f}'.format})
M
[24]:
array([[0.00688945, -0.00008867, 0.00004848],
       [0.00031787, -0.00650340, -0.00007319],
       [-0.00000782, -0.00003023, 0.00025634]])

Plot all derotated Zernikes as a function of hexapod motion

For completeness, we also plot all other measured Zernikes (besides Z7, Z8, Z4 considered for sensitivity matrix) as a function of hexapod motion.

[25]:
def plot_sens_rot(axis = 'x', verbose=True, add_angle=0, mult_angle = 1, pre='-'):
    if axis == 'x':
        dxs = np.linspace(-2, 2, 10)
        intra_seq_nums = np.arange(71, 90, 2)

    elif axis == 'y':
        dxs = np.linspace(-2, 2, 10) # this is dy
        intra_seq_nums = np.arange(91, 110, 2)

    elif axis == 'z':
        dxs = np.linspace(-0.1, 0.1, 10) # this is dz
        intra_seq_nums = np.arange(172, 191, 2)

    # read in the appropriate zernikes
    zernikes = []
    for seq_num in intra_seq_nums:
        zernikes.append(
            butler.get(
                "zernikeEstimateAvg",
                dataId={'instrument':'LATISS', 'detector':0,
                        'visit':int(f'20230310{seq_num+1:05d}')}
            )
        )
    zernikes = np.array(zernikes)

    # first, pad with 4 zeros so that array[N]  corresponds to ZkN
    zk1_22_list = []
    for zk4_22 in zernikes:
        zk1_22 = np.pad(zk4_22,[4,0])
        zk1_22_list.append(zk1_22)
    zk1_22_arr = np.array(zk1_22_list)

    # now derotate using the  EFD angle = el-rot
    zk1_22_rot_list = []
    for pair in range(len(intra_seq_nums)):

        zk1_22_unrot = zk1_22_arr[pair]

        seq_num = intra_seq_nums[pair]
        intra_name = 'AT_O_20230310_'+   str(seq_num).zfill(6)

        # derotate by applying matrix multiplication
        angle =  d[d['intra'] == intra_name]['angle'].value[0]

        # derotate using angle = rot-el
        #                      = Nasmyth2CalculatedAngle- elevationCalculatedAngkle
        zk1_22rot = np.matmul(zk1_22_unrot,
                              zernikeRotMatrix(22, np.deg2rad(add_angle+mult_angle*angle)))
        zk1_22_rot_list.append(zk1_22rot)

    zk1_22_rot_arr = np.array(zk1_22_rot_list)

    # plot the derotated and the rotated Zks
    fig, axes = plt.subplots(nrows=7, ncols=3, figsize=(12,22))
    axes = axes.ravel()
    xdata = dxs


    # unrotated
    if verbose: print('\n Unrotated ')
    for j in range(4, 23):
        ydata = 1000*zk1_22_arr[:,j] # use nm
        axes[j-4].scatter(dxs, ydata,)# label='unrot')

        # fit straight line
        x=np.arange(np.min(xdata), np.max(xdata), np.abs(np.max(xdata) - np.min(xdata))/100 )
        popt,pcov = curve_fit(line, xdata, ydata)
        if verbose:
            print(f'Z{j} as func of dx displacement fit intercept and slope ',popt)
        axes[j-4].plot(x,line(x, *popt),
         label=f'unrot slope {np.round(popt[1],1)}nm / mm', alpha=0.8)

        if verbose: print('1/slope=', 1/popt[1])

        axes[j-4].set_title(f"Z{j}")

    # rotated
    if verbose:
        print('\n Rotated ')
    for j in range(4, 23):
        ydata = 1000*zk1_22_rot_arr[:,j] # use nm
        axes[j-4].scatter(dxs, ydata,)# label='rot' )

        # fit straight line
        x=np.arange(np.min(xdata), np.max(xdata),
                    np.abs(np.max(xdata) - np.min(xdata))/100 )
        popt,pcov = curve_fit(line, xdata, ydata)
        if verbose:
            print(f'Z{j} as func of dx displacement fit intercept and slope ',popt)
        axes[j-4].plot(x,line(x, *popt),
         label=f'rot slope {np.round(popt[1],1)}nm / mm', alpha=0.8)

        if verbose: print('1/slope=', 1/popt[1])

    # Plot slopes based on
    #     self.matrix_sensitivity = [
    #             [1.0 / 206.0, 0.0, 0.0],
    #             [0.0, -1.0 / 206.0, -(109.0 / 206.0) / 4200],
    #             [0.0, 0.0, 1.0 / 4200.0],
    #         ]

    loc_string = 'lower left'

    # multiplication factor of the original sensitivity matrix terms ...
    mult = int(pre+'1')

    # we're plotting   zk7, zk8  ,zk4
    zk0 = 4 # starting from Zk4....
    if axis == 'x':
        # dx slopes
        # Z7 as a function of dx : Cx
        ax_idx = 7-zk0 # zk7
        popt = [+7.20791437, mult*206]
        axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm')
        axes[ax_idx].legend(title='Z7', loc=loc_string,
                           bbox_to_anchor=(0.9, 0.7),
          bbox_transform=fig.transFigure)


    elif axis == 'y':
        # dy slopes
        # defocus as function of dy : Czy
        ax_idx = 4-zk0

        popt = [+7.20791437, mult*(-109)]
        axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm')
        axes[ax_idx].legend(title='Z4', loc=loc_string,
                            bbox_to_anchor=(0.9, 0.82),
                            bbox_transform=fig.transFigure)

        # Z8 (comaY) as function of dy : Cy
        ax_idx = 8-zk0
        popt = [+7.20791437, mult*(-206)]
        axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm')
        axes[ax_idx].legend(title='Z8', loc=loc_string,
                            bbox_to_anchor=(0.9, 0.7),
                            bbox_transform=fig.transFigure)

    elif axis == 'z':
        # dz slopes
        # defocus as function of dz : Dz
        ax_idx = 4-zk0
        popt = [+7.20791437, mult*4200]
        axes[ax_idx].plot(x, line(x,*popt), label=f'{pre}senM: {popt[1]} nm / mm')
        axes[ax_idx].legend(title='Z4', loc=loc_string,
                            bbox_to_anchor=(0.9, 0.82),
                            bbox_transform=fig.transFigure)

    for ax in axes[:-2]:
        ax.set_ylim(-300, 300)
        ax.axhline(0, c='k')

    # add just one set of labels
    fig.text(0.44, 0.1,  f"M2 d{axis} [mm]", fontsize=14)
    fig.text(0.06 ,0.4,"WFE [nm]", rotation='vertical',fontsize=14 )
    axes[-2].axis('off')
    axes[-1].axis('off')
    fig.text(0.3,0.9,
             f'Rotation by  Galsim({add_angle} + {mult_angle}*angle),\
             slope= {pre}senM term',
             fontsize=14)
    fig.subplots_adjust(hspace=0.25)
    plt.tight_layout()
    plt.show()


output_collection = 'u/scichris/latiss_230310_run/wep_with_transpose_'
butler = Butler(
    "/sdf/data/rubin/repo/embargo/",
    collections=[output_collection],
    instrument='LATISS'
)

for axis in 'xyz':
    plot_sens_rot(axis=axis, add_angle=90, mult_angle = -1, verbose=False)
_images/index_53_0.png
_images/index_53_1.png
_images/index_53_2.png

Summary

We obtained an estimate of the sensitivity matrix using the 2023-03-10 auxiliary telescope data. The resulting wavefront estimates (coefficients of Noll Zernike polynomials) from ts_wep were derotated using the information about the rotator and elevation angle. As a sanity check, we ensured that the rotationally-invariant total moments of each optical aberration (total coma, total trefoil) were identical between the original and derotated datasets. Plotting the derotated Zernike coefficients as a function of hexapod displacement dx, dy, dz, we obtained the sensitivity matrix that describes the amount of mm of hexapod motion needed to correct a value in nm of given Zernike coefficient. Currently WEP estimates annular Noll Zernikes 4-22, yet the sensitivity matrix only includes Z4 (defocus), Z7 (coma-x) and Z8 (coma-y). Future improvements to this work could entail inclusion of more terms of Zernikes polynomial in the sensitivity matrix, as well as an estimate of uncertainty in the estimate of each Zernike mode.