Skip to content

Commit d2c71cf

Browse files
authored
Merge pull request #384 from pllim/fits-io-table
BUG/API: read_fits_spec now more flexible but at a cost
2 parents 0d1064f + 0edcfb2 commit d2c71cf

File tree

12 files changed

+198
-130
lines changed

12 files changed

+198
-130
lines changed

CHANGES.rst

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,15 @@
1-
1.3.1 (unreleased)
1+
1.4.0 (unreleased)
22
==================
33

4+
- ``read_fits_spec()`` now uses ``astropy.table.QTable.read`` for parsing to
5+
ensure that the correct ``TUNITn`` is read. As a result, ``wave_unit`` and
6+
``flux_unit`` keywords are deprecated and no longer used in that function.
7+
Additionally, if any ``TUNITn`` in the table is invalid, regardless whether
8+
the column is used or not, an exception will now be raised. [#384]
9+
10+
- ``read_spec()`` now detects whether given filename is FITS more consistently
11+
w.r.t. ``astropy``. [#384]
12+
413
1.3.0 (2023-11-28)
514
==================
615

docs/synphot/overview.rst

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,9 +396,7 @@ keywords (unless you overwrite them with non-default values in
396396
* ``TTYPE2`` set to "FLUX" (for source spectrum) or "THROUGHPUT"
397397
(for bandpass).
398398

399-
While these were required in ASTROLIB PYSYNPHOT, they are optional here in that
400-
default units would be applied, where applicable, if they are missing from
401-
the header. Regardless, setting them is highly recommended:
399+
These were required in ASTROLIB PYSYNPHOT, as well as this package:
402400

403401
* ``TUNIT1`` set to :ref:`supported wavelength unit name <synphot_units>`.
404402
* ``TUNIT2`` set to :ref:`supported flux unit name <synphot_units>`

synphot/reddening.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
# THIRD-PARTY
88
import numpy as np
99
from astropy import units as u
10+
from astropy.io.fits.connect import is_fits
1011

1112
# LOCAL
1213
from synphot import exceptions, specio, units
@@ -137,8 +138,8 @@ def to_fits(self, filename, wavelengths=None, **kwargs):
137138
def from_file(cls, filename, **kwargs):
138139
"""Create a reddening law from file.
139140
140-
If filename has 'fits' or 'fit' suffix, it is read as FITS.
141-
Otherwise, it is read as ASCII.
141+
If filename is recognized by ``astropy.io.fits`` as FITS,
142+
it is read as such. Otherwise, it is read as ASCII.
142143
143144
Parameters
144145
----------
@@ -156,13 +157,12 @@ def from_file(cls, filename, **kwargs):
156157
Empirical reddening law.
157158
158159
"""
159-
if 'flux_unit' not in kwargs:
160+
if is_fits("", filename, None):
161+
if 'flux_col' not in kwargs:
162+
kwargs['flux_col'] = 'Av/E(B-V)'
163+
elif 'flux_unit' not in kwargs: # pragma: no cover
160164
kwargs['flux_unit'] = cls._internal_flux_unit
161165

162-
if ((filename.endswith('fits') or filename.endswith('fit')) and
163-
'flux_col' not in kwargs):
164-
kwargs['flux_col'] = 'Av/E(B-V)'
165-
166166
header, wavelengths, rvs = specio.read_spec(filename, **kwargs)
167167

168168
return cls(Empirical1D, points=wavelengths, lookup_table=rvs,
@@ -217,13 +217,12 @@ def from_extinction_model(cls, modelname, **kwargs):
217217

218218
filename = cfgitem()
219219

220-
if 'flux_unit' not in kwargs:
220+
if is_fits("", filename, None):
221+
if 'flux_col' not in kwargs:
222+
kwargs['flux_col'] = 'Av/E(B-V)'
223+
elif 'flux_unit' not in kwargs: # pragma: no cover
221224
kwargs['flux_unit'] = cls._internal_flux_unit
222225

223-
if ((filename.endswith('fits') or filename.endswith('fit')) and
224-
'flux_col' not in kwargs):
225-
kwargs['flux_col'] = 'Av/E(B-V)'
226-
227226
header, wavelengths, rvs = specio.read_remote_spec(filename, **kwargs)
228227
header['filename'] = filename
229228
header['descrip'] = cfgitem.description

synphot/specio.py

Lines changed: 48 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,10 @@
1212
from astropy import log
1313
from astropy import units as u
1414
from astropy.io import ascii, fits
15+
from astropy.io.fits.connect import is_fits
16+
from astropy.table import QTable
1517
from astropy.utils.data import get_readable_fileobj
18+
from astropy.utils.decorators import deprecated_renamed_argument
1619
from astropy.utils.exceptions import AstropyUserWarning
1720

1821
# LOCAL
@@ -88,7 +91,7 @@ def read_spec(filename, fname='', **kwargs):
8891
elif not fname: # pragma: no cover
8992
raise exceptions.SynphotError('Cannot determine filename.')
9093

91-
if fname.endswith('fits') or fname.endswith('fit'):
94+
if is_fits("", fname, None):
9295
read_func = read_fits_spec
9396
else:
9497
read_func = read_ascii_spec
@@ -143,12 +146,15 @@ def read_ascii_spec(filename, wave_unit=u.AA, flux_unit=units.FLAM, **kwargs):
143146
return header, wavelengths, fluxes
144147

145148

149+
@deprecated_renamed_argument(
150+
["wave_unit", "flux_unit"], [None, None], ["1.4", "1.4"],
151+
alternative='TUNITn as per FITS standards')
146152
def read_fits_spec(filename, ext=1, wave_col='WAVELENGTH', flux_col='FLUX',
147153
wave_unit=u.AA, flux_unit=units.FLAM):
148154
"""Read FITS spectrum.
149155
150-
Wavelength and flux units are extracted from ``TUNIT1`` and ``TUNIT2``
151-
keywords, respectively, from data table (not primary) header.
156+
Wavelength and flux units are extracted from respective ``TUNITn``
157+
keywords, from data table (not primary) header.
152158
If these keywords are not present, units are taken from
153159
``wave_unit`` and ``flux_unit`` instead.
154160
@@ -164,9 +170,11 @@ def read_fits_spec(filename, ext=1, wave_col='WAVELENGTH', flux_col='FLUX',
164170
Wavelength and flux column names (case-insensitive).
165171
166172
wave_unit, flux_unit : str or `~astropy.units.Unit`
167-
Wavelength and flux units, which default to Angstrom and FLAM,
168-
respectively. These are *only* used if ``TUNIT1`` and ``TUNIT2``
169-
keywords are not present in table (not primary) header.
173+
Wavelength and flux units. These are *no longer used*.
174+
Define your units in the respective ``TUNITn``
175+
keywords in table (not primary) header.
176+
177+
.. deprecated:: 1.4
170178
171179
Returns
172180
-------
@@ -177,37 +185,42 @@ def read_fits_spec(filename, ext=1, wave_col='WAVELENGTH', flux_col='FLUX',
177185
Wavelength and flux of the spectrum.
178186
179187
"""
188+
wave_col = wave_col.lower()
189+
flux_col = flux_col.lower()
190+
180191
try:
181192
fs = fits.open(filename)
182-
header = dict(fs[str('PRIMARY')].header)
183-
wave_dat = fs[ext].data.field(wave_col).copy()
184-
flux_dat = fs[ext].data.field(flux_col).copy()
185-
fits_wave_unit = fs[ext].header.get('TUNIT1')
186-
fits_flux_unit = fs[ext].header.get('TUNIT2')
187-
188-
if fits_wave_unit is not None:
189-
try:
190-
wave_unit = units.validate_unit(fits_wave_unit)
191-
except (exceptions.SynphotError, ValueError) as e: # pragma: no cover # noqa: E501
192-
warnings.warn(
193-
'{0} from FITS header is not valid wavelength unit, using '
194-
'{1}: {2}'.format(fits_wave_unit, wave_unit, e),
195-
AstropyUserWarning)
196-
197-
if fits_flux_unit is not None:
198-
try:
199-
flux_unit = units.validate_unit(fits_flux_unit)
200-
except (exceptions.SynphotError, ValueError) as e: # pragma: no cover # noqa: E501
201-
warnings.warn(
202-
'{0} from FITS header is not valid flux unit, using '
203-
'{1}: {2}'.format(fits_flux_unit, flux_unit, e),
204-
AstropyUserWarning)
205-
206-
wave_unit = units.validate_unit(wave_unit)
207-
flux_unit = units.validate_unit(flux_unit)
208-
209-
wavelengths = wave_dat * wave_unit
210-
fluxes = flux_dat * flux_unit
193+
subhdu = fs[ext]
194+
195+
# Need to fix table units
196+
for key in subhdu.header["TUNIT*"]:
197+
val = subhdu.header[key]
198+
if not val:
199+
continue
200+
newval = units.validate_unit(val)
201+
subhdu.header[key] = newval.to_string() # Must be generic to handle mag # noqa: E501
202+
203+
with warnings.catch_warnings():
204+
warnings.filterwarnings("ignore", category=u.UnitsWarning,
205+
message=".* did not parse as fits unit")
206+
t = QTable.read(subhdu)
207+
header = dict(fs["PRIMARY"].header)
208+
209+
# https://github.com/astropy/astropy/issues/16221
210+
lower_colnames = [c.lower() for c in t.colnames]
211+
t_col_wave = t.columns[lower_colnames.index(wave_col)]
212+
if t_col_wave.unit:
213+
t_col_wave_unit = units.validate_unit(t_col_wave.unit.to_string())
214+
else:
215+
t_col_wave_unit = u.dimensionless_unscaled
216+
t_col_flux = t.columns[lower_colnames.index(flux_col)]
217+
if t_col_flux.unit:
218+
t_col_flux_unit = units.validate_unit(t_col_flux.unit.to_string())
219+
else:
220+
t_col_flux_unit = u.dimensionless_unscaled
221+
wavelengths = t_col_wave.value * t_col_wave_unit
222+
fluxes = t_col_flux.value * t_col_flux_unit
223+
211224
finally:
212225
if isinstance(filename, str):
213226
fs.close()

synphot/spectrum.py

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
# ASTROPY
1515
from astropy import log
1616
from astropy import units as u
17+
from astropy.io.fits.connect import is_fits
1718
from astropy.modeling import Model
1819
from astropy.modeling.core import CompoundModel
1920
from astropy.modeling.models import RedshiftScaleFactor, Scale
@@ -1921,8 +1922,8 @@ def to_fits(self, filename, wavelengths=None, **kwargs):
19211922
def from_file(cls, filename, **kwargs):
19221923
"""Creates a bandpass from file.
19231924
1924-
If filename has 'fits' or 'fit' suffix, it is read as FITS.
1925-
Otherwise, it is read as ASCII.
1925+
If filename is recognized by ``astropy.io.fits`` as FITS,
1926+
it is read as such. Otherwise, it is read as ASCII.
19261927
19271928
Parameters
19281929
----------
@@ -1940,13 +1941,12 @@ def from_file(cls, filename, **kwargs):
19401941
Empirical bandpass.
19411942
19421943
"""
1943-
if 'flux_unit' not in kwargs:
1944+
if is_fits("", filename, None):
1945+
if 'flux_col' not in kwargs:
1946+
kwargs['flux_col'] = 'THROUGHPUT'
1947+
elif 'flux_unit' not in kwargs: # pragma: no cover
19441948
kwargs['flux_unit'] = cls._internal_flux_unit
19451949

1946-
if ((filename.endswith('fits') or filename.endswith('fit')) and
1947-
'flux_col' not in kwargs):
1948-
kwargs['flux_col'] = 'THROUGHPUT'
1949-
19501950
header, wavelengths, throughput = specio.read_spec(filename, **kwargs)
19511951
return cls(Empirical1D, points=wavelengths, lookup_table=throughput,
19521952
keep_neg=True, meta={'header': header})
@@ -2009,13 +2009,12 @@ def from_filter(cls, filtername, **kwargs):
20092009

20102010
filename = cfgitem()
20112011

2012-
if 'flux_unit' not in kwargs:
2012+
if is_fits("", filename, None):
2013+
if 'flux_col' not in kwargs:
2014+
kwargs['flux_col'] = 'THROUGHPUT'
2015+
elif 'flux_unit' not in kwargs: # pragma: no cover
20132016
kwargs['flux_unit'] = cls._internal_flux_unit
20142017

2015-
if ((filename.endswith('fits') or filename.endswith('fit')) and
2016-
'flux_col' not in kwargs):
2017-
kwargs['flux_col'] = 'THROUGHPUT'
2018-
20192018
header, wavelengths, throughput = specio.read_remote_spec(
20202019
filename, **kwargs)
20212020
header['filename'] = filename

synphot/tests/test_binning.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ def setup_class(self):
198198
get_pkg_data_filename(
199199
os.path.join('data', 'hst_acs_hrc_f555w.fits'),
200200
package='synphot.tests'),
201-
flux_col='THROUGHPUT', flux_unit=u.dimensionless_unscaled)
201+
flux_col='THROUGHPUT')
202202

203203
# Binned data.
204204
bins = generate_wavelengths(

synphot/tests/test_reddening.py

Lines changed: 22 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33

44
# STDLIB
55
import os
6-
import shutil
7-
import tempfile
86

97
# THIRD-PARTY
108
import numpy as np
@@ -156,32 +154,26 @@ def test_redlaw_from_model_exception():
156154
ReddeningLaw.from_extinction_model('foo')
157155

158156

159-
class TestWriteReddeningLaw:
157+
@pytest.mark.parametrize('ext_hdr', [None, {'foo': 'foo'}])
158+
def test_write_reddening_law(tmp_path, ext_hdr):
160159
"""Test ReddeningLaw ``to_fits()`` method."""
161-
def setup_class(self):
162-
self.outdir = tempfile.mkdtemp()
163-
self.x = np.linspace(1000, 5000, 5)
164-
self.y = np.linspace(1, 5, 5) * 0.1
165-
self.redlaw = ReddeningLaw(
166-
Empirical1D, points=self.x, lookup_table=self.y)
167-
168-
@pytest.mark.parametrize('ext_hdr', [None, {'foo': 'foo'}])
169-
def test_write(self, ext_hdr):
170-
outfile = os.path.join(self.outdir, 'outredlaw.fits')
171-
172-
if ext_hdr is None:
173-
self.redlaw.to_fits(outfile, overwrite=True)
174-
else:
175-
self.redlaw.to_fits(outfile, overwrite=True, ext_header=ext_hdr)
176-
177-
# Read it back in and check
178-
redlaw2 = ReddeningLaw.from_file(outfile)
179-
np.testing.assert_allclose(redlaw2.waveset.value, self.x)
180-
np.testing.assert_allclose(redlaw2(self.x).value, self.y)
181-
182-
if ext_hdr is not None:
183-
hdr = fits.getheader(outfile, 1)
184-
assert 'foo' in hdr
185-
186-
def teardown_class(self):
187-
shutil.rmtree(self.outdir)
160+
x = np.linspace(1000, 5000, 5)
161+
y = np.linspace(1, 5, 5) * 0.1
162+
redlaw = ReddeningLaw(
163+
Empirical1D, points=x, lookup_table=y, meta={"expr": "ebv(test)"})
164+
165+
outfile = str(tmp_path / 'outredlaw.fits')
166+
167+
if ext_hdr is None:
168+
redlaw.to_fits(outfile, overwrite=True)
169+
else:
170+
redlaw.to_fits(outfile, overwrite=True, ext_header=ext_hdr)
171+
172+
# Read it back in and check
173+
redlaw2 = ReddeningLaw.from_file(outfile)
174+
np.testing.assert_allclose(redlaw2.waveset.value, x)
175+
np.testing.assert_allclose(redlaw2(x).value, y)
176+
177+
if ext_hdr is not None:
178+
hdr = fits.getheader(outfile, 1)
179+
assert 'foo' in hdr

0 commit comments

Comments
 (0)