Source code for esis.flights.f1.optics._instruments._instruments

import pathlib
import numpy as np
import astropy.units as u
import astropy.table
import named_arrays as na
import optika
import esis
from esis.flights.f1.spectrum import He_I, Mg_X, O_V
from .. import primaries
from .. import gratings
from .. import filters

_directory_data = pathlib.Path(__file__).parent / "_data"

__all__ = [
    "design_full",
    "design",
    "design_single",
    "as_built",
    "distortion_fit",
    "distortion_fit_bounds",
]


[docs] def design_full( grid: None | optika.vectors.ObjectVectorArray = None, axis_channel: str = "channel", num_distribution: int = 11, ) -> esis.optics.Instrument: """ Load the entire optical design including the inactive channels. This instance includes all six channels instead of the four active channels included in :func:`design`. Parameters ---------- grid sampling of wavelength, field, and pupil positions that will be used to characterize the optical system. axis_channel The name of the logical axis corresponding to changing camera channel. num_distribution number of Monte Carlo samples to draw when computing uncertainties """ num_folds = 8 num_channels = 6 name_channel = na.arange(0, num_channels, axis=axis_channel) angle_per_channel = (360 * u.deg) / num_folds cos_per_channel = np.cos(angle_per_channel / 2) angle_channel_offset = -angle_per_channel / 2 angle_channel = na.linspace( start=0 * u.deg, stop=num_channels * angle_per_channel, axis=axis_channel, num=num_channels, endpoint=False, ) angle_channel = angle_channel + angle_channel_offset # dashstyle = (0, (1, 3)) # dashstyle_channels = na.ScalarArray( # ndarray=np.array( # object=[dashstyle, "solid", "solid", "solid", "solid", dashstyle], # dtype=object, # ), # axes="channel", # ) # alpha_channels = na.ScalarArray(np.array([0, 1, 1, 1, 1, 0]), axes="channel") radius_primary_clear = 77.9 * u.mm primary = esis.optics.PrimaryMirror( sag=optika.sags.ParabolicSag( focal_length=-1000 * u.mm, parameters_slope_error=optika.metrology.SlopeErrorParameters( step_size=4 * u.mm, kernel_size=2 * u.mm, ), parameters_roughness=optika.metrology.RoughnessParameters( period_min=0.06 * u.mm, period_max=6 * u.mm, ), parameters_microroughness=optika.metrology.RoughnessParameters( period_min=1.6 * u.um, period_max=70 * u.um, ), ), num_folds=8, width_clear=2 * radius_primary_clear * cos_per_channel, width_border=(83.7 * u.mm - radius_primary_clear) * cos_per_channel, material=primaries.materials.multilayer_design(), translation=na.Cartesian3dVectorArray( x=na.UniformUncertainScalarArray( nominal=0 * u.mm, width=1 * u.mm, num_distribution=num_distribution, ), y=na.UniformUncertainScalarArray( nominal=0 * u.mm, width=1 * u.mm, num_distribution=num_distribution, ), z=0 * u.mm, ), ) front_aperture = esis.optics.FrontAperture( translation=na.Cartesian3dVectorArray( x=0 * u.mm, y=0 * u.mm, z=primary.sag.focal_length - 500 * u.mm, ), ) point_tuffet_1 = na.Cartesian2dVectorArray(2.54, 37.1707) * u.mm point_tuffet_2 = na.Cartesian2dVectorArray(24.4876, 28.0797) * u.mm difference_tuffet = point_tuffet_2 - point_tuffet_1 slope_tuffet = difference_tuffet.y / difference_tuffet.x radius_tuffet = point_tuffet_1.y - slope_tuffet * point_tuffet_1.x central_obscuration = esis.optics.CentralObscuration( num_folds=num_folds, halfwidth=radius_tuffet * cos_per_channel, remove_last_vertex=True, translation=na.Cartesian3dVectorArray(z=-1404.270) * u.mm, ) field_stop = esis.optics.FieldStop( num_folds=num_folds, radius_clear=1.82 * u.mm, radius_mechanical=2.81 * u.mm, translation=na.Cartesian3dVectorArray( x=primary.translation.x.copy(), y=primary.translation.y.copy(), z=primary.sag.focal_length, ), ) radius_grating = 597.830 * u.mm error_radius_grating = 0.4 * u.percent width_grating_border = 2 * u.mm width_grating_border_inner = 4.58 * u.mm var_grating_z_single = np.square(2.5e-5 * u.m) var_grating_z_systematic = np.square(5e-6 * u.m) var_grating_z = var_grating_z_single / 3 + var_grating_z_systematic error_grating_z = np.sqrt(var_grating_z) grating = esis.optics.Grating( angle_input=1.301 * u.deg, angle_output=8.057 * u.deg, sag=optika.sags.SphericalSag( radius=na.UniformUncertainScalarArray( nominal=-radius_grating, width=radius_grating * error_radius_grating, num_distribution=num_distribution, ), parameters_slope_error=optika.metrology.SlopeErrorParameters( step_size=2 * u.mm, kernel_size=1 * u.mm, ), parameters_roughness=optika.metrology.RoughnessParameters( period_min=0.024 * u.mm, period_max=2.4 * u.mm, ), parameters_microroughness=optika.metrology.RoughnessParameters( period_min=0.02 * u.um, period_max=2 * u.um, ), ), material=gratings.materials.multilayer_design(), rulings=gratings.rulings.ruling_design( num_distribution=num_distribution, ), num_folds=num_folds, halfwidth_inner=13.02 * u.mm - width_grating_border_inner, halfwidth_outer=10.49 * u.mm - width_grating_border, width_border=width_grating_border, width_border_inner=width_grating_border_inner, clearance=1.25 * u.mm, distance_radial=2.074999998438000e1 * u.mm, azimuth=angle_channel.copy(), translation=na.Cartesian3dVectorArray( x=na.UniformUncertainScalarArray( nominal=0 * u.mm, width=1 * u.mm, num_distribution=num_distribution, ), y=na.UniformUncertainScalarArray( nominal=0 * u.mm, width=1 * u.mm, num_distribution=num_distribution, ), z=na.UniformUncertainScalarArray( nominal=primary.sag.focal_length - 374.7 * u.mm, width=error_grating_z, num_distribution=num_distribution, ), ), yaw=-4.469567242792327 * u.deg, roll=na.UniformUncertainScalarArray( nominal=0 * u.deg, width=1.3e-2 * u.rad, num_distribution=num_distribution, ), ) filter = esis.optics.Filter( material=filters.materials.thin_film_design(), radius_clear=15 * u.mm, width_border=0 * u.mm, distance_radial=95.9 * u.mm, azimuth=angle_channel.copy(), translation=na.Cartesian3dVectorArray( x=0 * u.mm, y=0 * u.mm, z=grating.translation.z.nominal + 1.301661998854058 * u.m, ), yaw=-3.45 * u.deg, roll=45 * u.deg, ) sensor = esis.optics.Sensor( # The physical mask on the ESIS-I detectors was undersized, leaving # readout-buffer rows exposed to light. Science data extends into # those rows, so the active area is 2 x 1040 rows, matching the # 1040-row halves of the Level-1 frames. num_pixel_y=2 * 1040, distance_radial=108 * u.mm, azimuth=angle_channel.copy(), translation=na.Cartesian3dVectorArray( x=0 * u.mm, y=0 * u.mm, z=filter.translation.z + 200 * u.mm, ), yaw=-12.252 * u.deg, material=optika.sensors.materials.e2v_ccd97( temperature=-55 * u.deg_C, ), ) camera = esis.optics.Camera( sensor=sensor, gain=2.5 * u.electron / u.DN, channel=name_channel, channel_trigger=1, timedelta_sync=1 * u.ms, ) if grid is None: grid = optika.vectors.ObjectVectorArray( wavelength=629.77 * u.AA, field=na.Cartesian2dVectorLinearSpace( start=-1, stop=1, axis=na.Cartesian2dVectorArray("field_x", "field_y"), num=11, centers=True, ), pupil=na.Cartesian2dVectorLinearSpace( start=-1, stop=1, axis=na.Cartesian2dVectorArray("pupil_x", "pupil_y"), num=11, centers=True, ), ) if num_distribution == 0: primary.translation = na.nominal(primary.translation) field_stop.translation = na.nominal(field_stop.translation) grating.sag.radius = na.nominal(grating.sag.radius) grating.rulings.spacing.coefficients[0] = na.nominal( grating.rulings.spacing.coefficients[0] ) grating.rulings.spacing.coefficients[1] = na.nominal( grating.rulings.spacing.coefficients[1] ) grating.rulings.spacing.coefficients[2] = na.nominal( grating.rulings.spacing.coefficients[2] ) grating.rulings.depth = na.nominal(grating.rulings.depth) grating.rulings.ratio_duty = na.nominal(grating.rulings.ratio_duty) grating.translation = na.nominal(grating.translation) grating.roll = na.nominal(grating.roll) return esis.optics.Instrument( name="ESIS 1 final design (all channels)", axis_channel=axis_channel, front_aperture=front_aperture, central_obscuration=central_obscuration, primary_mirror=primary, field_stop=field_stop, grating=grating, filter=filter, camera=camera, wavelength=grid.wavelength, field=grid.field, pupil=grid.pupil, )
[docs] def design( grid: None | optika.vectors.ObjectVectorArray = None, axis_channel: str = "channel", num_distribution: int = 11, ) -> esis.optics.Instrument: """ Load the final optical design prepared by Charles Kankelborg and Hans Courrier. Parameters ---------- grid sampling of wavelength, field, and pupil positions that will be used to characterize the optical system. axis_channel The name of the logical axis corresponding to changing camera channel. num_distribution number of Monte Carlo samples to draw when computing uncertainties """ result = design_full( grid=grid, axis_channel=axis_channel, num_distribution=num_distribution, ) slice_active = {axis_channel: slice(1, 5)} result.grating.azimuth = result.grating.azimuth[slice_active] result.filter.azimuth = result.filter.azimuth[slice_active] result.camera.channel = result.camera.channel[slice_active] result.camera.sensor.azimuth = result.camera.sensor.azimuth[slice_active] return result
[docs] def design_single( grid: None | optika.vectors.ObjectVectorArray = None, axis_channel: str = "channel", num_distribution: int = 11, ) -> esis.optics.Instrument: """ Load only a single channel of the optical design. Since the system is rotationally symmetric, sometimes it's nice to model only one channel Parameters ---------- grid sampling of wavelength, field, and pupil positions that will be used to characterize the optical system. axis_channel The name of the logical axis corresponding to changing camera channel. num_distribution number of Monte Carlo samples to draw when computing uncertainties """ result = design( grid=grid, axis_channel=axis_channel, num_distribution=num_distribution, ) index = {axis_channel: 0} result.grating.azimuth = result.grating.azimuth[index] result.filter.azimuth = result.filter.azimuth[index] result.camera.channel = result.camera.channel[index] result.camera.sensor.azimuth = result.camera.sensor.azimuth[index] result.roll = -result.grating.azimuth return result
[docs] def as_built( grid: None | optika.vectors.ObjectVectorArray = None, axis_channel: str = "channel", num_distribution: int = 11, ) -> esis.optics.Instrument: """ Load the as-built optical model. Based on :func:`design`, but includes efficiency and figure measurements of the primary mirror and gratings, as well as gain measurements of the sensor. Parameters ---------- grid sampling of wavelength, field, and pupil positions that will be used to characterize the optical system. axis_channel The name of the logical axis corresponding to changing camera channel. num_distribution number of Monte Carlo samples to draw when computing uncertainties Examples -------- Load the as-built optical model and print its parameters. .. jupyter-execute:: import esis esis.flights.f1.optics.as_built() """ result = design( grid=grid, axis_channel=axis_channel, num_distribution=num_distribution, ) result.primary_mirror.material = primaries.materials.multilayer_fit() result.grating.serial_number = na.stack( arrays=[ "89025", "89024", "89026", "89027", ], axis=axis_channel, ) result.grating.manufacturing_number = na.stack( arrays=[ "UBO-16-024", "UBO-16-017", "UBO-16-019", "UBO-16-014", ], axis=axis_channel, ) radius_014 = [597.170, 597.210, 597.195] * u.mm radius_017 = [597.065, 597.045, 597.050] * u.mm radius_019 = [597.055, 597.045, 597.030] * u.mm radius_024 = [596.890, 596.870, 596.880] * u.mm # the measurements report the magnitude of the radius of curvature; # the sag convention is negative for these concave gratings (compare # the -597.83 mm radius of the design) result.grating.sag.radius = -na.stack( arrays=[ radius_024.mean(), radius_017.mean(), radius_019.mean(), radius_014.mean(), ], axis=axis_channel, ) result.grating.material = gratings.materials.multilayer_fit() result.grating.rulings = gratings.rulings.ruling_measurement( num_distribution=num_distribution, ) result.camera.sensor.serial_number = na.stack( arrays=[ "SN6", "SN7", "SN9", "SN10", ], axis=axis_channel, ) axis_tap_x = result.camera.axis_tap_x axis_tap_y = result.camera.axis_tap_y # Results from Laurel Rachmeler presented on 2017-07-06 and 2017-07-12. result.camera.gain = na.ScalarArray( ndarray=[ [ [2.55, 2.63], [2.57, 2.57], ], [ [2.57, 2.53], [2.50, 2.52], ], [ [2.57, 2.59], [2.53, 2.52], ], [ [2.60, 2.58], [2.60, 2.54], ], ] * u.electron / u.DN, axes=(axis_channel, axis_tap_y, axis_tap_x), ) result.camera.sensor.readout_noise = 6 * u.electron return result
[docs] def distortion_fit( grid: None | optika.vectors.ObjectVectorArray = None, axis_channel: str = "channel", num_distribution: int = 11, axis_time: None | str = None, ) -> esis.optics.Instrument: """ Apply the best-fit distortion parameters to the ESIS-I :func:`design`. The per-channel parameters are loaded from ``_data/distortion_reference.ecsv``, the best distortion fit of the ESIS-I flight data, optimized against the ``time=15`` frame of the 2019-09-30 flight (:func:`esis.flights.f1.data.level_1`, with a start time of 2019-09-30T18:08:41.642 UTC). The provenance of the fit is recorded in the file header. If `axis_time` is given, the instrument pointing additionally carries the fitted per-frame payload pointing along that axis, one element per frame of :func:`esis.flights.f1.data.level_1`. During the flight the payload pointing drifted by several arcseconds (dominated by yaw, which sweeps monotonically from :math:`+3.3''` at the first frame to :math:`-4.4''` at the last); the optics are otherwise held fixed at the reference fit. The offsets are common to all four channels (a rigid-payload model) and are loaded from ``_data/distortion_pointing.ecsv``, which records the provenance of the per-frame fits in its header. Parameters ---------- grid sampling of wavelength, field, and pupil positions that will be used to characterize the optical system. axis_channel The name of the logical axis corresponding to changing camera channel. num_distribution number of Monte Carlo samples to draw when computing uncertainties axis_time The name of the logical axis corresponding to changing time. If :obj:`None`, the pointing is that of the ``time=15`` reference fit; otherwise the pitch, yaw, and roll gain one element per Level-1 frame. Examples -------- Overplot the ray-traced detector footprint of each spectral line onto the Level-1 frame that the distortion fit was optimized against. Each line's footprint should land on its corresponding image of the field stop. .. jupyter-execute:: import numpy as np import astropy.units as u import named_arrays as na import esis l1 = esis.flights.f1.data.level_1()[dict(time=15)] model = esis.flights.f1.optics.distortion_fit(num_distribution=0) rays = model.system.rayfunction_default.outputs position = rays.position.to(u.um).mean(axis=("pupil_x", "pupil_y")) position = position / model.camera.sensor.width_pixel * u.pixel fig, ax = na.plt.subplots( figsize=(8, 17), constrained_layout=True, axis_rows="channel", nrows=l1.shape["channel"], sharex=True, origin="upper", ) fig.suptitle( "ESIS-I distortion fit vs. Level-1 data" " (2019-09-30 18:08:41 UTC)" ) na.plt.set_xlabel("detector $x$ (pix)", ax=ax[dict(channel=~0)]) na.plt.set_ylabel("detector $y$ (pix)", ax=ax) na.plt.set_aspect("equal", ax=ax) na.plt.pcolormesh( l1.inputs.pixel.x, l1.inputs.pixel.y, C=l1.outputs.value, ax=ax, vmax=np.percentile(l1.outputs.value, 99), ) na.plt.text( x=0.5, y=1.01, s=l1.channel, transform=na.plt.transAxes(ax), ax=ax, ha="center", va="bottom", ) spectral_lines = ["He I", "Mg X", "O V"] colors = ["red", "orange", "yellow"] for i in range(len(spectral_lines)): j = dict(wavelength=i) na.plt.scatter( position.x[j] + 1024 * u.pixel, position.y[j] + 512 * u.pixel, color=colors[i], ax=ax, s=8, where=rays.unvignetted[j], label=spectral_lines[i], ) ax.ndarray[0].legend(loc="upper right"); """ model = design( grid=grid, axis_channel=axis_channel, num_distribution=num_distribution, ) model.wavelength = na.ScalarArray( u.Quantity( [ He_I.wavelength, Mg_X.wavelength, O_V.wavelength, ] ), axes="wavelength", ) parameters = esis.optics.DistortionParameters.from_file( path=_directory_data / "distortion_reference.ecsv", axis=axis_channel, ) model = parameters.to_instrument(model) if axis_time is not None: pointing = astropy.table.QTable.read( _directory_data / "distortion_pointing.ecsv", format="ascii.ecsv", ) model.pitch = model.pitch + na.ScalarArray( u.Quantity(pointing["pitch"]), axes=axis_time, ) model.yaw = model.yaw + na.ScalarArray( u.Quantity(pointing["yaw"]), axes=axis_time, ) model.roll = model.roll + na.ScalarArray( u.Quantity(pointing["roll"]), axes=axis_time, ) return model
[docs] def distortion_fit_bounds( parameters: esis.optics.DistortionParameters, ) -> tuple[esis.optics.DistortionParameters, esis.optics.DistortionParameters]: r""" Compute the parameter bounds used when fitting the ESIS-I distortion. Most parameters are bounded at :math:`\pm 20\%` of the given initial guess. The roll angles and the primary-mirror displacement are instead given the hand-tuned absolute bounds of the ``ESISI_distortion_optimization_20260213_151715`` run, the best fit of the ESIS-I flight data (and the source of the values in :func:`distortion_fit`), since their initial guesses are zero or nearly so. The bounds are expressed in the same units as `parameters` so that flattening both with :func:`named_arrays.pack` yields consistent vectors. Parameters ---------- parameters The initial guess of the fit. Examples -------- Compute the bounds for fitting the ESIS flight-1 design. .. jupyter-execute:: import named_arrays as na import esis instrument = esis.flights.f1.optics.design(num_distribution=0) parameters = esis.optics.DistortionParameters.from_instrument(instrument) lower, upper = esis.flights.f1.optics.distortion_fit_bounds(parameters) na.pack(lower), na.pack(upper) """ p = parameters lower = esis.optics.DistortionParameters( yaw_grating=np.minimum(0.8 * p.yaw_grating, 1.2 * p.yaw_grating), pitch_grating=np.minimum(0.8 * p.pitch_grating, 1.2 * p.pitch_grating), roll_grating=(-2 * u.deg).to(na.unit(p.roll_grating)), roll_field_stop=(-4 * u.deg).to(na.unit(p.roll_field_stop)), spacing_rulings=np.minimum(0.8 * p.spacing_rulings, 1.2 * p.spacing_rulings), displacement_primary=(-10 * u.mm).to(na.unit(p.displacement_primary)), pitch=np.minimum(0.8 * p.pitch, 1.2 * p.pitch), yaw=np.minimum(0.8 * p.yaw, 1.2 * p.yaw), roll=(-4 * u.deg).to(na.unit(p.roll)), ) upper = esis.optics.DistortionParameters( yaw_grating=np.maximum(0.8 * p.yaw_grating, 1.2 * p.yaw_grating), pitch_grating=np.maximum(0.8 * p.pitch_grating, 1.2 * p.pitch_grating), roll_grating=(2 * u.deg).to(na.unit(p.roll_grating)), roll_field_stop=(4 * u.deg).to(na.unit(p.roll_field_stop)), spacing_rulings=np.maximum(0.8 * p.spacing_rulings, 1.2 * p.spacing_rulings), displacement_primary=(0 * u.mm).to(na.unit(p.displacement_primary)), pitch=np.maximum(0.8 * p.pitch, 1.2 * p.pitch), yaw=np.maximum(0.8 * p.yaw, 1.2 * p.yaw), roll=(0 * u.deg).to(na.unit(p.roll)), ) return lower, upper