Skip to content

Commit c2d1715

Browse files
authored
added function to measure cross dispersion profile (#214)
1 parent bc96f9d commit c2d1715

File tree

6 files changed

+417
-29
lines changed

6 files changed

+417
-29
lines changed

Diff for: CHANGES.rst

+4
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ New Features
1111
the ``interp_degree_interpolated_profile`` parameter) to generate a continuously varying
1212
spatial profile that can be evaluated at any wavelength. [#173]
1313

14+
- Added a function to measure a cross-dispersion profile. A profile can be
15+
obtained at a single pixel/wavelength, or an average profile can be obtained
16+
from a range/set of wavelengths. [#214]
17+
1418
API Changes
1519
^^^^^^^^^^^
1620

Diff for: docs/extraction_quickstart.rst

+1-1
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ included here.
158158
Putting all these steps together, a simple extraction process might look
159159
something like::
160160

161-
from specreduce.trace import FlatTrace
161+
from specreduce.tracing import FlatTrace
162162
from specreduce.background import Background
163163
from specreduce.extract import BoxcarExtract
164164

Diff for: specreduce/extract.py

+28-28
Original file line numberDiff line numberDiff line change
@@ -118,34 +118,6 @@ def _ap_weight_image(trace, width, disp_axis, crossdisp_axis, image_shape):
118118
return wimage
119119

120120

121-
def _align_along_trace(img, trace_array, disp_axis=1, crossdisp_axis=0):
122-
"""
123-
Given an arbitrary trace ``trace_array`` (an np.ndarray), roll
124-
all columns of ``nddata`` to shift the NDData's pixels nearest
125-
to the trace to the center of the spatial dimension of the
126-
NDData.
127-
"""
128-
# TODO: this workflow does not support extraction for >2D spectra
129-
if not (disp_axis == 1 and crossdisp_axis == 0):
130-
# take the transpose to ensure the rows are the cross-disp axis:
131-
img = img.T
132-
133-
n_rows, n_cols = img.shape
134-
135-
# indices of all columns, in their original order
136-
rows = np.broadcast_to(np.arange(n_rows)[:, None], img.shape)
137-
cols = np.broadcast_to(np.arange(n_cols), img.shape)
138-
139-
# we want to "roll" each column so that the trace sits in
140-
# the central row of the final image
141-
shifts = trace_array.astype(int) - n_rows // 2
142-
143-
# we wrap the indices so we don't index out of bounds
144-
shifted_rows = np.mod(rows + shifts[None, :], n_rows)
145-
146-
return img[shifted_rows, cols]
147-
148-
149121
@dataclass
150122
class BoxcarExtract(SpecreduceOperation):
151123
"""
@@ -816,6 +788,34 @@ def __call__(self, image=None, trace_object=None,
816788
spectral_axis=self.image.spectral_axis)
817789

818790

791+
def _align_along_trace(img, trace_array, disp_axis=1, crossdisp_axis=0):
792+
"""
793+
Given an arbitrary trace ``trace_array`` (an np.ndarray), roll
794+
all columns of ``nddata`` to shift the NDData's pixels nearest
795+
to the trace to the center of the spatial dimension of the
796+
NDData.
797+
"""
798+
# TODO: this workflow does not support extraction for >2D spectra
799+
if not (disp_axis == 1 and crossdisp_axis == 0):
800+
# take the transpose to ensure the rows are the cross-disp axis:
801+
img = img.T
802+
803+
n_rows, n_cols = img.shape
804+
805+
# indices of all columns, in their original order
806+
rows = np.broadcast_to(np.arange(n_rows)[:, None], img.shape)
807+
cols = np.broadcast_to(np.arange(n_cols), img.shape)
808+
809+
# we want to "roll" each column so that the trace sits in
810+
# the central row of the final image
811+
shifts = trace_array.astype(int) - n_rows // 2
812+
813+
# we wrap the indices so we don't index out of bounds
814+
shifted_rows = np.mod(rows + shifts[None, :], n_rows)
815+
816+
return img[shifted_rows, cols]
817+
818+
819819
@dataclass
820820
class OptimalExtract(HorneExtract):
821821
"""

Diff for: specreduce/tests/test_utils.py

+170
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import numpy as np
2+
import pytest
3+
from astropy.modeling import fitting, models
4+
from specreduce.tracing import FitTrace
5+
from specreduce.utils.utils import measure_cross_dispersion_profile
6+
from specutils import Spectrum1D
7+
from astropy.nddata import NDData
8+
import astropy.units as u
9+
10+
11+
def mk_gaussian_img(nrows=20, ncols=16, mean=10, stddev=4):
12+
""" Makes a simple horizontal gaussian image."""
13+
14+
# note: this should become a fixture eventually, since other tests use
15+
# similar functions to generate test data.
16+
17+
np.random.seed(7)
18+
col_model = models.Gaussian1D(amplitude=1, mean=mean, stddev=stddev)
19+
index_arr = np.tile(np.arange(nrows), (ncols, 1))
20+
21+
return col_model(index_arr.T)
22+
23+
24+
def mk_img_non_flat_trace(nrows=40, ncols=100, amp=10, stddev=2):
25+
"""
26+
Makes an image with a gaussian source that has a non-flat trace dispersed
27+
along the x axis.
28+
"""
29+
spec2d = np.zeros((nrows, ncols))
30+
31+
for ii in range(spec2d.shape[1]):
32+
mgaus = models.Gaussian1D(amplitude=amp,
33+
mean=(9.+(20/spec2d.shape[1])*ii),
34+
stddev=stddev)
35+
rg = np.arange(0, spec2d.shape[0], 1)
36+
gaus = mgaus(rg)
37+
spec2d[:, ii] = gaus
38+
39+
return spec2d
40+
41+
42+
class TestMeasureCrossDispersionProfile():
43+
44+
@pytest.mark.parametrize('pixel', [None, 1, [1, 2, 3]])
45+
@pytest.mark.parametrize('width', [10, 9])
46+
def test_measure_cross_dispersion_profile(self, pixel, width):
47+
"""
48+
Basic test for `measure_cross_dispersion_profile`. Parametrized over
49+
different options for `pixel` to test using all wavelengths, a single
50+
wavelength, and a set of wavelengths, as well as different input types
51+
(plain array, quantity, Spectrum1D, and NDData), as well as `width` to
52+
use a window of all rows and a smaller window.
53+
"""
54+
55+
# test a few input formats
56+
images = []
57+
mean = 5.0
58+
stddev = 4.0
59+
dat = mk_gaussian_img(nrows=10, ncols=10, mean=mean, stddev=stddev)
60+
images.append(dat) # test unitless
61+
images.append(dat * u.DN)
62+
images.append(NDData(dat * u.DN))
63+
images.append(Spectrum1D(flux=dat * u.DN))
64+
65+
for img in images:
66+
67+
# use a flat trace at trace_pos=10, a window of width 10 around the trace
68+
# and use all 20 columns in image to create an average (median)
69+
# cross dispersion profile
70+
cdp = measure_cross_dispersion_profile(img, width=width, pixel=pixel)
71+
72+
# make sure that if we fit a gaussian to the measured average profile,
73+
# that we get out the same profile that was used to create the image.
74+
# this should be exact since theres no noise in the image
75+
fitter = fitting.LevMarLSQFitter()
76+
mod = models.Gaussian1D()
77+
fit_model = fitter(mod, np.arange(width), cdp)
78+
79+
assert fit_model.mean.value == np.where(cdp == max(cdp))[0][0]
80+
assert fit_model.stddev.value == stddev
81+
82+
# test passing in a FlatTrace, and check the profile
83+
cdp = measure_cross_dispersion_profile(img, width=width, pixel=pixel)
84+
fit_model = fitter(mod, np.arange(width), cdp)
85+
assert fit_model.mean.value == np.where(cdp == max(cdp))[0][0]
86+
np.testing.assert_allclose(fit_model.stddev.value, stddev)
87+
88+
@pytest.mark.filterwarnings("ignore:Model is linear in parameters")
89+
def test_cross_dispersion_profile_non_flat_trace(self):
90+
"""
91+
Test measure_cross_dispersion_profile with a non-flat trace.
92+
Tests with 'align_along_trace' set to both True and False,
93+
to account for the changing center of the trace and measure
94+
the true profile shape, or to 'blur' the profile, respectivley.
95+
"""
96+
97+
image = mk_img_non_flat_trace()
98+
99+
# fit the trace
100+
trace_fit = FitTrace(image)
101+
102+
# when not aligning along trace and using the entire image
103+
# rows for the window, the center of the profile should follow
104+
# the shape of the trace
105+
peak_locs = [9, 10, 12, 13, 15, 16, 17, 19, 20, 22, 23, 24, 26, 27, 29]
106+
for i, pixel in enumerate(range(0, image.shape[1], 7)):
107+
profile = measure_cross_dispersion_profile(image,
108+
trace=trace_fit,
109+
width=None,
110+
pixel=pixel,
111+
align_along_trace=False,
112+
statistic='mean')
113+
peak_loc = (np.where(profile == max(profile))[0][0])
114+
assert peak_loc == peak_locs[i]
115+
116+
# when align_along_trace = True, the shape of the profile should
117+
# not change since (there is some wiggling around though due to the
118+
# fact that the trace is rolled to the nearest integer value. this can
119+
# be smoothed with an interpolation option later on, but it is 'rough'
120+
# for now). In this test case, the peak positions will all either
121+
# be at pixel 20 or 21.
122+
for i, pixel in enumerate(range(0, image.shape[1], 7)):
123+
profile = measure_cross_dispersion_profile(image,
124+
trace=trace_fit,
125+
width=None,
126+
pixel=pixel,
127+
align_along_trace=True,
128+
statistic='mean')
129+
peak_loc = (np.where(profile == max(profile))[0][0])
130+
assert peak_loc in [20, 21]
131+
132+
def test_errors_warnings(self):
133+
img = mk_gaussian_img(nrows=10, ncols=10)
134+
with pytest.raises(ValueError,
135+
match='`crossdisp_axis` must be 0 or 1'):
136+
measure_cross_dispersion_profile(img, crossdisp_axis=2)
137+
138+
with pytest.raises(ValueError, match='`trace` must be Trace object, '
139+
'number to specify the location '
140+
'of a FlatTrace, or None to use '
141+
'center of image.'):
142+
measure_cross_dispersion_profile(img, trace='not a trace or a number')
143+
144+
with pytest.raises(ValueError, match="`statistic` must be 'median' "
145+
"or 'mean'."):
146+
measure_cross_dispersion_profile(img, statistic='n/a')
147+
148+
with pytest.raises(ValueError, match='Both `pixel` and `pixel_range` '
149+
'can not be set simultaneously.'):
150+
measure_cross_dispersion_profile(img, pixel=2, pixel_range=(2, 3))
151+
152+
with pytest.raises(ValueError, match='`pixels` must be an integer, '
153+
'or list of integers to specify '
154+
'where the crossdisperion profile '
155+
'should be measured.'):
156+
measure_cross_dispersion_profile(img, pixel='str')
157+
158+
with pytest.raises(ValueError, match='`pixel_range` must be a tuple '
159+
'of integers.'):
160+
measure_cross_dispersion_profile(img, pixel_range=(2, 3, 5))
161+
162+
with pytest.raises(ValueError, match='Pixels chosen to measure cross '
163+
'dispersion profile are out of '
164+
'image bounds.'):
165+
measure_cross_dispersion_profile(img, pixel_range=(2, 12))
166+
167+
with pytest.raises(ValueError, match='`width` must be an integer, '
168+
'or None to use all '
169+
'cross-dispersion pixels.'):
170+
measure_cross_dispersion_profile(img, width='.')

Diff for: specreduce/utils/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
"""
22
General purpose utilities for specreduce
33
"""
4+
5+
from .utils import * # noqa

0 commit comments

Comments
 (0)