Adaptive optics with a Shack-Hartmann wavefront sensor
======================================================
We will simulate a closed-loop adaptive optics system, based on the the
Spectro-Polarimetric High-contrast Exoplanet REsearch (SPHERE) adaptive
optics (AO) system, that uses a Shack-Hartmann WFS. We will simulate
calibration and on-sky operation of this simulated AO system.
We first start by importing the relevant python modules.
.. code:: ipython3
from hcipy import *
from progressbar import progressbar
import numpy as np
import matplotlib.pyplot as plt
import scipy.ndimage as ndimage
import time
import os
%matplotlib inline
Simulating the VLT pupil
------------------------
We will model here the VLT pupil, of 8m in diameter (the primary mirror
is 8.2m but the actual pupil is restricted by M2 to 8m, there is an
extra margin used for choping in the infrared).
.. code:: ipython3
telescope_diameter = 8. # meter
central_obscuration = 1.2 # meter
central_obscuration_ratio = central_obscuration / telescope_diameter
spider_width = 0.05 # meter
oversizing_factor = 16 / 15
We represent the pupil by a grid of 240px. This is the sampling used by
the SH WFS of SPHERE, which is an EMCCD of 240x240px (6px per
subapertures, with 40 subapertures on one diameter). To avoid some
numerical problems at the edges, we oversize the pupil grid by a factor
16 / 15, e.g. the grid is represented by a grid of 240 \* 16 / 15 = 256
px.
.. code:: ipython3
num_pupil_pixels = 240 * oversizing_factor
pupil_grid_diameter = telescope_diameter * oversizing_factor
pupil_grid = make_pupil_grid(num_pupil_pixels, pupil_grid_diameter)
VLT_aperture_generator = make_obstructed_circular_aperture(telescope_diameter,
central_obscuration_ratio, num_spiders=4, spider_width=spider_width)
VLT_aperture = evaluate_supersampled(VLT_aperture_generator, pupil_grid, 4)
The factor 4 indicates that each pixel will be evaluated with 4x
supersampling, effectively averaging 4x4=16 subpixels for each pixel.
.. code:: ipython3
imshow_field(VLT_aperture, cmap='gray')
plt.xlabel('x position(m)')
plt.ylabel('y position(m)')
plt.colorbar()
plt.show()
.. image:: output_8_0.png
As shown above, the pupil is not exactly that of the VLT (the 4 spiders
of the VLT intersect on the perimetre of M2, and not at the center), but
this is not important here.
Incoming wavefront
------------------
Let’s assume we work with monochromatic light at 700nm for wavefront
sensing and in the K band at 2.2 micron for the scientific channel
.. code:: ipython3
wavelength_wfs = 0.7e-6
wavelength_sci = 2.2e-6
wf = Wavefront(VLT_aperture, wavelength_sci)
wf.total_power = 1
Let visualize the corresponding diffraction pattern. To do so, we need
to propagate the wavefront from the pupil to a focal plane. We assume
here a perfect lens (see :class:``FraunhoferPropagator`` for details on
the model).
We also need to sample the electric field on a focal plane. We use here
4 pixels per resolution elements and set the field of view to 30
lambda/D in radius at the science wavelength.
.. code:: ipython3
spatial_resolution = wavelength_sci / telescope_diameter
focal_grid = make_focal_grid(q=4, num_airy=30, spatial_resolution=spatial_resolution)
propagator = FraunhoferPropagator(pupil_grid, focal_grid)
unaberrated_PSF = propagator.forward(wf).power
imshow_field(np.log10(unaberrated_PSF / unaberrated_PSF.max()), cmap='inferno', vmin=-6)
plt.colorbar()
plt.show()
.. image:: output_14_0.png
Wavefront sensor
----------------
The WFS is a squared 40x40 Shack Hartmann WFS. The diameter of the beam
needs to be reshaped with a magnifier, otherwise the spots are not
resolved by the pupil grid: the spots have a size of
:math:`F \lambda = 35\mathrm{\mu m}` with a F-ratio of 50. If the beam
is 5mm, then 1px is 20 micron and we resolve the spots, albeit barely.
.. code:: ipython3
f_number = 50
num_lenslets = 40 # 40 lenslets along one diameter
sh_diameter = 5e-3 # m
magnification = sh_diameter / telescope_diameter
magnifier = Magnifier(magnification)
.. code:: ipython3
shwfs = SquareShackHartmannWavefrontSensorOptics(pupil_grid.scaled(magnification), f_number, \
num_lenslets, sh_diameter)
shwfse = ShackHartmannWavefrontSensorEstimator(shwfs.mla_grid, shwfs.micro_lens_array.mla_index)
We assume a noiseless detector. In practice the EMCCD of SPHERE has RON
of about 1 electron.
.. code:: ipython3
camera = NoiselessDetector(focal_grid)
Let’s look at the SH image for an undisturbed wavefront
.. code:: ipython3
wf = Wavefront(VLT_aperture, wavelength_wfs)
camera.integrate(shwfs(magnifier(wf)), 1)
image_ref = camera.read_out()
imshow_field(image_ref, cmap='inferno')
plt.colorbar()
plt.show()
.. image:: output_22_0.png
And zooming in a bit on some of the spots to see a little more detail…
.. code:: ipython3
imshow_field(image_ref, cmap='inferno')
plt.xlim(-0.001, 0.002)
plt.ylim(-0.001, 0.002)
plt.show()
.. image:: output_24_0.png
We select subapertures to use for wavefront sensing, based on their
flux. The sub-pupils seeing the spiders receive about 75% of the flux of
the unobscured sup-apertures. We want to include those, but we do not
want to incldues superatures at the edge of the pupil that receive less
than 50% of that flux. We therefore use a threshold at 50%.
.. code:: ipython3
fluxes = ndimage.measurements.sum(image_ref, shwfse.mla_index, shwfse.estimation_subapertures)
flux_limit = fluxes.max() * 0.5
estimation_subapertures = shwfs.mla_grid.zeros(dtype='bool')
estimation_subapertures[shwfse.estimation_subapertures[fluxes > flux_limit]] = True
shwfse = ShackHartmannWavefrontSensorEstimator(shwfs.mla_grid, shwfs.micro_lens_array.mla_index, estimation_subapertures)
Calculate reference slopes.
.. code:: ipython3
slopes_ref = shwfse.estimate([image_ref])
Deformable mirror
-----------------
Let’s assume we control 500 disk harmonic modes with the DM.
.. code:: ipython3
num_modes = 500
dm_modes = make_disk_harmonic_basis(pupil_grid, num_modes, telescope_diameter, 'neumann')
dm_modes = ModeBasis([mode / np.ptp(mode) for mode in dm_modes], pupil_grid)
deformable_mirror = DeformableMirror(dm_modes)
Calibrating the interaction matrix
----------------------------------
Then we need to calibrate the interaction matrix: you excite
individually each mode of the DM and estimate the centroids of the
spots.
.. code:: ipython3
probe_amp = 0.01 * wavelength_wfs
response_matrix = []
wf = Wavefront(VLT_aperture, wavelength_wfs)
wf.total_power = 1
# Set up animation
plt.figure(figsize=(10, 6))
anim = FFMpegWriter('response_matrix.mp4', framerate=5)
for i in progressbar(range(num_modes)):
slope = 0
# Probe the phase response
amps = [-probe_amp, probe_amp]
for amp in amps:
deformable_mirror.flatten()
deformable_mirror.actuators[i] = amp
dm_wf = deformable_mirror.forward(wf)
wfs_wf = shwfs(magnifier(dm_wf))
camera.integrate(wfs_wf, 1)
image = camera.read_out()
slopes = shwfse.estimate([image])
slope += amp * slopes / np.var(amps)
response_matrix.append(slope.ravel())
# Only show all modes for the first 40 modes
if i > 40 and (i + 1) % 20 != 0:
continue
# Plot mode response
plt.clf()
plt.suptitle('Mode %d / %d: DM shape' % (i + 1, num_modes))
plt.subplot(1,2,1)
plt.title('DM surface')
im1 = imshow_field(deformable_mirror.surface, cmap='RdBu', mask=VLT_aperture)
plt.subplot(1,2,2)
plt.title('SH spots')
im2 = imshow_field(image)
plt.quiver(shwfs.mla_grid.subset(shwfse.estimation_subapertures).x,
shwfs.mla_grid.subset(shwfse.estimation_subapertures).y,
slope[0,:], slope[1,:],
color='white')
anim.add_frame()
response_matrix = ModeBasis(response_matrix)
plt.close()
anim.close()
# Show created animation
anim
.. parsed-literal::
100% (500 of 500) |######################| Elapsed Time: 0:01:01 Time: 0:01:01
.. raw:: html
We invert the interaction matrix using Tikhonov regularisation
.. code:: ipython3
rcond = 1e-3
reconstruction_matrix = inverse_tikhonov(response_matrix.transformation_matrix, rcond=rcond)
print(reconstruction_matrix.shape)
.. parsed-literal::
(500, 2432)
We initialise the DM with a random position by setting the DM actuators
at random values.
.. code:: ipython3
# Put actuators at random values, putting a little more power in low-order modes
deformable_mirror.actuators = np.random.randn(num_modes) / (np.arange(num_modes) + 10)
# Normalize the DM surface so that we get a reasonable surface RMS.
deformable_mirror.actuators *= 0.3 * wavelength_sci / np.std(deformable_mirror.surface)
imshow_field(deformable_mirror.phase_for(wavelength_wfs), mask=VLT_aperture, cmap='RdBu')
plt.colorbar()
plt.show()
.. image:: output_37_0.png
Closing the loop without atmospheric disturbance
------------------------------------------------
.. code:: ipython3
zero_magnitude_flux = 3.9e10 #3.9e10 photon/s for a mag 0 star
stellar_magnitude = 5
delta_t = 1e-3 # sec, so a loop speed of 1kHz.
wf_wfs = Wavefront(VLT_aperture, wavelength_wfs)
wf_sci = Wavefront(VLT_aperture, wavelength_sci)
wf_wfs.total_power = zero_magnitude_flux * 10**(-stellar_magnitude / 2.5)
wf_sci.total_power = zero_magnitude_flux * 10**(-stellar_magnitude / 2.5)
print("Photon flux per WFS frame {:g}".format(wf_wfs.total_power * delta_t))
.. parsed-literal::
Photon flux per WFS frame 390000
.. code:: ipython3
deformable_mirror.actuators = np.random.randn(num_modes) / (np.arange(num_modes) + 10)
deformable_mirror.actuators *= 0.3 * wavelength_sci / np.std(deformable_mirror.surface)
gain = 0.6
leakage = 0.01
num_iterations = 20
long_exposure = 0
# Set up animation
plt.figure(figsize=(8, 8))
anim = FFMpegWriter('AO_simulation_without_turbulence.mp4', framerate=10)
for timestep in progressbar(range(num_iterations)):
# Propagate through SH-WFS
wf_after_sh = shwfs(magnifier(deformable_mirror(wf_wfs)))
# Propagate the NIR wavefront
wf_sci_after_dm = propagator(deformable_mirror(wf_sci))
# Read out WFS camera
camera.integrate(wf_after_sh, delta_t)
wfs_image = camera.read_out()
wfs_image = large_poisson(wfs_image).astype('float')
# Calculate slopes from WFS image
slopes = shwfse.estimate([wfs_image + 1e-10])
slopes -= slopes_ref
slopes = slopes.ravel()
# Perform wavefront control and set DM actuators
deformable_mirror.actuators = (1 - leakage) * deformable_mirror.actuators - gain * reconstruction_matrix.dot(slopes)
# Plotting
plt.clf()
plt.suptitle('Timestep %d / %d' % (timestep, num_iterations))
plt.subplot(2,2,1)
plt.title('DM surface [$\\mu$m]')
imshow_field(deformable_mirror.surface * 1e6, cmap='RdBu', vmin=-1, vmax=1, mask=VLT_aperture)
plt.colorbar()
plt.subplot(2,2,2)
plt.title('WFS image [counts]')
imshow_field(wfs_image, cmap='inferno')
plt.colorbar()
plt.subplot(2,2,3)
plt.title('Instantaneous PSF at 2.2$\\mu$m [log]')
imshow_field(np.log10(wf_sci_after_dm.power / wf_sci_after_dm.power.max()), vmin=-6, vmax=0, cmap='inferno')
plt.colorbar()
anim.add_frame()
plt.close()
anim.close()
# Show created animation
anim
.. parsed-literal::
100% (20 of 20) |########################| Elapsed Time: 0:00:13 Time: 0:00:13
.. raw:: html