Repository: spacetelescope/synphot_refactor

Issue:
Consolidate with specutils
There might be some overlap with `specutils`, which is an affiliated `astropy` package. When this package is stable, consolidate redundancy between its API and ours.

http://specutils.readthedocs.org/en/latest/
https://github.com/astropy/specutils


Discussion:

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 4f7e3d4..eee74f6 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -1,6 +1,11 @@
 0.3.0 (unreleased)
 ==================
 
+- ``SourceSpectrum`` and ``SpectralElement`` now have ``to_spectrum1d`` and
+  ``from_spectrum1d`` to write to and read from ``specutils.Spectrum1D``
+  object, respectively. [#243]
+- Dropped support for Python 3.5 and ``astropy`` 2.x. This version is only
+  compatible with Python 3.6 or later and ``astropy`` 3.x or later. [#243]
 
 0.2.1 (2019-12-20)
 ==================
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 86ddc7d..41144eb 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -68,7 +68,7 @@ stages:
     - script: |
         sudo apt-get install libxml2-utils
         python -m pip install --upgrade pip setuptools
-        pip install -e .[test]
+        pip install -e .[test,all]
         pip install pytest-cov codecov
         pytest --pyargs synphot docs --cov --cov-report html --open-files --remote-data
       displayName: 'Run tests'
@@ -101,14 +101,17 @@ stages:
     parameters:
       name: 'Older_Versions'
       numpyCmd: 'pip install numpy==1.16.6'
-      astropyCmd: 'pip install astropy==2.0.12'
+      astropyCmd: 'pip install astropy==3.2.2'
       scipyCmd: 'pip install scipy==1.1.0'
+      specutilsCmd: 'echo "Skipping specutils install"'
       pythonVersion: '3.6'
 
   - template: azure-templates.yml
     parameters:
       name: 'Dev_Astropy'
       astropyCmd: 'pip install git+https://github.com/astropy/astropy.git@master#egg=astropy'
+      specutilsCmd: 'pip install git+https://github.com/astropy/specutils.git@master#egg=specutils'
+      pythonVersion: '3.8'
 
   - template: azure-templates.yml
     parameters:
diff --git a/azure-templates.yml b/azure-templates.yml
index 8f2172c..29b69d5 100644
--- a/azure-templates.yml
+++ b/azure-templates.yml
@@ -6,6 +6,7 @@ parameters:
   numpyCmd: 'pip install numpy'
   astropyCmd: 'pip install astropy'
   scipyCmd: 'pip install scipy'
+  specutilsCmd: 'pip install specutils'
   pythonVersion: '3.7'
   pytestExtraArgs: ''
 
@@ -42,6 +43,7 @@ jobs:
       ${{ parameters.numpyCmd }}
       ${{ parameters.scipyCmd }}
       ${{ parameters.astropyCmd }}
+      ${{ parameters.specutilsCmd }}
       pip install -e .[test]
       pytest --pyargs synphot docs --open-files $(PYTEST_EXTRA_ARGS)
     displayName: 'Run tests'
diff --git a/docs/conf.py b/docs/conf.py
index d21e74f..a74a7f9 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -127,7 +127,8 @@ man_pages = [('index', project.lower(), project + u' Documentation',
 
 intersphinx_mapping.update({
     'astropy': ('https://docs.astropy.org/en/latest/', None),
-    'stsynphot': ('https://stsynphot.readthedocs.io/en/latest/', None)})
+    'stsynphot': ('https://stsynphot.readthedocs.io/en/latest/', None),
+    'specutils': ('https://specutils.readthedocs.io/en/latest/', None)})
 
 # -- Options for linkcheck output ---------------------------------------------
 linkcheck_retry = 5
diff --git a/docs/index.rst b/docs/index.rst
index f4d546b..0356759 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -42,13 +42,14 @@ reported) or contact `STScI Help Desk <https://hsthelp.stsci.edu>`_.
 Installation and Setup
 ======================
 
-**synphot** works for Python 3.5 or later only. It requires the following
+**synphot** works for Python 3.6 or later only. It requires the following
 packages:
 
 * numpy
 * astropy
 * scipy
 * matplotlib (optional for plotting)
+* specutils (optional)
 
 You can install **synphot** using one of the following ways:
 
diff --git a/docs/synphot/bandpass.rst b/docs/synphot/bandpass.rst
index c807e7d..a503a9a 100644
--- a/docs/synphot/bandpass.rst
+++ b/docs/synphot/bandpass.rst
@@ -14,6 +14,7 @@ A bandpass can be constructed by one of the following methods:
   keywords needed to define it into a
   :class:`~synphot.spectrum.SpectralElement` object.
 * Build a composite bandpass using :ref:`synphot-spec-math-op`.
+* Build an empirical bandpass using :ref:`synphot-bandpass-specutils`.
 
 It has various :ref:`photometric properties <synphot_formulae>` and these
 main components:
@@ -269,3 +270,36 @@ set at 0.8:
     from synphot import SpectralElement
     bp = SpectralElement(Const1D, amplitude=0.8)
     bp.plot([1000, 10000], title='Flat Bandpass')
+
+
+.. _synphot-bandpass-specutils:
+
+specutils
+---------
+
+A bandpass can be constructed from and written to `specutils.Spectrum1D`
+object. See :ref:`specutils documentation <specutils:specutils>` for more
+information on how to use `~specutils.Spectrum1D`.
+
+The example below writes a :ref:`synphot-box-bandpass` to a
+`~specutils.Spectrum1D` object:
+
+.. doctest-requires:: specutils
+
+    >>> from astropy import units as u
+    >>> from synphot import SpectralElement
+    >>> from synphot.models import Box1D
+    >>> bp = SpectralElement(Box1D, amplitude=1, x_0=600*u.nm, width=10*u.nm)
+    >>> spec = bp.to_spectrum1d()
+
+Meanwhile, this example reads in a bandpass from a
+`~specutils.Spectrum1D` object:
+
+.. doctest-requires:: specutils
+
+    >>> from astropy import units as u
+    >>> from specutils import Spectrum1D
+    >>> from synphot import SpectralElement
+    >>> spec = Spectrum1D(spectral_axis=[100, 300]*u.nm,
+    ...                   flux=[0.1, 0.8]*u.dimensionless_unscaled)
+    >>> bp = SpectralElement.from_spectrum1d(spec)
diff --git a/docs/synphot/spectrum.rst b/docs/synphot/spectrum.rst
index 281ef09..0fe28e7 100644
--- a/docs/synphot/spectrum.rst
+++ b/docs/synphot/spectrum.rst
@@ -21,6 +21,7 @@ A source spectrum can be constructed by one of the following methods:
   :meth:`~synphot.thermal.ThermalSpectralElement.thermal_source`.
 * Build a composite source using :ref:`synphot-spec-math-op`.
   (Also see example in :ref:`synphot_getting_started`.)
+* Build an empirical source using :ref:`synphot-source-specutils`.
 
 It has these main components:
 
@@ -442,6 +443,39 @@ wavelength of 1 micron and an index of -2:
     plt.axhline(sp.model.amplitude, color='k', ls='--')  # Ref flux
 
 
+.. _synphot-source-specutils:
+
+specutils
+---------
+
+A source can be constructed from and written to `specutils.Spectrum1D`
+object. See :ref:`specutils documentation <specutils:specutils>` for more
+information on how to use `~specutils.Spectrum1D`.
+
+The example below writes a :ref:`synphot-gaussian` to a `~specutils.Spectrum1D`
+object in the flux unit of Jansky:
+
+.. doctest-requires:: specutils
+
+    >>> from astropy import units as u
+    >>> from synphot import SourceSpectrum
+    >>> from synphot.models import GaussianFlux1D
+    >>> sp = SourceSpectrum(GaussianFlux1D, mean=1.8*u.micron, fwhm=200*u.nm,
+    ...                     total_flux=1e-26*u.W/(u.m**2))
+    >>> spec = sp.to_spectrum1d(flux_unit=u.Jy)
+
+Meanwhile, this example reads in a source from a `~specutils.Spectrum1D`
+object:
+
+.. doctest-requires:: specutils
+
+    >>> from astropy import units as u
+    >>> from specutils import Spectrum1D
+    >>> from synphot import SourceSpectrum
+    >>> spec = Spectrum1D(spectral_axis=[100, 300]*u.nm, flux=[0.1, 0.8]*u.nJy)
+    >>> sp = SourceSpectrum.from_spectrum1d(spec)
+
+
 .. _synphot_thermal:
 
 Thermal
diff --git a/setup.cfg b/setup.cfg
index 2530028..742e077 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -8,6 +8,8 @@ text_file_format = rst
 addopts = --doctest-rst
 xfail_strict = true
 filterwarnings =
+    ignore:can't resolve package from __spec__
+    ignore:numpy.ufunc size changed:RuntimeWarning
     ignore:BlackBody provides the same capabilities
     ignore:The MexicanHat1D class is deprecated
 
@@ -39,11 +41,13 @@ packages = find:
 zip_safe = False
 install_requires =
     numpy>=1.16
-    astropy>=2
+    astropy>=3
     scipy
-python_requires = >=3.5
+python_requires = >=3.6
 
 [options.extras_require]
+all =
+    specutils>=0.7
 test =
     pytest-astropy
 docs =
diff --git a/synphot/compat.py b/synphot/compat.py
index 6618376..66c37bb 100644
--- a/synphot/compat.py
+++ b/synphot/compat.py
@@ -4,7 +4,14 @@
 import astropy
 from astropy.utils.introspection import minversion
 
-__all__ = ['ASTROPY_LT_2_0', 'ASTROPY_LT_4_0']
+try:
+    import specutils  # noqa
+except ImportError:
+    HAS_SPECUTILS = False
+else:
+    HAS_SPECUTILS = True
+
+__all__ = ['ASTROPY_LT_2_0', 'ASTROPY_LT_4_0', 'HAS_SPECUTILS']
 
 ASTROPY_LT_2_0 = not minversion(astropy, '2.0')
 ASTROPY_LT_4_0 = not minversion(astropy, '4.0')
diff --git a/synphot/spectrum.py b/synphot/spectrum.py
index 29713e7..3c70218 100644
--- a/synphot/spectrum.py
+++ b/synphot/spectrum.py
@@ -778,6 +778,76 @@ class BaseSpectrum(object):
         w, y = self._get_arrays(wavelengths)
         self._do_plot(w, y, **kwargs)
 
+    @classmethod
+    def from_spectrum1d(cls, spec, keep_neg=False):
+        """Create a spectrum from `specutils.Spectrum1D` object.
+
+        Parameters
+        ----------
+        spec : `specutils.Spectrum1D`
+
+        keep_neg : bool
+            See `~synphot.models.Empirical1D`.
+
+        Returns
+        -------
+        sp : `BaseSourceSpectrum`
+            Empirical spectrum.
+
+        """
+        # Remove masking here if specutils does it natively, see
+        # https://github.com/astropy/specutils/issues/585
+        if spec.mask is not None:
+            msk = ~spec.mask
+            points = spec.spectral_axis[msk]
+            lookup_table = spec.flux[msk]
+        else:
+            points = spec.spectral_axis
+            lookup_table = spec.flux
+
+        # Spectrum1D is designed to be immutable, so no need to make
+        # copies of spectral_axis nor flux.
+        return cls(Empirical1D, points=points, lookup_table=lookup_table,
+                   keep_neg=keep_neg, meta={'header': spec.meta.copy()})
+
+    def to_spectrum1d(self, wavelengths=None, **kwargs):
+        """Create a `specutils.Spectrum1D` object from spectrum.
+
+        Parameters
+        ----------
+        wavelengths : array-like, `~astropy.units.quantity.Quantity`, or `None`
+            Wavelength values for sampling.
+            If not a Quantity, assumed to be in Angstrom.
+            If `None`, ``self.waveset`` is used.
+
+        flux_unit : str or `~astropy.units.core.Unit` or `None`
+            This option is not applicable to unitless spectrum like bandpass.
+            Flux is converted to this unit before written out.
+            If not given, internal unit is used.
+            Count and magnitudes are not supported.
+
+        Returns
+        -------
+        spec : `specutils.Spectrum1D`
+
+        Raises
+        ------
+        ImportError
+            ``specutils`` is not installed.
+
+        """
+        from .compat import HAS_SPECUTILS
+
+        if not HAS_SPECUTILS:  # pragma: no cover
+            raise ImportError('specutils must be installed to use this method')
+
+        import specutils
+
+        w, y = self._get_arrays(wavelengths, **kwargs)
+
+        return specutils.Spectrum1D(spectral_axis=w, flux=y,
+                                    meta=self.meta.copy())
+
 
 class BaseSourceSpectrum(BaseSpectrum):
     """Base class to handle spectrum with flux unit like source spectrum


Test patch:
diff --git a/synphot/conftest.py b/synphot/conftest.py
index 1b36a1a..1bae947 100644
--- a/synphot/conftest.py
+++ b/synphot/conftest.py
@@ -19,9 +19,10 @@ enable_deprecations_as_exceptions()
 # from the list of packages for which version numbers are displayed
 # when running the tests.
 PYTEST_HEADER_MODULES['Astropy'] = 'astropy'
-PYTEST_HEADER_MODULES['scikit-image'] = 'skimage'
-if 'h5py' in PYTEST_HEADER_MODULES:
-    del PYTEST_HEADER_MODULES['h5py']
+PYTEST_HEADER_MODULES['specutils'] = 'specutils'
+PYTEST_HEADER_MODULES.pop('Matplotlib')
+PYTEST_HEADER_MODULES.pop('Pandas')
+PYTEST_HEADER_MODULES.pop('h5py')
 
 packagename = os.path.basename(os.path.dirname(__file__))
 TESTED_VERSIONS[packagename] = version
diff --git a/synphot/tests/test_spectrum.py b/synphot/tests/test_spectrum.py
index a4a5845..c0cc99f 100644
--- a/synphot/tests/test_spectrum.py
+++ b/synphot/tests/test_spectrum.py
@@ -19,6 +19,7 @@ from astropy.io import fits
 from astropy.modeling.models import (
     BrokenPowerLaw1D, Const1D, ExponentialCutoffPowerLaw1D, LogParabola1D,
     PowerLaw1D, RedshiftScaleFactor)
+from astropy.tests.helper import assert_quantity_allclose
 from astropy.utils import minversion
 from astropy.utils.data import get_pkg_data_filename
 from astropy.utils.exceptions import AstropyUserWarning
@@ -26,7 +27,7 @@ from astropy.utils.exceptions import AstropyUserWarning
 # LOCAL
 from .test_units import _area, _wave, _flux_jy, _flux_photlam, _flux_vegamag
 from .. import exceptions, units
-from ..compat import ASTROPY_LT_4_0
+from ..compat import ASTROPY_LT_4_0, HAS_SPECUTILS  # noqa
 from ..models import (
     BlackBodyNorm1D, Box1D, ConstFlux1D, Empirical1D, Gaussian1D,
     GaussianAbsorption1D, GaussianFlux1D, Lorentz1D, MexicanHat1D,
@@ -129,7 +130,7 @@ def test_filter_exception():
 
 
 @pytest.mark.skipif('not HAS_SCIPY')
-class TestEmpiricalSourceFromFile(object):
+class TestEmpiricalSourceFromFile:
     """This is the most common model used in ASTROLIB PYSYNPHOT."""
     def setup_class(self):
         specfile = get_pkg_data_filename(
@@ -215,7 +216,7 @@ class TestEmpiricalSourceFromFile(object):
 
 
 @pytest.mark.skipif('not HAS_SCIPY')
-class TestEmpiricalBandpassFromFile(object):
+class TestEmpiricalBandpassFromFile:
     """This is the most common model used in ASTROLIB PYSYNPHOT."""
     def setup_class(self):
         bandfile = get_pkg_data_filename(
@@ -338,7 +339,7 @@ class TestEmpiricalBandpassFromFile(object):
         assert f.unit == units.FLAM
 
 
-class TestBoxBandpass(object):
+class TestBoxBandpass:
     """Test bandpass with Box1D model."""
     def setup_class(self):
         self.bp = SpectralElement(Box1D, amplitude=1, x_0=5000, width=100)
@@ -373,7 +374,7 @@ class TestBoxBandpass(object):
                 width=[100, 1], n_models=2)
 
 
-class TestBlackBodySource(object):
+class TestBlackBodySource:
     """Test source spectrum with BlackBody1D model."""
     def setup_class(self):
         self.sp = SourceSpectrum(BlackBodyNorm1D, temperature=5500)
@@ -388,7 +389,7 @@ class TestBlackBodySource(object):
             rtol=2.5e-3)
 
 
-class TestGaussianSource(object):
+class TestGaussianSource:
     """Test source spectrum with GaussianFlux1D model."""
     def setup_class(self):
         tf = 4.96611456e-12 * u.erg / (u.cm * u.cm * u.s)
@@ -444,7 +445,7 @@ def test_gaussian_source_watts():
     np.testing.assert_allclose(tf.to(flux.unit), flux, rtol=1e-4)
 
 
-class TestPowerLawSource(object):
+class TestPowerLawSource:
     """Test source spectrum with PowerLawFlux1D model."""
     def setup_class(self):
         self.sp = SourceSpectrum(PowerLawFlux1D, amplitude=1, x_0=6000,
@@ -469,7 +470,7 @@ class TestPowerLawSource(object):
         np.testing.assert_allclose(self.sp(600 * u.nm).value, 1)
 
 
-class TestBuildModels(object):
+class TestBuildModels:
     """Test compatiblity with other models not tested above."""
     def test_BrokenPowerLaw1D(self):
         sp = SourceSpectrum(
@@ -529,7 +530,7 @@ class TestBuildModels(object):
 
 
 @pytest.mark.skipif('not HAS_SCIPY')
-class TestCheckOverlap(object):
+class TestCheckOverlap:
     """Test spectrum overlap check. This method is ever only used
     in the form of ``bp.check_overlap(sp)``, so that is what is
     tested here.
@@ -590,7 +591,7 @@ class TestCheckOverlap(object):
             self.bp.check_overlap(1)
 
 
-class TestForceExtrap(object):
+class TestForceExtrap:
     """Test forcing extrapolation on a source spectrum."""
 
     @pytest.mark.skipif('not HAS_SCIPY')
@@ -618,7 +619,7 @@ class TestForceExtrap(object):
 
 
 @pytest.mark.skipif('not HAS_SCIPY')
-class TestNormalize(object):
+class TestNormalize:
     """Test source spectrum normalization."""
     def setup_class(self):
         """``expr`` stores the equivalent IRAF SYNPHOT command."""
@@ -754,7 +755,7 @@ class TestNormalize(object):
             sp.normalize(100 * u.ct, band=self.abox, area=_area)
 
 
-class TestWaveset(object):
+class TestWaveset:
     """Tests related to spectrum waveset."""
     def test_none(self):
         sp = SourceSpectrum(Const1D, amplitude=1)
@@ -843,7 +844,7 @@ class TestWaveset(object):
             get_waveset('foo')
 
 
-class TestRedShift(object):
+class TestRedShift:
     """Test redshifted source spectrum.
 
     ``waveset`` already tested in `TestWaveset`.
@@ -896,7 +897,7 @@ class TestRedShift(object):
 
 
 @pytest.mark.skipif('not HAS_SCIPY')
-class TestMathOperators(object):
+class TestMathOperators:
     """Test spectrum math operators."""
     def setup_class(self):
         self.sp_1 = SourceSpectrum(
@@ -1032,7 +1033,7 @@ class TestMathOperators(object):
 
     def test_bandpass_mul_div_exceptions(self):
         """Only mul is tested but truediv uses the same validation."""
-        class DummyObject(object):
+        class DummyObject:
             pass
 
         with pytest.raises(exceptions.IncompatibleSources):
@@ -1052,7 +1053,7 @@ class TestMathOperators(object):
 
 
 @pytest.mark.skipif('not HAS_SCIPY')
-class TestWriteSpec(object):
+class TestWriteSpec:
     """Test spectrum to_fits() method."""
     def setup_class(self):
         self.outdir = tempfile.mkdtemp()
@@ -1094,3 +1095,179 @@ class TestWriteSpec(object):
 
     def teardown_class(self):
         shutil.rmtree(self.outdir)
+
+
+@pytest.mark.skipif('not HAS_SPECUTILS')
+class TestSpecutilsBridge:
+    def test_from_spectrum1d_Empirical1D_source(self):
+        import specutils
+
+        lamb = [1000, 5000, 10000] * u.AA
+        flux = [0, -0.5e-17, 5.6e-17] * units.FLAM
+        spec = specutils.Spectrum1D(spectral_axis=lamb, flux=flux)
+        spec.meta['source'] = [1, 2, 3]
+        with pytest.warns(AstropyUserWarning,
+                          match=r'contained negative flux or throughput'):
+            sp = SourceSpectrum.from_spectrum1d(spec, keep_neg=False)
+
+        w = sp.waveset
+        y = sp(w, flux_unit=units.FLAM)
+        assert isinstance(sp.model, Empirical1D)
+        assert sp.meta['header']['source'] == spec.meta['source']
+        assert_quantity_allclose(w, lamb)
+        assert_quantity_allclose(y, [0, 0, 5.6e-17] * units.FLAM)
+
+        # Ensure metadata is copied, not referenced
+        spec.meta['source'][1] = 99
+        assert sp.meta['header']['source'] == [1, 2, 3]
+        sp.meta['header']['source'][0] = 100
+        assert spec.meta['source'] == [1, 99, 3]
+
+    def test_from_spectrum1d_Empirical1D_source_masked(self):
+        import specutils
+
+        lamb = [1000, 5000, 10000] * u.AA
+        flux = [0, -0.5e-17, 5.6e-17] * units.FLAM
+        mask = np.array([False, True, False])
+        spec = specutils.Spectrum1D(spectral_axis=lamb, flux=flux, mask=mask)
+        sp = SourceSpectrum.from_spectrum1d(spec, keep_neg=False)
+
+        w = sp.waveset
+        y = sp(w, flux_unit=units.FLAM)
+        assert_quantity_allclose(w, [1000, 10000] * u.AA)
+        assert_quantity_allclose(y, [0, 5.6e-17] * units.FLAM)
+
+    def test_to_spectrum1d_Empirical1D_source(self):
+        lamb = [1000, 5000, 10000] * u.AA
+        flux = [1.5, 0.5, 99.9] * u.nJy
+        sp = SourceSpectrum(Empirical1D, points=lamb, lookup_table=flux,
+                            meta={'source': 'foo'})
+        spec = sp.to_spectrum1d(flux_unit=u.nJy)
+
+        assert_quantity_allclose(spec.spectral_axis, lamb)
+        assert_quantity_allclose(spec.flux, flux)
+        assert spec.meta['source'] == 'foo'
+
+        # Ensure redshifting does not change Spectrum1D
+        sp.z = 0.1
+        assert_quantity_allclose(spec.flux, flux)
+        with pytest.raises(AssertionError):
+            assert_quantity_allclose(sp(lamb, flux_unit=u.nJy), flux)
+
+        # Unsupported flux unit
+        with pytest.raises(exceptions.SynphotError) as e:
+            sp.to_spectrum1d(flux_unit=u.count)
+        assert 'Area is compulsory' in str(e.value)
+
+    def test_to_spectrum1d_GaussianFlux1D(self):
+        from specutils.analysis import gaussian_fwhm
+
+        total_flux = 1 * (u.erg / u.s / u.cm / u.cm)
+        fwhm = 10 * u.AA
+        sp = SourceSpectrum(GaussianFlux1D, mean=5000 * u.AA, fwhm=fwhm,
+                            total_flux=total_flux)
+        spec = sp.to_spectrum1d(flux_unit=units.FLAM)
+
+        assert_quantity_allclose(spec.spectral_axis, sp.waveset)
+        assert_quantity_allclose(
+            spec.flux, sp(sp.waveset, flux_unit=units.FLAM))
+        assert_quantity_allclose(gaussian_fwhm(spec), fwhm, rtol=1e-5)
+        assert spec.meta['expr'] == 'em(5000, 10, 1, FLAM)'
+
+    def test_to_spectrum1d_ConstFlux1D(self):
+        flux = 1 * units.PHOTLAM
+        sp = SourceSpectrum(ConstFlux1D, amplitude=flux)
+
+        with pytest.raises(exceptions.SynphotError) as e:
+            spec = sp.to_spectrum1d()
+        assert 'Provide wavelengths for sampling' in str(e.value)
+
+        w = [100, 500, 1000] * u.nm
+        spec = sp.to_spectrum1d(wavelengths=w)
+
+        assert_quantity_allclose(spec.spectral_axis, w)
+        assert_quantity_allclose(spec.flux, flux)
+        assert len(spec.meta) == 0
+
+    def test_to_spectrum1d_compound_source(self):
+        from specutils.analysis import line_flux
+
+        total_flux = 0.5 * (u.erg / u.s / u.cm / u.cm)
+        fwhm = 1 * u.AA
+        g1 = SourceSpectrum(GaussianFlux1D, mean=300 * u.nm, fwhm=fwhm,
+                            total_flux=total_flux)
+        g2 = SourceSpectrum(GaussianFlux1D, mean=400 * u.nm, fwhm=fwhm,
+                            total_flux=total_flux)
+        sp = g1 + g2
+        spec = sp.to_spectrum1d(flux_unit=units.FLAM)
+        integrated_flux = sp.integrate(flux_unit=units.FLAM)
+        assert_quantity_allclose(
+            integrated_flux, 1 * total_flux.unit, rtol=0.002)
+        assert_quantity_allclose(integrated_flux, line_flux(spec), rtol=1e-5)
+
+    def test_from_spectrum1d_Empirical1D_bandpass(self):
+        import specutils
+
+        lamb = [1000, 5000, 10000] * u.AA
+        thru = [0, 1, -1] * units.THROUGHPUT
+        spec = specutils.Spectrum1D(spectral_axis=lamb, flux=thru)
+        with pytest.warns(AstropyUserWarning,
+                          match=r'contained negative flux or throughput'):
+            bp = SpectralElement.from_spectrum1d(spec, keep_neg=False)
+
+        w = bp.waveset
+        assert isinstance(bp.model, Empirical1D)
+        assert_quantity_allclose(w, lamb)
+        assert_quantity_allclose(bp(w), [0, 1, 0])
+
+    def test_from_spectrum1d_Empirical1D_bandpass_masked(self):
+        import specutils
+
+        lamb = [1000, 5000, 10000] * u.AA
+        thru = [0, 1, -1] * units.THROUGHPUT
+        mask = np.array([False, False, True])
+        spec = specutils.Spectrum1D(spectral_axis=lamb, flux=thru, mask=mask)
+        bp = SpectralElement.from_spectrum1d(spec, keep_neg=False)
+
+        w = bp.waveset
+        assert isinstance(bp.model, Empirical1D)
+        assert_quantity_allclose(w, [1000, 5000] * u.AA)
+        assert_quantity_allclose(bp(w), [0, 1])
+
+    def test_to_spectrum1d_Empirical1D_bandpass(self):
+        lamb = [1000, 5000, 10000] * u.AA
+        thru = [0, 1, 0]
+        bp = SpectralElement(Empirical1D, points=lamb, lookup_table=thru)
+        spec = bp.to_spectrum1d()
+
+        assert_quantity_allclose(spec.spectral_axis, lamb)
+        assert_quantity_allclose(spec.flux, thru)
+
+    def test_to_spectrum1d_Const1D(self):
+        thru = 0.88
+        bp = SpectralElement(Const1D, amplitude=thru)
+
+        with pytest.raises(exceptions.SynphotError) as e:
+            spec = bp.to_spectrum1d()
+        assert 'Provide wavelengths for sampling' in str(e.value)
+
+        w = [100, 500, 1000] * u.nm
+        spec = bp.to_spectrum1d(wavelengths=w)
+
+        assert_quantity_allclose(spec.spectral_axis, w)
+        assert_quantity_allclose(spec.flux, thru)
+
+    def test_to_spectrum1d_compound_bandpass(self):
+        from specutils.analysis import line_flux
+
+        box = SpectralElement(
+            Box1D, amplitude=0.5, x_0=5000 * u.AA, width=1 * u.AA)
+        bp = box * box
+        spec = bp.to_spectrum1d()
+
+        w = bp.waveset
+        integrated_thru = bp.integrate()
+        assert_quantity_allclose(spec.spectral_axis, w)
+        assert_quantity_allclose(spec.flux, bp(w))
+        assert_quantity_allclose(integrated_thru, 0.25 * u.AA)
+        assert_quantity_allclose(integrated_thru, line_flux(spec))