Skip to content

Commit dd988ef

Browse files
committed
Merge branch 'release/4.x' into enh/gnirs_nonlinearity
2 parents b58ac2b + c660d07 commit dd988ef

12 files changed

Lines changed: 290 additions & 36 deletions

geminidr/core/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from .primitives_standardize import Standardize
1111
from .primitives_telluric import Telluric
1212
from .primitives_visualize import Visualize
13+
from .primitives_stats import Stats
1314

1415
# These are currently inherited by Image
1516
#from geminidr.core.primitives_register import Register

geminidr/core/parameters_spect.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ class resampleToCommonFrameConfig(config.Config):
563563
output_wave_scale = config.ChoiceField("Output wavelength scale", str,
564564
allowed={"reference": "Reference input",
565565
"linear": "Linear",
566-
#"loglinear": "Log-linear",
566+
"loglinear": "Log-linear",
567567
},
568568
default="linear", optional=False)
569569
dq_threshold = config.RangeField("Fraction from DQ-flagged pixel to count as 'bad'",

geminidr/core/parameters_stats.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from gempy.library import config
2+
3+
4+
class statsConfig(config.Config):
5+
prefix = config.Field('4-character header prefix', str, None, optional=True)
6+

geminidr/core/primitives_spect.py

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -435,7 +435,7 @@ def slit_pa(ext):
435435
else:
436436
log.warning(f"{ad.filename}:{ext.id} Cross-correlation failed")
437437

438-
if debug_plots:
438+
if debug_plots: # pragma: no cover
439439
fig, ax = plt.subplots()
440440
print(f"Comparing {ad.filename} to reference {refad.filename} "
441441
f"(expected peak at {expected_peak - corr.size // 2})")
@@ -1545,7 +1545,7 @@ def determineDistortion(self, adinputs=None, **params):
15451545
# TODO: Some logging about quality of fit
15461546
# print(np.min(diff), np.max(diff), np.std(diff))
15471547

1548-
if debug:
1548+
if debug: # pragma: no cover
15491549
self.viewer.color = "red"
15501550
spatial_coords = np.linspace(ref_coords[dispaxis].min(), ref_coords[dispaxis].max(),
15511551
ext.shape[1 - dispaxis] // (step * 10))
@@ -1924,7 +1924,7 @@ def find_slits(mdf, ystep=50):
19241924
log.warning(f"Did not find expected number of {slit_name}s "
19251925
f"(found {nfound}, expected {len(slit_lengths)}).")
19261926

1927-
if debug_plots:
1927+
if debug_plots: # pragma: no cover
19281928
for pos in edges_1:
19291929
if pos:
19301930
plt.axvline(pos, color='Blue', alpha=0.5,
@@ -3151,7 +3151,7 @@ def flagCosmicRays(self, adinputs=None, **params):
31513151
del crmask, skyfit_input
31523152
gc.collect()
31533153

3154-
if debug:
3154+
if debug: # pragma: no cover
31553155
fig, axes = plt.subplots(5, 3, sharex=True, sharey=True,
31563156
tight_layout=True)
31573157
for i, ext in enumerate(ad_tiled):
@@ -3387,10 +3387,18 @@ def fluxCalibrate(self, adinputs=None, **params):
33873387
# Reconstruct the spline and evaluate it at every wavelength
33883388
sens_factor = sensfunc(waves.to(std_wave_unit).value) * std_flux_unit
33893389
try: # conversion from magnitude/logarithmic units
3390-
sens_factor = sens_factor.physical
3390+
# See comment below
3391+
with warnings.catch_warnings(category=RuntimeWarning,
3392+
action="ignore"):
3393+
sens_factor = sens_factor.physical
33913394
except AttributeError:
33923395
pass
33933396

3397+
# This avoids extrapolative blow-ups when flux-calibrating XD
3398+
# data that covers the full wavelength range but the standard
3399+
# only covers a small part.
3400+
sens_factor[(ext.mask & DQ.no_data) > 0] = 0
3401+
33943402
# Apply airmass correction. If none is needed/possible, we
33953403
# don't need to try to do this
33963404
if delta_airmass:
@@ -3942,6 +3950,8 @@ def resampleToCommonFrame(self, adinputs=None, **params):
39423950
r + ref_pixels_dict[j][0]).T.astype(int)
39433951
for ad, r in zip(adinputs,
39443952
ref_pixels_dict[j])]
3953+
else:
3954+
dispaxis = 0
39453955

39463956
# Gather information from all the spectra (Chebyshev1D model,
39473957
# w1, w2, dw, npix), and compute the final bounds (w1out, w2out)
@@ -3963,9 +3973,13 @@ def resampleToCommonFrame(self, adinputs=None, **params):
39633973
adinfo.append(model_info)
39643974
w1_arr[i, iext] = model_info['w1']
39653975
w2_arr[i, iext] = model_info['w2']
3966-
39673976
info.append(adinfo)
39683977

3978+
# Are we combining multiple spectra with different wavelength settings
3979+
# into a single spectrum? This is important for later.
3980+
combining_multiple_wavelengths = (single_spectral and
3981+
len(set(w1_arr.ravel())) > 1)
3982+
39693983
# Compute the output wavelength range for each extension. We can
39703984
# calculate the overall output range if we're combining to a single
39713985
# extension later
@@ -3994,15 +4008,16 @@ def resampleToCommonFrame(self, adinputs=None, **params):
39944008
# parameters as the 4th is then calculable. First, we copy the
39954009
# start and end wavelengths if those aren't specified. If neither
39964010
# dw nor npix are specified, the behaviour depends on whether we
3997-
# are resampling to a single wavelength scale: if so, then we want
3998-
# to preserve the dispersion to avoid undersampling but, if not,
3999-
# then we want to preserve the number of pixels per extension.
4011+
# are resampling multiple spectra to a single wavelength scale:
4012+
# if so, then we want to preserve the dispersion to avoid
4013+
# undersampling but, if not, then we want to preserve the number
4014+
# of pixels per extension.
40004015
while nparams < 3:
40014016
if w1 is None:
40024017
w1 = wave_min
40034018
elif w2 is None:
40044019
w2 = wave_max
4005-
elif single_spectral and dw is None:
4020+
elif combining_multiple_wavelengths and dw is None:
40064021
w1 = np.full_like(w1, np.nanmin(w1))
40074022
w2 = np.full_like(w2, np.nanmax(w2))
40084023
if output_spectral == "linear":
@@ -4011,7 +4026,7 @@ def resampleToCommonFrame(self, adinputs=None, **params):
40114026
else:
40124027
# dw has been calculated assuming the spectrum is
40134028
# linear, so we repeat that assumption
4014-
dw = np.array([extinfo['dw'] / extinfo['w2'] - 1
4029+
dw = np.array([extinfo['dw'] / extinfo['w2']
40154030
for adinfo in info for extinfo in adinfo])
40164031
dw = np.full_like(w1, dw.min())
40174032
elif npix is None:
@@ -4025,12 +4040,18 @@ def resampleToCommonFrame(self, adinputs=None, **params):
40254040
npix = np.ceil((w2 - w1) / dw).astype(int) + 1
40264041
w2 = w1 + (npix - 1) * dw
40274042
else: # loglinear
4028-
npix = np.ceil(np.log(w2 / w1) / np.log(1 + dw) - 1)
4043+
npix = np.ceil(np.log(w2 / w1) / np.log(1 + dw) - 1).astype(int) + 1
40294044
w2 = w1 * (1 + dw) ** (npix - 1)
40304045
elif w1 is None:
4031-
w1 = w2 - (npix - 1) * dw
4046+
if output_spectral == "linear":
4047+
w1 = w2 - (npix - 1) * dw
4048+
else: # loglinear
4049+
w1 = w2 / (1 + dw) ** (npix - 1)
40324050
elif w2 is None:
4033-
w2 = w1 + (npix - 1) * dw
4051+
if output_spectral == "linear":
4052+
w2 = w1 + (npix - 1) * dw
4053+
else: # loglinear
4054+
w2 = w1 * (1 + dw) ** (npix - 1)
40344055
elif output_spectral == "linear": # dw is None
40354056
dw = (w2 - w1) / (npix - 1)
40364057
else: # dw is None and we're loglinearizing
@@ -4084,7 +4105,6 @@ def resampleToCommonFrame(self, adinputs=None, **params):
40844105
actual_limits = new_wave_model([0, this_npix - 1])
40854106
w1[iext] = actual_limits.min()
40864107
w2[iext] = actual_limits.max()
4087-
yy = new_wave_model([this_npix-3,this_npix-2,this_npix-1])
40884108

40894109
# Calculation for all extensions
40904110
dw = (w2 - w1) / (npix - 1)
@@ -4164,6 +4184,8 @@ def resampleToCommonFrame(self, adinputs=None, **params):
41644184
if i == 0 and not new_wave_scale:
41654185
log.fullinfo(f"{ad.filename}: No interpolation")
41664186
msg = "Resampling"
4187+
if this_conserve:
4188+
msg += " (with flux conservation)"
41674189
if new_wave_scale:
41684190
msg += f" and {output_spectral}izing"
41694191
dwstr = (f"{dw[iext]:.6f}" if output_spectral == "loglinear"
@@ -5220,7 +5242,8 @@ def _get_spectrophotometry(self, filename, in_vacuo=False):
52205242
if isinstance(unit, u.UnrecognizedUnit):
52215243
# Try chopping off the trailing 's'
52225244
try:
5223-
unit = u.Unit(re.sub(r's$', '', col.unit.name.lower()))
5245+
unit = u.Unit(re.sub(r's$', '',
5246+
col.unit.name.lower()))
52245247
except:
52255248
unit = None
52265249
if unit is None:
@@ -5232,7 +5255,8 @@ def _get_spectrophotometry(self, filename, in_vacuo=False):
52325255
else:
52335256
if orig_colname == 'FNU':
52345257
unit = u.Unit("erg cm-2 s-1 Hz-1")
5235-
elif orig_colname in ('FLAM', 'FLUX') or np.median(col.data) < 1:
5258+
elif (orig_colname in ('FLAM', 'FLUX') or
5259+
np.median(col.data) < 1):
52365260
unit = u.Unit("erg cm-2 s-1 AA-1")
52375261
else:
52385262
unit = u.mag
@@ -5241,15 +5265,21 @@ def _get_spectrophotometry(self, filename, in_vacuo=False):
52415265
# We've created a column called "MAGNITUDE" but it might be a flux
52425266
if col.name == 'MAGNITUDE':
52435267
try:
5244-
unit.to(u.W / u.m ** 3, equivalencies=u.spectral_density(1. * u.m))
5268+
unit.to(u.W / u.m ** 3,
5269+
equivalencies=u.spectral_density(1. * u.m))
52455270
except:
52465271
pass
52475272
else:
52485273
col.name = 'FLUX'
52495274

5250-
wavecol = spec_table["WAVELENGTH"].quantity
52515275
if in_vacuo is None:
5252-
in_vacuo = min(wavecol) < 300 * u.nm
5276+
in_vacuo = min(spec_table["WAVELENGTH"].quantity) < 300 * u.nm
5277+
5278+
# The default (and best) specutils vacuum/air conversion has a
5279+
# singularity in the FUV, so we cut the wavelength scale.
5280+
# See https://github.com/astropy/specutils/issues/1162
5281+
spec_table = spec_table[spec_table["WAVELENGTH"] >= 300 * u.nm]
5282+
wavecol = spec_table["WAVELENGTH"].quantity
52535283

52545284
if in_vacuo:
52555285
spec_table["WAVELENGTH_VACUUM"] = spec_table["WAVELENGTH"]

geminidr/core/primitives_stats.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from copy import deepcopy
2+
3+
import numpy as np
4+
5+
from geminidr import PrimitivesBASE
6+
from gempy.gemini import gemini_tools as gt
7+
from recipe_system.utils.decorators import parameter_override, capture_provenance
8+
9+
from . import parameters_stats
10+
11+
@parameter_override
12+
class Stats(PrimitivesBASE):
13+
14+
def _initialize(self, adinputs, **kwargs):
15+
super()._initialize(adinputs, **kwargs)
16+
self._param_update(parameters_stats)
17+
18+
def stats(self, adinputs=None, **params):
19+
"""
20+
Adds headers to the AD object giving some statistics of the unmasked
21+
pixel values
22+
By default, adds:
23+
PIXMEAN - the arithmetic mean of the pixel values
24+
PIXSTDV - the standard deviation of the pixel values
25+
PIXMED - the median of the pixel values.
26+
27+
Parameters
28+
----------
29+
30+
adinputs: list of :class:`~astrodata.AstroData`
31+
32+
prefix: Prefix for header keywords. Maximum of 4 characters, defaults
33+
to PIX.
34+
"""
35+
log = self.log
36+
log.debug(gt.log_message("primitive", self.myself(), "starting"))
37+
38+
pre = params['prefix']
39+
40+
if pre is None:
41+
pre = 'PIX'
42+
else:
43+
pre = pre[:4]
44+
45+
for ad in adinputs:
46+
for ext in ad:
47+
try:
48+
ext.hdr[pre+'MEAN'] = (np.mean(ext.data[ext.mask==0]),
49+
"Mean of unmasked pixel values")
50+
except ValueError:
51+
# Things like NaN can't be written to FITS headers
52+
pass
53+
try:
54+
ext.hdr[pre+'STDV'] = (np.std(ext.data[ext.mask==0]),
55+
"Standard Deviation of pixel values")
56+
except ValueError:
57+
pass
58+
try:
59+
ext.hdr[pre+'MED'] = (np.median(ext.data[ext.mask == 0]),
60+
"Median of unmasked of pixel values")
61+
except ValueError:
62+
pass
63+
return adinputs

geminidr/core/tests/test_spect.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,14 @@
3838
from astropy.modeling import models
3939
from scipy import optimize
4040

41+
from gwcs import coordinate_frames as cf
42+
from gwcs.wcs import WCS as gWCS
43+
4144
from specutils.utils.wcs_utils import air_to_vac
4245

4346
import astrodata, gemini_instruments
44-
from astrodata.testing import ad_compare
47+
from astrodata.testing import ad_compare, assert_most_close
4548
from gempy.library import astromodels as am
46-
from gempy.library.wavecal import LineList
4749
from gempy.library.config.config import FieldValidationError
4850
from geminidr.core import primitives_spect
4951
from geminidr.f2.primitives_f2_longslit import F2Longslit
@@ -624,6 +626,40 @@ def test_slit_rectification(filename, instrument, change_working_dir,
624626
np.testing.assert_allclose(ad_out[0].SLITEDGE[coeff], 0, atol=0.25)
625627

626628

629+
@pytest.mark.parametrize("gnirs1d", [np.ones((1000,), dtype=np.float32),
630+
np.arange(1000, dtype=np.float32)],
631+
indirect=True)
632+
@pytest.mark.parametrize("wavescale", ["linear", "loglinear"])
633+
def test_resample1d_conserve(gnirs1d, wavescale):
634+
"""
635+
Simple test to resample a synthetic 1D spectrum with a linear wavelength
636+
solution to linear and loglinear and check that the flux is conserved.
637+
"""
638+
gnirs1d[0].hdr['BUNIT'] = 'electron'
639+
p = GNIRSLongslit([gnirs1d])
640+
sum_before = gnirs1d[0].data.sum()
641+
adout = p.resampleToCommonFrame(output_wave_scale=wavescale).pop()
642+
sum_after = adout[0].data.sum()
643+
# Tolerance allows for edge effects
644+
assert sum_after == pytest.approx(sum_before, rel=0.002)
645+
646+
647+
@pytest.mark.parametrize("gnirs1d", [np.arange(1000, 2000, dtype=np.float32)],
648+
indirect=True)
649+
@pytest.mark.parametrize("wavescale", ["linear", "loglinear"])
650+
def test_resample1d_interpolate(gnirs1d, wavescale):
651+
"""
652+
Simple test to resample a synthetic 1D spectrum with a linear wavelength
653+
solution to linear and loglinear and check that the data are interpolated.
654+
The input spectrum has signal=wavelength so it's easy to check the result.
655+
"""
656+
gnirs1d[0].hdr['BUNIT'] = 'W / (m2 AA)' # will interpolate
657+
p = GNIRSLongslit([gnirs1d])
658+
adout = p.resampleToCommonFrame(output_wave_scale=wavescale).pop()
659+
waves = adout[0].wcs(np.arange(adout[0].data.size))
660+
assert_most_close(waves, adout[0].data, max_miss=2, rtol=1e-5)
661+
662+
627663
def test_trace_apertures():
628664
# Input parameters ----------------
629665
width = 400
@@ -788,6 +824,22 @@ def test_transfer_distortion_model(change_working_dir, path_to_inputs, path_to_r
788824

789825
# --- Fixtures and helper functions -------------------------------------------
790826

827+
@pytest.fixture
828+
def gnirs1d(request, astrofaker):
829+
ad = astrofaker.create('GNIRS', mode="SPECT")
830+
ad.init_default_extensions()
831+
data = request.param
832+
mask = np.zeros_like(data, dtype=np.uint16)
833+
variance = np.ones_like(data, dtype=np.float32)
834+
ad[0].reset(data=data, mask=mask, variance=variance)
835+
wave_model = models.Shift(1000) | models.Scale(1)
836+
input_frame = astrodata.wcs.pixel_frame(1)
837+
output_frame = cf.SpectralFrame(axes_order=(0,), unit=u.nm,
838+
axes_names=("WAVE",))
839+
ad[0].wcs = gWCS([(input_frame, wave_model),
840+
(output_frame, None)])
841+
return ad
842+
791843

792844
def create_zero_filled_fake_astrodata(height, width):
793845
"""

geminidr/gemini/primitives_gemini.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from gempy.library import astromodels as am, astrotools as at, transform
1818

1919
from geminidr.core import Bookkeeping, CalibDB, Preprocess
20-
from geminidr.core import Visualize, Standardize, Stack
20+
from geminidr.core import Visualize, Standardize, Stack, Stats
2121

2222
from .primitives_qa import QA
2323
from . import parameters_gemini
@@ -29,7 +29,7 @@
2929
@parameter_override
3030
@capture_provenance
3131
class Gemini(Standardize, Bookkeeping, Preprocess, Visualize, Stack, QA,
32-
CalibDB):
32+
CalibDB, Stats):
3333
"""
3434
This is the class containing the generic Gemini primitives.
3535

0 commit comments

Comments
 (0)