import math
import re
from astrodata import astro_data_tag, astro_data_descriptor, TagSet, returns_list
from ..gemini import AstroDataGemini, use_keyword_if_prepared
from ..common import build_group_id
from .lookup import detector_properties, nominal_zeropoints, read_modes
from .lookup import pixel_scale_shrt, pixel_scale_long, dispersion_by_config
# NOTE: Temporary functions for test. gempy imports astrodata and
# won't work with this implementation
from .. import gmu
[docs]
class AstroDataGnirs(AstroDataGemini):
__keyword_dict = dict(central_wavelength='GRATWAVE',
array_name='ARRAYID',
grating_order='GRATORD')
@staticmethod
def _matches_data(source):
return source[0].header.get('INSTRUME', '').upper() == 'GNIRS'
@astro_data_tag
def _tag_instrument(self):
return TagSet(['GNIRS'])
@astro_data_tag
def _type_dark(self):
if self.phu.get('OBSTYPE') == 'DARK':
return TagSet(['DARK', 'CAL'], blocks=['IMAGE', 'SPECT'])
@astro_data_tag
def _type_arc(self):
if self.phu.get('OBSTYPE') == 'ARC':
return TagSet(['ARC', 'CAL'])
@astro_data_tag
def _type_image(self):
if self.phu.get('ACQMIR') == 'In':
return TagSet(['IMAGE'])
@astro_data_tag
def _type_thruslit(self):
if 'Acq' not in self.phu.get('SLIT', ''):
return TagSet(['THRUSLIT'], if_present=['IMAGE'])
@astro_data_tag
def _type_spect(self):
if self.phu.get('ACQMIR') == 'Out':
tags = {'SPECT'}
slit = self.phu.get('SLIT', '').lower()
grat = self.phu.get('GRATING', '')
prism = self.phu.get('PRISM', '')
if 'ifu' in slit:
tags.add('IFU')
elif ('arcsec' in slit or 'pin' in slit) and 'mm' in grat:
if 'MIR' in prism:
tags.add('LS')
elif 'XD' in prism:
tags.add('XD')
return TagSet(tags)
@astro_data_tag
def _type_flats(self):
if self.phu.get('OBSTYPE') == 'FLAT':
if 'Pinholes' in self.phu.get('SLIT', ''):
return TagSet(['PINHOLE', 'CAL'], remove=['GCALFLAT'],
blocks=['FLAT'])
return TagSet(['FLAT', 'CAL'])
[docs]
@returns_list
@astro_data_descriptor
def array_name(self):
"""
Returns the name of each array
Returns
-------
list of str/str
the array names
"""
return self.phu.get(self._keyword_for('array_name'))
[docs]
@astro_data_descriptor
def dispersion(self, asMicrometers=False, asNanometers=False, asAngstroms=False):
"""
Returns the dispersion in meters per pixel as a list (one value per
extension) or a float if used on a single-extension slice. It is
possible to control the units of wavelength using the input arguments.
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
-------
list/float
The dispersion(s)
"""
grating = self._grating(pretty=True, stripID=True)
if self.pixel_scale() == pixel_scale_shrt:
camera = "Short"
elif self.pixel_scale() == pixel_scale_long:
camera = "Long"
else:
camera = None
filter = str(self.filter_name(pretty=True))[0]
config = f"{grating}, {camera}"
dispersion = dispersion_by_config.get(config, {}).get(filter)
if dispersion is None:
return None
unit_arg_list = [asMicrometers, asNanometers, asAngstroms]
output_units = "meters" # By default
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"
if dispersion is not None:
dispersion = gmu.convert_units('angstroms', dispersion, output_units)
if not self.is_single:
dispersion = [dispersion] * len(self)
return dispersion
[docs]
@returns_list
@astro_data_descriptor
def dispersion_axis(self):
# TODO: Document and make sure the axis is the proper one
return 2
[docs]
@astro_data_descriptor
def array_section(self, pretty=False):
"""
Returns the section covered by the array(s) relative to the detector
frame. For example, this can be the position of multiple amps read
within a CCD. 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 extension(s) using Python slice values
string or list of strings
Position of extension(s) using an IRAF section format (1-based)
"""
return self._parse_section('FULLFRAME', pretty)
[docs]
@returns_list
@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._parse_section('FULLFRAME', 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]
@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:
offset = self.phu.get('QOFFSET') / self.pixel_scale()
except TypeError: # either is None
return None
# Flipped if on bottom port unless AO is operating
return -offset if (self.phu.get('INPORT') == 1 and
not self.is_ao()) else offset
[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:
return -self.phu.get('POFFSET') / self.pixel_scale()
except TypeError: # either is None
return None
[docs]
@astro_data_descriptor
def disperser(self, stripID=False, pretty=False):
"""
Returns the name of the disperser group as the name of the grating
and of the prims joined with '&', unless the acquisition mirror is
in the beam, then returns the string "MIRROR". The component ID can
be removed with either 'stripID' or 'pretty' set to True.
Parameters
----------
stripID : bool
If True, removes the component ID and returns only the name of
the disperser.
pretty : bool
Same as for stripID. Pretty here does not do anything more.
Returns
-------
str
The disperser group, as grism&prism, with or without the
component ID.
"""
if self.phu.get('ACQMIR') == 'In':
return 'MIRROR'
grating = self._grating(stripID=stripID, pretty=pretty)
prism = self._prism(stripID=stripID, pretty=pretty)
if prism is None or grating is None:
return None
if prism.startswith('MIR'):
return grating
return "{}&{}".format(grating, prism)
[docs]
@astro_data_descriptor
def focal_plane_mask(self, stripID=False, pretty=False):
"""
Returns the name of the focal plane mask group as the slit and the
decker joined with '&', or as a shorter (pretty) version.
The component ID can be removed with either 'stripID' or 'pretty'
set to True.
Parameters
----------
stripID : bool
If True, removes the component ID and returns only the name of
the focal plane mask.
pretty : bool
If True, removes the component IDs and returns a short string
representing broadly the setting.
Returns
-------
str
The name of the focal plane mask with or without the component ID.
"""
try:
slit = self.slit(stripID=stripID,
pretty=pretty).replace('Acquisition', 'Acq')
decker = self.decker(stripID=stripID,
pretty=pretty).replace('Acquisition', 'Acq')
except AttributeError: # either slit or decker is None
return None
# Default fpm value
fpm = "{}&{}".format(slit, decker)
if pretty:
if "Long" in decker:
fpm = slit
elif "XD" in decker:
fpm = "{}XD".format(slit)
elif "HR-IFU" in slit and "HR-IFU" in decker:
fpm = "HR-IFU"
elif "LR-IFU" in slit and "LR-IFU" in decker:
fpm = "LR-IFU"
elif "IFU" in slit and "IFU" in decker:
fpm = "IFU"
elif "Acq" in slit and "Acq" in decker:
fpm = "Acq"
return fpm
@returns_list
@use_keyword_if_prepared
@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.
"""
read_mode = self.read_mode()
well_depth = self.well_depth_setting()
return getattr(detector_properties.get((read_mode, well_depth)),
'gain', 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, mode of observation, and data type will have its own rules.
Returns
-------
str
A group ID for compatible data.
"""
tags = self.tags
if 'DARK' in tags:
desc_list = ['read_mode', 'exposure_time', 'coadds']
else:
# The descriptor list is the same for flats and science frames
desc_list = ['observation_id', 'filter_name', 'camera', 'read_mode']
desc_list.extend(['well_depth_setting', 'detector_section',
'disperser', 'focal_plane_mask'])
if 'IMAGE' in tags and 'FLAT' in tags:
additional_item = 'GNIRS_IMAGE_FLAT'
else:
additional_item = None
return build_group_id(self, desc_list, prettify=('filter_name',
'disperser', 'focal_plane_mask'),
additional=additional_item)
[docs]
@astro_data_descriptor
def nominal_photometric_zeropoint(self):
"""
Returns the nominal photometric zeropoint for the observation.
This value is obtained from a lookup table based on gain, the
camera used, and the filter used.
Returns
-------
float
The nominal photometric zeropoint as a magnitude.
"""
gain = self.gain()
camera = self.camera()
filter_name = self.filter_name(pretty=True)
in_adu = self.is_in_adu()
zpt = nominal_zeropoints.get((camera, filter_name))
# 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]
@use_keyword_if_prepared
@astro_data_descriptor
def non_linear_level(self):
"""
Returns the level at which the array becomes non-linear, in the same
units as of the data. A lookup table is used and the value
is based on read_mode, well_depth_setting, and saturation_level.
Returns
-------
int/list
Level at which the non-linear regime starts.
"""
read_mode = self.read_mode()
well_depth = self.well_depth_setting()
limit = getattr(detector_properties.get((read_mode, well_depth)),
'linearlimit', None)
sat_level = self.saturation_level()
if self.is_single:
try:
return int(limit * sat_level)
except TypeError:
return None
else:
return [int(limit * s) if limit and s else None
for s in sat_level]
[docs]
@astro_data_descriptor
def pixel_scale(self):
"""
Returns the pixel scale in arc seconds. GNIRS pixel scale is determined
soley by the camera used, long or short, regardless of color band
(red|blue).
GNIRS instrument page,
https://www.gemini.edu/sciops/instruments/gnirs/spectroscopy
Short camera (0.15"/pix) -- lookup.pixel_scale_shrt
Long camera (0.05"/pix) -- lookup.pixel_scale_long
Returns
-------
<float>,
Pixel scale in arcsec
Raises
------
ValueError
If 'camera' is neither short nor long, it is unrecognized.
"""
try:
camera = self.camera().lower()
except AttributeError:
return None
if 'short' in camera:
return pixel_scale_shrt
elif 'long' in camera:
return pixel_scale_long
else:
raise ValueError("Unrecognized GNIRS camera, {}".format(camera))
[docs]
@astro_data_descriptor
def position_angle(self):
"""
Returns the position angle of the instruement
Returns
-------
float
the position angle (East of North) of the +ve y-direction
"""
return (self.phu[self._keyword_for('position_angle')] + 90) % 360
[docs]
@astro_data_descriptor
def ra(self):
"""
Returns the Right Ascension of the center of the field in degrees.
Uses the RA derived from the WCS, unless it is wildly different from
the target RA stored in the headers (with telescope offset and in
ICRS). When that's the case the target RA is used.
Returns
-------
float
Right Ascension of the target in degrees.
"""
# In general, the GNIRS WCS is the way to go. But sometimes the DC
# has a bit of a senior moment and the WCS is miles off (presumably
# still has values from the previous observation or something.
# Who knows. So we do a sanity check on it and use the target values
# if it's messed up
wcs_ra = self.wcs_ra()
if wcs_ra is None:
return self._ra()
try:
tgt_ra = self.target_ra(offset=True, icrs=True)
except: # Return WCS value if we can't get our sanity check
return wcs_ra
delta = abs(wcs_ra - tgt_ra)
# wraparound?
if delta > 180:
delta = abs(delta - 360)
delta = delta * 3600 # to arcsecs
# And account for cos(dec) factor
delta /= math.cos(math.radians(self.dec()))
# If more than 1000" arcsec different, WCS is probably bad
return (tgt_ra if delta > 1000 else wcs_ra)
[docs]
@astro_data_descriptor
def dec(self):
"""
Returns the Declination of the center of the field in degrees.
Uses the Dec derived from the WCS, unless it is wildly different from
the target Dec stored in the headers (with telescope offset and in
ICRS). When that's the case the target Dec is used.
Returns
-------
float
Declination of the center of the field in degrees.
"""
# In general, the GNIRS WCS is the way to go. But sometimes the DC
# has a bit of a senior moment and the WCS is miles off (presumably
# still has values from the previous observation or something.
# Who knows. So we do a sanity check on it and use the target values
# if it's messed up
wcs_dec = self.wcs_dec()
if wcs_dec is None:
return self._dec()
try:
tgt_dec = self.target_dec(offset=True, icrs=True)
except: # Return WCS value if we can't get our sanity check
return wcs_dec
delta = abs(wcs_dec - tgt_dec)
# wraparound?
if delta > 180:
delta = abs(delta - 360)
delta = delta * 3600 # to arcsecs
# If more than 1000" arcsec different, WCS is probably bad
return (tgt_dec if delta > 1000 else wcs_dec)
[docs]
@astro_data_descriptor
def read_mode(self):
"""
Returns the read mode for the observation. Uses a lookup table
indexed on the number of non-destructive read pairs (LNRS) and
the number of digital averages (NDAVGS)
Returns
-------
str
Read mode for the observation.
"""
return read_modes.get((self.phu.get('LNRS'), self.phu.get('NDAVGS')),
"Unknown")
@returns_list
@use_keyword_if_prepared
@astro_data_descriptor
def read_noise(self):
"""
Returns the detector read noise, in electrons.
A lookup table indexed on read_mode and well_depth_setting is
used to retrieve the read noise.
Returns
-------
float
Detector read noise in electrons.
"""
# Determine the read mode and well depth from their descriptors
read_mode = self.read_mode()
well_depth = self.well_depth_setting()
coadds = self.coadds()
read_noise = getattr(detector_properties.get((read_mode, well_depth)),
'readnoise', None)
try:
return read_noise * math.sqrt(coadds)
except TypeError:
return None
@use_keyword_if_prepared
@astro_data_descriptor
def saturation_level(self):
"""
Returns the saturation level or the observation, in the units of the
data. A lookup table indexed on read_mode and well_depth_setting is used
to retrieve the saturation level for raw data, and it is expected that
this will be inserted into the headers as processing continues.
Returns
-------
int/list
Saturation level in the units of the data
"""
gain = self.gain()
coadds = self.coadds()
read_mode = self.read_mode()
well_depth = self.well_depth_setting()
well = getattr(detector_properties.get((read_mode, well_depth)),
'well', None)
if self.is_single:
try:
return int(well * coadds / gain)
except TypeError:
return None
else:
return [int(well * coadds / g) if well and g else None
for g in gain]
[docs]
@astro_data_descriptor
def slit(self, stripID=False, pretty=False):
"""
Returns the name of the slit mask. The component ID can be removed
with either 'stripID' or 'pretty' set to True.
Parameters
----------
stripID : bool
If True, removes the component ID and returns only the name of
the slit.
pretty : bool
Same as for stripID. Pretty here does not do anything more.
Returns
-------
str
The name of the slit with or without the component ID.
"""
try:
slit = self.phu['SLIT'].replace(' ', '')
except KeyError:
return None
return gmu.removeComponentID(slit) if stripID or pretty else slit
[docs]
@astro_data_descriptor
def slit_width(self):
"""
Returns the width of the slit in arcseconds
Returns
-------
float/None
the slit width in arcseconds
"""
fpmask = self.slit(pretty=True)
if 'arcsec' in fpmask:
return float(fpmask.replace('arcsec', ''))
return None
[docs]
@astro_data_descriptor
def well_depth_setting(self):
"""
Returns the well depth setting used for the observation.
For GNIRS, this is either 'Shallow' or 'Deep'.
Returns
-------
str
Well depth setting.
"""
try:
biasvolt = self.phu['DETBIAS']
except KeyError:
return None
if abs(0.3 - abs(biasvolt)) < 0.1:
return "Shallow"
elif abs(0.6 - abs(biasvolt)) < 0.1:
return "Deep"
else:
return "Unknown"
# --------------------------------------
# Private methods
def _grating(self, stripID=False, pretty=False):
"""
Returns the name of the grating used for the observation.
The component ID can be removed with either 'stripID' or 'pretty'
set to True.
Parameters
----------
stripID : bool
If True, removes the component ID and returns only the name of
the disperser.
pretty : bool
Same as for stripID. Pretty here does not do anything more.
Returns
-------
str
The name of the grating with or without the component ID.
"""
grating = self.phu.get('GRATING')
try:
match = re.match(r"([\d/m]+)[A-Z]*(_G)(\d+)", grating)
ret_grating = "{}{}{}".format(*match.groups())
except (TypeError, AttributeError):
ret_grating = grating
if stripID or pretty:
return gmu.removeComponentID(ret_grating)
return ret_grating
def _prism(self, stripID=False, pretty=False):
"""
Returns the name of the prism. The component ID can be removed
with either 'stripID' or 'pretty' set to True.
Parameters
----------
stripID : bool
If True, removes the component ID and returns only the name of
the prism.
pretty : bool
Same as for stripID. Pretty here does not do anything more.
Returns
-------
str
The name of the prism with or without the component ID.
"""
prism = self.phu.get('PRISM')
try:
match = re.match(r"(?:[A-Z0-9]*\+)?([A-Z]*_G\d+)", prism)
ret_prism = match.group(1)
except (TypeError, AttributeError): # prism=None, no match
return None
if stripID or pretty:
ret_prism = gmu.removeComponentID(ret_prism)
return ret_prism