Skip to content

Commit 494f618

Browse files
committed
.
1 parent c16abd4 commit 494f618

File tree

6 files changed

+384
-188
lines changed

6 files changed

+384
-188
lines changed

specreduce/background.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -98,16 +98,27 @@ def __post_init__(self):
9898
dispersion axis
9999
crossdisp_axis : int
100100
cross-dispersion axis
101+
mask_treatment : string
102+
The method for handling masked or non-finite data. Choice of `filter`,
103+
`omit`, or `zero-fill`. If `filter` is chosen, masked/non-finite data
104+
will be filtered during the fit to each bin/column (along disp. axis) to
105+
find the peak. If `omit` is chosen, columns along disp_axis with any
106+
masked/non-finite data values will be fully masked (i.e, 2D mask is
107+
collapsed to 1D and applied). If `zero-fill` is chosen, masked/non-finite
108+
data will be replaced with 0.0 in the input image, and the mask will then
109+
be dropped. For all three options, the input mask (optional on input
110+
NDData object) will be combined with a mask generated from any non-finite
111+
values in the image data.
101112
"""
102113

103114
# Parse image, including masked/nonfinite data handling based on
104-
# choice of `mask_treatment`. returns a Spectrum1D
115+
# choice of `mask_treatment`. Any uncaught nonfinte data values will be
116+
# masked as well. Returns a Spectrum1D.
105117
self.image = self._parse_image(self.image)
106118

107-
# _parse_image returns a Spectrum1D. convert this to a masked array
108-
# for ease of calculations here (even if there is no masked data).
109-
# Note: uncertainties are dropped, this should also be addressed at
110-
# some point probably across the package.
119+
# always work with masked array, even if there is no masked
120+
# or nonfinite data, in case padding is needed. if not, mask will be
121+
# dropped at the end and a regular array will be returned.
111122
img = np.ma.masked_array(self.image.data, self.image.mask)
112123

113124
if self.width < 0:
@@ -120,6 +131,9 @@ def __post_init__(self):
120131

121132
bkg_wimage = np.zeros_like(self.image.data, dtype=np.float64)
122133
for trace in self.traces:
134+
# note: ArrayTrace can have masked values, but if it does a MaskedArray
135+
# will be returned so this should be reflected in the window size here
136+
# (i.e, np.nanmax is not required.)
123137
windows_max = trace.trace.data.max() + self.width/2
124138
windows_min = trace.trace.data.min() - self.width/2
125139
if windows_max >= self.image.shape[self.crossdisp_axis]:
@@ -136,6 +150,7 @@ def __post_init__(self):
136150
self.crossdisp_axis,
137151
self.image.shape)
138152

153+
139154
if np.any(bkg_wimage > 1):
140155
raise ValueError("background regions overlapped")
141156
if np.any(np.sum(bkg_wimage, axis=self.crossdisp_axis) == 0):

specreduce/core.py

-1
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,6 @@ def _mask_and_nonfinite_data_handling(self, image, mask):
139139
# nothing needs to be done. input mask (combined with nonfinite data)
140140
# remains with data as-is.
141141

142-
143142
if mask_treatment == 'zero-fill':
144143
# make a copy of the input image since we will be modifying it
145144
image = deepcopy(image)

specreduce/tests/test_background.py

+44-39
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,13 @@ def test_background(mk_test_img_raw, mk_test_spec_no_spectral_axis,
2424

2525
# all the following should be equivalent, whether image's spectral axis
2626
# is in pixels or physical units:
27-
bg1 = Background(image, [trace-bkg_sep, trace+bkg_sep], width=bkg_width)
27+
bg1 = Background(image, [trace - bkg_sep, trace + bkg_sep], width=bkg_width)
2828
bg2 = Background.two_sided(image, trace, bkg_sep, width=bkg_width)
2929
bg3 = Background.two_sided(image, trace_pos, bkg_sep, width=bkg_width)
3030
assert np.allclose(bg1.bkg_image().flux, bg2.bkg_image().flux)
3131
assert np.allclose(bg1.bkg_image().flux, bg3.bkg_image().flux)
3232

33-
bg4 = Background(image_um, [trace-bkg_sep, trace+bkg_sep], width=bkg_width)
33+
bg4 = Background(image_um, [trace - bkg_sep, trace + bkg_sep], width=bkg_width)
3434
bg5 = Background.two_sided(image_um, trace, bkg_sep, width=bkg_width)
3535
bg6 = Background.two_sided(image_um, trace_pos, bkg_sep, width=bkg_width)
3636
assert np.allclose(bg1.bkg_image().flux, bg4.bkg_image().flux)
@@ -74,7 +74,7 @@ def test_background(mk_test_img_raw, mk_test_spec_no_spectral_axis,
7474
stats = ['average', 'median']
7575

7676
for st in stats:
77-
bg = Background(img, trace-bkg_sep, width=bkg_width, statistic=st)
77+
bg = Background(img, trace - bkg_sep, width=bkg_width, statistic=st)
7878
assert np.isnan(bg.image.flux).sum() == 2
7979
assert np.isnan(bg._bkg_array).sum() == 0
8080
assert np.isnan(bg.bkg_spectrum().flux).sum() == 0
@@ -101,7 +101,7 @@ def test_warnings_errors(mk_test_spec_no_spectral_axis):
101101
with pytest.warns(match="background window extends beyond image boundaries"):
102102
Background.two_sided(image, 7, 5, width=6)
103103

104-
trace = ArrayTrace(image, trace=np.arange(10)+20) # from 20 to 29
104+
trace = ArrayTrace(image, trace=np.arange(10) + 20) # from 20 to 29
105105
with pytest.warns(match="background window extends beyond image boundaries"):
106106
with pytest.raises(ValueError,
107107
match="background window does not remain in bounds across entire dispersion axis"): # noqa
@@ -112,8 +112,8 @@ def test_warnings_errors(mk_test_spec_no_spectral_axis):
112112
with pytest.raises(ValueError, match="width must be positive"):
113113
Background.two_sided(image, 25, 2, width=-1)
114114

115-
def test_trace_inputs(mk_test_img_raw):
116115

116+
def test_trace_inputs(mk_test_img_raw):
117117
"""
118118
Tests for the input argument 'traces' to `Background`. This should accept
119119
a list of or a single Trace object, or a list of or a single (positive)
@@ -143,78 +143,84 @@ def test_trace_inputs(mk_test_img_raw):
143143
with pytest.raises(ValueError, match=match_str):
144144
Background(image, 'non_valid_trace_pos')
145145

146+
146147
class TestMasksBackground():
147148

148149
"""
149150
Various test functions to test how masked and non-finite data is handled
150-
in `Background.
151+
in `Background. There are three currently implemented options for masking
152+
in Background: filter, omit, and zero-fill.
151153
"""
152154

153155
def mk_img(self, nrows=4, ncols=5, nan_slices=None):
154156
"""
155-
Make a simpleimage to test masking in Background.
156-
Optionally add NaNs to data. Returned array is in u.DN.
157+
Make a simple gradient image to test masking in Background.
158+
Optionally add NaNs to data with `nan_slices`. Returned array is in
159+
u.DN.
157160
"""
158161

159-
img = np.tile((np.arange(1., ncols+1)), (nrows, 1))
162+
img = np.tile((np.arange(1., ncols + 1)), (nrows, 1))
160163

161164
if nan_slices: # add nans in data
162165
for s in nan_slices:
163166
img[s] = np.nan
164167

165168
return img * u.DN
166169

167-
def test_fully_masked_column(self):
170+
@pytest.mark.parametrize("mask", ["filter", "omit", "zero-fill"])
171+
def test_fully_masked_column(self, mask):
168172
"""
169-
Test what happens when a full column is masked, not the entire
170-
image. In this case, the background value for that fully-masked
171-
column should be 0.0, with no error or warning raised.
173+
Test background with some fully-masked columns (not fully masked image).
174+
In this case, the background value for that fully-masked column should
175+
be 0.0, with no error or warning raised. This is the case for
176+
mask_treatment=filter, omit, or zero-fill.
172177
"""
173178

174-
img = np.ones((12, 12))
179+
img = self.mk_img(nrows=10, ncols=10)
175180
img[:, 0:1] = np.nan
176181

177-
bkg = Background(img, traces=FlatTrace(img, 6))
178-
182+
bkg = Background(img, traces=FlatTrace(img, 6), mask_treatment=mask)
179183
assert np.all(bkg.bkg_image().data[:, 0:1] == 0.0)
180184

181-
182-
def test_fully_masked(self):
185+
@pytest.mark.parametrize("mask", ["filter", "omit", "zero-fill"])
186+
def test_fully_masked_image(self, mask):
183187
"""
184188
Test that the appropriate error is raised by `Background` when image
185189
is fully masked/NaN.
186190
"""
187191

188192
with pytest.raises(ValueError, match='Image is fully masked.'):
189193
# fully NaN image
190-
img = np.zeros((4, 5)) * np.nan
191-
Background(img, traces=FlatTrace(self.mk_img(), 2))
194+
img = self.mk_img() * np.nan
195+
Background(img, traces=FlatTrace(self.mk_img(), 2), mask_treatment=mask)
192196

193197
with pytest.raises(ValueError, match='Image is fully masked.'):
194-
# fully masked image (should be equivilant)
198+
# fully masked image (should be equivalent)
195199
img = NDData(np.ones((4, 5)), mask=np.ones((4, 5)))
196-
Background(img, traces=FlatTrace(self.mk_img(), 2))
200+
Background(img, traces=FlatTrace(self.mk_img(), 2), mask_treatment=mask)
197201

198202
# Now test that an image that isn't fully masked, but is fully masked
199-
# within the window determined by `width`, produces the correct result
203+
# within the window determined by `width`, produces the correct result.
204+
# only applicable for mask_treatment=filter, because this is the only
205+
# option that allows a slice of masked values that don't span all rows.
200206
msg = 'Image is fully masked within background window determined by `width`.'
201207
with pytest.raises(ValueError, match=msg):
202208
img = self.mk_img(nrows=12, ncols=12, nan_slices=[np.s_[3:10, :]])
203209
Background(img, traces=FlatTrace(img, 6), width=7)
204210

205211
@pytest.mark.filterwarnings("ignore:background window extends beyond image boundaries")
206212
@pytest.mark.parametrize("method,expected",
207-
[("filter", np.array([1., 2., 3., 4., 5., 6., 7.,
213+
[("filter", np.array([1., 2., 3., 4., 5., 6., 7.,
208214
8., 9., 10., 11., 12.])),
209-
("omit", np.array([0., 2., 3., 0., 5., 6.,
210-
7., 0., 9., 10., 11., 12.])),
211-
("zero-fill", np.array([ 0.58333333, 2., 3.,
212-
2.33333333, 5., 6., 7.,
213-
7.33333333, 9., 10., 11.,
214-
12.]))])
215+
("omit", np.array([0., 2., 3., 0., 5., 6.,
216+
7., 0., 9., 10., 11., 12.])),
217+
("zero-fill", np.array([0.58333333, 2., 3.,
218+
2.33333333, 5., 6., 7.,
219+
7.33333333, 9., 10., 11.,
220+
12.]))])
215221
def test_mask_treatment_bkg_img_spectrum(self, method, expected):
216-
"""
217-
This test function tests `Backgroud.bkg_image` and
222+
"""
223+
This test function tests `Backgroud.bkg_image` and
218224
`Background.bkg_spectrum` when there is masked data. It also tests
219225
background subtracting the image, and returning the spectrum of the
220226
background subtracted image. This test is parameterized over all
@@ -227,8 +233,8 @@ def test_mask_treatment_bkg_img_spectrum(self, method, expected):
227233

228234
# make image, set some value to nan, which will be masked in the function
229235
image1 = self.mk_img(nrows=img_size, ncols=img_size,
230-
nan_slices=[np.s_[5:10, 0], np.s_[7:12, 3],
231-
np.s_[2, 7]])
236+
nan_slices=[np.s_[5:10, 0], np.s_[7:12, 3],
237+
np.s_[2, 7]])
232238

233239
# also make an image that doesn't have nonf data values, but has
234240
# masked values at the same locations, to make sure they give the same
@@ -240,13 +246,13 @@ def test_mask_treatment_bkg_img_spectrum(self, method, expected):
240246
for image in [image1, image2]:
241247

242248
# construct a flat trace in center of image
243-
trace = FlatTrace(image, img_size/2)
249+
trace = FlatTrace(image, img_size / 2)
244250

245251
# create 'Background' object with `mask_treatment` set
246252
# 'width' should be > size of image to use all pix (but warning will
247253
# be raised, which we ignore.)
248254
background = Background(image, mask_treatment=method,
249-
traces=trace, width=img_size+1)
255+
traces=trace, width=img_size + 1)
250256

251257
# test background image matches 'expected'
252258
bk_img = background.bkg_image()
@@ -265,7 +271,7 @@ def test_sub_bkg_image(self):
265271
"""
266272
Test that masked and nonfinite data is handled correctly when subtracting
267273
background from image, for all currently implemented masking
268-
options ('filter', 'omit', and 'zero-fill').
274+
options ('filter', 'omit', and 'zero-fill').
269275
"""
270276

271277
# make image, set some value to nan, which will be masked in the function
@@ -288,7 +294,7 @@ def test_sub_bkg_image(self):
288294
# 2d mask is reduced to a 1d mask to mask out full columns in the
289295
# presence of any nans - this means that (as tested above in
290296
# `test_mask_treatment_bkg_img_spectrum`) those columns will have 0.0
291-
# background. In this case, image.mask is expanded to mask full
297+
# background. In this case, image.mask is expanded to mask full
292298
# columns - the image itself will not have full columns set to np.nan,
293299
# so there are still valid background subtracted data values in this
294300
# case, but the corresponding mask for that entire column will be masked.
@@ -314,4 +320,3 @@ def test_sub_bkg_image(self):
314320

315321
assert np.all(np.isfinite(subtracted_img_zero_fill.data))
316322
assert np.all(subtracted_img_zero_fill.mask == 0)
317-

specreduce/tests/test_extract.py

+60
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def test_boxcar_extraction(mk_test_img):
8181
assert np.allclose(spectrum.flux.value, np.full_like(spectrum.flux.value, 67.15))
8282

8383

84+
8485
def test_boxcar_outside_image_condition(mk_test_img):
8586
#
8687
# Trace is such that extraction aperture lays partially outside the image
@@ -326,3 +327,62 @@ def test_horne_interpolated_nbins_fails(mk_test_img):
326327
spatial_profile={'name': 'interpolated_profile',
327328
'n_bins_interpolated_profile': 100})
328329
ex.spectrum
330+
331+
class TestMasksExtract():
332+
333+
def mk_flat_gauss_img(nrows=200, ncols=160, nan_slices=None, add_noise=True):
334+
335+
"""
336+
Makes a flat gaussian image for testing, with optional added gaussian
337+
nosie and optional data values set to NaN. Variance is included, which
338+
is required by HorneExtract. Returns a Spectrum1D with flux, spectral
339+
axis, and uncertainty.
340+
"""
341+
342+
sigma_pix = 4
343+
col_model = models.Gaussian1D(amplitude=1, mean=nrows/2,
344+
stddev=sigma_pix)
345+
spec2dvar = np.ones((nrows, ncols))
346+
noise = 0
347+
if add_noise:
348+
np.random.seed(7)
349+
sigma_noise = 1
350+
noise = np.random.normal(scale=sigma_noise, size=(nrows, ncols))
351+
352+
index_arr = np.tile(np.arange(nrows), (ncols, 1))
353+
img = col_model(index_arr.T) + noise
354+
355+
if nan_slices: # add nans in data
356+
for s in nan_slices:
357+
img[s] = np.nan
358+
359+
wave = np.arange(0, img.shape[1], 1)
360+
objectspec = Spectrum1D(spectral_axis=wave*u.m, flux=img*u.Jy,
361+
uncertainty=VarianceUncertainty(spec2dvar*u.Jy*u.Jy))
362+
363+
return objectspec
364+
365+
def test_boxcar_fully_masked():
366+
"""
367+
Test that the appropriate error is raised by `BoxcarExtract` when image
368+
is fully masked/NaN.
369+
"""
370+
371+
img = mk_img()
372+
373+
with pytest.raises(ValueError, match='Image is fully masked.'):
374+
# fully NaN image
375+
img = np.zeros((4, 5)) * np.nan
376+
Background(img, traces=FlatTrace(self.mk_img(), 2))
377+
378+
with pytest.raises(ValueError, match='Image is fully masked.'):
379+
# fully masked image (should be equivalent)
380+
img = NDData(np.ones((4, 5)), mask=np.ones((4, 5)))
381+
Background(img, traces=FlatTrace(self.mk_img(), 2))
382+
383+
# Now test that an image that isn't fully masked, but is fully masked
384+
# within the window determined by `width`, produces the correct result
385+
msg = 'Image is fully masked within background window determined by `width`.'
386+
with pytest.raises(ValueError, match=msg):
387+
img = self.mk_img(nrows=12, ncols=12, nan_slices=[np.s_[3:10, :]])
388+
Background(img, traces=FlatTrace(img, 6), width=7)

0 commit comments

Comments
 (0)