import re
import math
from astrodata import astro_data_tag, TagSet, astro_data_descriptor, returns_list
from ..gemini import AstroDataGemini
from .lookup import array_properties, nominal_zeropoints
from astropy.wcs import WCS, FITSFixedWarning
import warnings
from ..common import section_to_tuple, build_group_id
from .. import gmu
[docs]class AstroDataF2(AstroDataGemini):
__keyword_dict = dict(camera = 'LYOT',
central_wavelength = 'GRWLEN',
disperser = 'GRISM',
focal_plane_mask = 'MOSPOS',
lyot_stop = 'LYOT',
)
@staticmethod
def _matches_data(source):
return source[0].header.get('INSTRUME', '').upper() in ('F2', 'FLAM')
@astro_data_tag
def _tag_instrument(self):
return TagSet(['F2'])
@astro_data_tag
def _tag_dark(self):
ot = self.phu.get('OBSTYPE')
dkflt = False
for f in (self.phu.get('FILTER1', ''), self.phu.get('FILTER2', '')):
if re.match('DK.?', f):
dkflt = True
break
if dkflt or ot == 'DARK':
return TagSet(['DARK', 'CAL'], blocks=['IMAGE', 'SPECT'])
@astro_data_tag
def _tag_image(self):
if self.phu.get('GRISM') == 'Open' or self.phu.get('GRISMPOS') == 'Open':
return TagSet(['IMAGE'])
# Do not tag this as astro_data_tag. It's a helper function
def _tag_is_spect(self):
grism = self.phu.get('GRISM', '')
grpos = self.phu.get('GRISMPOS', '')
for pattern in ("JH.?", "HK.?", "R3K.?"):
if re.match(pattern, grism) or re.match(pattern, grpos):
return True
return False
@astro_data_tag
def _tag_is_ls(self):
if not self._tag_is_spect():
return
decker = self.phu.get('DECKER') == 'Long_slit' or self.phu.get('DCKERPOS') == 'Long_slit'
if decker or re.match(".?pix-slit", self.phu.get('MOSPOS', '')):
return TagSet(['LS', 'SPECT'])
@astro_data_tag
def _tag_is_mos(self):
if not self._tag_is_spect():
return
decker = self.phu.get('DECKER') == 'mos' or self.phu.get('DCKERPOS') == 'mos'
if decker or re.match("mos.?", self.phu.get('MOSPOS', '')):
return TagSet(['MOS', 'SPECT'])
@astro_data_tag
def _tag_arc(self):
if self.phu.get('OBSTYPE') == 'ARC':
return TagSet(['ARC', 'CAL'])
@astro_data_tag
def _tag_flat(self):
if self.phu.get('OBSTYPE') == 'FLAT':
return TagSet(['FLAT', 'CAL'])
@astro_data_tag
def _tag_twilight(self):
if self.phu.get('OBJECT', '').upper() == 'TWILIGHT':
rej = set(['FLAT']) if self.phu.get('GRISM') != 'Open' else set()
return TagSet(['TWILIGHT', 'CAL'], blocks=rej)
@astro_data_tag
def _tag_disperser(self):
disp = self.phu.get('DISPERSR', '')
if disp.startswith('DISP_WOLLASTON'):
return TagSet(['POL'])
elif disp.startswith('DISP_PRISM'):
return TagSet(['SPECT', 'IFU'])
[docs] @returns_list
@astro_data_descriptor
def array_section(self, pretty=False):
"""
Returns the rectangular section that includes the pixels that would be
exposed to light. If pretty is False, a tuple of 0-based coordinates
is returned with format (x1, x2, y1, y2). If pretty is True, a keyword
value is returned without parsing as a string. In this format, the
coordinates are generally 1-based.
One tuple or string is return per extension/array, in a list. If the
method is called on a single slice, the section is returned as a tuple
or a string.
Parameters
----------
pretty : bool
If True, return the formatted string found in the header.
Returns
-------
tuple of integers or list of tuples
Location of the pixels exposed to light using Python slice values.
string or list of strings
Location of the pixels exposed to light using an IRAF section
format (1-based).
"""
value_filter = (str if pretty else section_to_tuple)
# TODO: discover reason why this is hardcoded, rather than from keyword
return value_filter('[1:2048,1:2048]')
# TODO: sort out the unit-handling here
[docs] @astro_data_descriptor
def central_wavelength(self, asMicrometers=False, asNanometers=False,
asAngstroms=False):
"""
Returns the central wavelength in meters or the specified units
Parameters
----------
asMicrometers : bool
If True, return the wavelength in microns
asNanometers : bool
If True, return the wavelength in nanometers
asAngstroms : bool
If True, return the wavelength in Angstroms
Returns
-------
float
The central wavelength setting
"""
unit_arg_list = [asMicrometers, asNanometers, asAngstroms]
if unit_arg_list.count(True) == 1:
# Just one of the unit arguments was set to True. Return the
# central wavelength in these units
if asMicrometers:
output_units = "micrometers"
if asNanometers:
output_units = "nanometers"
if asAngstroms:
output_units = "angstroms"
else:
# Either none of the unit arguments were set to True or more than
# one of the unit arguments was set to True. In either case,
# return the central wavelength in the default units of meters.
output_units = "meters"
central_wavelength = float(self.phu['GRWLEN'])
if self.phu['FILTER1'] == 'K-long-G0812':
central_wavelength = 2.2
if central_wavelength < 0.0:
return None
else:
return gmu.convert_units('micrometers', central_wavelength,
output_units)
[docs] @astro_data_descriptor
def data_section(self, pretty=False):
"""
Returns the rectangular section that includes the pixels that would be
exposed to light. If pretty is False, a tuple of 0-based coordinates
is returned with format (x1, x2, y1, y2). If pretty is True, a keyword
value is returned without parsing as a string. In this format, the
coordinates are generally 1-based.
One tuple or string is return per extension/array, in a list. If the
method is called on a single slice, the section is returned as a tuple
or a string.
Parameters
----------
pretty : bool
If True, return the formatted string found in the header.
Returns
-------
tuple of integers or list of tuples
Location of the pixels exposed to light using Python slice values.
string or list of strings
Location of the pixels exposed to light using an IRAF section
format (1-based).
"""
return self.array_section(pretty=pretty)
[docs] @astro_data_descriptor
def detector_section(self, pretty=False):
"""
Returns the section covered by the detector relative to the whole
mosaic of detectors. If pretty is False, a tuple of 0-based coordinates
is returned with format (x1, x2, y1, y2). If pretty is True, a keyword
value is returned without parsing as a string. In this format, the
coordinates are generally 1-based.
One tuple or string is return per extension/array, in a list. If the
method is called on a single slice, the section is returned as a tuple
or a string.
Parameters
----------
pretty : bool
If True, return the formatted string found in the header.
Returns
-------
tuple of integers or list of tuples
Position of the detector using Python slice values.
string or list of strings
Position of the detector using an IRAF section format (1-based).
"""
return self.array_section(pretty=pretty)
[docs] @returns_list
@astro_data_descriptor
def dispersion_axis(self):
"""
Returns the axis along which the light is dispersed.
Returns
-------
(list of) int (2)
Dispersion axis.
"""
return 2 if 'SPECT' in self.tags else None
[docs] @astro_data_descriptor
def detector_x_offset(self):
"""
Returns the offset from the reference position in pixels along
the positive x-direction of the detector
Returns
-------
float
The offset in pixels
"""
try:
return -self.phu.get('QOFFSET') / self.pixel_scale()
except TypeError: # either is None
return None
[docs] @astro_data_descriptor
def detector_y_offset(self):
"""
Returns the offset from the reference position in pixels along
the positive y-direction of the detector
Returns
-------
float
The offset in pixels
"""
try:
offset = -self.phu.get('POFFSET') / self.pixel_scale()
except TypeError: # either is None
return None
# Bottom port flip
return -offset if self.phu.get('INPORT')==1 else offset
[docs] @astro_data_descriptor
def filter_name(self, stripID=False, pretty=False):
"""
Returns the name of the filter(s) used. The component ID can be
removed with either 'stripID' or 'pretty'. If a combination of filters
is used, the filter names will be join into a unique string with '&' as
separator. If 'pretty' is True, filter positions such as 'Open',
'Dark', 'blank', and others are removed leaving only the relevant
filters in the string.
Parameters
----------
stripID : bool
If True, removes the component ID and returns only the name of
the filter.
pretty : bool
Parses the combination of filters to return a single string value
wi the "effective" filter.
Returns
-------
str
The name of the filter combination with or without the component ID.
"""
try:
filter1 = self.phu['FILTER1']
filter2 = self.phu['FILTER2']
except KeyError:
try:
# Old (pre-20100301) keyword names
filter1 = self.phu['FILT1POS']
filter2 = self.phu['FILT2POS']
except KeyError:
return None
if stripID or pretty:
filter1 = gmu.removeComponentID(filter1)
filter2 = gmu.removeComponentID(filter2)
filter = [filter1, filter2]
if pretty:
# Remove filters with the name 'open'
if 'open' in filter2 or 'Open' in filter2:
del filter[1]
if 'open' in filter1 or 'Open' in filter1:
del filter[0]
if ('Block' in filter1 or 'Block' in filter2 or 'Dark' in filter1
or 'Dark' in filter2):
filter = ['blank']
if 'DK' in filter1 or 'DK' in filter2:
filter = ['dark']
if len(filter) == 0:
filter = ['open']
# Return &-concatenated names if we still have two filter names
return '&'.join(filter[:2])
[docs] @returns_list
@astro_data_descriptor
def gain(self):
"""
Returns the gain used for the observation. This is read from a
lookup table using the read_mode and the well_depth.
Returns
-------
float
Gain used for the observation
"""
lnrs = self.phu.get('LNRS')
gain = getattr(array_properties.get(self.read_mode()), 'gain', None)
# F2 adds the reads (in ADU), so the electron-to-ADU conversion
# needs to be divided by the number of reads
return gain / lnrs if gain and lnrs else None
[docs] @astro_data_descriptor
def group_id(self):
"""
Returns a string representing a group of data that are compatible
with each other. This is used when stacking, for example. Each
instrument and mode of observation will have its own rules. F2's
is quite a mouthful.
Returns
-------
str
A group ID for compatible data.
"""
# essentially a copy of the NIRI group_id descriptor,
# adapted for F2.
tags = self.tags
# Descriptors required for each frame type
dark_id = ["exposure_time", "coadds"]
flat_id = ["filter_name", "camera", "exposure_time", "observation_id"]
flat_twilight_id = ["filter_name", "camera"]
science_id = ["observation_id", "filter_name", "camera", "exposure_time"]
## !!! KL: added exposure_time to science_id for QAP. The sky subtraction
## !!! seems unable to deal with difference exposure time circa Sep 2015.
## !!! The on-target dither sky-sub falls over completely.
## !!! Also, we do not have a fully tested scale by exposure routine.
# This is used for imaging flats and twilights to distinguish between
# the two types
additional_item = None
# Update the list of descriptors to be used depending on image type
## This requires updating to cover all spectral types
## Possible updates to the classification system will make this usable
## at the Gemini level
if "DARK" in tags:
id_descriptor_list = dark_id
elif 'IMAGE' in tags and 'FLAT' in tags:
id_descriptor_list = flat_id
additional_item = "F2_IMAGE_FLAT"
elif 'IMAGE' in tags and 'TWILIGHT' in tags:
id_descriptor_list = flat_twilight_id
additional_item = "F2_IMAGE_TWILIGHT"
else:
id_descriptor_list = science_id
# Add in all of the common descriptors required
id_descriptor_list.extend(["read_mode", "detector_section"])
if "SPECT" in tags:
id_descriptor_list.extend(["disperser", "focal_plane_mask"])
return build_group_id(self, id_descriptor_list,
prettify=["filter_name", "disperser", "focal_plane_mask"],
additional=additional_item)
# @astro_data_descriptor
# def instrument(self):
# """
# Returns the name of the instrument, coping with the fact that early
# data apparently had the keyword INSTRUME='Flam'
# Returns
# -------
# str
# The name of the instrument, namely 'F2'
# """
# return 'F2'
[docs] @returns_list
@astro_data_descriptor
def nominal_photometric_zeropoint(self):
"""
Returns the nominal zeropoints (i.e., the magnitude corresponding to
a pixel value of 1) for the extensions in an AD object.
Returns
-------
list/float
zeropoint values, one per SCI extension
"""
gain = self.gain()
filter_name = self.filter_name(pretty=True)
camera = self.camera(pretty=True)
zpt = nominal_zeropoints.get((filter_name, camera))
in_adu = self.is_in_adu()
# Zeropoints in table are for electrons, so subtract 2.5*log10(gain)
# if the data are in ADU
if self.is_single:
try:
return zpt - (2.5 * math.log10(gain) if in_adu else 0)
except TypeError:
return None
else:
return [(zpt - (2.5 * math.log10(g) if in_adu else 0) if zpt and g
else None) for g in gain]
[docs] @returns_list
@astro_data_descriptor
def nonlinearity_coeffs(self):
return getattr(array_properties.get(self.read_mode()), 'coeffs', None)
[docs] @returns_list
@astro_data_descriptor
def non_linear_level(self):
"""
Returns the level at which the data become non-linear, in ADU.
Returns
-------
list/int
Value at which the data become non-linear
"""
# Element [3] gives the fraction of the saturation level at which
# the data become non-linear
fraction = getattr(array_properties.get(self.read_mode()),
'linlimit', None)
sat_level = self.saturation_level()
# Saturation level might be an element or a list
if self.is_single:
try:
return int(fraction * sat_level)
except TypeError:
return None
else:
return [int(fraction * s) if fraction and s else None
for s in sat_level]
# TODO: is 'F2_DARK' still a tag?
[docs] @astro_data_descriptor
def observation_type(self):
"""
Returns the observation type (OBJECT, DARK, BIAS, etc.)
Returns
-------
str
Observation type
"""
return 'DARK' if 'F2_DARK' in self.tags else self.phu.get('OBSTYPE')
[docs] @astro_data_descriptor
def pixel_scale(self):
"""
Returns the image scale in arcseconds per pixel
Returns
-------
float
pixel scale
"""
# Try to use the Gemini-level helper method
try:
return self._get_wcs_pixel_scale()
except KeyError:
return self.phu.get('PIXSCALE')
[docs] @astro_data_descriptor
def read_mode(self):
"""
Returns the read mode (i.e., the number of non-destructive read pairs)
Returns
-------
str
readout mode
"""
lnrs = self.phu.get('LNRS')
return None if lnrs is None else str(lnrs)
[docs] @returns_list
@astro_data_descriptor
def read_noise(self):
"""
Returns the read noise in electrons.
Returns
-------
float
read noise
"""
# Element [0] gives the read noise
return getattr(array_properties.get(self.read_mode(), None),
'readnoise', None)
[docs] @returns_list
@astro_data_descriptor
def saturation_level(self):
"""
Returns the saturation level (in ADU)
Returns
-------
int/float
saturation level
"""
well_depth = getattr(array_properties.get(self.read_mode(), None),
'welldepth', None)
gain = self.gain()
if self.is_single:
try:
return int(well_depth / gain)
except TypeError:
return None
else:
saturation_adu = [int(well_depth / g) if well_depth and g else None
for g in gain]
return saturation_adu
# TODO: document why these are reversed
# Ruben Diaz thinks it has to do with the fact that the F2 long slit,
# and the N-S direction in imaging at PA0, are along the X detector axis.
# So if they wanted to keep the XOFFSET motion still referring to the
# motion you see in the detector, then it will be perpendicular respect
# to the designation in other instruments for which the slit is along the
# detector Y axis. (March 2018)
[docs] @astro_data_descriptor
def telescope_x_offset(self):
"""
Returns the x offset from origin of this image
Returns
-------
float
x offset
"""
try:
return -self.phu['YOFFSET']
except KeyError:
return None
[docs] @astro_data_descriptor
def telescope_y_offset(self):
"""
Returns the y offset from origin of this image
Returns
-------
float
y offset
"""
try:
return -self.phu['XOFFSET']
except KeyError:
return None
def _get_wcs_coords(self):
"""
Returns the RA and dec of the middle of the data array
Returns
-------
tuple
(right ascension, declination)
"""
# Cass rotator centre (according to Andy Stephens from gacq)
x, y = 1034, 1054
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=FITSFixedWarning)
wcs = WCS(self[0].hdr)
result = wcs.wcs_pix2world(x,y,1, 1) if wcs.naxis==3 \
else wcs.wcs_pix2world(x,y, 1)
ra, dec = float(result[0]), float(result[1])
if 'NON_SIDEREAL' in self.tags:
ra, dec = gmu.toicrs('APPT', ra, dec, ut_datetime=self.ut_datetime())
return (ra, dec)