././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1643306919.748087 specutils-1.6.0/0000755000503700020070000000000000000000000015604 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7003136 specutils-1.6.0/.github/0000755000503700020070000000000000000000000017144 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7058005 specutils-1.6.0/.github/workflows/0000755000503700020070000000000000000000000021201 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/.github/workflows/ci_cron_weekly.yml0000644000503700020070000000350500000000000024723 0ustar00rosteenSTSCI\science00000000000000name: Weekly cron on: schedule: # run every Monday at 6am UTC - cron: '0 6 * * 1' jobs: tests: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} if: github.repository == 'astropy/specutils' strategy: fail-fast: false matrix: include: # We check numpy-dev also in a job that only runs from cron, so that # we can spot issues sooner. We do not use remote data here, since # that gives too many false positives due to URL timeouts. We also # install all dependencies via pip here so we pick up the latest # releases. - name: Python 3.7 with dev version of key dependencies os: ubuntu-latest python: 3.8 toxenv: py38-test-devdeps - name: Documentation link check os: ubuntu-latest python: 3.7 toxenv: linkcheck # TODO: Uncomment when 3.10 is more mature. Should we use devdeps? # Test against Python dev in cron job. #- name: Python dev with basic dependencies # os: ubuntu-latest # python: 3.10-dev # toxenv: pydev-test steps: - name: Checkout code uses: actions/checkout@v2 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - name: Install language-pack-de and tzdata if: ${{ matrix.os == 'ubuntu-latest' }} run: sudo apt-get install language-pack-de tzdata - name: Install graphviz if: ${{ matrix.toxenv == 'linkcheck' }} run: sudo apt-get install graphviz - name: Install Python dependencies run: python -m pip install --upgrade tox - name: Run tests run: tox ${{ matrix.toxargs}} -e ${{ matrix.toxenv}} -- ${{ matrix.toxposargs}} ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/.github/workflows/ci_workflows.yml0000644000503700020070000000707200000000000024442 0ustar00rosteenSTSCI\science00000000000000name: CI on: push: pull_request: jobs: tests: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - name: Code style checks os: ubuntu-latest python: 3.x toxenv: codestyle - name: Python 3.7 with minimal dependencies os: ubuntu-latest python: 3.7 toxenv: py37-test - name: Python 3.8 with all optional dependencies os: ubuntu-latest python: 3.8 toxenv: py38-test-alldeps toxargs: -v --develop toxposargs: --open-files # NOTE: In the build below we also check that tests do not open and # leave open any files. This has a performance impact on running the # tests, hence why it is not enabled by default. - name: Python 3.7 with oldest supported version of all dependencies os: ubuntu-18.04 python: 3.7 toxenv: py37-test-oldestdeps - name: Python 3.7 with numpy 1.17 and full coverage os: ubuntu-latest python: 3.7 toxenv: py37-test-alldeps-numpy117-cov toxposargs: --remote-data=astropy - name: Python 3.8 with all optional dependencies (Windows) os: windows-latest python: 3.8 toxenv: py38-test-alldeps toxposargs: --durations=50 - name: Python 3.7 with all optional dependencies (MacOS X) os: macos-latest python: 3.7 toxenv: py37-test-alldeps toxposargs: --durations=50 - name: Python 3.7 doc build os: ubuntu-latest python: 3.7 toxenv: build_docs steps: - name: Checkout code uses: actions/checkout@v2 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - name: Install language-pack-de and tzdata if: startsWith(matrix.os, 'ubuntu') run: | sudo apt-get update sudo apt-get install language-pack-de tzdata graphviz - name: Install Python dependencies run: python -m pip install --upgrade tox codecov - name: Run tests run: tox ${{ matrix.toxargs }} -e ${{ matrix.toxenv }} -- ${{ matrix.toxposargs }} # TODO: Do we need --gcov-glob "*cextern*" ? - name: Upload coverage to codecov if: ${{ contains(matrix.toxenv,'-cov') }} uses: codecov/codecov-action@v2 with: file: ./coverage.xml allowed_failures: name: ${{ matrix.name }} runs-on: ${{ matrix.os }} strategy: fail-fast: false matrix: include: - name: (Allowed Failure) Python 3.8 with remote data and dev version of key dependencies os: ubuntu-latest python: 3.8 toxenv: py38-test-devdeps toxposargs: --remote-data=any steps: - name: Checkout code uses: actions/checkout@v2 with: fetch-depth: 0 - name: Set up Python uses: actions/setup-python@v2 with: python-version: ${{ matrix.python }} - name: Install language-pack-de and tzdata if: startsWith(matrix.os, 'ubuntu') run: | sudo apt-get update sudo apt-get install language-pack-de tzdata - name: Install Python dependencies run: python -m pip install --upgrade tox codecov - name: Run tests run: tox ${{ matrix.toxargs }} -e ${{ matrix.toxenv }} -- ${{ matrix.toxposargs }} ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/.gitignore0000644000503700020070000000142700000000000017600 0ustar00rosteenSTSCI\science00000000000000# Compiled files *.py[cod] *.a *.o *.so __pycache__ # Ignore .c files by default to avoid including generated code. If you want to # add a non-generated .c extension, use `git add -f filename.c`. *.c # Other generated files */version.py */cython_version.py htmlcov .coverage MANIFEST .ipynb_checkpoints # Sphinx docs/api docs/_build # Eclipse editor project files .project .pydevproject .settings # Pycharm editor project files .idea # Floobits project files .floo .flooignore # Visual Studio Code project files .vscode # Packages/installer info *.egg *.egg-info dist build eggs .eggs parts bin var sdist develop-eggs .installed.cfg distribute-*.tar.gz # Other .cache .tox .*.sw[op] *~ .project .pydevproject .settings pip-wheel-metadata/ # Mac OSX .DS_Store .vscode .pytest_cache ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/.readthedocs.yml0000644000503700020070000000030300000000000020666 0ustar00rosteenSTSCI\science00000000000000version: 2 build: image: latest python: version: 3.8 system_packages: false install: - method: pip path: . extra_requirements: - docs - all formats: [] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/CHANGES.rst0000644000503700020070000003067400000000000017420 0ustar00rosteenSTSCI\science000000000000001.6.0 (2022-01-27) ------------------ New Features ^^^^^^^^^^^^ - Add collapse methods to Spectrum1D. [#904, #906] - SpectralRegion and Spectrum1D now allow descending (in wavelength space) as well as ascending spectral values. [#911] 1.5.0 ----- New Features ^^^^^^^^^^^^ - Convolution-based smoothing will now apply a 1D kernel to multi-dimensional fluxes by convolving along the spectral axis only, rather than raising an error. [#885] - ``template_comparison`` now handles ``astropy.nddata.Variance`` and ``astropy.nddata.InverseVariance`` uncertainties instead of assuming the uncertainty is standard deviation. [#899] Bug Fixes ^^^^^^^^^ - Speed up JWST s3d loader and reduce memory usage. [#874] - ``SpectralRegion`` can now handle pixels. [#886] - Fix bug where ``template_comparison`` would return the wrong chi2 value. [#872] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ``fit_lines`` now makes use of unit support in ``astropy.modeling``. [#891] - ``Spectrum1D.with_spectral_units`` now attempts to fall back on the ``spectral_axis`` units if units could not be retrieved from the WCS. [#892] - ``ndcube`` package pin updated to released version (2.0). [#897] - Minor changes for astropy 5.0 compatibility. [#895] 1.4.1 ----- Bug Fixes ^^^^^^^^^ - Fix JWST s3d loader. [#866] 1.4 --- New Features ^^^^^^^^^^^^ - Allow overriding existing astropy registry elements. [#861] - ``Spectrum1D`` will now swap the spectral axis with the last axis on initialization if it can be identified from the WCS and is not last, rather than erroring. [#654, #822] Bug Fixes ^^^^^^^^^ - Change loader priorities so survey loaders always override generic ones. [#860] - Handle "FLUX_ERROR" header keyword in addition to "ERROR" in JWST loader. [#856] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - ``Spectrum1D`` now subclasses ``NDCube`` instead of ``NDDataRef``. [#754, #822, #850] 1.3.1 ----- New Features ^^^^^^^^^^^^ - Add ``SpectrumList`` loader for set of JWST _x1d files. [#838] Bug Fixes ^^^^^^^^^ - Handle new ``astropy.units.PhysicalType`` class added in astropy 4.3. [#833] - Handle case of WCS with None values in ``world_axis_physical_types`` when initializing Spectrum1D. [#839] - Fix bug in apStar loader. [#839] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Improve continuum flux calculation in ``equivalent_width``. [#843] 1.3 --- New Features ^^^^^^^^^^^^ - Added ability to slice ``Spectrum1D`` with spectral axis values. [#790] - Added ability to replace a section of a spectrum with a spline or model fit. [#782] Bug Fixes ^^^^^^^^^ - Fix infinite recursion when unpickling a ``QuantityModel``. [#823] - Changed positional to keyword arguments in ``fit_continuum``. [#806] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Fix inaccuracy about custom loading in docs. [#819] - Use non-root logger to prevent duplicate messages. [#810] - Removed unused astropy config code. [#805] 1.2 --- New Features ^^^^^^^^^^^^ - Add support for reading IRAF MULTISPEC format with non-linear 2D WCS into ``SpectrumCollection`` to default_loaders. [#708] - ``SpectralRegion`` objects can now be created from the ``QTable`` object returned from the line finding rountines. [#759] - Include new 6dFGS loaders. [#734] - Include new OzDES loaders. [#764] - Include new GAMA survey loaders. [#765] - Include new GALAH loaders. [#766] - Include new WiggleZ loaders. [#767] - Include new 2dF/AAOmega loaders. [#768] - Add loader to handle IRAF MULTISPEC non-linear 2D WCS. [#708] - Add ability to extract minimum bounding regions of ``SpectralRegion`` objects. [#755] - Implement new moment analysis function for specutils objects. [#758] - Add new spectral slab extraction functionality. [#753] - Include new loaders for AAT and other Australian surveys. [#719] - Improve docstrings and intialization of ``SpectralRegion`` objects. [#770] Bug Fixes ^^^^^^^^^ - Fix ``extract_region`` behavior and slicing for ``Spectrum1D`` objects that have multi-dimensional flux arrays. Extracting a region that extends beyond the limits of the data no longer drops the last data point in the returned spectrum. [#724] - Fixes to the jwst loaders. [#759] - Fix handling of ``SpectralCollection`` objects in moment calculations. [#781] - Fix issue with non-loadable x1d files. [#775] - Fix WCS handling in SDSS loaders. [#738] - Fix the property setters for radial velocity and redshift. [#722] - Fix line test errors and include python 3.9 in tests. [#751] - Fix smoothing functionality dropping spectrum meta information. [#732] - Fix region extraction for ``Spectrum1D`` objects with multi-dimensional fluxes. [#724] Documentation ^^^^^^^^^^^^^ - Update SDSS spectrum documentation examples. [#778] - Include new documentation on working with ``SpectralCube`` objects. [#726, #784] - Add documentation on spectral cube related functionality. [#783] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Improved error messages when creating ``SpectralRegion`` objects. [#759] - Update documentation favicons and ensure color consistency. [#780] - Remove fallback ``SpectralCoord`` code and rely on upstream. [#786] - Move remaining loaders to use utility functions for parsing files. [#718] - Remove unnecessary data reshaping in tabular fits writer. [#730] - Remove astropy helpers and CI helpers dependencies. [#562] 1.1 --- New Features ^^^^^^^^^^^^ - Added writer to ``wcs1d-fits`` and support for multi-D flux arrays with 1D WCS (identical ``spectral_axis`` scale). [#632] - Implement ``SpectralCoord`` for ``SpectrumCollection`` objects. [#619] - Default loaders work with fits file-like objects. [#637] - Implement bin edge support on ``SpectralCoord`` objects using ``SpectralAxis`` subclass. [#645] - Implement new 6dFGS loader. [#608] - Implement uncertainty handling for ``line_flux``. [#669] - Implement new 2SLAQ-LRG loader. [#633] - Implement new 2dFGRS loader. [#695] - Default loaders now include WCS 1D (with multi-dimensional flux handling) writer. [#632] - Allow continuum fitting over multiple windows. [#698] - Have NaN-masked inputs automatically update the ``mask`` appropriately. [#699] Bug Fixes ^^^^^^^^^ - Fixed ``tabular-fits`` handling of 1D+2D spectra without WCS; identification and parsing of metadata and units for ``apogee`` and ``muscles`` improved; enabled loading from file-like objects. [#573] - Fix ASDF handling of ``SpectralCoord``. [#642] - Preserve flux unit in ``resample1d`` for older versions of numpy. [#649] - Fix setting the doppler values on ``SpectralCoord`` instances. [#657] - Properly handle malformed distances in ``SkyCoord`` instances. [#663] - Restrict spectral equivalencies to contexts where it is required. [#573] - Fix ``from_center`` descending spectral axis handling. [#656] - Fix factor of two error in ``from_center`` method of ``SpectralRegion`` object. [#710] - Fix handling of multi-dimensional mask slicing. [#704] - Fix identifier for JWST 1D loader. [#715] Documentation ^^^^^^^^^^^^^ - Display supported loaders in specutils documentation. [#675] - Clarify inter-relation of specutils objects in relevant docstrings. [#654] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Remove pytest runtime dependency. [#603] - Change implementation of ``.quantity`` to ``.view`` in ``SpectralCoord``. [#614] - Ensure underlying references point to ``SpectralCoord`` object. [#640] - Deprecate ``spectral_axis_unit`` property. [#618] - Backport ``SpectralCoord`` from astropy core for versions <4.1. [#674] - Improve SDSS loaders and improve handling of extensions. [#667] - Remove spectral cube testing utilities. [#683] - Change local specutils directory creation behavior. [#691] - Ensure existing manipulation and analysis functions use ``mask`` attribute. [#670] - Improve mask handling in analysis functions. [#701] 1.0 --- New Features ^^^^^^^^^^^^ - Implement ``SpectralCoord`` object. [#524] - Implement cross-correlation for finding redshift/radial velocity. [#544] - Improve FITS file identification in default_loaders. [#545] - Support ``len()`` for ``SpectrumCollection`` objects. [#575] - Improved 1D JWST loader and allow parsing into an ``SpectrumCollection`` object. [#579] - Implemented 2D and 3D data loaders for JWST products. [#595] - Include documentation on how to use dust_extinction in specutils. [#594] - Include example of spectrum shifting in docs. [#600] - Add new default excise_regions exciser function and improve subregion handling. [#609] - Implement use of ``SpectralCoord`` in ``Spectrum1D`` objects. [#610] Bug Fixes ^^^^^^^^^ - Fix stacking and unit treatment in ``SpectrumCollection.from_spectra``. [#578] - Fix spectral axis unit retrieval. [#581] - Fix bug in subspectrum fitting. [#586] - Fix uncertainty to weight conversion to match astropy assumptions. [#594] - Fix warnings and log messages from ASDF serialization tests. [#597] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Remove spectral_resolution stub from Spectrum1D. [#606] 0.7 --- New Features ^^^^^^^^^^^^ - Make specutils compatible with Astropy 4.0 (breaking change). [#462] - Remove all wcs adapter code and rely on APE14 implementation. [#462] Bug Fixes ^^^^^^^^^ - Address ``MexicanHat1D`` name change in documentation. [#564] 0.6.1 ----- API Changes ^^^^^^^^^^^ - Resamplers now include ``extrapolation_treatment`` argument. [#537] - Template fitting now returns an array of chi squared values for each template. [#551] New Features ^^^^^^^^^^^^ - Masks now supported in fitting operations. [#519] - Resamplers now support resamping beyond the edge of a spectrum using. [#537] - New template fitting for redshift finding. [#527] - New continuum checker to discern whether continuum is normalized or subtracted. [#538] - Include documentation on how to achieve splicing using specutils. [#536] - Include function to calculate masks based on S/N thresholding. [#509] Bug Fixes ^^^^^^^^^ - Include new regions regression tests. [#345] - Fix fitting documentation code block test. [#478] - Fix Apogee loader to incorporate spectral axis keyword argument. [#560] - Fix tabular fits writer and include new regression test. [#539] - Fix dispersion attribute bug in ``Spectrum1D`` objects. [#530] - Correctly label regression tests that require remote data. [#525] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Switch to using ``gaussian_sigma_width`` for ``Gaussian1D`` fitting estimator. [#434] - Update documentation side bar to include page listing. [#556] - New documentation on ``spectrum_mixin``. [#532] - Model names are now preserved in the ``fit_lines`` operation. [#526] - Clearer error messages for incompatible units in line fitting. [#520] - Include travis stages in test matrix. [#515] 0.6 --- New Features ^^^^^^^^^^^^ - New redshift and radial velocity storage on `Spectrum1D` object. - Spectral template matching including resampling. - Error propagation in convolution smoothing. - Sub-pixel precision for fwhm calculations. - New spectral resampling functions. - New IRAF data loaders. - New FWZI calculation. Bug Fixes ^^^^^^^^^ - Stricter intiailizer for ``Spectrum1D``. - Correct handling of weights in line fitting. - Array size checking in `Spectrum1D` objects. - Fix for continuum fitting on pixel-axis dispersions. Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 0.5.3 ----- Bug Fixes ^^^^^^^^^ - Fix comparison of FITSWCS objects in arithmetic operations. - Fix example documentation when run in python interpreter. 0.5.2 (2019-02-06) ---------------- Bug Fixes ^^^^^^^^^ - Bugfixes for astropy helpers, pep8 syntax checking, and plotting in docs [#416,#417,#419] - All automatically generated ``SpectrumList`` loaders now have identifiers. [#440] - ``SpectralRange.from_center`` parameters corrected after change to SpectralRange interface. [#433] Other Changes and Additions ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - Improve explanation on creating spectrum continua. [#420] - Wrap IO identifier functions to ensure they always return True or False and log any errors. [#404] 0.5.1 (2018-11-29) ------------------ Bug Fixes ^^^^^^^^^ - Fixed a bug in using spectral regions that have been inverted. [#403] - Use the pytest-remotedata plugin to control tests that require access to remote data. [#401,#408] 0.5 (2018-11-21) ---------------- This was the first release of specutils executing the [APE14](https://github.com/astropy/astropy-APEs/blob/main/APE14.rst) plan (i.e. the "new" specutils) and therefore intended for broad use. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/CITATION0000644000503700020070000000072300000000000016743 0ustar00rosteenSTSCI\science00000000000000If you use Specutils for work/research presented in a publication (whether directly, or as a dependency to another package), please cite the Zenodo DOI for the appopriate version of Specutils. The versions (and their BibTeX entries) can be found at: https://doi.org/10.5281/zenodo.1421356 We also encourage the acknowledgement of Astropy, which is a dependency of Specutils. See http://www.astropy.org/acknowledging.html for more on the recommendations for this. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/MANIFEST.in0000644000503700020070000000042700000000000017345 0ustar00rosteenSTSCI\science00000000000000include README.rst include CHANGES.rst include setup.cfg include pyproject.toml recursive-include specutils *.pyx *.c *.pxd recursive-include docs * recursive-include licenses * recursive-include scripts * prune build prune docs/_build prune docs/api global-exclude *.pyc *.o ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1643306919.748239 specutils-1.6.0/PKG-INFO0000644000503700020070000001202400000000000016700 0ustar00rosteenSTSCI\science00000000000000Metadata-Version: 2.1 Name: specutils Version: 1.6.0 Summary: Package for spectroscopic astronomical data Home-page: https://specutils.readthedocs.io/ Author: Specutils Developers Author-email: coordinators@astropy.org License: BSD 3-Clause Platform: UNKNOWN Requires-Python: >=3.7 Description-Content-Type: text/x-rst Provides-Extra: test Provides-Extra: docs License-File: licenses/LICENSE.rst Specutils ========= .. image:: https://github.com/astropy/specutils/workflows/CI/badge.svg :target: https://github.com/astropy/specutils/actions :alt: GitHub Actions CI Status .. image:: https://readthedocs.org/projects/specutils/badge/?version=latest :target: http://specutils.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. image:: http://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: http://www.astropy.org/ Specutils is an `Astropy affiliated package `_ with the goal of providing a shared set of Python representations of astronomical spectra and basic tools to operate on these spectra. The effort is also meant to be a "hub", helping to unite the Python astronomical spectroscopy community around shared effort, much as Astropy is meant to for the wider astronomy Python ecosystem. This broader effort is outlined in the `APE13 document `_. Note that Specutils is not intended to meet all possible spectroscopic analysis or reduction needs. While it provides some standard analysis functionality (following the Python philosophy of "batteries included"), it is best thought of as a "tool box" that provides pieces of functionality that can be used to build a particular scientific workflow or higher-level analysis tool. To that end, it is also meant to facilitate connecting together disparate reduction pipelines and analysis tools through shared Python representations of spectroscopic data. Getting Started with Specutils ------------------------------ For details on installing and using Specutils, see the `specutils documentation `_. Note that Specutils now only supports Python 3. While some functionality may continue to work on Python 2, it is not tested and support cannot be guaranteed (due to the sunsetting of Python 2 support by the Python and Astropy development teams). License ------- This project is Copyright (c) Specutils Developers and licensed under the terms of the BSD 3-Clause license. This package is based upon the `Astropy package template `_ which is licensed under the BSD 3-clause license. See the ``licenses`` folder for more information. Contributing ------------ We love contributions! specutils is open source, built on open source, and we'd love to have you hang out in our community. **Imposter syndrome disclaimer**: We want your help. No, really. There may be a little voice inside your head that is telling you that you're not ready to be an open source contributor; that your skills aren't nearly good enough to contribute. What could you possibly offer a project like this one? We assure you - the little voice in your head is wrong. If you can write code at all, you can contribute code to open source. Contributing to open source projects is a fantastic way to advance one's coding skills. Writing perfect code isn't the measure of a good developer (that would disqualify all of us!); it's trying to create something, making mistakes, and learning from those mistakes. That's how we all improve, and we are happy to help others learn. Being an open source contributor doesn't just mean writing code, either. You can help out by writing documentation, tests, or even giving feedback about the project (and yes - that includes giving feedback about the contribution process). Some of these contributions may be the most valuable to the project as a whole, because you're coming to the project with fresh eyes, so you can see the errors and assumptions that seasoned contributors have glossed over. Note: This disclaimer was originally written by `Adrienne Lowe `_ for a `PyCon talk `_, and was adapted by specutils based on its use in the README file for the `MetPy project `_. If you locally cloned this repo before 22 Mar 2021 -------------------------------------------------- The primary branch for this repo has been transitioned from ``master`` to ``main``. If you have a local clone of this repository and want to keep your local branch in sync with this repo, you'll need to do the following in your local clone from your terminal:: git fetch --all --prune # you can stop here if you don't use your local "master"/"main" branch git branch -m master main git branch -u origin/main main If you are using a GUI to manage your repos you'll have to find the equivalent commands as it's different for different programs. Alternatively, you can just delete your local clone and re-clone! ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/README.rst0000644000503700020070000001120500000000000017272 0ustar00rosteenSTSCI\science00000000000000Specutils ========= .. image:: https://github.com/astropy/specutils/workflows/CI/badge.svg :target: https://github.com/astropy/specutils/actions :alt: GitHub Actions CI Status .. image:: https://readthedocs.org/projects/specutils/badge/?version=latest :target: http://specutils.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. image:: http://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: http://www.astropy.org/ Specutils is an `Astropy affiliated package `_ with the goal of providing a shared set of Python representations of astronomical spectra and basic tools to operate on these spectra. The effort is also meant to be a "hub", helping to unite the Python astronomical spectroscopy community around shared effort, much as Astropy is meant to for the wider astronomy Python ecosystem. This broader effort is outlined in the `APE13 document `_. Note that Specutils is not intended to meet all possible spectroscopic analysis or reduction needs. While it provides some standard analysis functionality (following the Python philosophy of "batteries included"), it is best thought of as a "tool box" that provides pieces of functionality that can be used to build a particular scientific workflow or higher-level analysis tool. To that end, it is also meant to facilitate connecting together disparate reduction pipelines and analysis tools through shared Python representations of spectroscopic data. Getting Started with Specutils ------------------------------ For details on installing and using Specutils, see the `specutils documentation `_. Note that Specutils now only supports Python 3. While some functionality may continue to work on Python 2, it is not tested and support cannot be guaranteed (due to the sunsetting of Python 2 support by the Python and Astropy development teams). License ------- This project is Copyright (c) Specutils Developers and licensed under the terms of the BSD 3-Clause license. This package is based upon the `Astropy package template `_ which is licensed under the BSD 3-clause license. See the ``licenses`` folder for more information. Contributing ------------ We love contributions! specutils is open source, built on open source, and we'd love to have you hang out in our community. **Imposter syndrome disclaimer**: We want your help. No, really. There may be a little voice inside your head that is telling you that you're not ready to be an open source contributor; that your skills aren't nearly good enough to contribute. What could you possibly offer a project like this one? We assure you - the little voice in your head is wrong. If you can write code at all, you can contribute code to open source. Contributing to open source projects is a fantastic way to advance one's coding skills. Writing perfect code isn't the measure of a good developer (that would disqualify all of us!); it's trying to create something, making mistakes, and learning from those mistakes. That's how we all improve, and we are happy to help others learn. Being an open source contributor doesn't just mean writing code, either. You can help out by writing documentation, tests, or even giving feedback about the project (and yes - that includes giving feedback about the contribution process). Some of these contributions may be the most valuable to the project as a whole, because you're coming to the project with fresh eyes, so you can see the errors and assumptions that seasoned contributors have glossed over. Note: This disclaimer was originally written by `Adrienne Lowe `_ for a `PyCon talk `_, and was adapted by specutils based on its use in the README file for the `MetPy project `_. If you locally cloned this repo before 22 Mar 2021 -------------------------------------------------- The primary branch for this repo has been transitioned from ``master`` to ``main``. If you have a local clone of this repository and want to keep your local branch in sync with this repo, you'll need to do the following in your local clone from your terminal:: git fetch --all --prune # you can stop here if you don't use your local "master"/"main" branch git branch -m master main git branch -u origin/main main If you are using a GUI to manage your repos you'll have to find the equivalent commands as it's different for different programs. Alternatively, you can just delete your local clone and re-clone! ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/conftest.py0000644000503700020070000000047100000000000020005 0ustar00rosteenSTSCI\science00000000000000from importlib.util import find_spec import pkg_resources entry_points = [] for entry_point in pkg_resources.iter_entry_points('pytest11'): entry_points.append(entry_point.name) if "asdf_schema_tester" not in entry_points and find_spec('asdf') is not None: pytest_plugins = ['asdf.tests.schema_tester'] ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1643306919.714216 specutils-1.6.0/docs/0000755000503700020070000000000000000000000016534 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/docs/Makefile0000644000503700020070000001074500000000000020203 0ustar00rosteenSTSCI\science00000000000000# Makefile for Sphinx documentation # # You can set these variables from the command line. SPHINXOPTS = SPHINXBUILD = sphinx-build PAPER = BUILDDIR = _build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest #This is needed with git because git doesn't create a dir if it's empty $(shell [ -d "_static" ] || mkdir -p _static) help: @echo "Please use \`make ' where is one of" @echo " html to make standalone HTML files" @echo " dirhtml to make HTML files named index.html in directories" @echo " singlehtml to make a single large HTML file" @echo " pickle to make pickle files" @echo " json to make JSON files" @echo " htmlhelp to make HTML files and a HTML help project" @echo " qthelp to make HTML files and a qthelp project" @echo " devhelp to make HTML files and a Devhelp project" @echo " epub to make an epub" @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" @echo " latexpdf to make LaTeX files and run them through pdflatex" @echo " text to make text files" @echo " man to make manual pages" @echo " changes to make an overview of all changed/added/deprecated items" @echo " linkcheck to check all external links for integrity" clean: -rm -rf $(BUILDDIR) -rm -rf api -rm -rf generated html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." singlehtml: $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml @echo @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." pickle: $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle @echo @echo "Build finished; now you can process the pickle files." json: $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json @echo @echo "Build finished; now you can process the JSON files." htmlhelp: $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp @echo @echo "Build finished; now you can run HTML Help Workshop with the" \ ".hhp project file in $(BUILDDIR)/htmlhelp." qthelp: $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp @echo @echo "Build finished; now you can run "qcollectiongenerator" with the" \ ".qhcp project file in $(BUILDDIR)/qthelp, like this:" @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/Astropy.qhcp" @echo "To view the help file:" @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/Astropy.qhc" devhelp: $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp @echo @echo "Build finished." @echo "To view the help file:" @echo "# mkdir -p $$HOME/.local/share/devhelp/Astropy" @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/Astropy" @echo "# devhelp" epub: $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub @echo @echo "Build finished. The epub file is in $(BUILDDIR)/epub." latex: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." @echo "Run \`make' in that directory to run these through (pdf)latex" \ "(use \`make latexpdf' here to do that automatically)." latexpdf: $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex @echo "Running LaTeX files through pdflatex..." make -C $(BUILDDIR)/latex all-pdf @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." text: $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text @echo @echo "Build finished. The text files are in $(BUILDDIR)/text." man: $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man @echo @echo "Build finished. The manual pages are in $(BUILDDIR)/man." changes: $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes @echo @echo "The overview file is in $(BUILDDIR)/changes." linkcheck: $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck @echo @echo "Link check complete; look for any errors in the above output " \ "or in $(BUILDDIR)/linkcheck/output.txt." doctest: @echo "Run 'python setup.py test' in the root directory to run doctests " \ @echo "in the documentation." ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7152724 specutils-1.6.0/docs/_static/0000755000503700020070000000000000000000000020162 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/_static/logo.png0000644000503700020070000034067700000000000021651 0ustar00rosteenSTSCI\science00000000000000PNG  IHDRcPsRGB pHYs.#.#x?vYiTXtXML:com.adobe.xmp 1 L'Y@IDATx %Uu_uAը@O5"FofD̠F =G C_y̍~Po 11ʌ 3ӧkծUk^8}yj޵{OwS't@؅ mӭkǝ|`{W|Ѣpޠc;/O56- [cFAo}?7/z? gaz#=gx 8}ɳKǏfgsudᑴ4ewEyooדT(1Ǐ ^ {6}oyN@@@@@@@@@{@` XwcƢv4|jFj{ eAM|)i`S9%r g⥘u^k<8˞wlwWѢ׾u٭1kE         0'K_3n#dDt3.7[%iˉpoN|(3klG W"2}DF+V:&l2 Siy|Biߥ_><@k,ؾOK Eӛ8RS?:ns*}Spi?, ɑFWs}_[=Q :%7+C xWڊ>3Ztr[/%tĝQk:9t-o? @@@@@@@@Fڿm,X08Wүt z 5SO=!=YgRcL@@@@@@@@@J C{K@90 _DSI 6+/%Z=Ϣ:>{V?7OG_Ͽ 0Ǒ04_oMa>4 Bm|4I:;/[lV_՞m_ygG>tȜ0h IcӋE{dUFof@I4vbϭy}e%$};y 7}[F/]q?ZH@\}S޳%oғݷ[,"n[T&+SMݼOUy K︱Vp 미˯b#,@@@@@@@@`D g6PJ  D`jzz|H𿱵dəT_jyM߳+! K͛\?_Z>Qg{1 ۩7־=Au@@@@@@@RªPo'@ÿ#lpU7^K}SoǘM2^^bPEi?U)#_d,wj=j8)}Q - 4{å/Fi1olGqCE~)#fXMs[ϕ iQZg0>2}I7]Ch@@@@@@@@@`x?<@%Ӌ-zQځ^Lm4yXs^4|EGT4*Hyٵ< BQ]FxGEw#7|` 4ĥ{ǴYjmt kwRFQKʩ^Xt ^>,'j;i=go^9?hA@@@@@@@ xǮUvAm?ps tA~)&{Xsb__υ*RR%ayʡQ1Ss;qV{켓oa+_]vY @@@@@@@@@` ?@`K÷WLN[vVK{]hMs_$Br:P||#;Q,w\w=?N@  4J QuU;P+W”&{%K|nk89ce6(Y(v̜eOMQ|śggqgO>MOqa@@@@@@@@@kEE_x7\vyMI U$KpYc}Essr՚y[NR0*Kɀ]j+jQZkPKuMC7ISnk[~Q@        }J >}bcذF~P%K(MT%a;%-uUͼZ"~)E˷kGaccW|@        }K }jU?t|A.z?+wR^ite%6i6A N R⤾%,TXOY X]aJ+(IJɷNyv_^:TW(@@@@@@@@@}d Z)nt;7T{'~ۣے\WOΞ n8PidNNv1yftsX&7;jŬbTנI{tr*iGSnpP@}d J)=(T~Q7wi[Q"zJ1sMJzi^N3Z~)/Rה,#=!\Ϊ.9j>9+VEUd,jM7*OaQƈ#V:RqUX1osm~5cQpʩ^ῼC          K ?W#T!-|-)웺(Ԧbߚǣk^a=ITZg)S9I]{gkdJfb 8EgjS/>tO&T[UvЃ4'sf#q]ܞ.zpՖP`bt̛y(nX;x\ m܊Xwh[~7]s_?S  .tnArv$p&l s#7we_fyjs}%YfI27)X}U$# Tftku0\iAx,D]8CWIhz)OO˵8>74=1A{)zl sl s +o]/ g%g'm../}v}֪Rjk,C' ODX;8ͮQNXVYsk2qh80ۮ9igoy@@@@@@@`4 P?azL6 0>仙_ \o)5{ -No:{,[>J3*(^]͛Vժ#_mYBDd4zٳ*MOT~~TI`-{ɘdWF#={byZٗ6;̍kbmђ_~m׽* 6m6k)&[9@}hg5|Zdm\heO4dO5'n䖻;qbՅîQiB/,~&ucyr($XFƢ-1Vv,I6 !Q 1JnSoj(@@@@@@@`4\Dlre 7| @ >|Z;y_"OoPnz74&{}[3y5x(>蚦swޣ򈚥Z;JRt*JAt}}}U(HwcF Q"(=Z'/Z,^(.-ob _J>J]nY%ZGiBQEEmQ/KkK.]ժVĕkvӛ8J4E~xy( k_vu<^r.)      #@`6I'&~; 1@`T>L'뜼7/NMKK箥(JrNnJ)7vS}V5hqͪiîTx~}Dc-yŖ_A w:Oxr,KW$TZ8Wd%.ch&0u;^C8?hs2/MxNS,t+e4ٓ1g/u3eY}k*%q6 ՛m-sݫVqQhPF#=0"1Жʝ5N%BvT.ϳMd6X+NBEi,L>BnքM[A5e|lRa       L`wL-} ?,[| 椺uU[[QE8hٲ%?7;/ؽe\'jnk۱0#,Kovk*kg5UW<+e(˵ecY_y7c @@@@@@@@j@ÿ4e˯?pɲpj~vY^,],]5[`~;zJK(w-^eo9h~A+9qge*^L$szg: ܊X|u_+_봘\ ;s!pp`i߾EN<|n~ڏ(̫_(2~ؚvp+wMo1kr*IҥфϨwzzr%VZI+;蠏]?xr+\?W}|֧K$ 5ܣLW߯/_^mi&^)Zl#`{nsF۞98쀃.Oo91!!qzhc!4i~}ww>yyk4Ah槧5J!m|d{fO(^݊Ck Ipװ|`xm~;ϟZJIE&NwIc`o-hHUԩJ?wӧo{>,       0$fhWwk-L4 u8ǎ;`-;~s*m8Wt3٧YK{'~*mlXnVԪ;ca,>QovhrtM16EN?a3$wbiSQ[k(XڂIbK<7ܰ/.v&A@@@@@@:?W*55%OQ]`rob 4)_b/]ߛ-y߷)bi"kas{?v8ݰFw;Wry8:%"N81JvM%"RyXQu(Nq,FL9Enyv3{_pW@      v4̾O*|xU"9ƀ;$(#^E8hqRQ.c w3s?Gy,ך7QԺq(DrYE["y]ՙ][R  {fP"o6\-_ ` v(CgoK}˔㹱2qL;S^T(V*\!soW(C66q\J Wx0_?IlI_o,6+M#5_y  b!kNnV㱚kJvhפ3P L_2=xoi&hJRa*j!Y($]]:/]CjNVݠ&MA~Dh="l؎4K|;$4;.K#d±C8kh9Wsϡ+d       sԼN=wR!̑s wh A:nf-Bf^jsYrѺLUXn$ϡ,'GD5޴e6GsQ؍-1#ޱ~R5WެqVEU"S.?~u|O)b8@E#_ύ~l ]&v; 7^~qE7R%zi(#I V4(m6 EeRZה(%q\t_i&._C+px[;vg~T|"/{4 bgюX+z1~{*\sj9;}"jVf       0\/=i5n}QN]DO-Bi¬e3S'uG3f2/,Vh+hs;Kٕ2GcmKd(RQF4zF)cY.v= cH,Zl[=ہ~GYj<֤И'•ӵ_ܰﱁ@3h7/_4 ޒ5=w6M faۍnER7T&~cO6Q~ɘUJ̝N"ٵGwym&f:gonn˕+ZE+L_=D6⥍vNmT;zzONeO>%;kp }7[lM_ fn#py_%QTs?Nj^%¼ ]y@@@@@@@@` '?|gC8fɋLNIJ%<6EN{2sPaN^% \U#8dW4 eHMhx1WnTč}r1~bKuS#ckMdPyqe3 `wq 'vS6-͂`      m`1@B$EΌ}0!a+ݾLjϸlㆡwS^XwKAה|5c| JnV%&;6oKc+TL58vIbK\Uӥ{~{*T86-Q&Q1&2fL)d+eqXmm^X3ż9o7?w+o/*@@@@@@@@rpK윰[|*ٟ&*UCQ RQ4Xc\t_I"be84311|g/ew]SeQ* 67`bFϱ)yRv5ha,xnG7zGR$+{k)_݇/@*sMS/e']SP9u ~ƹb8cʖke_27b(cU1 -2ddR? .n[ЗzWu&)`\UKI3w&f2&F͛جf?kUoK <wpnd1UTM'c-cT`-;8ϓ'*W,ъW{&NbGSSiĭw+2an^fR+̫ \Mn,%e*.ڻfml5"MӚ8&%3m&;cͳMأ7m4+L6=*4        J ='Q|iiKzMnb;feɮp=]ƂAA 11E<%RM"5hwTV+ifjϢZIbKb7@n/4AU"=LAxlrhȋV`ؒyVQXQ!nśn:@DA@@@@@@@@ =8N#DKTb9~uMML\OQ^W ~DQXј6pWIsfLPwr\zGc4ǻn̂)KǮ ?V} N  g'8txbE>eWJ֚[S,ՆTȺFK]X4ENEG*bӞNqSOo8,*,Zen띹bJ0'wxԆv%_0tL`cO8@-}8+&} _rX2bdOƬDcgr4XJvT.ϳMd6Xە棥87qr>)+炙%{Δ#3cjs퍏+M cNj•qI6oIy<eo8FKVxo}Y^=Q%<<'tkQH>۾}%*O"bZ2vi6K3+b_:;a|^?_\ b,~5Q5zss{ ڍY(2MWjxdS^ID+o7Y_Z^S|'NY(h%        #B .?'(4^՘F`wUw6@F9'%)bkr鬝bJ?K%MXQ]k x&ڍi3v0+RjEl,w c{̬X?.Fy~׆זxC        0"}WF>o&S4Ki*aZP%\,lηkSmPs6C\]m,̩03& "2?J?9əPƜҳ#v2$ɘF慃J6 kd.<%QxXa󎙧oRض<`kLX}?f0AhwI?+?ԊOM=4THS sOC)I{XkPe4: qʒ5V%3]t y3g~ؒ,X3cy+M]\1'5,bqGZ[?i0l2g~"ǩz7֡`o )3+[hA       E .<'|ՍԄ[r~ɖo饍RN(b_:_qj,'7W r_JOZ姢(xi99{y9n5<>UĥV=ʛ< k=QIR!V'TUR(s{e&y]uE5ucj*A=_:'CS/&'I=]@@        0 pnaj9+_LK/oiKPqSYsc,'7(M` KHq0EYވ܊}\Mn,%H*.ڻz]/Ҵ?d+*L9G*X*gNN__ 3q[HZa3'01@`( Ɠn꺛^]k+iJzIXgג&ӚZǘ;v,F)TUE1e,۳T9GybRT%vrm^煂XF1i{jbFEoɟC      P 0Q@ HWwQ6NÍ@x"[Ÿb$c–|^BȘ*c~UomuEE|˯C$,M_{+V*1 f KF7\fn牧*Sf]1] eEA+OHj(p~9Q5S2@@@@@@@@oxG.bqcVbQyDI:]գ<(:%v%V۞.=6ױ1ֱj*T/M+A6%SbK,d[VAjSSءWgrYMꤹ_hba{h '\y=閫> Zoƞ:7GadX8HL+R+ 'C{̯5Ͷ8kC,sX=>EqSNH]rRSEI\ GLOxҋ:vƩʽ_`Ia?&        H )\( ||tOHQU_*"/w9T0a(lE仂^hYwU9=77FW-z H(бụVhdߌN_!_~eǜ3ǝ!   C >|jfڃ=t$c] u變Fg1oToAiJ4oVq<]-j /xݷru~I0'vFoKzQ~oYysCjLTnGh3Voڲ"~v<7;QMˉt;vDF ǎ-j? z'5h0hFy?= ǦL3=t)>dtvQSw|) wsܵQ;Gy{pRNůqF?VΠF|赑u-J-~]N5^xMЫDPrԆ^F:A@@_\|YAYV#Ik?p*h+RXKAsvg+KɷB5(׺jw]Y5((8Y| koGP:1ËKYcw#:u- Љ'Qb>4vjA^өV|q\RvV4.NZn%8e8sFP~_l=5O*-?ﴙ}mx}_2EyJTۉ9Brs*4Om_HJ(:O_q#BʺMn&WRdM| iQhvzYX\5l06caݞx;o:uٯkκ6*^ιW1,8%!6xh{So~n~͚=ƶO푩MH='OdQؗe4X^?Il %/ם8 \f.2zCY#.)7GoK}X^duNY򊵌ibXkξwKwu^py?-$s3&?_qrq3b!SrMp%uLQkϺvDcLI6ʦ3Gwm|`9"cr5G虵qH06>D|̵[Ax#őyi6S s∞ZJ弮įkV : kb6jd~&׵3vj='qIa%@>`&> (Z `2YU۝(>g Ɔ>isS*fٟƗ$Gv: }<%˗Gd׫M桨-f>QL]CԒxZZ{[a^X*ط,J>{[[+VAlh](u}/lΩPf,h_dT=WKUgU[v{D kGkN%o)v\tu,&1{;JJ+'&ԝ]f7Dbj 퉦+4_\H.,9vf|4a3g?Ow5|8~ w*9д}A@@ O+o3!?EnUMR~հ&ے~yc|47^`JcrÊ;XFԘ7tf1fu}~= geO@F(HjsλڷS ) LsdϽ.pje l-I͑g3]dԼ%WN7A_6=&HTc}!5b7~nfY+jNM_=w^z\3-t [~:蘚z8$kR?&ُ$ $eޑ<4vl5tBsy'b:&5K&'Q;حk#!矌Gat}g3)2\}VsY-,>&ZbuUV' t鱠4[o_WcTQ-綫5W`5'Nj*)cߞ^W(ʢDly,ږ\1ޅ b ů3N`&/A5Pg&z'rGR~L\K8~8yd4 #ǧYdAyqK#GUvb K5=QsfϼYS/WY4\Y?_js s[af ˴Ah z)r G p땎v0ѱoHw/H4$hSGϙ9FWs\H nCzc~( Uť&,\:gFG3~7gplŠOh&o9{x5жњNUI?l!IjZ\6a5ajLt˟4֚ }ԛM:nBҿ鷫?@IDATOc{\mb#@}ubv xbd4Rc1 k+7yep(rpPwi"*fUTM$=w>OVapLY a&5WQlshw`syO~QkܘS&D +G昤o@滚M-|[0KM!>22Ǔʎt=LX,ͻGR #?Ks^Lsp  D+~յT NIZ&zn#٭׼~+n f~\mcC4Vve;sZ^X(Mnz;ˤ3{]D`HeyIĝIIo|d\ǢH=.?rΙ%bd^fN$]OjCQS0L̕3|[ieɦqXnBҫC#{'kz Ԁ],6]hZt3:qfh?%?G1N& @@5WӢ4=? So wrZVM$H*{WIJ;=|SrNm[W,hDU%Qm:5X晛%6*|; 9A}97ώGuiKee<%ɟΊWsMXn/V2 ׬&qnRfL\$2fl 90W4,d¾qSfa0;"gGWƮAc}ӄ#l >iNE0Kp8ԑ'') q_^smYdCKa$Wc E[4j틎k[K=h63YqKN*brGdve@2[UԬT KɽB5+a<<"%X/ (xt u6Z6U%j<~XE^s'Ozߟ iJtzeS_*쀮Ž\g֭ 69Npcy )Enѻo̼ͣ5''3|N"Orm۲;^yHn;qtLƷIQU-'(޶䱃G;~R7o zAvYK9>>Еm$fCj/.俏퇱%<6Whq旟y ߞgɸh?, Mٻx?\eB5/ԙ˒FM'n  uI* :Qr@(ov5-Wk\D֟%ɐK"n8J. ٖ&p4AfSG>4)tW; 3`]A\?߃ޫ0G <s2sUNr|e:nMs+7mmz+<?Gt8ϓۜ @@` PBJ0=/=hjOc-=6c=v-qߓ/֎80=&Fۮ8zE\9' HdL4+c5oJ`{􆎅}Uko|\iUsR~Ds"WFż[On 73k7V~?B,B̜t9 ~-uWVjm畛8%ܼ5rKbTXKNF--) ~'?? W=cR1_hZM$ pKbF jƊcşGE8%ѺV=B'xL /#A6H'ګN׎ Cjpo4搊gP~"ioqr XMxn4EGI=H[$ LS4C25t^Β36˫(%jU_ZYI`3Y1EWӹ^TjbOߍͷN|ktm1n۰<_I4QM(nZL1Eُ1[  3MyMsrl sL~(jGerU5nU=-|h3"]r<G0bWWmgT~Gԁ2@@%@iRU z2c=*)ӣ$:#٭;~U'4]'-\ƴ8e~TQtͭLs4r餼,4)&~,޸׫NE*5 }q{ jA >-LӢMW*3޲q= ׻ulEQ1L|~l]#`^mZxށ', EWzVQmF0$@okJâ$F4 l^#|)f_2H4ϼ5YYl~Őy [Y-O{ᡇK-JfvF躦X.1Q˃>}#86˭V—:⠃ٷ/vDpOLSYwfk֬Lbqd_hts]#5, ;ٍc?"3;5.m)5?gdQ X U#(M : `ًvGԬ_%oiQcl@ MYwnCgj?/nl9n޵hAڎ>0.;.{  D iojXdz>O5j%izE rD&(t笓kl?vԮ`gnrC$]g^Z=~Vf_K)H7t'|GU冿QRz.h@~?OzA+qj  ~ ݌%5 ּuԝ$;{NsWaء46j=`o&ϧ~͇mVjZs)|O=Y.ax<߫V=i{nE\]ksиQ͛ʹnh+ngW0[.2gYpΞ//[q/bdɶKXcWw-$w~T)"$=6f{˼Y$=Vj;JTЙ{Yo?qsW{to"WnnZP~+Kf8[ | H Stl;Z.P+['٣sщ{)99fy$Dfh߿W!OvBzA'{A9@@:&4M~[;;NT*c4,"}O0 -ƖC?%uuX:t8EwnP橲D؋4=&y1)|i]݉l7e^;u?K݇xxGZu$E̫P' =}툂-9ܰO[`%%:^\t5z_7E{F{ܼ+Vx;h;(8kq';'+WLWJ5]یֲӼ.zxew`Ǐ~mbnws3{nvo[~lvkg7Tj@F(#˖|~;vaꗖk>r`uKٹ3Ete׳֪Bey=m|dO<#UƎ+~n"ݶ9޽Fwz"@F 5[gۍ;yf֝330F,^zd;J6\:ש{~!u$!l]ݿ-|m`?0ɺ&4}R8HU?խlKCY޷Fޠ^}bK\Uӥ{~{t}@aЭ7DHTb)%-*">4V ػNvȶ{`۷!٭8RɨU]ǭ'%1sΉKvNhf1Ʌ_}'?;kƆY0]3}r!GXG¿xq s.}ѕxL Jtg nISH&< 6nN hNbzn%gm;ⅪaIUS8I@@ hm_)>L-h>,[dP9ܖ,T,ѵg?I<~)Ų:VEQ"c/Z$yhn6븩5DјȞǼ-*.,ob6%tq~TGUX!V{4\bGyT|>Df3g=X6(*;wV7^,Ǟ珼]}wF0==? l(lKIig- ,@|?᾵O҈IYl0b|An2(KIAyP'0J:M}eWc*[w3\KV\ /a#)ƞCEDdWI/Ș+r+֦ `dYlAf]On^/eԋ:tSW+Q呪S>q#ZR=JsL-SU$"FG*v݇!1xn6T `E3xԷ( zoz- zY* }f " @WMXN(`'8I 5JfZhd7j?Jǜi׾NYnث#{>EIf7^~) 2SŤy+ܜ~xyynmRBQU3uysCdk-JY)8u%cx]S(%q\t_ĭvMOQ{qp~y"0;F?s u aSP4_~rdi|$h}++RklT17bEy;fyvfqgG$5ڞ/- KrXyG˵~P"Y"*šY,iEyh7R W[w5]bWjK~_XW(S(bUE 5^M.h3M}]m}3Tl/)47Rj?{{@4`wx"[/+2 3%r!׳ؒό4(bDЎ6%G1]$%8:h`'̮Zk_oS@8FMOPC ;4 0 ?=_tW[[e)iѧ(\U_r<]ce˦yo7m,6~uL1EISzg}6wҼaPe !I쳝ܬ;.4[ƙ)5,ZR"6>}Jfb} Ntnc#=@nx]>5Qlkl.6큾zzA%eqݜ|զ2:f[H0Cf-=$YV7e"of%m q#.')4Du*w~GF{x|\v!nv E۷;   t~zEl35l`Ґ@}_/ѻ(O_.yT(vy4"{@861xgb&cbĭJ{tL؊p3Uڎ͵7>*yzɫX(Ӎ:ܮG7?+*1 tMfcޖeO}$=٦hq旟叼G$2$-yƞ~ӟ>֌??"˺s&zV7 `͓h\ea^<-;ϝR۷\3Oy~\ˍͮ|5*ft [?w]rU|sq~ z?Ჰ[r ;}=tfe>0Mc{o{~㏛X[>!]t;\@@F@ 🇃a`/5 @5EQt$wE?/@̱߷ߵm[e1嫫M7?fvjk80jE{v;~*bVҶ-/tӟWZgC %f6 [] x)c?> sz^ eeR2l+C!lv a⚥eZm3AE cy!X?2&V!TmDF>/v73&vX;}e,'3ts ,oGjm畛%ܼ5r9&g=z)X/eQO5r7&b**3Hd4h=f,Fj qoSA競|c@u ~W7F7\+^|;3 `dbvCӳk, =gd8FcL*ZϨ1撋5nſfϵN)k -hFgts{Ll?]CrʑkL3wuÒɍcK9)LIWS S;Q(23Sׁ67 (@+ L ORU̻O}K̗E\J> _0[k$w# 51g'(vV?ǞiBp V{EG{(Ur;N6?e?7OE9   PJ`T)Xtu6p Wܶz4,*\ g4ޙb^lxc5N=(RVT bzQ^"'YmHys%{T8u?kY Jb/4i cJco% \UHVkpy޹8#~Ԃ·(<=nN҈f%W7ok4[*8>-c}EZWu|VOo D  CE`'`ܸ°}ݞJ)Bʲ֚8c?MyNۇFS S\b lMKYʼ` L2{6mlu5ǛEn$ FuSvl3ٳGOȶ٤ En,EȘXʮ@G;UmX^vT̍cgUQU=~wx?<=O{V4˨]O~˄H6riPnnQTO&5掛QtQQ5מ-jm`F4;yOMzV>>LS8sH\~9f_o5@@`4L\掚v0kH`9v+.?:.dm5wđ98gslz_99G{D[}%p")2܌zT6u< ʹt)"qV_nVZɋ9$quw٩A~=qS}}RJ,?u]gŋc,[./v ^|ء,ndMo +=̺sw j7|KɅtE `!X ,a]|15-7S D7 ƷXs}4k)Y4 'sH oFϬɥ&g4EY9(HNؔ.[ݟ>mA9aۯ@Q@Uf[߼qͱ~̫s5~M|?#36 Oy&A ?;,nIe7Eb786˫Z?Jz4T bN5ʵ*q(q >V*8N ^Bnۦ6Ы?~_ri+Hvw9Y0=/^:6E`E$U_6Zie~&ڙZt?zu0 8cjf t> [Z+&   PB`DU D<?sRU}^<_J >'Yq sM{$~{4EͻϩwKhBպᚵ-o[ϡkt\oXKEb[eIGc`%I[&^yHb!N'x O\x<:-1ھgkIķ+\uJb&>3~tst:iw]:A89~!9=N_\J7՛72,~Y:04^&đ?xY{'4O IJ&٬޶٬21(/omϋ)J םϛ*͑ey-l̚f7/ISO~X[o_I' UmߢE։gx7bBFsG?r+|+5Z寧lozӖALq84l;x|sW'vQb㰜d "@hy i;ÛV_y袽+ʍ¡ᦵrs} w@+󜧻Fj\¾䓱`uX-{bcBJ1۳cl~)+B%! be_3@35w.8Z\  D`cdf0.y|u_m :a۾ٽ먂:!غd[ OrkÍN2 p8%}~m;CdI_xi6uN0H_3*REF:'Mڴyj ӂ:$&c7qmyƞ훗ɪr5&+쵾]=yapj2?mU @=|5TMV^z$zj_yӌC`+JH5ݼS޾8W/k5'j&   0,<>}=ס:g[iZFj%]2ָ ֚ Q>Wo5u]send&-yppg9t1 K"'%NmMRK7ŷyѡCPJe˗.zB0.Q}曯ۺg5F^/W^3w߷ z,+ L/d{f93|PbOƷkRaLI^<  L`Gj:ѦoW_ͩG} BmR4{ÙKk͈q5kbbՑ j=\.r=֕}Gr_Y|]ٸOo6W#r5ssfqgAK^G?+/5H>YG>9d {VU}:< K]`ɓamYfB1#n/gj)[puWh|ˆ* ŀv;%Ts з r'W.olk(ӞϻWXRcnm{_ fsچ.9ՔtS, yKLY}(y64 hqYїe}`KvZrt3h}<ܹ𥅸? }_5XX{h;NH2!@+4aȂ_gX߼!^s3Qkkx$kKm";+xÍ#(⾉6&4}iy e3!4WR$,𗨃SYI.rW1T֟uVo~-C ΃}@b0g(Dۚ7L[>^rFouזwBt+HPE@("ZN^No{% wu۶׬5[&wpc=Vt{Yo}V 1j6^IGV=,:&LRK:P|M2?ed8W~s)@hŝWFP(8wֻFoFI'{gC4q#и3ǽCYLxmnxx&׍)n0/;(T"("4 *&TQOj +5̆WWx<Ͼ]J~Opa ~yq` <G:k,t-I{1cuGTCPE@P!ȣ3nUWTѨ_}ynB,c1]T>z//Ep5=XdG܍I!<.%QoՉC) 5},?`: Y\L~_H.:\HgMTx?2%Hv]ֻ޲;>벱'1BvvÎ#[]-7,q>׾aexI$'b _i @5UQE@P pb*󠷧UD1?=w^V{bE*&wIe7EM^6Amҳ"?7`pꮶ_ ՗"OxKo{[Z/a˟Ї_XJġez97dJ y72LJ:>+cעfNP㦿 A~ p'jv>'fy?QPE@<\x]Wu&N1/[knEP͎d5nԦJ6q7 p⦢I}X3V]JLNV.>B,_qEUE*'qm't겸fs5g,H;Dptݰ&1`UQ,XjV4IK> x`l*X@P/ R"("4ߨ soZ# 7WrVWV0}_dE,8ܢTɦ>&HoFQ 3`fS94("4|/>bW[?G7ԛC(r7}v/zjW 5X|ٺ5Yx+ᡪ[C˓~SgNGQϼ̠q{/Sg[?%/C)Sԙz!a m\7%AQ +_;E`Xnz5i N&Oyjk8658gqBJ\PE@P_ep?#p@gƺO}S/үc(~# ?J ۣ`$N>́o%'?N_,ʊWOW1'O|D;Vr@ּ7:tixԗH6FXv)m&B}؞$? &IBr8NGd'ZLVBn'v;þ/OpJ8a)FPjJ_8`]YZ?y⩥&Ёܟ=sjχNL w:t՟Plp?2ZKͮd2OޓUE[O"("p?$T3P_Fc-ob, ee$׵)+l<}6=3ű?7R_?hxϴUѭOI}-Oh=q~P*%ddoc n, gDooZݏ[r s_FaIR8 mCIb6 С:A YTAc:f|I\[KXmwBMgr~=e+c*Ö_ x^D`{u=ԫ)"(@s2dsK͌75T[]޺VCX6^]+Uƨ?GCJ bTp&-cy_Î)L R]cZ]!o3 K 1sY@L -aI؛ׯO~sq:jR%Xr{״&ȳ߆/~?Ҧ(k:}IH_g Ml + Nƍ%"QuFxP Z̶9 IjpVQ6,j' Y1¯O~plw]| ~Es:N)l?L^{뷠cHa}5l|7#Cs0ep[͔%)*r(zXqCFޞ ^a-u.:ܘe@o o+/3Qv1w鋜oq~[ ( Eqt nw<.2_7%o:O]Gq~@ۂD:$;x< ֲ:nҩS:!~QH # ]G > ~uFeh ЄJvp#E@PE`?ns G/6?{LqtKk{=|OA+ \XCD7E4Vs[bT5ػ'~ '.Jؒ{ P#*]XMuG7dz>]ǧdk~wޤKt,+g4h"i&"˝a7鿾o>>fj("L>>2Q}Equ],»e,*Fz7t[G}܆ϵ\%ȃ?${IW9gv#s5)qk(~6XNJB嵗RΑZƾ&{;s7<sZ48?prq0qwn^<~dr#^y&AckqcnRA˸{),Q/U1{sqk7V7هÖ?aleOҊ@݆d :3SW9:"("L'P?~νbrӬ??-fv*e܋,|e.iՎW+KoFN~cW]\O1C)ңhI(pod"B #:@C ͯF!@t֟Qv7ww f; +kO.Kد/9rRทܪ@MǸS`K+4&\>@?+& l|Qسf]?QO)zn: 6l9k&"(D=m|~8p#n5+ғ&%<#\GoHgo6'oϹҗ?#kwN%X#U,]{SiH}7Y79@WEVKr@hυ>+mhl/?PQ.kE}qHa^ \mQ7Q$QRh\I #G?D$>:3aw/_VA\Si,30vcO&6 )8:E@PE`8<̃%8m3r[N5AB8Zym@D#1|o|c`[*GOg y=vOUاB;왿iyG鼅'@rY0o%TSOssG$7gR[y"ގM}Q(9w)-s85p?=Up|6L,t#Fn@qH MrAh*"("0B ljd}ª7>ů7ʓ$X1mǦI 񃽌)_%hRGyÆP7.RnȪI2R&˨MTdH.JW6uU!M| 6 ?Hza)NI2q {f[^E-Ƒ 9BɵޤqfՈ ;![Sf]$E`H9CUj ϒY"("0d0[8=޺\/~*ˎJU_~!Zx[튶mJn0%!8=c+m~?( , XSF/?8`D~GY >>k?z49 x]䲂+%ǶIpjaO=+vؒK)aP^^(a}Q(98MJKk|vZ}ls󦜫" Q='`i;E@PE@PEG ;8ܣ!ǽ;!Iԭ}Y±EV_z3?Z(YM/( WB#SiJ.eVBrq ȰĜe-uHX'  =\Sǜp>3es39oLJpnt]~|kvG%;rjQ#,$aܸWfA3d\ǽ_(֝8άQuî1_I,9ý?+2E"("("(} E8:Z8*d+oR sV`_,&#[Pxb6(978 Ǟ9}vr\ءnьB0M&:ӐzkEOFW6ٜ﷫=uL_zA?Ses "@Z'NtOhg"o4ElBgDZ("53s(8/cX?.aV?}9,J+"\ݤҧICsQE@P]Cȫ >uC1C-Sn UϏxZȊ"*]r/HΒÑo&p /Lp?+?-Ò T31=Mj[&cIÖuW^vQC9{\ װ_!gJ Mb]_(^$:FEQ ET]FF~Y9¹61! )#E=VkNp pߦfLCZnT>"Pv}!ZikE@PE` 0p> -–?٪{P \a[/ M*!=f!9flD%Oa iW7M$Ω4px)ͨ0"ȱ37 oӏcޮd{rѹ9' w؟;W>e8ѽ*=]F`# @8,YwKt.%6=`omTj!#'ԗZFXn﫠>+*(߻xK2a!۳7k"("("0^[,AXt hD6˻5(xjɊ]{A4?fl?JVrEO{詧ب7tpX/~6x ncMϖq)39E)th 9B*:W ⧀<GBVsA /GiiGt%&y3 wؑ"c&%uˏfTN2. E;xdWHyya(~'֞~K/T*bı ܨ/ Gz߹2Rt_LwQ%Q/΄ϣt-wfsE0>]itu?ka?խ[J[Ӱhπn[K:C`z("(Ah ǠXXl˟=whbKyЈr7)G/:"]U|y%Ϋ@O) .o ,_ ޮgǶYg~|yԅǶD[i"ޛ8W U^>P6[:RדTp@g~03Y[Y՘?q}ՊfӮnuנٛ}:!0֚F]O깉nbV"("LP~цwqאGhԪ;;b 4 d*mUAX]Jʢy]-k%?QH2 /H.z̈[B5z& F? p;Cy+Tn7\~[?:s=8!`0+i0G]<]rL<=P!r9ӈ2/Ze h? Se6Cl{R,.v隕fttqEl5}llWi4{i>:7E@PE`@EH|8~ UuCQP s<:74ԏVom Po%~7ZS"{ޛ" v];*QUrzux^3e[ݠޜC~viz("_xvRLl WȾ3/|'=&pѶu^/6Ba!g)켢^L^=9oFca{<~Ǽ/V(tE:QrPЯK(>/x:,wmupgfur|GwO,4sE@P#hE@PE@Pf( dk}p~a 5(Lgͭc"R1ll]*YET 㑛dI|Ȟ'[yY {@ e܋W_ÇXμP_qQI^W.&C Jq{ J(1 ( n*3]_z ᅣ+g}ԻT[PfA2!յReE@PE@(?K }p ̓=p>7 q PXy KaH3JѽTߢ,w{ܫRyJa}9>jC8AH·&(b?ԱtϽ%Ԩwiޫ2Y7{,ItΦ ţ7_dM}b@zi%9ÿ@[GgZB1nݍIFRZC] 78"("(pc '8QA"~ES޻@ 痾oxc)yp'H֤rO-IWLӰ3A}?$[#5YLQ{78}Ŷm-!-Z] FW6%I8$6҇ 2LbHJs/a{g][="ڋ> m(f}cm?goԹw6E`fXDE@PE@hP?8A~G3wC?-y`X]oCV\OƳJ$_|*afJ`b$ϟIw(m}xU8$e~[7ovp?m@;!E5|7*H&r=ך˟p6pÆ Ķq ?qcGKx۝}Əkz"0JW>s(CjF!HdE@PE@!{m} -߽M06>uǞ{l(&Ų&XK>{}3/Ѷ Vs[@` _w.c_qIC(B<տ"?5\O0x%Wd;]IN<|u)yðx%XD tuG;.8e0zs\zk,6Zw!3L V%^Xk8|O Fmua1m&,v|y8t3X-&Fl5}:f|{`&"(" (ʯ@;:2ji`,B b.؂:rÅ$oùՊFsQDֳNƒK\n[(oB!oS ckpcT]rWs1\si~ܳpܗO_߱e υ0Qإ PǛuM%!(T}]si";bqwԸZͱF$F9NM+7uH~og̅k&( 'Þmn+W(9 t?ɚa9dVdmzwd/AC *(L+E'!8}ܳן+}4=wۚ{3lo_*PlA9[=8<nLTb|X#"'%&a1tsKȶJyLxX#?qRd:ތ}=w`t 5QC[DdQn^5$[]PE@PF(12Cn R"=~qFU] xp%yԗ8@> s%R^bO]*}CW@09<(>plgY_)-A Xn҄Gri#̄XKk>jf.="("0dLB_/}Om](cx]XI^쥯t$dRR}0%=3ʅA;G:k`d :m}ޱҴI){D)זnpm~?ťE}ڳ ߾jau/` 0=t{" {&ʳtήK)qތ=}`/HE#K3(\hnqύb8O;?qxoUt)u~?.ŇjfχqnW'"qOnkEy[ʬ;>S6׈ބqfZp"("0@ o't]AyA iӨ{{sI'cǵ#b"D=Q< ՚ӂ9xa{}'$%'l /!EU:!eE۔! [iؔfoΈ("(}!EWhֱ}\eN Xoa(ڨ{c9֍ZSG$Wf`7)é h_ JŔ .o 8MJGH?nG7Ey?ףWy>Ns,Aк3(j5רD="q1hiJO.ܺ/&vK_(wȶ =p004+ߏ؁r0Z&8.W5Ө|4E@PE@PDWu?/>RT_+H%xd"nQGS`dXtG'3<sTIrƇFiH"FF[ὕ(ӢBG ĽjEqkGRBП1+XpF4?_+ot:@F( ' B%* yܐ>nAZwŵU:GV?Wzv&sͿ?0Xk ~Xu;4ꀞx ٯ-<"eM6CM>45$l4E@PE@\(/A*'g~ FDthDM8gRo [~QY#Ǽ>$:sECmPo%~7Zz"Eʊ^+ ܚR>Rш"o-*"'ۯz`&7X%I%XPE@P&>{˦s DJH& C}&!KG3*{h|HDcd%~Sod] FOǞ9q ҝ۷GxL/ң>AVyo~"]pckmOjswK=i2ȡ$d?+E s_|ً?32R멨bȸ~uJRޔ2wd<{SOORjފF6wh7XxN^Xt r@[/"("LP?)wj}snTLe$) E^:)$67"!Z, I y}`V`='^kگfz#ր8RIq:u z 0_s0[8!ɸ8vU '7ȥG>X^adݺyʴHy]to"i|Rf /%-CZ/iPY6.R,]"W"o_fMm[49uM*mͥ󃚝QE@PEid+f^{*~2(ecY8":-Y*.bV౰ɞz+Sb]G$E|c߹kc{spQ)es$"'B*SLsCW:^__}Q<#sHsMɬ9)6eW^x6U)>H3ކ!VRj~eH}-\⊿ZIWW&[}# t֚V̴>znw[4 7 ϻzcyulI~We"("F* 뺭,Ţ40( .ҦýOH}yĉi-UP\CRJHbcȞz+Swp3AC@R!.\&hPY9*t-}Y"fxX?z ψhFe^LMt[; {uʯnMt7U3t Tvf1mʃ{D٢t]_9Y|"("(ӋLmPz*͵ޛ0 bYdqSgpKFRȺ˛87?8%|?gP $Vm~/Q ɞ\Esjo{ǝs$JS}d͢_s5 D.{#!dWMSo#z+C Er{I}܂ϵqiq_uU\a@ "v}r۸<"p#X:tx3+\GR"("L>~K5fn>uP6& MaMPiOCރ64=\idm^|6#']d<|xo}$nFkop<5܅tQw`'+ Q ̙LziT*dyyj>`<~YC%gEZf Hn>(\\Ư Į(n%kk/~Wά"M(k[6#bTdmv>zkou~;ըs3̉Ίf}MoҞ("("0h|w} '9pt9Ej&*mQy!q6HIz*ԓ:"B,@:$NtS$ Vy_vg5;a@}kJɂEQ_r/R*"h[K+ؕnGILr F'J7!~eicSy iD͛8ҪT://H[XIrþoއ$,6Jݿ2+ksIYw& xnR][ Gw5Ct...m~K/z)S )AQxOR}37IH>rM^{xkNi3 ܲ@<\ _P)e4 V M@Mssv. c]Onz(Ti 3\~=Rɕ* @{gj:ٱ#=xnC c\ &-rD&nluOcgNXPE`&U+0@]'9tC3 Ql)dޯkD]tqM>;Q4GE(Q8Vq)i>s<{ܿ?P,WNR G<8\*4z3t Ϟy#,eF0:>]Lϊt:Ԣt@`mn8jE`n=0vjB> ve}~_p@î/>)tG3W|UQE@Pn{[?7AڀT)=Ḧԇ}Y"B!o̸d{& pH6亮4I{ˋ2Jr)KQR[y ^m`Fm!ϕ>30j"/[2Mo-s-[7o ʪ |IP-'2ٸ~TP|2R.iYׂG)@V5|n75m\COYۮv+Pա84pբ/Ja5aZG&,_MWPE@6Uw,Uo؂y(sYHV$ l>ϋXH7+n!8NtS;l %6#)ydƕVX_-#u&.2/\HvnX8!ʟk}7yw/N2ߋen #͋ Qk7y^rj YAI\@ im%: (.{lu ˻?<"=/Hmy%V?^<%Ϝs(^J,<ũĨ7\-RيB ز;N`34JwYfjD_u617IirZ CE@P!pׄj HplZXBn֯Y[җId1in2wHxmk% ds2ՠI*"¸qQ'q~ZR8ծv>J豧sRf l ࿣J_s?{ʷ?E\9HDųc-q$bPH'OVpxp׾)KP![v`# X1,|WQudj Q9[uukӰBg(" #=.HܿJHPY:"VHz% Y87}[˵Ҹh% KQ^a?8c#yMX(mX *1q'~v\p~@:Լ!m8hH:2 G"2cߖ30m"("Rq Z V"-L6F3\ y is>RtSe yҕ)%  &}Y ~9>.>Zl f4 iӗ&t/8wKWꌤqaߏ‰ȋ S4[c"oRZs̛{u[O1Lfed:8$mHjC~E=(bՎ{5l7s:~ y!zv&z6a;u/,vF<w:$eg[6R9J("(!zk oW@b]BȖ!]煬fu7`ʕq)QO|/ {Vyo1 ?lE(&iPH;:1wn\N[3 Q~+ "ՠ A<;6%Po%8+cx ϵ9)K{b)21)YW\ϝܓ70澞( :dx8PdhdaI|㱻RG{ZuqiUSWX6bAQ]s_c1*#m%{++A?mF`y) TQE@PE`*-:龭[5rX*o~?!;*^>%7!C/7J45%.dQŃ>xo=ugTW\O9u)0BuǥT5[9lۼmiǝ /"pueJ}R\9&Ŕ=2P"@IDAT{? =vuJVYO9I|?AxSת mZʅ?<-+!zʝܛL)OK j?E۷ {;V{zTVZ3Oo:vR-*㵕Z:PҴp6k`7-N5@lGR-S;J&|IwZ:磟q/5MyFBy` ("02*?ϭv[o)ʳ/b6T>׭̤CTT*%- _qJO>ō{wnLԈ;53ʤ,(Σt`+S%R${t[Ő鴷 Ǥ$f~DdK:YμυE¢lVUeC mUz6sKrh%'K)h_[BQ pX0l2@hҷNaq^y5s!sNǍ),phSWv'n%ηu;:vsٽxe9~R0[guuqI]SSǢ>]-P-]|g Ş]9~pz7 y:y㙷ձ3xTӐ({m^u5cbP iQ*t~ lK=.|AԦ_c&^-&M߿_ֆ0vsPsPgop 8}/]b?57w|C_Ls*B2Qj+UGߨ2qv{!ҸOQ\dT)AผG7zGWT. aǽKHVrU¯/80Jye0m}S˸&)S".utqyqc-~g~1ł5eoÂ?϶w-]d0p7_ Pӝ, |O4 ŀlه*$ :!mtPQE@H{ͺڛ}e! i|r^@JrQ=JQȋ5i~.* /{Eˉ~LjENWN"kaΙz9Fʹ~`T2Alؿ?DB9pI%J>pv%@6_,/ZUeE!NSj,gsT.宑Λ3ȹ!"&CG^# v>ٗAk +:7 CFwO=ͶQzMl6,X1I+% ƭL&aRiV%iX= xL&\k`X kh3#d-@-:."8k@k+REo>NiX@C[2Ṱ07\&V⥂RFT˂^,h-]+q3eS=FfRҽrAvjx7CkKLsG)>JRϾpjP\Bn@[W6a1IfireVזTR`snE'p~yUtͳq~7'嬲(M' P1LnX?e6M-nkݞYuߔblI*<uݺ>-9ʞ𵁯1Ik^<.uoU_%VP#3TԛY؏ 73 Bo?^l("/x<ҖulE9ɬ/U=_ύ ZxbRf5lH{s-=q[ xFban, +m Ē4mA8lgaKn/c/ڋWj9Gf=ϖo |_u*&AI{;rI^7̈́ÚCIj{GUgQzVCӱ։ U(?b犭oVw^璃//졿>zEN9(@yT9]phU\* =;oKYVpp"  "_ cOc 1hBH6x|'x֟[q쐶pJV"@_[tGXRE=Ԭ+ԋmDiR#<>tgMΛ.ޖFXIm?Wjq"`%|;}[ԇUAC_U[6`Foli>0Xz`sr4գnt͊ Z EVpŵ)ڙt 0 ?H+~(O7,n4T<V@9PAo&TjW{5i<?g! %}}8p:Xhgz[?Mp?Ÿ#3.nu?'׷4Q;Ql4Auu=Ue`>PgF/!k[ƭh+=:YBݬf`WWw"-q\˲U~.??p/9G9.u+t>9KS*"~Eʝ^3œ[;$7xP.` ~Oȷ-z+Iͫ;r ĵt0ns2jT gEOংpr |k'I`﫮| #tHh.ďKJ ypdO+-Hz7G,A}υ ZP \J KkѧC=ţVwkU;ǃyCl,T(却C? K8Sߏ:O|i&Oy>AAnʉv@"8N hl{Po%!47zi&4^{ f׆XX[?/q `F["0dOV^~Z '=n(?ࡴƎ~m Ǟ`8TފzS;P;2` nb+\u*訊"0Sy^ߝf Lv@ ڡBˬ1I"m#{[izDA܌ {܎/FCrK}!HWhdAMl:/F3J WDooxkOE ·ԧA~|Y b|[Aǂ^1Nւ@dapM.IH?8ήo 4~*Kfv;J(i<žI_ߌ~v=RՊnVR&Ur ѹs7AX2&|&!'U Sҗђ@b*|ug_aۇ>LV6F?4&SMd_-ʍ%gV?1'mr:+Sv~&q:iWOzS¤*Ll7`zHcZY2HsTP"0Ka/w3yJ*[xgUuQaVjNpl,49BS~}-QAܜI%CӋr*>^ p\,YGRG>Uc+dcaGh^[י{7ac_z>׎1" [K[AN=lЄ!CWq m=kMt* V8g>qҦ$JN? yOw`4*+q='zf}&ϵk(t<2޴PJ[|1z?xĠ>x Ң*NdK6^Ze1_?=GO3qgۥi)Nuf8&޵?k{ŇF6j|~pk1wx({jEGUWDVt\olNfU>#/dL{f6cʸa3LI?rl&.ÂmZN%D=r8Qk}Q(9w)g1,1/} a%Aͪ af{m`˫}\ÕF<+Sp4}s.!>} $Ӳi^fE=R~)k[.P\@ HVsΚd")0~.9s>,y|La_V*;ޱg L9۳N7ʔ\0g7)ū$V{U=O yѷ ϓ91p-I )Q`^"i=FU^E~Q$f[V?3D@oД'i"&)iubK0kHp|Š웧e+fVu'e ]>|]WgcɮbP(nՊmXQT؏],Ź]2\>#%^P^ۣ:m~=:"[U+XO"Kq$ \er A[11!#=gQiԞC#tXkwiǩ6UwbҔD ?Rj%(e}6R3N|m~iKaqMt]\30f[[aTKW\\ZjjI2/_}'wЅ 8B4ck *Z ~,6:Fir#>G*m`;$n5ɫ7Od]0kW41L"8{nrWr72 ~yW^pQ̈́Ǹ*s=a;sfu~2Ҹ EW_?Kw7ⵗ|[?y߫¥}P3'#(Gm[)iG=ܹ亴۽}[sfwdyBeK DX=OVx0mSlhַU ~am!X٥?I.#7br;ρ2>k!ŵLSAZ מV{7,q̽gݾ !7M+hӸh[DeBI}P6*s߶Gb[uC{ll @Z?YOw3 cq.VCKGck 2a-S^֍ .bU%nvˉSiΒUk&\1f?s9hΊ鬥r(ƾ(k 3鋆DӉ$õ-jHǬR#K(^)%g]^~FͽsSd??}r_I_ #^c,xdBߌԸ%2uJ-(|4)Sh!p;}-|m_$f8I'W !)7NΓi:ڶ1`wA;g۩ZF1FEA'ZjjUt/$ou|Jkkt0]_u1кf_ h+AXfc9hv}أxs;*A.>{Lғ6{ygydI=//>;9lhH?E¢}};oSnJ'//zT葙ތr :d}nUel7ݤf疖~34)D.RA&S{_4U~x\^Nqklsh?LnZm$K J|u`88L%UcOK'I镂 {NFg+jӿ4{޲ړ]-~}80Ss,l2GwS\,Ҧyl!:C=I~:dw59cWKOS:燨r5f)4q|3LM%%^Ck|i|rSW'N&=9sB}֮]zώinOr+@g'>]SKgim hS|ssYu}jP aSdI.Q^8F1E1^A18YGm\К]n6;Eq:8x@~ŜV4}hQ_؍˿]C/3IejY)iŌv^r; #װݬF˵ YR,t({r1y{K#P/zwm},j]m[`BɟbC*.~rݑ*R;ٷ{'4BDgԝvvC3-6]ҚFj Jh+=gh<.<}O =#mV5-_^y7;8/:.mxCp*'t¤tWW#-=仡\P"nɚz |9X kQe_|I-ǵ/,caKtĿƥn|r$bp2A)iszv#?vW/F҂r=rage~܂ʫlv;.ںȆT=O3i6gq/}dqW%_}\KN?O.Jz \j:bھ2eablKW0`a+suQŭ|.3~Yқ &D5gr {'3k'l_WjxE(ض@伮6Ja~zo44yc-_IhB z[ t!:V|ͺth=N/Wḏ|qޏ 2.|k9ﶣ$'= 9nHKڞzmAďg+RF{P\# mԨ bZ&I^Yk5mֲŸ-ۿڼMLXOvSD;OpD =?I/OH_R34gJ \-@~2Z&#!CYr:GtGQvOL~_QGOtpgEQA:n%bȋ^ V=)"8((dڂ/hۑ-O̶k5lq zBϳbg%I0 Vo7OOyLdV CZd1niDiزgM.k'f'3h+*}@5"%OKDбI^Mq3J֮U=OO + L |ZMמ)|E-"/I;LQk"/ Ry!?=KA:.﹣sYEÔ<umNe| u3Mscd.[~=?H'9E.g,? Q؎uk\}9@Ma1٣p)6BvŅ_G:>MuX)#@ɸ/xweL\A 3CN@9qj?_Ƿ{&_Fu=W*SD ˆ/1QYa;h L&9ڗx8Mi𱚾?DPx%,m hݼO6czb6Xkún_>IR^;_-tD&^˒ i:ny}zÈ|׸ ZSxH E6 Ei~"`fQ+J!I )?Hy+$H?)f':8$|vO Dϯ; 49=[n-okmM/h-eM~WPTd",O$F㵰<&GI^#(HDL125@r>ӱ ZצJk{^/SA]: @IDATB| r 9 8ItLuE.(`s<'^,Sqi0i/e?Oַ5^gE0)t;9Na]opk 4%;o4Dm#œ##eQO.GS21}=_Y?: QaVjg_8u_G/"Mx߀eT!any}9\3P7'byu"nsݺ:OӊgE+7θm>@A ~(lqt=O=ۖl=[[jͪ}5qY4Ra[^ {jϸ0>:2kbf<.}QtlC#q|Qmldm;md+*Ȝtkߙmcs]/$fj+ׄ29)%@ @ 6$Ε)}d4Gծ살P$BVAb?Xj$)(UqBp9{mݟS*Aly{ՈM?S_S0!tIY =4M^٠18*D #}))ҫ, o m)UiK_n'^YD `}<῰H7K*a*4ͭ+Žp^-1 y''I.8rVb#`UM+nVoU 3E`8o @ @ W:TbBJ{ z {cm {FH(mn)4 wlݒb{rg4ۉkLIՄbSWh'*_4[mrHiXfTՄO۶oKDo'-F)*IA}6Œ8fn&n۳RsT4Hu0Q @ @ 0ŦTl5r1Kg*aJ̢ό8}4WRڅ>m%̂8|sƕYuWǶ>{wlw7 $w } ЩP&!ZҳҾ _},{]L$`jmV9Hvh/qw st<D5DJ^%Yzï]JXYӺ(51b3u|͟or9@ @ E h\qah6}e&/jLVdD[P1nJ{[^kEX\s]=;D@r'3M,-Pi fckڱgW @ @` RR4,NQK:'@vb$f*#O[NCBeyS9X} )[Tq|Ꟗc\Sd2%ֽ5{Gc6YbR혥Ψt|Z-5OKxfr2k:~ӿ_A @ @lĤ`8xNStK\˶⯋nM͙¡((\x)@`\.cB"Y1 BKH {ARN $Ŗ=IWyUq|Ogo/%8cˣ:+IJ*bCCW(?+w6m.8 @ @` `v  N/WVR^صμUz9R0 ;+rJմϝOΜ?SڽK*k%'+*k5fRK'5B07ʡ6Jޞ,KhXz s^vR#_q _ß5$BeO?WjUC3Z^KV}Z40_R @ @`c!`w{HUY/.Q飄QsOE~9{-DKB[BX%\ _U-G>TpI)?v>^o:JL-$Mu/P  1G33"} g^+BjZT qBا|}щR8t>ҹ  R>җPa ƺQDzBf$}-ݗ  {W@ @ Ou%~?,0IYKAOžmGu_X\/6Lt6BxP>kEɱZBg2}nYL-\m~Y/C?N=糧ҵwGq+}Ž+4E_6]gLjяr3g4S~NKe&_SL/}7|x(ȷwBOM%ʼ$b>0I|ۿ6@ @ q0_;!/mKR\G [DGŵq ß5 ";J:XB>>:L!0nݓy zq"ffmnC;@ǩ+ yg(Vw"EL75ҩ,2{Qn/ &ZJʂqtyW\o(%*3YG/d[o`_ @ @?mdHϐF?vћmrmrҨ LS`ƭ^/M[=MfQLkKc_iq´3:qٱ_}dVra᲎->Sjw|;4_ۗ䮵ȴ*:^I[\ @ @0yKLvԊ-Zђ&lSR\Q(FG%Mt/nEӮ. 1ǸP?^/eY7ڥE35.sc`vtisbڱ০~[/ }W]ȓz?j^zvfΤ '=f}cfʵEd cs& 3ۿ9zzi%4M쩽L ]}lӸd}|ʉKUV7 ѷ=X@ @ @ 1ߔ\| W⣪%X_6}Ӈϻ6#SթK"csYFOV33'_c25X6bۦ_G"qe^i$ ϝw%gWyd)}E&)&{?/=He,e uIK.m}L#b.}!QT0yj,@ @ [zY*;[ﴰl;+ e2ECvZ^-K%HKdTBEO8}t?"//=<j7cޢD~P zOfs #g{ǿ>R=0XJ+RuQlR}ŶBW F|ȢXLu_XyVfp3|?|W= @ @Flw<P\Xи(O\[mdCwKW7Ĭ#"NT7%˺a$ qu6ƿq/|L EQ73N"K0pԘAoxs3MGasi604r> @ @d*:=>9)P>T3J^X5-9n]% -*D{Yۆo~=;– \Y|͙XY$8%_{) _tw8̶9y%-(w]6i Џ|S: @ @B 6(ZxrM9!\^ۑ:6U^4ȮuI~z{qyr6+w/LUjLn)FI0h 5O.e;x>jV]x.kS౛S`:a[_u< @ @ #VbovoNlXi\;7+0ھD017>ddki.>5V# +70 ٗn3$sLc>7RɬYtv31d`MZRp/E+B8pmݬ\l0)=L3 @ @ #?Ba(*3ui飬Bb.*9ӽf2]|X|Ţ4Dmn}N$e?N4*oS>6׮2s2f;Cl^^߭]'{']r i? zW5r=dIV6-XR~-O@ @ -piIaJ\ֲv%z/u"gu˟v-t!lOptK5L`sc< +ڦ"t3/6l_o!Zҗ})uT~E ȡh=\5F\%ebs*#Aioڪ/J~nVSE.W 3ᜇO]cU?H}G$@ @ h@m81A)PeWE/%] {q-}* .#}.Sf05+R-2_)~hKk)9PObx`R2 zS[#}GmЙ8ss> O{YyrdbBIL~$Wb:G]\ĒP*Z_ _<@ @ @-_k\ Y^BRsCĂ$|X"%RU/b¶`] Av,^ ._m}L+yy->iOPa9xڡOxZ>Qv>YzUH34)vnȇ>d ]- ?C @ @hj ˷E{:,eZ\K d$&es$106bSU^8$vTOͫznw8z@< G,z DJg׵v5_lϻ ƣT%ؗ 8nSiS('WCUbN ]}cq*'I[|&^)rwR\TD[:jDO4{?xΒRm`s saU*G j:T籕Ox @ @4"Pf=F/ fv\G* -K%HKdTB)2M0 EOzk,Y;fOȽw~ F>#+f=;Yxnji<xvz,qMK野O~on'!t2(J_iN*Ki[*xz{(s>@ @ mhQO)}LȄN20 }9B.d(}Y۞ u{Cb麢FkЩP&ZVTv۷nNxH&;n[4W"dlܛ'g=݂H?PфH1S>_g#-zuhϞp+=gK*U U,2RK/n.OG#Ou @ @64n47|_aLS/-os#жx~mn"M& ai>^q9mC/@nVrҢ|>aN-'NF[ ?3_bXF^:]=pom륍1sE]Ū4U! )2abX9}dj)fF A+6@8B%dvTh%Rv8v50_ߨJ@ @ h|?u9 ^]V4}]55WeekbddGJC.\ӕKу=a_;Hjh^Q [;J DӉgsg&Rj껯nq|Wnkr @ @ 'SGǟKTrEC2]bAosřYK?*!ӷ<7' 7OHP3.*h>S+8m3nˊO:tbk{K'.[, ^Nɢ=b/;JMW=2vb>n5LM(HfцNf;Fz ђSK @ @ hodhz|IZ6b1l+UCl)5p6W LWZ[a4d:b -E0)ZЯ+'xdU#'s`9V, fi6>O73ϥۀg[f,t黯nqkyzx/D2 @ @@˓ش(iWNa[܅?li@ @ Eu܅ w\Yå?碖_NDSL&OJ_:=鵯tZNY;fEKJE Eb/3jaA1*Gy2LۊRL74G.$y޲8\ʻQO_7lES K_)Bnr(JSr}G\@ϞuƑyZ{q6YIok W~:*7-}#"QFl @ @ @t,hg %/e2bZf=7qu颉[ݧBPaHڥ/ =Ş,R( CL iCϽG<3-fm*#R?`iǶ2pez&tnbuCA,y|#S zݥkOB) 9* (j GĞx\;z>` '>r` @ @ [ps[|q`_k,Y*K%B:&jbZ}Iotj*1J쩒1u+hy$NgN86OǏ79fcw]vslԬIZ{O&ڷ7_w')介t)9ٱm{  kpՐD2@@ @ T)_w?s4N bfUd4T>K܉?Eȓn  &]TZꥑ˷V]پr*1ڙ+3*3 oO~}[_g΁N00V%# 2w\#w @ Xt'㞴VԻhq5qY SMھDۗ43 a-~u 1XRDjRk%g__ϲcUF <'Nʸ~r7Ԥo-Oz);e_c = u.%]SgT}<_^] wu @ @ ˌHi8}fY$nڱ4vE/w[X-CHbgCųc^Ob~v3SW\@ @ X9F./ /'hw\Kq$_2a7Q FДU-Bzߙ֌g㲅Rym)N[7i]۶-%,!M5CRvԧG,-%]x5q\o$b̅lؙͭqqNdc}犯o}d榟W* @ @ %A`?p^ۭTk)}^8nV;pV̗tnhR]s.LoǮo1NZ{=y쥽aV:IWC6ARV8/mԚb5lc27އޢ1nw$%ߪcN}F♴4vi%vz+t2vٹ?S@ @ @Kqb O{>/|yYpM[ R_jH(k<3vD(hݼ}7ڑEMVakľzǶؼ8}aoU7 N6UvT/՚5-}%{w&Þ)~-oM8K"T*L~iYϨd0UV=@ @ V ѿSگX5}9C,t-9!uCU~^{.Nt*~Ǽ?%_RIzqbfbF\~ʿIۛg,58,O'[#O{L~#o~` @ @G`?g; -gٻ2rF{b 9p"I ݃yw.Bq)B酃xK*bg99¸B ]mp2' <ө͡%+G(nƜ=ڞlnԠUɟύ"3 D3yw @ @ @`4C[>g*u4e7V;phg܎Vf$I[X ~}qs^zJK QFCᦽG4Pt_Wǹ8zscx;ƖV6ъ,2Ka7qNowd @\*^SVϟ~~~R傸@ @`oO?JɏSr ~ntnvn…2)BJoPeqeȩ |޾s}l(/j޺v5fG'":5f6ZN{&zMw  Xox>c2>1`G [p8C'u[nMHy:Ν;w/nh@ ]Oa&sSbRS,%nVR`sSN~fjl)mKJh3ɫ]Sg(~Kdfn(S.JMʑH_Įv5=b<9B\%:1yq|9l2L[n:`@f;15k_ڛh}~0MM:nٲe{?L@ a͛.cj`f廙;Ӹ݂ھL8.Fcc;cϸ{v*v\"||"׍}vmͽib)#lks}/Yݬ:j³6Za%Kÿՙu @ pbbm3Z@ A`9, Ҙ_8ӑDSz-\`G G\0( .!HKeLM{^}--2 GG,k;¸2^tEOvEm^{2+Ѕ0/!gz2޳.MsQK }!ZKLIF'`x8 @,3~wSS2@ Zϓۺu73m3}8bunde^)tZJ%Of$/a15/Hkg~l.p!yH۷lNvСjE\,>v^k^z ef %+ - Q<|  !t?⚉cWb Y+҇#}j$tV-K%HKdË+~gi  @;v6SS OHh!@ AeKꎷ[?ߤC=1 ܲ> z'j܊bXrt8Gh[_:8:C6Y|Έ׽ut ӛ=M??YC{󽎧~@ :G?}_;˧fI1@ 8\wTEj{r zi9)궾͵hҴ)if1f*ƣ/ݻ,;O)<QRl=uZ cNpB[~C!]3vDu{ae Y1WVULM| GⰭXen돢b?mEr 'g?W@ Nؾ}35 1|>ñӊ}=c"$n(_kGJ7 셽b-\8*'0Gc2g:;xnviO|Kɭz+~_9X-[?o2=~M7eWӔm:OzX><@ 6(V> oߠx`@`= @_w4MߋOy:Ѐc#K{%o>wȸMEH_ 9ƈ {QRGQl__V3MR7p)5Es>{5QZJ]T@!q;ְfcvEAZB>5 cX4t_Ji>VI*&ɁY땦/m +r΋mSOY<.K@f&6mڴ`@ #{?u:L~L-.u(r/)I_KiJ臎ogc [8e"}1m42Ø_;n{]#Hss@x ڦHBϽЅ#lo6A|סkV .IT( :@2WlrWnyņxZ}CE[c#ls?yoL  G3p# | GԐ$e)[*2O0,rQu;!ՆgN.u_F\l:zɃ4CLۻqⲃ{]~<=:ڽlW 7ضyS]FY^JQe ^}\S%7GZQTB>B!DC27E +m}Zb0q;:wl3OF:o:5a>G7X ~0@ *o)/EO>kk"K$ E`Y KBNʧo%җ})5J cSh3bX9}7D+35+R`?ٞ]K4/3]ⅽ]>ӗM^~-Q*3[$nosKptOR=0 ]L<+ozgm&oG|4э٘xXM>x8` @ e+?|ʊSEriq~Bk!Y-.,Z)4)vfWv$7dU"p{@\1 ,r:z:_ovXfARO`r2n}Ii cC澦e~US^+ dS!u_xLkڱ4&~6"BWgr#v>L \7p>(|Nˑ遽='n4_y#m @ e+l/L>TB5 qɼ,fTᱛSuUNsF슴'K(~u#=>qzmPk'&q7ͭo]3ïL-qިqؽq!Rapקn3tQ`>w|,STk@·x9дr5iݬQr@ @r6m4SDz@6/zͿOI2ُŕFz!rXG9fH6Q<=Rt&fkV3#[񯛤'􏵋krU@)G,Uq&2}?=Ju=[*kEj6x)]?6 mCyy_kv^> zPy:6|?/<:ho߬9,z+G8sAJa~3a&-`Ϳgmy E٧k_/ikr@gIr_^](y5W g~ڝ7S/^ګa=eUqӟrM1[-yr!99Zmדx=Q΅|^|tZ &Ʊqf㸬~ I:t 6oJIC&..ϳ ԋ[<&W5r>xվ9ᄉ,|&)J䃐\p_Ɗ ifG_G_|Ӷ-X{:O.ikk_|MuS8ȓɓϞsuq}N\z1Y훼GOc!F>~ |>wg?7 Xx7^}ERǨr!%Nќ<5j"%$a{p|ot9Q0%P"8Sšss \[lPCc~y_˗nf):t[tjf8<\Nӱ߱Y$s} A nC &dl1G~$Z GtC}֊G0ZϺk{3֓Nsğ{mı4BkqdO][ttp&6E >tZ쌮Gz癔wY5abI$y/|fnwS~}ᵲP~Xgr0}lvi-kx\"! MGzϞIN^}-.<|NZYIyYskJ?)};b?3-N~Aqr{_OΓbLLu?Kb~תJ2QeWջwʫMbwO >G輼0<&IT%z;}^n%tL (so865׭@W=ESaǟiyN4ʹ"t[Hh{f׽@O03|p^?x~_}t _WEqĿ/;D;5u@y󓧭_lyMP~|x[mb$?HMmٮ'XYyu~F?PeN $llo檉ȾISk/Q盌ZUZɟGクtw;t$>H?g… !|?ZЍv~ 59 t-7K_Lu#3T0x+7h9XpwI(JVYR0"-q"f Żyv]HڳıtfbqfzKuĥ cdڎ$f!lͭ]ɱ܍Fs<7zN/4b9 \_oLYw c5css0|1鲏^pySdt0Q\J^'!a.9R''L@_@s<U$ǥ#8E:_ l+*Fq39,l>%QEޢ}?-t]@~nuFJ$/?|RVKsxY|ӵG^z# [յeВS AѹnƊ߻}o#:$ݼhmmXuJbB珯阢S7y]v 7qh ۚh]7"g(ƍ?Ó#x!wqZAcKnr>cSNo0fE}~ q_ۻ5iR+Ly!sD5|5<<4x=YMUƜ/z{l#[Ȟ?˜xw9(\4*f[7Hy^Y쫷o+/./&>EOEuLz,SAx BX!Хp%20@F.!'e=:e(e_VKL[X!@ ] mt63ǻgcu5 ,ΌP(f[`T 0T%?}/2##}?/oy7n{{2^J܎J?7ΔJ۾HKUNG_4 r˃B A돠ެ s]zC"~{?~x#'|𦹜v_ :xֺ.ba!MF@ci/`<=4Ou_suқPI*kVK}P4翎,K>7ɡ;R?%p9G3~Yݖ9?fMjC:NHdp_uOuBg\*]WZp]-s?FuV%"!`:*Y ں[T+ES3ƌwJ9v"*)nVxsEE(ŽX|T/8uIwQopBǴ'B(x3 LW2-jbqg hK5iN:3ԭ#Al|c_-P0IKtTVz.2Eo/AZ.a39D 6Mh=Ftrؑr<샍tsTZ ^4"P{FklO\95_W)u@}gIx+~ о8f^ uXLΧCY`5Y:1|G<,嘹tWfi\Ƀ-<:Jpk-?GqdY;2'hk9w¶c]xah~O'#isz8X}vMg%iۗ3%8QyN2#J뛓8c?ekHV2ι{p_XܴYMcˉK޺6b(7PLbA?6++;L"X-z|C'/ZBNhǯFхJrRrWӍH}| SVZJ>a4.9㯝ɢ"KZrBvQ*^-ۯ{KL*W7H$+3d9R*,ҕ B= /Lx-0_Եs\hyȋӜO+* {q:*S/#6 6]8v&-NM Bh:4m4K(?.m15ĥϦXI,DB\, ZeѲauh˵!yCBF!Gc~q8NdrƋG:4e8UQj٥T˙v|  @|o7@*+%pͫcn~[ñ$Ɍ=sђzX tRQNy񺤆d2y JpP?U|mפ>ź!ò"xvik뛱g4d[~W a<̡cZETcnA)>Ǿ\*sppmԐu.E0%! GB׿mp 'y[7bTE,drpL!7}8g'|!^5*7vkdKפOAv0иsr >QA-{|iI:<<=q.q 艺&t>)-FU&Y^1 "?C8kyzz%"T %_ -J?RT~0?;ޮ"M|`IaMyhq_SUExž_\v>_t|.ۊ`M}WO _^iY(e%*釮+/֢ƣD><*YtEyUC\]7,˚_oD_Yv**^ЧPk_;:GGC,k{ kM)o!|$ 4:NB8͌9Y7̑H'ż)o0nWQ6[sZ1$>}ac55gHzDzDz2WIN|]pDCvܘeL*%C]pha2 /'N+Pȫ$q饿S@{,_]tj++lbPN~wR(nK%yRDmIBVI5V8͝ɴE_mOBSyn4l?~6޵g1eOg^݈7k ; :ZY8_8V2sJO/]ҹpӶPqI9tMg7-jVc:<&9rfǦ =*is_w4ہb;<8;;q]"Ӄ'w z}n! Zsʖr1~t<^(y^KY~ݏ6CU[BB&<;w&N+O|Qb ޢLJ5\¹̷{}m cJԐq2j5:2i1kx%NeCG&z|.=|JHsmxL}>s Zu8f~G@Kʺ-~Oq~]Zvᨠ(ڴ8ξٔB8巿mDm_8Sxȱt&t,q7e.Eե y(u{\g ]*Ƈ"|d_ƹ ^ M6Z>xM+k@ǡZ;tvS`/ K(G.vIơKHwEr{[rڲt9aI_LPwrciVr%^FTd=9Zhc;!P?;o#_F7/-$V k!9֋c˙p]ܮ@0>߷|_EL} \P`E<._a.Rir jN'u]:> BQؚP>%Āke4< 6PoQ7l\KOO{"G# kO3ȫ)<ϓ¦JɒB}S2c TZ.mͥ!+q{O`IJ(!#qS7!w vlfN["1/C["HWI{g e5|鮭.NS-'~ sz6ý[>i=ai,G!Oխq%':]ԻnЖxX׉K톷o .hR4ItVh>DvI 7@I[t5=%s-Y6Uoڛ B 2E<0V:LA8T~3PkX f(8Rht+2H^qyf rT[#3Ǐ O|[b[r6^rɁ^.ݑ. .8.qYw*8wMeJ2JEܱCma9?IoXbe6.oq/CrehoB} A<ǒłot繨cqZ (T}[]*臵adn`QW-cCi~< . 4RUa)-EF1nOCR;g$zH88C9EfdCm&iERD+S!1رcZ 4iM o.-P(IJK aǎSIyPB:U(F&PK*ف#n۩^3mn"xO=0Ŀ< .ynxuq$'ҵncU6c?u~a_Jb;k@٬V6n:|e5*)݋ ӗ2|:i˧5?KaijE8#*|WVX-ɓq 2M?ݪb]L\$Mv9b\2$_}}$|Z(ucI{stZIV@5Gp,MM -t;.俯3V@HGW-MܞjiW揾֗|.bٯerHk]=>XH:8 %/ H:YBIĕiai(&<\ă3MvQt̗3ϏI0GL܇S榲ԎqoHzЧ+ݧ ?@M⛌R f8z$x:%RU6[A菫R=oԱH8Zfsxu) orZ]* Za׃^BP5`D'|I>Y OK+t? |ߛ^=mg^ǥ\{8!=F80KuҬ)t#w9W|b0z=DAQ'~2\zͳ څYӢan&bNcŶW3 1}=x{29٪riԞ4-ǐ* =$7[Γhc%`|oЇ 87#qW|+u~1Ғ?"e(LbGa `O}R72xq?mvhn$qN~FEBPzr RE^-hS| G9W\qEE_bֵb@!OIJ)B]4UL&oI$'M8GoۏM4T6a.-͚Bq3>'p\.pmN$q/>CKϜ1lO;"yE|7۵.t%.ǭ"Ӌ)+N;^{ ݠ+e:9aI,Ӝ~nߺ&kR"g଄K=opJBI#^$,jyƐnS?86^A/4>u73 U~IBlĐIBLXLOT[qU^ .[u A[[' kǻs#>y4֡`E e hcΉ1} GL:.pq?Vy(M% Y-Dv -{ Tl:5꒮-Ԅ-~sxQe m]pk rz%,IT-m!6 +m ͣۊ%;- *nly"fp2˃{%h=4%|?>Q?}8ճꃈ~%qhoІRH~)5ː?{ ݔ+7k˩-bGgt_q鎬D>|o~JKBqvTv+5t-{ܕѻɥvaftJ?Q4p_?[.iË,#?$+!=c>I&BF=lXT(yvWq=MrX%x~%04\SKQa jSǔ/t}:B2kmKP,WqlZ[4)qVFU GlV^N~P R/JUg4[rm{=rOvf'z􋑄.Rukח>ތ*;o&Nпh[k'n_C sR_tvHmJcu9sq)ZEq~R iz`Jy#E m^a ,m urO-ry6jlSҪUz)_Z?߆>JNIfnvF?3m"=vBN= JV uHU.BUL^ _[?_[jdnqh|"WO /|%lx͚EP>-qں7=v6< 6 W׿$1Vk cl,Z@’Dܦ+vk 2m:\ 8zQMu<27 $7k%:>a9CzRPP^yжzH``l Vj)БX:NyTv˜NeIq'Io'U\tX'\6aJv ̽,ڎQ {KX=mx:O 6t;ꛘ-}ƿG~-QgǛ\9qb { RznNt/ _K'Tjk=~&8Ss|Fģuh?I[;x29(])q \v58~֥;.L `% Ǻv˥܎nrƸ8fr]乍?yxQEYGt eS)lC OHG?[bbs&C X#L|2α &՚Fƺc;nX0+c>&2_;:r&&6`<_=#!}N;91S%di2UA:jjvڒ=r_z[\g* zÿi.ǽgޕs.k;^ZӵN6 +wbY:.;zn|c\œ\?oW\sʧ[:XhYWE0-[DP7ְqйѷ2owl -ٵ h-$1Xż|->S 9:;x8aP,$vfqdYa YB36%os5NL5EQm xެ\LkqAOs"T5RV&}>L_B]O[[zv, BIߺW<;^+iRw;JQ^Gkv=7&W早 QaUό)|Ǝ)J Y|[ן>]zC?k~;"]W'.lD~f6er>P$d)]8.w ]ҾX+@gΜ}/g`7&{F(%i(o/*b?IDAT5ٗ^zm "Ǝ*eN&2D,ñt(oa_L0xlAs8>PŞNV,N6MkeDcqzoΡ|8G۵z8(>쟯MoxK>?{Ц/PO:Dzo/$!>8b8Q&Iׅ>R oqQE:m}--HOJ]E&㍣lJkyOW^"}&'XsfJp]rߔQbr%`by%E= \#e?6z ?B?Yr壌QjP~w{g]^*.u?r '@䎉UJ)=+? '7C_5tD @{`uZ}eѷ]ۮ8K>ۼ"I j)sTxAsS3#qQ.R!ԧo| spЧ[:XPc* g3C78 s8'^;vT&&*7H,BhŒZvm.SY W%ylWxAYPdIc$d5N ڀ.ΌRאEW=uZVriC y<ޯah#/]Gf(!NXއ_;[ hrsW DQ9[{V=SQ.jJ;aD>|2v b{{U,ljʖ@~Ĺ3?vhϮEdXsr)bEN 2:^<(|pbΓ!8< !|m&TD+XQN+XTNG$zLٿ2g:A#{^fv!]4[d]ătΦ9l3hsz뛡;Nط7;o8,r}^OǧySx83yI^`'&h7آSy<7&ĜIp}%H- H\8Ok};96íx])''j}da,6ڂMpm8}=>'y*|'*ȫY@>{.Z>} ӂMh$ =5^j=u1iȏe;}^dH?o9S-<!sr2 jcⰒS<;զ3a[aw>Fƞ[CO(ы/ ˚l8m:nhI+]RXD$]I(ӊjJAU 4&R#0Ұr$D: t́β02/דPP6V#52Rl kqöHg["MحKt+u c؉3gBN**<VJƋ%e(L'fnG>m?mAC6WvOW\/5֔^R~2ohe@׮Kب}OжBڨk[{9uvjG齎&ŗb +W?s >O˟ ;햾f|4 _|{eb!%!xy!\uť_m#SU]\/?)mG$Q[6]=?{(r%>W7-nTxץf~Rp |{*]T3eJvЮCf$HbGW}ADͅ1oKBy6zzH"/˅ta}j\UY ] h7s0c~>U<'^Ađ&uE3sVƛLRkæ:"3mj5o֯Fya2P3 gַvm4H UB\\}CduyN5cٺxNYp@H'Eyo?7a/Əxn~],h5\6 {3#+6mHLE⹙IʇEҵ'_y=J`WAXq8< ]C=ҵ2ɓ\_55y IeAHkYHG=d uQJ(v_XFӟWsҎ tRZ|rlZ\Óo=}[!?ޮv꿯><.vq^u`4Mh d%) >|@o~lot9)F嗔x\<M{n}a`pSo*W< kuL ?S,d.[y6>sGf u\~3C6ʳm}7nYbH댂oӇC>N2`F[J햜Uvs5[nX/|g)f6 p k/ǭ0?&hV˔4nUE58gaN e}\$ <[y@saxc[{IZTuS&BE4$U> ִ8 >8^Qp/Y+>ȓB]"n smU;T<BNQsG8χȝB}yQg~?{Wi{t4% -f$e\pG;Z[RI)tى 1Y~ps+g(}^gEϹY>cb1Jh0{]7 tKT[b]|rzgT( SԀ>L|֜v. $)da-%Ǜ`$%l\C 9h7i#=nHOЄq0"Z ON.ByFVx = > UGP:hKg;CEOZ\V },uu,FdT gI h7sU:UDѓH}lO;YF'/i|Ҝl!}_>EJ@KMy/yTbMFa)Mv+CmSUV~ŧ&Ǻ`I_U(ec'cW:B/l$ia:oj2y*IiN\: AZAwHuY0̽۸(x<_C[φ_Rŝ-m>tjp8R[~9=-E`Q$PK>5q-38 O=z8-?LwCSeSٹ)F:#䫁ɫw^?[z:ˋ.(UNMk[ŎmOF9 {ֶ>eox?`rr*k}hZ[WvY;"1kB-OJr ai3t'3AyDGLE)7TС %qNHtVh$MDƍ`_]9ՎQs"G,?U/ :[`(HM!Xm\)JJ f݆BߚؤD4:F24翮HQDum&T!t)]:v' T:^EP u]Np5 u߳ƹH\ E[O~G^oǕ#G?%MJKH pNfj:mҎ;MԂNU?s+%a/o4>"tuu%RJKO1. Rba}x,EozMm ..I[rAsJWUۚs;%PR( fz @Y##`AQ0G:}YX^{kz='kֶ>1]sKB-O ל9Am'ۚTiZgqރ܃Yxxp8'ʨ"~29Is ޠeUҼX!PxNƃ2m.-ԆI7>ab;}C6?I&VÚ7]3R׊D>,yM#аeq#d_1eO W%u,*޶08V+҉įe5H6:A#}V?> Du ]O?tM)Jpn34u;޲o|տ?t7chSsqB|)PO*,KSSؗ\(.K\ŝ m©a }񜖸F]?Lo*}.r;%bϨ6bnqqCuc 쇣fc ? #B vXp, I5Ezu#17.iQ.+ӢꩧZq|RP*7^ N{)5?E"Tei\z2aJnnW*Fe&o/ _}&([Gq\|cϢ?ԡq9}B  bA"f`7~5S1>(e˥4{ϴ8KGZtJVޢùRtF Gt?+,2NqN cQ0ysJBw+2 =\| 琂69""}v<ծK05=} $ 5md< ժ6ظw9^7BD'X*jyy0}$mIlϠڃicx<F%\ht]KJ,c غ_ːx %mi?^.6?S'SGj?c: ˊۅBQ0?=?;!Nd;ƵwanҹBF+k%m BFs`[O F*UUUd\(4! oʖY*N &;uy{N,ƩL'<@loS X,V6*p!Msp;dW@=+z 5AҘƄ bk|Jvplo]֔2g޼66 &O|d[yYĎ,Ǿ'YRW)At=h~4y8KWV&~}H*Js%X*{\||%%`gt=䛢\]YqA0y vb_!ea}8_O+~h#Drⷋ5?a(O~8o;?$i6W*0#G}7Oe-y8VK觼N 觾Ѕ5@N u9Z vU$'zM8.{q&9 .qx]Y?"s B98xnoW Hoy?A-8|K'Tju''J,vI?nI .p~LA!;{;{p-l;.vy=$sV3)zq7 <ϘL{]v\n\ſۘF@d4pE@ֺhF.MoËzQ<pn5䰠jeXƤʙ4Tynyz@_pSb~O;űSo7Ey[}4le.$NÉ"i/i' zYȪaX 'fUlC8(Icݯ90æ{ RX!C&8;71ܧGI{)zYO}{|'43|~"ύ"c`_"4h8ĸo}L#:391I⪴NJٖ#~[i:F5 #u1^|uH)S|޺;a\})˵Տi;`&I&nL&󼛏yx VaK_ \u.뫰yOǘ>լ0/M8i`׽+ӯ8Vx Cx? Q‘W9'ȝ 9Wk~ªF>u` % }/8'{Sd G/?xuiǾPZRN"B["K!g|d2Ͽ:.|Ǣյ3qVQ5*SĿv ˾_/ ziAmZ s% ?Kwtza8"P 6!ү`_/Hɀ/\Jp_UO.|NZEiq/)5tdc/>c2p8'Bv`}!<ںy `+'2pOk7:~t->4Y\ie xNq`XXi0BZ*/ry G l֧5kh aK0edَj@C}nf%,͖mx='}Cg_6̡VR?P~$fmQljqny4b=C ylAAM *`%%&Uyo)dm"oˮx9}a}de[, W!k3{HEf#k(ogWȋe6 YQơ,Xi[WyU ^b#f2C8wӷdblZGSQEqAdڭ՟tntʓ繭kjX!;ObTN)ja3gύ@L@*.rGVbh K\ԆUYV%^$ 7#e8+cdO"/qll숰#m G$AZo'rx`ڠ-Zҩt27v-݊9Ӡs,'lW"[mP@IhgX"b?ysvqnTWq>9in2v:9~ *[-]q6cF=5TݟPX` *26͡MӠ~:KqQW*rx|^Ef&rl[ҵd6Lmp!{ `ɼNc:qJl@f׻i>UJ'}idž/gSphe?8QΰCy[Nce755k,0^y ATbx>í%IQVd?F16'&u<w`<{`[`SڃYysskr9[.Tq1΁׺ @gr730I usUF{ W;Tٲ+a͂܊Xqy襜0G#&J4qLVǂ˪1 ̯$6,%l7FeаrN)u:y}9LwBPͪ~BK>٥;Ŏy8=^Y`:wX|J?>V. opzxɿ ̜7]&.r_TLUJCu*-)xX]pj1,yse\He `Hg%|ȐT%$"% R@N<#k?p2_ Ad,ȩoj1#P!}w>> 8ZaO!Qv; RފM=șBE)<+WqI*|hh(Ke`L[`28>0VfzTm4O')=6c,n67BO>5 ޙ vsJ$~ }K:c۲bΰ_Nsڄ獨TMt<{(1Sˑ,! i$Xc{>q/Z-<,"}>'.bAHe{|dnIgq)KH.plv>i9٨u?=6j;AJQv !'%L]v(EW/L5#:yjb9bz qś jGWqv:_GeyYu.};h=hcmq= }~hk-a?]X[H'lQsp._ yG',混C^Ex'&L|2ʠs E px=B;hYb=8NfO [|5E s/q BBEa 2}H:ß{˫^7\3.|ڌcŝ®_Y- T1Gsq[˹Xƕ8Ntc9l5+/cOE_|[Bt!,-y)wq\Jj(|5Z8o?nF3s2^M/8#\h5|hۂ A!*. dRRqʼwzJM/s!a%p+,Zbp:$NB7|9ma0ͪ5UşeRFV}AGuWco`elQ07HذěNt 1ߐwc6S/d Ė21 iЎ9J+⾠u\Jadvw#L=':8U -4f,=S)b7=+0f.:T&aP#7Xrq+E1?.sR/&sv<>Hqxrvlt|z.e5~caucg$Uڄ27|qmqs,dqڊs8:=n-cp|^kc5yC/ KʚXQs3zBg5HBn&ӹ~_]}Eǽ %*Y)$ᦗf>_>Q[t* : SiнoU3~1᣽%N| ޅ{5jE&06|C}LVeE $yS=P" }3 -go]jzGIDUlNth}xdB7.É^22u+ ,@3 :gP<>oJp~Egϣc2(gtzm󰭥 2^l@VSM VX!?ǁmj 2bЙK^I|(s|}[_lGbL^w%2?GD~ G8uvx`hM8ϠgCƵ߈v>+~ș[Ogu.aH3I'q,Άuzu1քߙgi(lnpUrk`Ma1vkSkc;|K}Z̛#*0ĸVG{?>eot&rm]K8@{qKRz~ꞿRhG~>tnF)A. hzU+CO\6oEJiwLywNM57o C0&r+^J/M QSpM!aߔ:X<Mmu]pzhnLfc@n_yX=w6DqC dgn-}RG6ѩt8έd[A.b>Ldtz[0^șiF !b>9 PzwGM-P3j;^ϘSσ)7 CbGPSF6  gn`a7x);IvK0K@-6\So2jd7wfnC+.?{WUO1:^AvPR=SKquT&"/vxbыvխ[J]98IMTj$u|Bl23 C0.^Jӗ C`܄[Ր6L>s? XOF0 [~p C2/X, y2.*? gEWfTM_w+%!V d~-'3wCm%-*t0F~59jן^~=Ґ+ MaX+w3Sx/xb!`lSF M;|H!09 oOh4D!`!PtZ *A+nzŏM~%jF$fRISU@$ëVx|ْ݇ȓG7_zW{]K1ע, cU *Y^to+~opte.<RR.R:d޾ M]:FY:ѕ\-qGNyG,(COF:+8]trfvqˋjo!`Eo@Uݿ4^kL!p#p ضG4!`!`Eg>x7y'lVv /;ϼ_sן k0H:o7V8gp-H88?t'qof҅cyM/-gl! J# C`t6o.C$17T㬲!`!`lyY8BCVo E%]js*a^v鮓Q۫CW']YѯE?}[?ؓչK?9ǣյ'3Э*^<[Vol4c^oC[f!`E vC |vmZfL,˂e{1_>0iccc_ h!`!` ׾{?Kǽ M!߃70;[ [͐u:a &~W!o*h-gv-i;ΰME8?zűCy<[r@Sơch^ۯ"C0 @ϽhKUiFW؍l!Аc馛YAٳg/c$C0 CpMMM}/;Ǹ|db*G{ӺoxfA߷JV )S(+WCˣ!zI8sz]]qIgMx4@|t_To6/,%k!`!lڍKkyTph8kF7 !/`M}P0;5ƟnۣW^q&Ə>y._Xt;.n{fOv(0 C0&:xhֹ~OZp׺W'e ,K]早|̷>gg_]qs8m!`(H{ßې,W }!s5QO'sw_)]֝(t[!S59pk!C?Wa#]=%1 C0zh {ʑa8B5Kp7o a64ZC^C ~x.Q_Oy 2&-,0C` vЦ0F.|PT-av]{XW1O| loV$6?5@Vw]y)r+.7$dmP>~vgvJuUS&h~מPZ}۩C0 C0 7\ͱ;M2 X u7+ oE!`!` 3\m+[qþ?FUV D|$nXCT8H;uDZ:CFOo!7 C0 C0 C0 C0 C!`[8$ s wA]?؈JoE1ޮ'kق.#|{}_q'2!`!`!`!`!Ї9 p ?߄$|߯KY/{^)mjܮZz-'.s얗|bi0 C0 C0 C0 C0A(J9~ ?x?Xl@"srϩO % .\k!`!`!`!`@. } 'Q|9:g*krIa}q9Cȸf#FtoTM!`!`!`!`!`\m/ԽǑ|YlPOquؑ0Fqi?kFG.ll|ݷ䯊l C0 C0 C0 C0 C"soR/8v =sJpG͍l3>W̹ ̥cf#慏qK>_0 C0 C0 C0 C0 C φȸsuOC-х[pg'|6?&RJOb??>1׿o C0 C0 C0 C0 C0˦P-;[Dxm 7羏+xB'E6m!`!`!`!`9EX6o3m`Mp0ɀ9Ut-(EG;znd0 C0 C0 C0 C0 ME x˟N+KRp_.8D>e|fi},=8Ln!`!`!`!`@#P&>?cj.L//B$˸KW~;&{N}}S/|] i" C0 C0 C0 C0 C))>/djJͨJ+JcNcnӌVJQseBse:y7Ɇ!`!`!`!`!`s׉z`ODT4u ^=QF0f@ }SoLE+7peTȡX C0 C0 C0 C0 CX?fۏw;ex2ht>M_:k@K _*\co+EԤ!`!`!`!`!s8o߱¹O]2uM3*]rMs#z~T*!|>0^Zx(&UW\<,g_|72M!`!`!`!`!`L%zg(=;/.}Ωhdz;4o .x {<655yIN;w>,1 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 C0 Ck{А.IENDB`././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/_static/logo_icon.ico0000644000503700020070000001047600000000000022636 0ustar00rosteenSTSCI\science00000000000000  (( @   ߿߾Oضn}ڷsٵpڹx׳iZ۶s*Uյo7Գiٺ|ʍtypɆ͏uڷsٶpݽŒ#̙fUͰg׷mݻm׹xؽұjѮ[ǚհbضrߺn̍ݼֱgֵcU yϴrϴoԺ~бiؾӰb׹{ضs‹ݿ}ڹzܽtUθ~ԢѺѻѺƘеp׾ؾؽǗœڿϒ‹Ȥ@ĬcԺjι~Ī^ī`ĩ\ʱoй5”Լ˫[ͮcԹ}ӷwϯcҳmѯcȇپϟ8 êd3Įcd̺bìeªaȲr͹OǭbϯƩS˯iҺѸ{̯fˬ]ίfջ‚бhع|!Sư][Ƕ{X[Vïkdzsìgϰ5=ū`ζy̳rȬaƝ˯gжwзwԴdήe㴇̿s²r²s̾±ròt±qɹʹ˜Ɲí_Ưgʵw͸z̩Էк}϶xăӼgHĵrmpȼnpmƷĴvҷƶ}cƳtŰoϿȴr͸zѾ˴tͶzĀθz}TtVOSvPTNiUDOбijyFƴx]ƱmDzrXë_¨ZϺҿ(U[UYyVYTl`1dŠ?Ĵy°pʹűq^d_ćưnULPJOrKOJ^nԽ dpֿoõ}bV[Uȶz¬c7.cmfhfhgm™ǤT{sN|Şϱбrorm̾ijthvnfifijcպa{obsѴźmprnʽǸ{m⟟@>NFBGmCGI*8F_U l?BDRSLt[U\:M!IKNqKNNCSaho¡)NWYSxa]ZnCǛACj>D4dĦHUkSHOHqWRObroliqU̱ekvKy5ӻmupüz¹yujKHnGA "YOat=$6YNu]WUTftXcF;QeBAF;AbZ{fbbƩPBE?Tg?DCͳ.Sƨd׻I'_*91J`.?.=K8>8L¤˯ŧЯɮqRHHWqy\c_m|^e_}v`g`{mEOGrYJ2UєSH[iDMHZlGPGicJRJhjSZTwd\UYf}0၎*HX{%5~,G])9*YQ.;.XV1=4eL)^@Iy+ՐN[y+9}1I^~.3ZX5A7kM@O^w(|6y-G\z+9|+YQ/;/XV2=8brUfl`c\ky[b[wq]d^vu`ifh1@v7v1s)H]r%z6u'VNx+~8y*UU099@f3fu#IUs'~6y%YPz)8}*QUsY@@u00x1bnUKr$v1w/g@,././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/_static/logo_icon.png0000644000503700020070000017126600000000000022655 0ustar00rosteenSTSCI\science00000000000000PNG  IHDR9JgAMA a cHRMz&u0`:pQ<eXIfMM*JR(iZ,,K pHYs.#.#x?vYiTXtXML:com.adobe.xmp 1 ^@IDATx ]E?~{/BJBXPpTEg>? ("8aOD23ttVe\q6q]pA@VYd [^u>]]]}-IN'VuշUݷwZmkgvhgvhgvhgvhg`Bg3d 3l֭:t3\u9Ӧufm*fGͽiud<4Ӟ{_<>.3 @aH8`;vg,m޼KSR.EYfYJx˚MlŽ?~ƢS<^' ;Nѻ}Ϳ׭{$m7#[ߐ303p2mc V :+{`EWa7zb(fݮ1WLdsf1$\IFQTMYPpžPm --Enw{0 ƶ8SVwVsbyp@"[ь܊`(v\74R{O喢ۻ6ouԋ"];Q;E-xusGf> ͹su6/ włmN'g z_;F򞛋^:ser]gsy-Wby30,jg`}_24\hw+7~MZᱝ"e30A3_V m2Boyh9(`9AiΆ!*d+ xt]^N_ݑonONB23?SĶv;wCMל_a6d C>5V3u~|< ߵ7o_mT ]`og`Kp;z9t2ϻ>1|̍mp/ĿK}޽*>ޓ NsX2Q(B&omCđw]S>5ONO hm71qsfXu{zךlm!6 [!M5 fybKg7 D@aLl>]LsG'ÁW0Vxtg O- ^Pghh8q \(~|~$Uy c' uۯy O=}Ʊsg} 68ņٰGxt'&Jd Q4|։'/7>0Ǥ!7]pykG{_kҎ\&[bA'ȉ \XN'B)NK|橪U?8cj7xNZ't;EooBl55)'j_bMEJQ9A]rnf1' urІ3 W~sod ,NnF௶X"뗭Pά']I٘'BQR&V<3͕ɽnfǕ+wʺ|@ahqU2\uhCx\\l55('6f L13E$ˣ"E@;u͛|駆ɺuqPm{ n9*iGuo' 2[slg~cH~=;YūjD'ґeS$< xWeުݻt_pwg `Nw-5X'pH:jpb@k%E$kͣ2{i({2XZ2i/3?;6>g=+׿we{kt&$P>֪ $[rJАʫ'D,wEј" 5G*f:lQ]g_ mn?v?-?6Ãa4|xtgrNVnL2 HM֚GUjIKA*EU](ןj6h_AwO~yrY|dnSd |C-irwWbp9+:9A@O7Z'qr@d+roeG3j:5Prn?_wtۘA w9>7RdO ApDP N @HL~9yZLrlAꞔLO&nnl|Ѻ#7w[por۠n;ݺCCo3/aͼ_dQNH?aS j9@bUe{k ͢^qz'[0x#o{vGu;2l(sC6wMC#.'NYH 'bI.%)̆J /3 sqS:lݖ5=wu7e>tw!^zCÇ6xnV9֜/"Q#u"$o ^i| ODr9™JEctxεm8p6 3zO9/5Q\9", 'i˻ړs}}8"*Qj$Pco'HchQVvRi\CqkZHqpq횋>:)mqn%5<cw?uOO`[l 8IӛL\kW@BA*i9q zPzM G ηӫ$Bnnt^rXTrcpH0eP|f =.ZO`;톱?M_}iWw_z5f}qQZPx<9Zx^m0Y>- EG%kp:MA JkJOLj1pcNTYtXQ0ZمCE祽Or9kkVa\mg_̛5#C#;HCM.,Eْ9D*=bYK6m-?VNUbS"oc( t?geWP VIctE/(V?' c} 38l4i/E~~gN=5o_׾1UqQ g9oY47(G͛99mPpQ'%$O/F͚] u|YHJ*QمQIxyG ǘ•܊YK W.G7=iC=gN2#?):娝/i(lώn*MZrP<&c*4>9gGan)|~Iu$(.ŌQ,&_bS!3sW'rr{z?ICÖ"JR*nfk^5ooo={F?a GRylT84 VQsPQ Å$Poe9MM/~=ڧ#VSv ?ߥS09@fgXw Ũ#5ƠShY6ߝ/^˜RG}1 C#ך<UDL*uay졅z QTEJ!ju%%-nñG;/f͉!H5衒5m3@TC^GŠҨߩcCG-ĔHsG }S盯8OP HGdtՅS%DG&hhuNO[AsھQN*Vd ђx.I(CUN. Y靡NY_oy c*< 55çxZ4nH?P76-S:"?~z/SpW2/1q< 7yR*fٓI4*!(U+(rs"e GƐkD;<͂@?d< =cdd1W^2N?TWk&.h NKOг3 +Jm(*> 'Tߔ&-Wlk0ʹe+puuŏsGͫ Xӕ*;d+qg>|׿&pWaL Oo]bz4 qsL&V jr{8#lobNz@Ϲ˯'XăRBL/3fR>O}hsW^ED0㡯Dp!BqtLG\~ƌ70{} (iots4D}S<JcDr9(_faG¼aւ*f•1p$!*`BPYD1L1b xP8S̃T/۠ڏ}l'<}}ټ=7ȥRkĦElid+螔KʯA6%w9=%Z[ 3EÅIJ Wa:דe:Tx?u%qA*dywRddzs6gMl|-]]p~ˏԮJ+R<|(P'nA Dq)f{S-9=sh }n{4VGC)IW iGM!Dǫeҟc)yL8GuрƁ0Ć2;.qe69ZaLμ?5CϙeKjکD()pkOt\Q0WE܂PR5bӓpGB ʃ]>@1h4"9JDzD;@(GGe p cg},3!%_xw )JShAYyK"4(9U% gyŠ:5l~n /7Y0<܏G5`t j\JJgܒGOh'aQ.5h ԣ B] nt3G|蒗i7ۀYpo眉u0BGpxEWqEE=e HLe&MʜֈҲE%jbp3@PjD;Ix"T0Ntx4ZԽ#jZAigTx4w|R5/ٰa&JaLL7b2UZˑd+D랔煃&)1y^W-T&9@"UEfyY4ud:Tx?R & wSwLUg0qvלwΣ#Ly(d+ND'*"SR=AK0U̺GKd|*CG*fIu *kfq Yp5 Ӹ1i=`.Hje \]E(%)fA)kDi/*!'5VR+ 5CcIJO֠@6kՅp7q𒐓,<7imrpq=V)Џ+bAp׊6+Δtrܟj77tq p3-\na$#sW,xuTE;\ŠJCp'$&KDю+c@K')4|y<z]h@Y@J  4p|'B<PB)+-ΐ /򲃸}mcן^8.FVXMLV=F%OӀW@DRÐP5)*\u(%#$Tcaȡ贗%Pʟo̥WdluARFLT7g]2K{1yŦmej0b}2^gT| 30uO ުjB c^bQaΏ(CSHe8*b+/7A!#h5&hOz+ý9h5&֩3Ǭ-8VUUZ5xy>!%IZU4Cz ƢK W>UxZ:=kA&DqBX2@DP R1M"RyQSզ6أY:;.ZD #cCoASJoU5ΐ` A(G,VJؐDHe8*bW1gB A`BJJ*KgdC1X_뽢0dթ2b̈ţndAq7"|8CԵq8*B@x_6tm0L歳5 D .Tͧ2)B+QFR3J1@:9 ɝ'5E->Po+db[^[+[kF%7O1С;x-2g |љ+;gPxxBQ f{$ cq4R GQ,*0hv È4y-TZSC1TW]xPnu,a-;d+q/V❙A_o0ќ{dMVEZp"PCä]=@}|JvQW|M;^a(S撳nKU&4lGJbxMx3J<0;I);d+T{xU $qIxlT[?! zUgEWxZF<'Ś vvY>j#"6ZPH)ĠJ%W!.Jax3Fq~$%7OޜV*^HaCe^(6x 7ۙYI\_arc*3c6WhXXjÕ']ycEAYg/{WWl^nPnl oJbx>?G1ky-<36fcbKY"(`kH,ü8q!kUM+?6x8+jhEf357TL> &ƋAQSǠ[)+<@?d<:3Us?۬bEd 2Hss 78<$8pk"us'%EF~PYA?xC gekx^-w8+Xfv+ 3a_t[ Ns:jtI!x1]b1Nh>H[1ItDLt\ܺSE[񁞇sC@Qvxl#[Β^ l jϔ[EgV!V^7gϏ7L,uXsYsÿo`iF)`-֊DHu ޚ')OZ 5PŬw 6OR"輱"cekZXEڋ(')6wpI%}qiM. Y?8-v7!F ^E);RI(rE/)1LaDm r'K+ LxE/N;;̞ki\?VrPg=qŲt)-V/C%/>!k^^nsvGu/\yF l7O"*Eޘ5 /9w2NkԘ!+JL=}xXXYӵ-{J,*Zu\ұ+< .|* WY]0c˂A}i)BЛ޸ioȴUnUOoz-7όn64m(*\u(ede^^<ϼ[9w~u፣b #5t]2!?l؞ٴ[~^u/[1",< 3F'9s?:5"sn8^0D?ފ]+3sLx d|f9%!Ie)y=_vzM}C׏SRV3d`JTxX&5/[ uu:#`=c%Éc#'Js]"9DBq(-{(ċ* !gy,6ƥPYFdf,~̲5UpίgU#3fZ#0"Õ3Wt,֭k4)5LI󑗯?o̙5;flEo'pzXp@ A ^#5$FI]<-[1FzQb@$PaWKL|>+L̺™lUg)=enap@mV @h$4e.i N9@M$"*vpt,=fekݪ:sLrBoI&e w9Ѥ! ~4Ap`ٟ+2tQQ:e#t0O/UfَBfqgNq2>$C + Q 3A@ѡ?bl/w{%v϶0sycbGB<\U 2ni^mZb^T-3%5" jc)T~ڨsd. =)6˒`Wby{')no6GAf& ћŢ25PY:7.DT7!QBXZNVԈ3Z &,I䀁`xY}J<>VyZ 6dJzsƓ1o/&e&q$Kġ.쐭X] fN}hs#<.J5Q*k 4wqAUg ~[= Hutn2ֲ.•0D*59~j5R[SR^zqf Ln2=\gt˘kY22s'7!Kk[ҋBD s u F3l˖d+(fuMrL` :a QG1<0 J &ff]&Z5d\?4-r8w2_5s"k0xR[hpK!H,]U!#/[ 0QߜU# :cN09|8貨F(u =%'p| P<_{ }?Orآ^>CtՅ`Ԉe4ނ`P>ER(Y,?zR"SBJ/  q4^|4&ko ~_Ngcke> )g0٪P937燾m11Ӳ8n<"{TVxj+A@>i*5n\Ri#b#ɄmüqGMc8n̜{9Bp8֥1sO/V*s7ixr/Zsl00FN.ԉ_,[̆7|V l:(nyl`6=[tyh6\ggnT1/N?JzM*`00 K{*d|t(i2^PՋȲ?~͋Qљc*8s13Cu|fI^播PQo@IDAT3uQݸ]8lws[{1wtci۠f` wҸ c%}ǝhJvP7qv~qkqmAȎ2"uG^Z*EU-v ͮh(䥹NqVOz39橬gvKYn$'=PzdY- QOs1j͛~%ў\jDZ Nm~˧W62-okɒWLXIΘQ7xf%,jz1*zPDT*V9nE+yN@ܿr % wݵ{gT:6sOO"W_ُYdVRG|dad֖v ISg1Mq̭ _Бc2K2 OvdBCi=h~b3693%噲~/_oޚlqkRxGETYKSr~kq,od Q3LH7e$Z B%dIF zqK- 7K=C5oL"O[(fGnfnԅF n (#@dy 'H'isH[wcNC™٬;$yln 9tO%|PsWb~K5 ?[ef`Hq 7N 7^d!/t[N? P %n T#G;pLΛ}FLO\H3{>1Tj FH%d¼őq.뒭S< DkD|yKRZ̡A6dt>oߚS1'<-uq6_C`%Ɛ*:;/T)Q+qe9u[B3|r%n>H կ]{ U!(%ACO>eO ٛREOMtN&<oH9̓WW)͙GN/㛖Z>n*y*K_G[\e6'$k [1V4/v_S8Uڌi#+x-S){ lEyM>='&G+K .Ñ/&}lԣ˂;FC4F 4^S?yś:v\:☽%0O/9( b6,fo3F-㙧̆ǎ>k~JkR)i*:k*!H&r&_mfIsE1} p#'vjj8&H K,޽v ̛1}G^ǔ0>w.nƋiG'"(Dg9+[͟A[V4Z 궚S<۠1{Un7Ҡi({TY1.&VsgΜ>\s5_'G}?%m6f:^`/Ց7شys#WSy x Flq=/??WHEY0xݣ$̭9,\z(HrQ}YaӸG*$l⁃*jsj`n47y ۏ⦏ GXjـoݜ = E$sz( vS ya>YL/^ɍxf4yc,_p ^[XlM- 쇯أ}+)B Nhʯe2C2>$GtЁYT ^,I#}YM^tw7x Cƨ&wA;dlid+Ҥ saމT(|'?P<;\'畭Gƚs͏~tg&uXs;-n[KWjQΓzXn.\H'숗ϑ$1 n(Jfn{x_/c2K2 x8ԈA85w][?kLTJ#S=\|[Kk2$ 4=O@]uRs3=1D;T N)Ϥ1n콾02~,x#~3Cuկˏ (Ʊ5ɆL9[b]LJJ"*=.> `sV{.K:pU} T-~+C?O4ſV<"<$8"kossUH:G[D:70zf [8 0594zyРO ^d c _Bf#{<~,<jy9*3Q >ZpDkNEWҗɃ˟oLKy/Dm*qo:;<Яk<&^ qԈsF o&,a RRiCɽX WĿ3?:O4;p:oIp餳_&i\[.yjs}w8[6TF/6ccph@tD% a^&鹓a2_82[HB͖NGh F̈́|1*:LlϐJ_6ǠڑAa>osb ru e &x,e [nt5W1k5+հ'8|&b*c cH֐v澦Vu{qͭ.Vu"tz:*=w@?]Mo2`UVY'Me|$O^:FEJD1ސ3}6 `:H){j:e8W >[G3+ʳ-U+k! >M6e !<(T8ؼ+oPM `M/9_̛"V%ɦP;l/\$EWoibMl疀;]v n@ ie+pCn` zZQt  cpdT(tL94Ef De};SSkdېZ3NJ<Ǐ{ BDTY>s:"`y< *yǝy[|>5{sPd0b0OGm4Dh'Wŭ.;Apx8&I;%d8]`i cO苏0W0)\Y`y@6A*iNЫvׯCuL1rUJ)Jc|<5C?aX`x*ACޑ4Eݱo0R܃ !T" ={|4@0.|^xn땭*\p1Xk3F /(ٸ' 05cp릊 S ֯Kedz@\Dw綬 dy~t=7nm~->xl%UQs/V(fjbm'iYryW[ s8Z2أSQ+r}rDl<-"y3M-|]x`z>vr }^5=C>?fxx\=y'L]$Qzd4~6:(̒ ݀%6<$/b m4Q壦pw-ލ4W_n_%q|*[==v=5=7^P %3*҄+3QNntyj ܚ3PŹ}tʫ < xD™V1 8x_?7A{1Vd+)ŬiXk QEЂ{d q(-꘽_~; Bؿ^Xa^p7f-]]{MK[x>(Kvtzuig("WD^p! eCDUauX G"Ee +JKU zmk8ϡ{v)֚5=v|[jk^\تtOjqMꞈxu%}lr$x6 >G<@|<2r"<CAl7GBzVd`-2 uTz~<^Жo?/n<])=tHF0o;/P7jE{# !(+@8$AYWkTMi UWW #nX2%H~TBY@MW9{Uwtx>5|UEHU.+2^ Й\͎({eb`TzcuP*uu^$ZOc^נM>4DCuk1i'=[S^a4mʬͥsÇZhC} PCߥq0Ep%:AomÜ(khƐ_Ȕoٰ <+x-b{W܊ٖ$o=\:x^*"\ftjT~c\b 6 j}͛5wc{i\༡K8+W.ٹ.Ǟ0_uxZ%Fn I{)98jPR2_lehWZN6q0׀1T-8ґ`e -g+3Ue%wX':xC-}Œp$ؑcY;$X7+?t)b!02t8@?t<}\T?{3xODdx^^2 8_ϋ) (itDZcm@4)p -A + 6 塩` 1 .Xx졬c "U c\]襠ڱѪ3*.fEYC-߄r'IxOKAw}10PzQ7XT( ttP V6.+J gm87(lѷ[w6Z 7eY8Vƭ.;,zKC;+ U͉#C?tT :aD,z>͏H Qg"Aq|$5;.xG(]Z^_6y,)U5'p>{rQŇxdU0MC#7GDe:sG4@C0aSA{'/)?B&Wuco{%3vFd ź? З'ql@~#Mw'A b0$Ҟ޴SΚ⬡ bTҸpHyztJ@syF/7|(Є Q&9QS5Xv5 QӟϸR|̒L[Bţ}e3za#OKo6}w{"ҠṳNsI=kgf YjrWFҟ߽S?Q ׯ2wB!._.2/g7+>Mп?{l >Г~'!jnY2uO+6QO~^-T}Sm#O?mR ~1^{vhsxc59l6ţ҉7< li; zIGW/G /p& }TLj0w?(dn䅧=l=c _2Pp;L|eNZ*8u>LX?]>rǠ͜) f?pZAZ #Ĕd\ ^l{ 㑵Ggv/мq-Z &#05*AS鞈T+0? yw!lU!Ơq[ GzSÝCqn=hyT&Tz_?; η ?=͵ݷocluʃCWX^\}=.Hjce}M٬7C (uQHPi_z:Soc Ie+7C?p1PO<y皷VZPh}F3f~Y<2ZR9CTфVVmgX-y[kZ~1,o)=ϷZݘ/%jeC$(FT5fE csG=6+rSsa` TڄԂ:-Eb2]:GRdhx*S P2LZ HWRx~us^:נ7Gsa8i*G"^pȇ^n%|v|kԢÏaW4)vQԎ!ZVFD"M'QJnZ?{T*܂yك>r)g-0x 8(CsYlf'_OD<Z9<9/94Tc0p cc?p>{9o 5jS5Y\eJW5acWnltجd\+ e&Pp:Q 4/UǠ{0H7( 5_L:|Z(+iOmӸqi% Nmcc0[@kVBŐk3$QJV^kOh[,1o/ʃ o}\Xrc*/%\眪BAZoq@:lcKҥmБМ&V$J{2<?4ח/vWhOϦUu4=9Jrh@IWK}_]<|kDiJ_E"7nͣeQz~PMr &wAa2Em8?TOrV`@. >YGb* vQҚGe66xThNcZA5 G@=ZuoN: `]Qѣs F50^DJVLj܋ k}nBHmpnZeI&JOB:=LeF[ 00J[}\sVGYQ+p3qX"8QX$ٗhc>jKӁ H,9 =>Nxk/+C>cZNVZ_ttد0N+ rڧ\PS A:f__Uj6MA [jeM7ẊFt:C0^kh~ waqTj4=JJD~4ꂔ܊ظg=C?1 aӟ+Pg).,rB7Ic8OP ÄN-z+=+JS8a~{cʘJ+ sGB2_ m ee=ȚִfyeI&ŽޓэaWPqv6+|8ԲG1W"^"y\UjxdD8hp,|Pm+v4_AK`?f dcT|.\qͭ.Bp ystܹ2 wAHjy])l >|CժaN'-GslUN(Yxt67J/|B=RamɂG50="\"Md0z]-(GҢtx*o<_&Q+NiNԓg2d=>ޏTzQ,q5֚rEs7㎵?7IRNn 0oHΏ-;sHQu?Q {hWX0{ў'qSpW'#Y{0oq+d%^2[1QVnRi]Ԃ:㌺CY1.}|Ȁ(#3 O0cN6PzG#;JiŢsFֹr'vxJ*Hrc>*C(xf?<*_GYC}UuC*0@ &P?Fn&A|s(rht״j/ W]¤^譪P\kېJ471TxF桙gSG;o2u"-ZP]|>< ^YXrWIgv ti1N A<{YnBۗ>cnuY6!cҗF1qD(}ZPzO7f +D|5T|:ppr|RrnšQԊ(+vaFG5cJu)tcv]<[ؿVd`ea-hEzi}x#k?huh@e :g{_~y]5A'{?ꖣ8E# Ebil6 `:v{鶻Y9=^ac nc: mCX ==ғNFf}==7"#"2+[ֽy2w%T5Y~3.sOVl_$ szN9'O8#n =FqG45ו"F0V_߷e´p9 7=, ć]n!g)+4ԙke|xB)VI|S Ȏ'*f\ ۇnݯ0W:A&V(&rdCnv^!Q%2_FCsMSDK&XY&X?@Z7u%^Z2ɥ#((f="3߸B!My3ݻ @.G}OJ]j*6>X"0s_]MwP1=T,\&T͏$ڱ<'f'ιͅF<,7Mjx7 7jQ;5cͶZ/}T 3!mQ20ťX}/6*.=F +߳~[e[ǹjEĺ:^0AU'9cǤxo6f^Gũajw1:s[.q6C3g빧1ۿbKW^E-U4B^VԚ<㔞[Gj }E씕hn #4'ɚ4Ε*Ѵ9_I& =1S.Ti)dY48tAFJB!TW# ._XcҚ!mZC!\Pe;2l*[wk[|;<*zЌ.>oi; ȴ֫eNx;tU\Ōf.+ytMg@*y_orFK5Hz \n$ePƑJGnQbηv*}锖g.ЏӧxlL򪒃[I Ү|ܹGuͰ/ж' ):_IU4>ռK:=|*d ͼ}zKjuh5˦0W=:+LKTΣ6ìb]2_Yn(ɝ%Иde A_xuJ/86;SQ% }K~rEL6#ܛnr6 DwkBoԫK2;z~_lܔf{PH.u'sAVgּύ"o9U(& 22$IOFa\#г2wЭ~Zqٔ=N$ȥqIAܼJDqWy'% ȢXGB_~Hqy䷤j{Phi"Zw[FFat+X^ <=iՒyYufH] GC;^Z81rp~C>4ow'hg5ᘇj/_#%PJVG#Or]Ɠ?X7Uif2f-BʊR.)a C1h'0U|uQ'by,4ӤĉوFҭb=ey{ZnJH;`% Z0EPjEo"weťe螹RrBF;4!'K$0=Y49Xmk8AY<&Rr`4: +Q)9>2ye4qN&cX||"+ z9ecp[-a㴭~fhpo}ͻў+\w@`L;}*hsѣ*g.U`ӈJ A"=5 2ACaj gQ' wDW(E(1x.4N AATB^=<;߽|~>۔x\:SZs7Jh@IPߞ;8csYOpb"=X+`=qL@!jc2F&t81;.=%7: @ֹ_:#F%̛<ڱθɗdڵ_Whk G>#:d-ʸ+KG5!7}>k@ vDW} L:ar‘K 2"9ͦVս@/<ԯ5/A:2zQ^m_ 7&e"+KzI(BzZ`A 0xB,8riK\%AIm5DO\K:Êrog¦H^9bһ C20V-wZ"zZƔ4ԯ6;@ztt/YHxCnI ˙#OlA,* c 2b C z .8sr೥N[u%ގuY.qN1$?Úl}NǁsOQ&n: 1 u|rRE ѡ*@A&,\p}>ּwaa4JS6ìbQЭ`R'is5/& ֪3OWgݏmQ-j0)` oH\(#s?0}# Ihmzy,KziUHkd<1N(&_~weތ2TcĒ,dӭE%C\d[O ^:Hӕ1 &k@2P^e]`Gyə>Rq",k%a;-[VLb,@U 2`rr鱕i}ʿy0B\rdKzuUVVZ"K^:YYZ.EA;St. j)齽{ ˜%}0&vP'W%N]>T6G¤Ƌ.\j#=z2c{^ R F|5CP we(@@t.n8ld/MzuA!k#WQ z=lOnUu 7D]ufr:p٩Gsoc:X VI oE`PgVL?V~͔,I):-k?DI Q.FqEACD)Tc ;p3,n J"Rs D {y/+#@IDAT ɏx5  = dt#d:VxZ=D";!EﲳgFԊ eQEn".\&h;[c$gcY ӡFiop$~I 4Zmc\JEPϝWzZM=mIӧ҂g(H7}:@dH QfI^yK<}q:хM6僤1ukʢk2*%[gmYkTs;m m2' qtb|Y9B蠅0BV〃`#{ a1W/q{u,//IHқ<]?1аcM޻{@ON~a]rm9w)q;%Am&9sHFFvS?Y].+x#dDV70ttv !;À;2rW|NZͷ^pE\)cYٽckhO/awUf"̆S`S"3涳\VV$3ʘIN50vH'Z|V8ā~קy3G:rDꝰ3_o3MÅ;OIv'I'XDseiY$ Y ;1?:bCu8o]ǒ #R;ǩQJGpJkͱ2<,5.ZGY/X^u,nX!h5z/([E;cH[S%jzYjV2>)tCp$V͆90Ⲙ.Mk<5 MKєư\:K&I,eéyyȔ=nt 䂱pCWU_zVVj^59<ёWЭMh%wE=*+ c{SO~wz3 .HMounU n->>xs,:^BQ$tit G =F|w}SuL?ҳi7 q{N<1;Ajm|?ν?>|{4y^{n3 @}S.>v^h KJ;Qs]+wݦUlYe{$Gߺcr,{mVJm'}{X=t}z¸{Oʙ^T}<_rDw)瞝n}`o vܺU%Fk OH`'Ϻ3؎Nɗ* Mԏ1ObPH0 .>MA%x^Ee~.^5}rk q+Hyμӡm\ ~_oೣUj(84]wV>ί~{'1SH^{+8ɢ0s1K0ݡ[\e*1׭2}@_Tu1Rji D&}z(/.{byD|_ /Po)Bfh7~^'=9PI璳]O`oJ.HRޔo)*15%ZP -I3iw%^\M*eѫ;t+I+MGh0&M,*F 6cYF71rV=q* $71<˝<ŽMOY]'-(Y3La:lQ( F~uA gdO dIRiM5JO' x0TD*]=L&q ɱYM0c*\osV(Mt16`_t^)iH&Sn.c0F(UgqQ=矅E=t2{ }xC˖D qr3 jȶ#"p2yt I:C#;*qM<}K{hF2mľUDeaf*SzS%~L)S^D|sC^K~CjtF316SNN`LX=5$bBLSO2@|y[ Ŧ,j?쳪'ztwN2Ԃ 3`6&׭&/"bFn9"'ƍJvVw2:ezG^`Z[= ۫M?q-;ZUsMf ӡ&ѶG%ϭn4ȳ@WOTFҀW7(N[cPP?cO쯵L-ƂB^g KK-fʘYi|K~:,X @SDKoIpݵUњor j1uuTn޹ZSaW+1 K_/8 VxM?I 9tdg^']XsxBFg(e90B-B01$.!]p90B4 R%q,lScK%.@.m:m-cL3E#{׾3t0\K\$7qj5،r)$L[Kfj.~f 1KrYsx4PI1/w?+hNdD#AT7W<ɘ֙-@L$CrHMBʈ= ôl_pcj2YT7pq, j{Wį%0\@(K/N+8tnY\iZ vcc=ek1d^ZoEl~B:Ȋ}4ijb vn^=1'A2X։i[O?}iF;]ЕtXOhyIHZomJDń$[XB8L8۾?gƙ;}=^5vVj8֘iRZ{Zc* 7t/_Xl}nc3ce_99r##O z!kj7}_ӻ1S*9}X?<Pա(ۣxݪ2 "-ZR7][d ݧR|@_+yelz5uo#ct/! WI?':]q>]ଛ0ުQp6FAveǞN=F ^ p2W.?QR;jhۣqۣ֗YH1!Ր'?yk:8B=8G8Ph"F;EbAz&(т* 2w &zU%e:#Keyҵ+=~p*n+w%ݝ,r78k#=ͥ 0?1z@Bjdþ}L>kk!nW>/J ^!jry0XHTUZ}Oh3bMFm)-"oU]oFfK\à^n53X*G>5Yo;o$-ډ^,6 cgk/};v^(Jc0潢`l,'b`v 1EzqD=])V[s(2E?I,z_APÕ _WcK}qL O3oJjĸ[G0/fԅTor ,N4{*5T^qx'%~#]se]\PS!L[v0:Fo+[XͶj`F^<}~MTBo]6h(ֻ*.SRT/8&"/MSSe 9.W#=wJJ`am##ѩRz3 t-T |fr@kNtUshFDI~귛 dfK|VܷYn|BF"/վ'Ӓi,@-C#N}j_af- V4`z-G_nmj2Y!=V6S} ZlueQ6!s`}w roO~]cT6>-sN91ugΉ74 =q;[ u#]yAmGzm/?BzXWO81If==5 /'{zMf٪{YDLcIJD3TC*ũF/U;{T-q;P&<`:PDI<~x%󿷕^a'/[yr( c}zoDu}A ۣv8.Pzp[!q@UiL=\Sͣ8F_*R7mT2*RIeY%?jbttIƚ0֫ |S)qz-uYjdj:5NMFsža ,gp!$tDWmXXrgSX^&nȗտ29Mײ8*A~'iO%<=y\N:Iz@AF~,_ WZP dRhQxRϳą+wv1rBF@@tAHzZo -5>y'VGBØc̨o].tp zƓ̂#9@X{[Ip}c8ܛRpunY"xT#7;ϊwD2 4 I,PSP{ H m!syDc` 8Ѷ'81u|( _+)ԋ[ݨD'_089pY -.*^ i%e#Ϋ s#՞9'T];ԓM*ܺH${*tV-[Y,-w|l;Pm4R9N3MshŘ1U.Sn"w`dqA2ۣWpoI6?6Z7=[uKe:ʨ֭ P_FS!M#w"FGxiAQ-q.V;j,*{%M4tyOvw~PI)4їЪ )qK|Al&,#%z1vyw%GQQ_al[iô> YQ,4PW 27คO1Bp;~h:D/cV!"-yʾq^h[n;ƲZ--=1E>4৲+풳NJcS;'a!h/`w A}4%[G*_al;X@r &h8-;vĕP;]SۖLPGM |F׮%Ho].tpbUۗm[szu/:_|\wNb|v&5cz1A`σQ\,#u_T85(UY4qTL+ϗiD"iN d| xL?@y)Go/Odz$ K{0_49I s b;Eb8)*f*+M$c" G20%Ȁa=9O8iQŘi앆qZ1gȑEɈz 2iu yUn2'^F2zT%ʤ(+J[!L&cGAM!YQjj @@tt%.|u89 r*^[gK\w0ʑ!"|"Q{{s" Kpi̔%7 U_*rD޻R_7&D=aP6WܭVV9m٘7C|J3ZU^8)L85tχd= P\n!5ӕ;@,[Mgӥ|yހN5HLh^P)d@YF+A*ءݟIc+ =hLFKa:"lf;u߃X҅o?atʦ 5(fI2eI"_o89@Z& {IO-2x{\hǁxA(1R8v {',e6 hRi:.vHĄ9t+ZoAyi:I&=K%NnrfB &Qq6c~~^sޛ/ʜ>W[ctpzhHOI+_PcMse=Es#fqIO![5qȀscH JǷfnؤ1yb-ةӰ%H_lR^va twoQ{Gv hPi4@`FMl;उx.W_Xvlh+9dTjټUw9(*Z fS]dT*Mix ׄQV sp7&𷾎)҈ :- X ~MNes_ߓ)ӆTNRzxq:o]5L6>2BQzn^gw|sC+P9"\@؁2u)BzztNW "arrIcpVXRF9wc ikoB L@A&4,\&y{OOp ^5@u_ R\iU'M妸1A ip}B\_KJsDZlPd@"L y;v&Wx^ܾUzk˝00/(<=Z$B/ƃ;2F GAO X G\z7nW2"py{s,hot&yygBUrqm<'oI181@L`!E1Ae~L\qOHYU)D3ď>HEB&^XH6[\Ͷy'j'5߱`0~\2#TFK&]gK8MxQuL!!}1FgC;jBORsbBam,m]nHn\lr̍(Sɽ&QZoG**P 4emdo,3UC*dɕd~]nS2ݼG~q7|$W27!U2HZʙP$WIYR{*@ǐuە՛l0|2bӑбLOCyOVեsʉ=L*(HK%'<ga aF")Is piGPmeRG2խ9\nVf:Z﶐Rbפ (J {9Vפ{֜}:xxЄkTb>ʹ|CV+.~b$[ԐNJXgL$F۞qH$^U]=^:m e6z P ECRs纟E5~e_?%~)?Er{0xG={tyalfPԊ?u_+X7_awݳp2F}b0:Zz(i)Yz =к`rٛLxddнCzA.qsh-[;̣BzWb8Y?6KuXA G&2|1uol>SNMOnY| 3$ ӴJty,d2cOsslZ0|߸m4IѶG?xnzWt$["H PK>.ӡRhCD*yh%2Z{^y±WY[6r4Fua3 )ECĿ= Lhn%}l]d!Z3ܲgn>q=F \@/3-Kύxֿo˔g|T&=7F K,Ej*ՂOq_Oc^ 1Ez;@#tc;}RJu  [`aУf|f3]Ch;*.:'pˇ-~'1M?M<-l(X/"Su/jݡ%Ș1Tmexzd[Evhc'0OŎd@l&/省FO8wԾ{|@&|{7g?mc=8z)[ &w'Z޵#pʮ]#'l < sܹ>0dno[\v=iĝ$f +z{?u"1KGJY׿U7oo[ 0s⢬taݱ$x|pxBYq)hdz6O' S~9ov{ed:u&]LCnAO ?r$ps'}m[[ٳmr'O*8h};ٽI!kͽ{\zbS Y[ <75Ca#ÿ8I/dbj؞&l8:s*e{vWTƍM4 ݦ}6Ք@zu&sJDKJ x.&wcO?ph^/ިxne4᭪K5fZwqUO4s 7lc0%hۣ]+8>Gos]E)jZDhs/<7Fk67?uUEqq0r)'/h}Ľ ΊMMCQiw7={n ?XLw {í֑/=qt-еѕgͬcju_Vز8ziR$@B1@&2 9t=|Bc<ތ|Izj'aI2$KGuvR@E[ 1AR]FKpxE~zN7Ę\HҺq/m d`CFC*/mړ' =Q _o믜nȘC(d>JL4;>!-+ I \z'70| N'g`=>M2n˾?G.NԾ]< Ku|rdOMzgω>E,o@SmI |eFt1DP:!4mO/F'"_w~L.1瘼'#׏=:,E2Q`8֫=[Qv0E' PNj,MU `Ÿj ‘f{k_LH9syʹw?V #>N^י,΍|\QBӳz{|D,ɣj9+11Ȏo㲓rvLzj6:oK}]7*-Dc8M2ѭ6K[Q1:acm'?iހf3jȶ#"p2ytŊh.xnh HQ Kխyd֓/jx/}xw}Gl*HzlrkF鈡\Ѷg\0cM)Q;2b$R4i\?7dLgϹxp Ü'b`KBz"t3p2yTX0hH563e:~\n~]3=N!˾`2/]E2QsȘ̨r1Kx{ RR'[rB[E<[[[? }D× W>&h.R?6lE.>抍+ %T'eKz)M;(0[h#iQzni"b' $ {, NNsڧ?|>H丣RpAZ.B M@հkUcܹ Ƽ^;;9[lvC( ͭu㴸pEFsvЬF'_.; ȶ+ ?m2*w^?RK92i v\iT3)K 53oRZ{? 9[f>]+}ky~mmy' p}پC.himLǂ)%p+ݾMvP%7B*vc)GFr/.'vɤ87$OpּGfۀk,wMMvjOz,7ߎc痹ZVٛX\/ʔY7sx.oNFfp#T.>/*UPQ0e``0IDAT1M}kW$wK .&jh6?Ӈ^|e \Uu$$g坉5{F*331PGj//!|L^Z8Gˑ5 9J5"3V>`zT@Sjf9cXo ?kgE<6qo;4ǒGhl7nG}QKRr#Yq%ikI6^2޲AO_vֱV)B"roP+dV;Wf AG_˚Mz܍)ٚY @Llk2Fۯ+3Ap'C3`h[MG Bz\&$:_숹䡈J;? έ7Z1!eLnSl1EH8 _-[m)Kj<'䝪ɓô|l@AF\a)`2wn8_{2J*5^<ނ@ ]\ RsNgv7֭,L!'t#Ghp%]փpKѺ|zB:6zWo|{9adlǤ HzvJHQW.`7ANRL9c܁$#uUߒ{w HWu1K Xw5laT\Q=aK2Ѓ]wsjŎ #=qȼeai(0?B:4GvP!c`dsGt.1eUAn 25.қMn`@6\z@64U6x2WlmW+:idݜ]씐>UJ\f;Ic+[Io4{w(A$g93/I*cr. a%e_&tSXwZUet-] d n*ByWC5ÁfB:X bѶ'ۅ2z3O{˯|V;a,׻iƎ F(wKpVYAyedQHrZ%hX/i[ñ[񧟜\jmeZfWŚh9fnjխZ%&s3kFWl֜ëJцG[Xv~^{W0\r{!5N \+e41r}n젝,XFPyTuML|nnuL)GCIϏ߿șe^ۭ6t6Yȼԯ0|TUW΂q!P!# \Fgpr ` Vo?㊟󸵵}z;Ʒݰc@6 G2E3iDN"+!0۞aZk,P|L! 2o} tqx/ ooܲvK¸~8Yeb j٤cLJJ; +AP7@&Cх0@zΕ]_zOD&i 5 +,\&oy{~ ݎv d9G䩵%.|?WK|n NT^ʄG2Ѓu4stp4ԗE vDW|d{7:\b dlo֝ǭ4Gou- _vQowe=e2yTy)._tF #rB 7D>Sэp` >=sIh+4EgLXaLA'1-/\$9 ;YcPx{KTNcD4%gaBSrt;\&4>6׭yd7ſ1{r <}[;~7e>V`G)P v*+X^ɄTl;gjfb@ P/rBx[n?;f2уld'6Vf"CC&,\:/BA0@61N*^̯[~ٲGqwe2brqQTiL(n%xN.eL>Z%c@H36υN \cSU>skI򦕒4Ry{\ YedF&9ZzG\YbWGC7s[y?FqY#v <w`XQ6~@ld423U)#&wPG3Rˠj?PAwQ}t˽t[ft;yBEomõ6ߖҎ (ep62уqQ| ŭOkK|R= ssƎ<(? d+.ڋG vWK趇E9h՚oHW(,G: Bf>C|MxuyL ,=-z5}sG[dCh0h,JK F@*`]=P}itf㘗~Ac0hC\3Vy56<92 /RR\ybL3p_aP-Ɨ;ZXYIkg1j@o Z~/ yi6>]g`L_&BO94:.ɶ|Ku뽕/aEJ=6m9 ̭U 4 -ʘЭ1HQd]3rF|j>ɢ9QQ9aP6Y-|DŽNX?S;2JE\z!sK7 ڔ`Hd<))Rpb[:gb~ᚏAu󿰗]A>H#/` 3grCgS:lf;t+b,)FiNsd1N{q,~r8ʚv¸_1|?w@ʖ+v HMREȀ%VtI$O"JzaюNyn;PE0n!'8V<&^aLkZr̥ Y`2: ',]dp$̻н94\kwOv ZO`9'TYzLOפc4(6q<8PCH^ xxJD;VNK0-s^]^+Ԑ=U 9[-d\A8d¥.txJhfc7Y!հ7O;Q+X ;~zJ=l^Z>ZE#b.phR('eTi)a#vNXJ|z[:Q~;~-m7 3M<빇砦Ä\5"fÙO<9Zw#|;c+ ]o|W+v/ZQqC+צ4tDp PK /[Lht>p+"#@ vDW}Ak K>p4c3rX b%Cv]%:{usݫ<~HvOT]ܫէæ\|#Ҧ1(p\|Lzcsb . Yo+W-.2=)몳ᇰ.?]U6'|a.]\. .ZcǓnW2"qi:)uMNQl.u|̓o?gy}\[j/D *2'0&Yٺ+bT}1eI).G+Ohz!ǝjdn7&"޵@7=\+0ȉj;"+&,K6i=: |d !C8@Idxכ_}ŋ_~7,2 *9㈰ R$I 2#V+E7:\V$#S/e[_[$h~bCt#\}OU s' /V@c$2o}WYKNMZ4.9!+Ako|}nw_ꘟ};l`b0'PwDWj2U;Y2IaCP 8$9ndC黟w6Q' w~ _[%M.zNeyu6Z#syCs}v+ z+]Ȇ [=&}l9AL-A_%FK+[,w~_Ϲ㮣<̧q?q^}[tߩjFĎp%7v="9un-\v))&ܽcˍQ,)8˽S';P]vVmiou(>ťtچ2=iM2,ZtRa?㿎7cTh: 3xً=?8vvSw:aKtԪ(F}'ܑhOv' ImګHv#vީ' /ǟ6Xzp;n6Hel#5t" R{JQ.mvmOȡxG~@~d=vꊕ_g{|{('e2`RPL7W sxchp5*^Z^>飿Ht䶳LdAɣ10/1rslw??Y̙#{̟0h?xݷ?o0ѦC2]·r 9qнGr{[jn;t8]îitwx/&Zo.8N ,H4?Iov^Ÿ,><[p;}]}[H:0l!v\zJ=xI^'75÷51\cy PhUZ]A@?w?̙ϝٝOr<͝{?<;ѕx;ɻCEk..Q: G *c<xkj2z q sRD_Jc:]"Mω|SukHor#LEvMt9,c+N1Զk(6~\>;}QnGnI\zk;A`Uf̓`yj nAbA[a `h~2;տBJ37yfhrʣQXX3掸FMh Opp3kG=3j<=4썍+/I* Ri*%3o;tut[t,j rv?q*$zFzߙN#N1fQp[ H!@2V+# -k4t):i}Fp/͍R]jhSũnB RrAVN#F[Tw\t<Oc`H2w|:?b#Dʏ)B:-4MBZYZ #U:zvtk}uR4ǡ&CW8)èH Jt14ҽh4f!#4uwuƁm/N&  ' .1'T(ڈi).@9N.|4 Fhc):,}x口Dhp#-mbIv:ULNB^٠X9 RQ!i|+!IN ;)j$(LYH I]5-tԏeɇT:C[rBn@I(U Vm~IUm# eqK 'cL/mV/ \=XaUqZWQOۍX4M;.?wOuf!FLYm4 %E|&(FIu+(t&&0 M OQȻ~) nwttdkSK)i( -g^'7@$[Z{)pAG`ǺGa]8^<}yf#'hoҾ4TaA0)-RD4M)CɌiA y-cffhx}ҚPJ,쑲yUTUDtk},\Ѩ{͎@S0v :_L~W2.&B|HREvAWyz3h0Hl(iuyK5ySx|U\Uq {ᬩt|+@(l;mMkFL%S0DnR("IDWJZ&_.%WesFZ! iyjcGOw@7TA!-suWjx4_]Xuq&48LaWإqD%WYKUa1L |C7 Q)(l(9"ڕO[0?ٶ#5G` tw:q:kvښf0(Щ`NKhqȣ@d+ʐ`ȍ rQRh60 oZgW_+m 7Fi ̚bzI.zj 4XKͮKчf%_nfy@yvj&GeU5`pUI~z}0C $e:+dx{NvGݍTS.$To&NwzWu^N t#F )ƏX>]:Gp׀x.u!]>~q!ywT(MެRo[r,ll6?AGNsbA{-yTo휼 ~rCkW9k+ɆPO`vA/:YgSg7ÝS_Ə%}[]Q=9YZI ~.80i F \0*3gʲy+7~w܂{pB[ɞT^yښR ތ$f+W n|, Y Pred|.!B|ã)wuwfrApF` cy&Yyx>InE9FjFd=6<8$a}[|[9THXKo]2 X!\ @Rvw->{ڝ҅aA F.t' b8Nljwz n8TbGew}SS#cvAw1@{XT./rϗ>ui yipK: k|+kzui?n#` X8ߑÛ wa;/6F`SxP}`Z{Gq|.#P3` &W;P8:&B =8 n&)M]Zq4>\0 ! (w/{b~}C1&M |E}(dp>~rmSn[|濱fLJ/2cҎPd!!cHLV܀?nl3n```(1zlƹ0 ?3. 2 ~8'eqcvƌ4؆`F`F`F`F`F`F`F&j'‘U]IENDB`././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/_static/specutils.css0000644000503700020070000000042200000000000022705 0ustar00rosteenSTSCI\science00000000000000@import url("bootstrap-astropy.css"); div.topbar a.brand { background: transparent url("logo_icon.png") no-repeat 8px 3px; background-image: url("logo_icon.png"), none; background-size: 32px 32px; } #logotext1 { color: #519EA8; } #logotext2 { color: #FF5000; } ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7007143 specutils-1.6.0/docs/_templates/0000755000503700020070000000000000000000000020671 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7158825 specutils-1.6.0/docs/_templates/autosummary/0000755000503700020070000000000000000000000023257 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/docs/_templates/autosummary/base.rst0000644000503700020070000000037200000000000024725 0ustar00rosteenSTSCI\science00000000000000{% extends "autosummary_core/base.rst" %} {# The template this is inherited from is in astropy/sphinx/ext/templates/autosummary_core. If you want to modify this template, it is strongly recommended that you still inherit from the astropy template. #}././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/docs/_templates/autosummary/class.rst0000644000503700020070000000037300000000000025121 0ustar00rosteenSTSCI\science00000000000000{% extends "autosummary_core/class.rst" %} {# The template this is inherited from is in astropy/sphinx/ext/templates/autosummary_core. If you want to modify this template, it is strongly recommended that you still inherit from the astropy template. #}././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/docs/_templates/autosummary/module.rst0000644000503700020070000000037400000000000025302 0ustar00rosteenSTSCI\science00000000000000{% extends "autosummary_core/module.rst" %} {# The template this is inherited from is in astropy/sphinx/ext/templates/autosummary_core. If you want to modify this template, it is strongly recommended that you still inherit from the astropy template. #}././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/analysis.rst0000644000503700020070000003170300000000000021115 0ustar00rosteenSTSCI\science00000000000000.. currentmodule:: specutils.analysis ======== Analysis ======== The specutils package comes with a set of tools for doing common analysis tasks on astronomical spectra. Some examples of applying these tools are described below. The basic spectrum shown here is used in the examples in the sub-sections below - a gaussian-profile line with flux of 5 GHz Jy. See :doc:`spectrum1d` for more on creating spectra: .. plot:: :include-source: true :context: >>> import numpy as np >>> from astropy import units as u >>> from astropy.nddata import StdDevUncertainty >>> from astropy.modeling import models >>> from specutils import Spectrum1D, SpectralRegion >>> np.random.seed(42) >>> spectral_axis = np.linspace(11., 1., 200) * u.GHz >>> spectral_model = models.Gaussian1D(amplitude=5*(2*np.pi*0.8**2)**-0.5*u.Jy, mean=5*u.GHz, stddev=0.8*u.GHz) >>> flux = spectral_model(spectral_axis) >>> flux += np.random.normal(0., 0.05, spectral_axis.shape) * u.Jy >>> uncertainty = StdDevUncertainty(0.2*np.ones(flux.shape)*u.Jy) >>> noisy_gaussian = Spectrum1D(spectral_axis=spectral_axis, flux=flux, uncertainty=uncertainty) >>> import matplotlib.pyplot as plt #doctest:+SKIP >>> plt.step(noisy_gaussian.spectral_axis, noisy_gaussian.flux) #doctest:+SKIP SNR --- The signal-to-noise ratio of a spectrum is often a valuable quantity for evaluating the quality of a spectrum. The `specutils.analysis.snr` function performs this task, either on the spectrum as a whole, or sub-regions of a spectrum: .. code-block:: python >>> from specutils.analysis import snr >>> snr(noisy_gaussian) # doctest:+FLOAT_CMP >>> snr(noisy_gaussian, SpectralRegion(6*u.GHz, 4*u.GHz)) # doctest:+FLOAT_CMP A second method to calculate SNR does not require the uncertainty defined on the `~specutils.Spectrum1D` object. This computes the signal to noise ratio DER_SNR following the definition set forth by the Spectral Container Working Group of ST-ECF, MAST and CADC. This algorithm is described at https://esahubble.org/static/archives/stecfnewsletters/pdf/hst_stecf_0042.pdf .. code-block:: python >>> from specutils.analysis import snr_derived >>> snr_derived(noisy_gaussian) # doctest:+FLOAT_CMP >>> snr_derived(noisy_gaussian, SpectralRegion(6*u.GHz, 4*u.GHz)) # doctest:+FLOAT_CMP The conditions on the data for this implementation for it to be an unbiased estimator of the SNR are strict. In particular: * the noise is uncorrelated in wavelength bins spaced two pixels apart * for large wavelength regions, the signal over the scale of 5 or more pixels can be approximated by a straight line Line Flux Estimates ------------------- While line-fitting (see :doc:`fitting`) is a more thorough way to measure spectral line fluxes, direct measures of line flux are very useful for either quick-look settings or for spectra not amedable to fitting. The `specutils.analysis.line_flux` function addresses that use case. The closely related `specutils.analysis.equivalent_width` computes the equivalent width of a spectral feature, a flux measure that is normalized against the continuum of a spectrum. Both are demonstrated below: .. note:: The `specutils.analysis.line_flux` function assumes the spectrum has already been continuum-subtracted, while `specutils.analysis.equivalent_width` assumes the continuum is at a fixed, known level (defaulting to 1, meaning continuum-normalized). :ref:`specutils-continuum-fitting` describes how continuua can be generated to prepare a spectrum for use with these functions. .. code-block:: python >>> from specutils.analysis import line_flux >>> line_flux(noisy_gaussian, SpectralRegion(7*u.GHz, 3*u.GHz)) # doctest:+FLOAT_CMP >>> line_flux(noisy_gaussian).to(u.erg * u.cm**-2 * u.s**-1) # doctest:+FLOAT_CMP These line_flux measurements also include uncertainties if the spectrum itself has uncertainties:: .. code-block:: python >>> flux = line_flux(noisy_gaussian) >>> flux.uncertainty.to(u.erg * u.cm**-2 * u.s**-1) # doctest:+FLOAT_CMP >>> line_flux(noisy_gaussian, SpectralRegion(7*u.GHz, 3*u.GHz)) # doctest:+FLOAT_CMP For the equivalent width, note the need to add a continuum level: .. code-block:: python >>> from specutils.analysis import equivalent_width >>> noisy_gaussian_with_continuum = noisy_gaussian + 1*u.Jy >>> equivalent_width(noisy_gaussian_with_continuum) # doctest:+FLOAT_CMP >>> equivalent_width(noisy_gaussian_with_continuum, regions=SpectralRegion(7*u.GHz, 3*u.GHz)) # doctest:+FLOAT_CMP Centroid -------- The `specutils.analysis.centroid` function provides a first-moment analysis to estimate the center of a spectral feature: .. code-block:: python >>> from specutils.analysis import centroid >>> centroid(noisy_gaussian, SpectralRegion(7*u.GHz, 3*u.GHz)) # doctest:+FLOAT_CMP While this example is "pre-subtracted", this function only performs well if the contiuum has already been subtracted, as for the other functions above and below. Moment ------ The `specutils.analysis.moment` function computes moments of any order: .. code-block:: python >>> from specutils.analysis import moment >>> moment(noisy_gaussian, SpectralRegion(7*u.GHz, 3*u.GHz)) # doctest:+FLOAT_CMP >>> moment(noisy_gaussian, SpectralRegion(7*u.GHz, 3*u.GHz), order=1) # doctest:+FLOAT_CMP >>> moment(noisy_gaussian, SpectralRegion(7*u.GHz, 3*u.GHz), order=2) # doctest:+FLOAT_CMP Line Widths ----------- There are several width statistics that are provided by the `specutils.analysis` submodule. The `~gaussian_sigma_width` function estimates the width of the spectrum by computing a second-moment-based approximation of the standard deviation. The `~gaussian_fwhm` function estimates the width of the spectrum at half max, again by computing an approximation of the standard deviation. Both of these functions assume that the spectrum is approximately gaussian. The function `~fwhm` provides an estimate of the full width of the spectrum at half max that does not assume the spectrum is gaussian. It locates the maximum, and then locates the value closest to half of the maximum on either side, and measures the distance between them. A function to calculate the full width at zero intensity (i.e. the width of a spectral feature at the continuum) is provided as `~fwzi`. Like the `~fwhm` calculation, it does not make assumptions about the shape of the feature and calculates the width by finding the points at either side of maximum that reach the continuum value. In this case, it assumes the provided spectrum has been continuum subtracted. Each of the width analysis functions are applied to this spectrum below: .. code-block:: python >>> from specutils.analysis import gaussian_sigma_width, gaussian_fwhm, fwhm, fwzi >>> gaussian_sigma_width(noisy_gaussian) # doctest: +FLOAT_CMP >>> gaussian_fwhm(noisy_gaussian) # doctest: +FLOAT_CMP >>> fwhm(noisy_gaussian) # doctest: +FLOAT_CMP >>> fwzi(noisy_gaussian) # doctest: +FLOAT_CMP Template comparison ------------------- The ~`specutils.analysis.template_comparison.template_match` function takes an observed spectrum and ``n`` template spectra and returns the best template that matches the observed spectrum via chi-square minimization. If the redshift is known, the user can set that for the ``redshift`` parameter and then run the ~`specutils.analysis.template_comparison.template_match` function. This function will: 1. Match the resolution and wavelength spacing of the observed spectrum. 2. Compute the chi-square between the observed spectrum and each template. 3. Return the lowest chi-square and its corresponding template spectrum, normalized to the observed spectrum (and the index of the template spectrum if the list of templates is iterable). It also If the redshift is unknown, the user specifies a grid of redshift values in the form of an iterable object such as a list, tuple, or numpy array with the redshift values to use. As an example, a simple linear grid can be built with: .. code-block:: python >>> rs_values = np.arange(1., 3.25, 0.25) The ~`specutils.analysis.template_comparison.template_match` function will then: 1. Move each template to the first term in the redshift grid. 2. Run steps 1 and 2 of the case with known redshift. 3. Move to the next term in the redshift grid. 4. Run steps 1 and 2 of the case with known redshift. 5. Repeat the steps until the end of the grid is reached. 6. Return the best redshift, the lowest chi-square and its corresponding template spectrum, and a list with all chi2 values, one per template. The returned template spectrum corresponding to the lowest chi2 is redshifted and normalized to the observed spectrum (and the index of the template spectrum if the list of templates is iterable). When multiple templates are matched with a redshift grid, a list-of-lists is returned with the trial chi-square values computed for every combination redshift-template. The external list spans the range of templates in the collection/list, while each internal list contains all chi2 values for a given template. An example of how to do template matching with an unknown redshift is: .. code-block:: python >>> from specutils.analysis import template_comparison >>> spec_axis = np.linspace(0, 50, 50) * u.AA >>> observed_redshift = 2.0 >>> min_redshift = 1.0 >>> max_redshift = 3.0 >>> delta_redshift = .25 >>> resample_method = "flux_conserving" >>> rs_values = np.arange(min_redshift, max_redshift+delta_redshift, delta_redshift) >>> observed_spectrum = Spectrum1D(spectral_axis=spec_axis*(1+observed_redshift), flux=np.random.randn(50) * u.Jy, uncertainty=StdDevUncertainty(np.random.sample(50), unit='Jy')) >>> spectral_template = Spectrum1D(spectral_axis=spec_axis, flux=np.random.randn(50) * u.Jy, uncertainty=StdDevUncertainty(np.random.sample(50), unit='Jy')) >>> tm_result = template_comparison.template_match(observed_spectrum=observed_spectrum, spectral_templates=spectral_template, resample_method=resample_method, redshift=rs_values) # doctest:+FLOAT_CMP Dust extinction --------------- Dust extinction can be applied to Spectrum1D instances via their internal arrays, using the ``dust_extinction`` package (http://dust-extinction.readthedocs.io/en/latest) Below is an example of how to apply extinction. .. code-block:: python from astropy.modeling.blackbody import blackbody_lambda from dust_extinction.parameter_averages import F99 wave = np.logspace(np.log10(1000), np.log10(3e4), num=10) * u.AA flux = blackbody_lambda(wave, 10000 * u.K) spec = Spectrum1D(spectral_axis=wave, flux=flux) # define the model ext = F99(Rv=3.1) # extinguish (redden) the spectrum flux_ext = spec.flux * ext.extinguish(spec.spectral_axis, Ebv=0.5) spec_ext = Spectrum1D(spectral_axis=wave, flux=flux_ext) Template Cross-correlation -------------------------- The cross-correlation function between an observed spectrum and a template spectrum that both share a common spectral axis can be calculated with the function `~template_correlate` in the `~specutils.analysis` module. An example of how to get the cross correlation follows. Note that the observed spectrum must have a rest wavelength value set. .. code-block:: python >>> from specutils.analysis import correlation >>> size = 200 >>> spec_axis = np.linspace(4500., 6500., num=size) * u.AA >>> f1 = np.random.randn(size)*0.5 * u.Jy >>> f2 = np.random.randn(size)*0.5 * u.Jy >>> rest_value = 6000. * u.AA >>> mean1 = 5035. * u.AA >>> mean2 = 5015. * u.AA >>> g1 = models.Gaussian1D(amplitude=30 * u.Jy, mean=mean1, stddev=10. * u.AA) >>> g2 = models.Gaussian1D(amplitude=30 * u.Jy, mean=mean2, stddev=10. * u.AA) >>> flux1 = f1 + g1(spec_axis) >>> flux2 = f2 + g2(spec_axis) >>> uncertainty = StdDevUncertainty(0.2*np.ones(size)*u.Jy) >>> ospec = Spectrum1D(spectral_axis=spec_axis, flux=flux1, uncertainty=uncertainty, velocity_convention='optical', rest_value=rest_value) >>> tspec = Spectrum1D(spectral_axis=spec_axis, flux=flux2, uncertainty=uncertainty) >>> corr, lag = correlation.template_correlate(ospec, tspec) The lag values are reported in km/s units. The correlation values are computed after the template spectrum is normalized in order to have the same total flux as the observed spectrum. Reference/API ------------- .. automodapi:: specutils.analysis :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/arithmetic.rst0000644000503700020070000000650600000000000021426 0ustar00rosteenSTSCI\science00000000000000=================== Spectrum Arithmetic =================== Specutils sports the ability to perform arithmetic operations over spectrum data objects. There is full support for propagating unit information. .. note:: Spectrum arithmetic requires that the two spectrum objects have compatible WCS information. .. warning:: Specutils does not currently implement interpolation techniques for converting spectral axes information from one WCS source to another. Basic Arithmetic ---------------- Arithmetic support includes addition, subtract, multiplication, and division. .. code-block:: python >>> from specutils import Spectrum1D >>> import astropy.units as u >>> import numpy as np >>> spec1 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> spec2 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> spec3 = spec1 + spec2 >>> spec3 #doctest:+SKIP , spectral_axis=)> Propagation of Uncertainties ---------------------------- Arithmetic operations also support the propagation of unceratinty information. .. code-block:: python >>> from astropy.nddata import StdDevUncertainty >>> spec1 = Spectrum1D(spectral_axis=np.arange(10) * u.nm, flux=np.random.sample(10)*u.Jy, uncertainty=StdDevUncertainty(np.random.sample(10) * 0.1)) >>> spec2 = Spectrum1D(spectral_axis=np.arange(10) * u.nm, flux=np.random.sample(10)*u.Jy, uncertainty=StdDevUncertainty(np.random.sample(10) * 0.1)) >>> spec1.uncertainty #doctest:+SKIP StdDevUncertainty([0.04386832, 0.09909487, 0.07589192, 0.0311604 , 0.07973579, 0.04687858, 0.01161918, 0.06013496, 0.00476118, 0.06720447]) >>> spec2.uncertainty #doctest:+SKIP StdDevUncertainty([0.00889175, 0.00890437, 0.05194229, 0.08794455, 0.09918037, 0.04815417, 0.06464564, 0.0164324 , 0.04358771, 0.08260218]) >>> spec3 = spec1 + spec2 >>> spec3.uncertainty #doctest:+SKIP StdDevUncertainty([0.04476039, 0.09949412, 0.09196513, 0.09330174, 0.12725778, 0.06720435, 0.06568154, 0.06233969, 0.04384698, 0.10648737]) Reference/API ------------- .. automodapi:: specutils.spectra.spectrum_mixin :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/conf.py0000644000503700020070000002000100000000000020024 0ustar00rosteenSTSCI\science00000000000000# -*- coding: utf-8 -*- # Licensed under a 3-clause BSD style license - see LICENSE.rst # # Astropy documentation build configuration file. # # This file is execfile()d with the current directory set to its containing dir. # # Note that not all possible configuration values are present in this file. # # All configuration values have a default. Some values are defined in # the global Astropy configuration which is loaded here before anything else. # See astropy.sphinx.conf for which values are set there. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # sys.path.insert(0, os.path.abspath('..')) # IMPORTANT: the above commented section was generated by sphinx-quickstart, but # is *NOT* appropriate for astropy or Astropy affiliated packages. It is left # commented out with this explanation to make it clear why this should not be # done. If the sys.path entry above is added, when the astropy.sphinx.conf # import occurs, it will import the *source* version of astropy instead of the # version installed (if invoked as "make html" or directly with sphinx), or the # version in the build directory (if "python setup.py build_sphinx" is used). # Thus, any C-extensions that are needed to build the documentation will *not* # be accessible, and the documentation will not build correctly. import os import sys import datetime from importlib import import_module import doctest try: from sphinx_astropy.conf.v1 import * # noqa except ImportError: print('ERROR: the documentation requires the sphinx-astropy package to be installed') sys.exit(1) # Get configuration information from setup.cfg from configparser import ConfigParser conf = ConfigParser() conf.read([os.path.join(os.path.dirname(__file__), '..', 'setup.cfg')]) setup_cfg = dict(conf.items('metadata')) # -- General configuration ---------------------------------------------------- # By default, highlight as Python 3. highlight_language = 'python3' # If your documentation needs a minimal Sphinx version, state it here. #needs_sphinx = '1.2' # To perform a Sphinx version check that needs to be more specific than # major.minor, call `check_sphinx_version("X.Y.Z")` here. # check_sphinx_version("1.2.1") # Include other packages to link against intersphinx_mapping['astropy'] = ('https://docs.astropy.org/en/latest/', None) intersphinx_mapping['gwcs'] = ('https://gwcs.readthedocs.io/en/latest/', None) # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. exclude_patterns.append('_templates') # This is added to the end of RST files - a good place to put substitutions to # be used globally. rst_epilog += """ """ # Manually register doctest options since matplotlib 3.5 messed up allowing them # from pytest-doctestplus IGNORE_OUTPUT = doctest.register_optionflag('IGNORE_OUTPUT') REMOTE_DATA = doctest.register_optionflag('REMOTE_DATA') FLOAT_CMP = doctest.register_optionflag('FLOAT_CMP') # -- Project information ------------------------------------------------------ # This does not *have* to match the package name, but typically does project = setup_cfg['name'] author = setup_cfg['author'] copyright = '{0}, {1}'.format( datetime.datetime.now().year, setup_cfg['author']) # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. import_module(setup_cfg['name']) package = sys.modules[setup_cfg['name']] # The short X.Y version. version = package.__version__.split('-', 1)[0] # The full version, including alpha/beta/rc tags. release = package.__version__ # -- Options for HTML output -------------------------------------------------- # A NOTE ON HTML THEMES # The global astropy configuration uses a custom theme, 'bootstrap-astropy', # which is installed along with astropy. A different theme can be used or # the options for this theme can be modified by overriding some of the # variables set in the global configuration. The variables set in the # global configuration are listed below, commented out. # Add any paths that contain custom themes here, relative to this directory. # To use a different custom theme, add the directory containing the theme. #html_theme_path = [] # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. To override the custom theme, set this to the # name of a builtin theme or the name of a custom theme in html_theme_path. html_static_path = ['_static'] # html_theme = None html_style = 'specutils.css' html_theme_options = { 'logotext1': 'spec', # white, semi-bold 'logotext2': 'utils', # orange, light 'logotext3': ':docs' # white, light } # Custom sidebar templates, maps document names to template names. #html_sidebars = {} html_sidebars['**'] = ['localtoc.html'] html_sidebars['index'] = ['globaltoc.html', 'localtoc.html'] # The name of an image file (relative to this directory) to place at the top # of the sidebar. #html_logo = '' # The name of an image file (within the static path) to use as favicon of the # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 # pixels large. html_favicon = '_static/logo_icon.ico' # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, # using the given strftime format. #html_last_updated_fmt = '' # The name for this set of Sphinx documents. If None, it defaults to # " v documentation". html_title = '{0} v{1}'.format(project, release) # Output file base name for HTML help builder. htmlhelp_basename = project + 'doc' # Prefixes that are ignored for sorting the Python module index modindex_common_prefix = ["specutils."] # -- Options for LaTeX output ------------------------------------------------- # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, author, documentclass [howto/manual]). latex_documents = [('index', project + '.tex', project + u' Documentation', author, 'manual')] # -- Options for manual page output ------------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [('index', project.lower(), project + u' Documentation', [author], 1)] # -- Options for the edit_on_github extension --------------------------------- if setup_cfg.get('edit_on_github').lower() == 'true': extensions += ['sphinx_astropy.ext.edit_on_github'] edit_on_github_project = setup_cfg['github_project'] edit_on_github_branch = "main" edit_on_github_source_root = "" edit_on_github_doc_root = "docs" # -- Resolving issue number to links in changelog ----------------------------- github_issues_url = 'https://github.com/{0}/issues/'.format(setup_cfg['github_project']) # -- Options for linkcheck output ------------------------------------------- linkcheck_retry = 5 linkcheck_ignore = [ r'https://github\.com/astropy/specutils/(?:issues|pull)/\d+', r'https://www\.sdss\.org/' ] linkcheck_timeout = 180 linkcheck_anchors = False # -- Turn on nitpicky mode for sphinx (to warn about references not found) ---- # nitpicky = True nitpick_ignore = [] # # Some warnings are impossible to suppress, and you can list specific references # that should be ignored in a nitpick-exceptions file which should be inside # the docs/ directory. The format of the file should be: # # # # for example: # # py:class astropy.io.votable.tree.Element # py:class astropy.io.votable.tree.SimpleElement # py:class astropy.io.votable.tree.SimpleElementWithContent # # Uncomment the following lines to enable the exceptions: # for line in open('nitpick-exceptions'): if line.strip() == "" or line.startswith("#"): continue dtype, target = line.split(None, 1) target = target.strip() nitpick_ignore.append((dtype, target)) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/docs/contributing.rst0000644000503700020070000000627700000000000022011 0ustar00rosteenSTSCI\science00000000000000.. highlight:: shell ============ Contributing ============ Contributions are welcome, and they are greatly appreciated! Every little bit helps, and credit will always be given. You can contribute in many ways: Types of Contributions ---------------------- Report Bugs ~~~~~~~~~~~ Report bugs at https://github.com/astropy/specutils/issues. If you are reporting a bug, please include: * Your operating system name and version. * Any details about your local setup that might be helpful in troubleshooting. * Detailed steps to reproduce the bug. Fix Bugs ~~~~~~~~ Look through the GitHub issues for bugs. Anything tagged with "bug" and "help wanted" is open to whoever wants to implement it. Implement Features ~~~~~~~~~~~~~~~~~~ Look through the GitHub issues for features. Anything tagged with "enhancement" and "help wanted" is open to whoever wants to implement it. Write Documentation ~~~~~~~~~~~~~~~~~~~ Specutils could always use more documentation, whether as part of the official specutils docs, in docstrings, or even on the web in blog posts, articles, and such. Submit Feedback ~~~~~~~~~~~~~~~ The best way to send feedback is to file an issue at https://github.com/astropy/specutils/issues. If you are proposing a feature: * Explain in detail how it would work. * Keep the scope as narrow as possible, to make it easier to implement. * Remember that this is a volunteer-driven project, and that contributions are welcome. Get Started! ------------ Ready to contribute? Here's how to set up :ref:`specutils ` for local development. 1. Fork the :ref:`specutils ` repo on GitHub. 2. Clone your fork locally:: $ git clone git@github.com:your_name_here/specutils.git 3. Install your local copy into a virtualenv. Assuming you have virtualenvwrapper installed, this is how you set up your fork for local development:: $ mkvirtualenv specutils $ cd specutils/ $ python setup.py develop 4. Create a branch for local development:: $ git checkout -b name-of-your-bugfix-or-feature Now you can make your changes locally. 5. When you're done making changes, check that your changes pass flake8 and the tests, including testing other Python versions with tox:: $ flake8 specutils tests $ python setup.py test or py.test $ tox To get flake8 and tox, just pip install them into your virtualenv. 6. Commit your changes and push your branch to GitHub:: $ git add . $ git commit -m "Your detailed description of your changes." $ git push origin name-of-your-bugfix-or-feature 7. Submit a pull request through the GitHub website. Pull Request Guidelines ----------------------- Before you submit a pull request, check that it meets these guidelines: 1. The pull request should include tests. 2. If the pull request adds functionality, the docs should be updated. Put your new functionality into a function with a docstring, and add the feature to the list in README.rst. 3. The pull request should work for Python 3.4, 3.5, and 3.6, and for PyPy. Check https://travis-ci.org/astropy/specutils/pull_requests and make sure that the tests pass for all supported Python versions. Tips ---- To run a subset of tests:: $ py.test tests.test_specutils ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/custom_loading.rst0000644000503700020070000001353600000000000022305 0ustar00rosteenSTSCI\science00000000000000************************************************* Loading and Defining Custom Spectral File Formats ************************************************* Loading From a File ------------------- Specutils leverages the astropy io registry to provide an interface for conveniently loading data from files. To create a custom loader, the user must define it in a separate python file and place the file in their ``~/.specutils`` directory. Loading from a FITS File ------------------------ A spectra with a *Linear Wavelength Solution* can be read using the ``read`` method of the :class:`~specutils.Spectrum1D` class to parse the file name and format .. code-block:: python import os from specutils import Spectrum1D file_path = os.path.join('path/to/folder', 'file_with_1d_wcs.fits') spec = Spectrum1D.read(file_path, format='wcs1d-fits') This will create a :class:`~specutils.Spectrum1D` object that you can manipulate later. For instance, you could plot the spectrum. .. code-block:: python import matplotlib.pyplot as plt plt.title('FITS file with 1D WCS') plt.xlabel('Wavelength (Angstrom)') plt.ylabel('Flux (erg/cm2/s/A)') plt.plot(spec.wavelength, spec.flux) plt.show() .. image:: img/read_1d.png Creating a Custom Loader ------------------------ Defining a custom loader consists of importing the `~specutils.io.registers.data_loader` decorator from specutils and attaching it to a function that knows how to parse the user's data. The return object of this function must be an instance of one of the spectral classes (:class:`~specutils.Spectrum1D`, :class:`~specutils.SpectrumCollection`, :class:`~specutils.SpectrumList`). Optionally, the user may define an identifier function. This function acts to ensure that the data file being loaded is compatible with the loader function. .. code-block:: python # ~/.specutils/my_custom_loader.py import os from astropy.io import fits from astropy.nddata import StdDevUncertainty from astropy.table import Table from astropy.units import Unit from astropy.wcs import WCS from specutils.io.registers import data_loader from specutils import Spectrum1D # Define an optional identifier. If made specific enough, this circumvents the # need to add ``format="my-format"`` in the ``Spectrum1D.read`` call. def identify_generic_fits(origin, *args, **kwargs): return (isinstance(args[0], str) and os.path.splitext(args[0].lower())[1] == '.fits') @data_loader("my-format", identifier=identify_generic_fits, extensions=['fits']) def generic_fits(file_name, **kwargs): with fits.open(file_name, **kwargs) as hdulist: header = hdulist[0].header tab = Table.read(file_name) meta = {'header': header} wcs = WCS(hdulist[0].header) uncertainty = StdDevUncertainty(tab["err"]) data = tab["flux"] * Unit("Jy") return Spectrum1D(flux=data, wcs=wcs, uncertainty=uncertainty, meta=meta) An ``extensions`` keyword can be provided. This allows for basic filename extension matching in the case that the ``identifier`` function is not provided. It is possible to query the registry to return the list of loaders associated with a particular extension. .. code-block:: python from specutils.io import get_loaders_by_extension loaders = get_loaders_by_extension('fits') The returned list contains the format labels that can be fed into the ``format`` keyword argument of the ``Spectrum1D.read`` method. After placing this python file in the user's ``~/.specutils`` directory, it can be utilized by referencing its name in the ``read`` method of the :class:`~specutils.Spectrum1D` class .. code-block:: python from specutils import Spectrum1D spec = Spectrum1D.read("path/to/data", format="my-format") .. _multiple_spectra: Loading Multiple Spectra ^^^^^^^^^^^^^^^^^^^^^^^^ It is possible to create a loader that reads multiple spectra from the same file. For the general case where none of the spectra are assumed to be the same length, the loader should return a `~specutils.SpectrumList`. Consider the custom JWST data loader as an example: .. literalinclude:: ../specutils/io/default_loaders/jwst_reader.py :language: python Note that by default, any loader that uses ``dtype=Spectrum1D`` will also automatically add a reader for `~specutils.SpectrumList`. This enables user code to call `specutils.SpectrumList.read ` in all cases if it can't make assumptions about whether a loader returns one or many `~specutils.Spectrum1D` objects. This method is available since `~specutils.SpectrumList` makes use of the Astropy IO registry (see `astropy.io.registry.read`). Creating a Custom Writer ------------------------ Similar to creating a custom loader, a custom data writer may also be defined. This again will be done in a separate python file and placed in the user's ``~/.specutils`` directory to be loaded into the astropy io registry. .. code-block:: python # ~/.spectacle/my_writer.py from astropy.table import Table from specutils.io.registers import custom_writer @custom_writer("fits-writer") def generic_fits(spectrum, file_name, **kwargs): flux = spectrum.flux.value disp = spectrum.spectral_axis.value meta = spectrum.meta tab = Table([disp, flux], names=("spectral_axis", "flux"), meta=meta) tab.write(file_name, format="fits") The custom writer can be used by passing the name of the custom writer to the ``format`` argument of the ``write`` method on the :class:`~specutils.Spectrum1D`. .. code-block:: python spec = Spectrum1D(flux=np.random.sample(100) * u.Jy, spectral_axis=np.arange(100) * u.AA) spec.write("my_output.fits", format="fits-writer") Reference/API ------------- .. automodapi:: specutils.io.registers :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/fitting.rst0000644000503700020070000005730300000000000020742 0ustar00rosteenSTSCI\science00000000000000===================== Line/Spectrum Fitting ===================== One of the primary tasks in spectroscopic analysis is fitting models of spectra. This concept is often applied mainly to line-fitting, but the same general approach applies to continuum fitting or even full-spectrum fitting. ``specutils`` provides conveniences that aim to leverage the general fitting framework of `astropy.modeling` to spectral-specific tasks. At a high level, this fitting takes the `~specutils.Spectrum1D` object and a list of `~astropy.modeling.Model` objects that have initial guesses for each of the parameters. these are used to create a compound model created from the model initial guesses. This model is then actually fit to the spectrum's ``flux``, yielding a single composite model result (which can be split back into its components if desired). Line Finding ------------ There are two techniques implemented in order to find emission and/or absorption lines in a `~specutils.Spectrum1D` spectrum. The first technique is `~specutils.fitting.find_lines_threshold` that will find lines by thresholding the flux based on a factor applied to the spectrum uncertainty. The second technique is `~specutils.fitting.find_lines_derivative` that will find the lines based on calculating the derivative and then thresholding based on it. Both techniques return an `~astropy.table.QTable` that contains columns ``line_center``, ``line_type`` and ``line_center_index``. We start with a synthetic spectrum: .. plot:: :include-source: :align: center >>> import numpy as np >>> from astropy.modeling import models >>> import astropy.units as u >>> from specutils import Spectrum1D, SpectralRegion >>> np.random.seed(42) >>> g1 = models.Gaussian1D(1, 4.6, 0.2) >>> g2 = models.Gaussian1D(2.5, 5.5, 0.1) >>> g3 = models.Gaussian1D(-1.7, 8.2, 0.1) >>> x = np.linspace(0, 10, 200) >>> y = g1(x) + g2(x) + g3(x) + np.random.normal(0., 0.2, x.shape) >>> spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) >>> from matplotlib import pyplot as plt >>> plt.plot(spectrum.spectral_axis, spectrum.flux) # doctest: +IGNORE_OUTPUT >>> plt.xlabel('Spectral Axis ({})'.format(spectrum.spectral_axis.unit)) # doctest: +IGNORE_OUTPUT >>> plt.ylabel('Flux Axis({})'.format(spectrum.flux.unit)) # doctest: +IGNORE_OUTPUT >>> plt.grid(True) # doctest: +IGNORE_OUTPUT While we know the true uncertainty here, this is often not the case with real data. Therefore, since `~specutils.fitting.find_lines_threshold` requires an uncertainty, we will produce an estimate of the uncertainty by calling the `~specutils.manipulation.noise_region_uncertainty` function: .. code-block:: python >>> from specutils.manipulation import noise_region_uncertainty >>> noise_region = SpectralRegion(0*u.um, 3*u.um) >>> spectrum = noise_region_uncertainty(spectrum, noise_region) >>> from specutils.fitting import find_lines_threshold >>> lines = find_lines_threshold(spectrum, noise_factor=3) >>> lines[lines['line_type'] == 'emission'] # doctest:+FLOAT_CMP line_center line_type line_center_index um float64 str10 int64 ----------------- --------- ----------------- 4.572864321608041 emission 91 4.824120603015076 emission 96 5.477386934673367 emission 109 8.99497487437186 emission 179 >>> lines[lines['line_type'] == 'absorption'] # doctest:+FLOAT_CMP line_center line_type line_center_index um float64 str10 int64 ----------------- ---------- ----------------- 8.190954773869347 absorption 163 An example using the `~specutils.fitting.find_lines_derivative`: .. code-block:: python >>> # Define a noise region for adding the uncertainty >>> noise_region = SpectralRegion(0*u.um, 3*u.um) >>> # Derivative technique >>> from specutils.fitting import find_lines_derivative >>> lines = find_lines_derivative(spectrum, flux_threshold=0.75) >>> lines[lines['line_type'] == 'emission'] # doctest:+FLOAT_CMP line_center line_type line_center_index um float64 str10 int64 ----------------- --------- ----------------- 4.522613065326634 emission 90 5.477386934673367 emission 109 >>> lines[lines['line_type'] == 'absorption'] # doctest:+FLOAT_CMP line_center line_type line_center_index um float64 str10 int64 ----------------- ---------- ----------------- 8.190954773869347 absorption 163 While it might be surprising that these tables do not contain more information about the lines, this is because the "toolbox" philosophy of ``specutils`` aims to keep such functionality in separate distinct functions. See :doc:`analysis` for functions that can be used to fill out common line measurements more completely. Parameter Estimation -------------------- Given a spectrum with a set of lines, the `~specutils.fitting.estimate_line_parameters` can be called to estimate the `~astropy.modeling.Model` parameters given a spectrum. For the `~astropy.modeling.functional_models.Gaussian1D`, `~astropy.modeling.functional_models.Voigt1D`, and `~astropy.modeling.functional_models.Lorentz1D` models, there are predefined estimators for each of the parameters. For all other models one must define the estimators (see example below). Note that in many (most?) cases where another model is needed, it may be better to create your own template models tailored to your specific spectra and skip this function entirely. For example, based on the spectrum defined above we can first select a region: .. code-block:: python >>> from specutils import SpectralRegion >>> from specutils.fitting import estimate_line_parameters >>> from specutils.manipulation import extract_region >>> sub_region = SpectralRegion(4*u.um, 5*u.um) >>> sub_spectrum = extract_region(spectrum, sub_region) Then estimate the line parameters it it for a Gaussian line profile:: >>> print(estimate_line_parameters(sub_spectrum, models.Gaussian1D())) # doctest:+FLOAT_CMP Model: Gaussian1D Inputs: ('x',) Outputs: ('y',) Model set size: 1 Parameters: amplitude mean stddev Jy um um ------------------ ---------------- ------------------- 1.1845669151078486 4.57517271067525 0.19373372929165977 If an `~astropy.modeling.Model` is used that does not have the predefined parameter estimators, or if one wants to use different parameter estimators then one can create a dictionary where the key is the parameter name and the value is a function that operates on a spectrum (lambda functions are very useful for this purpose). For example if one wants to estimate the line parameters of a line fit for a `~astropy.modeling.functional_models.RickerWavelet1D` one can define the ``estimators`` dictionary and use it to populate the ``estimator`` attribute of the model's parameters: .. code-block:: python >>> from specutils import SpectralRegion >>> from specutils.fitting import estimate_line_parameters >>> from specutils.manipulation import extract_region >>> from specutils.analysis import centroid, fwhm >>> sub_region = SpectralRegion(4*u.um, 5*u.um) >>> sub_spectrum = extract_region(spectrum, sub_region) >>> ricker = models.RickerWavelet1D() >>> ricker.amplitude.estimator = lambda s: max(s.flux) >>> ricker.x_0.estimator = lambda s: centroid(s, region=None) >>> ricker.sigma.estimator = lambda s: fwhm(s) >>> print(estimate_line_parameters(spectrum, ricker)) # doctest:+FLOAT_CMP Model: RickerWavelet1D Inputs: ('x',) Outputs: ('y',) Model set size: 1 Parameters: amplitude x_0 sigma Jy um um ------------------ ------------------ ------------------- 2.4220683957581444 3.6045476935889367 0.24416769183724707 Model (Line) Fitting -------------------- The generic model fitting machinery is well-suited to fitting spectral lines. The first step is to create a set of models with initial guesses as the parameters. To achieve better fits it may be wise to include a set of bounds for each parameter, but that is optional. .. note:: A method to make plausible initial guesses will be provided in a future version, but user defined initial guesses are required at present. Below are a series of examples of this sort of fitting. Simple Example ^^^^^^^^^^^^^^ Below is a simple example to demonstrate how to use the `~specutils.fitting.fit_lines` method to fit a spectrum to an Astropy model initial guess. .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(0) x = np.linspace(0., 10., 200) y = 3 * np.exp(-0.5 * (x- 6.3)**2 / 0.8**2) y += np.random.normal(0., 0.2, x.shape) spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit the spectrum and calculate the fitted flux values (``y_fit``) g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=6.1*u.um, stddev=1.*u.um) g_fit = fit_lines(spectrum, g_init) y_fit = g_fit(x*u.um) # Plot the original spectrum and the fitted. plt.plot(x, y, label="Original spectrum") plt.plot(x, y_fit, label="Fit result") plt.title('Single fit peak') plt.grid(True) plt.legend() Simple Example with Different Units ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Similar fit example to above, but the Gaussian model initial guess has different units. The fit will convert the initial guess to the spectral units, fit and then output the fitted model in the spectrum units. .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(0) x = np.linspace(0., 10., 200) y = 3 * np.exp(-0.5 * (x- 6.3)**2 / 0.8**2) y += np.random.normal(0., 0.2, x.shape) # Create the spectrum spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=61000*u.AA, stddev=10000.*u.AA) g_fit = fit_lines(spectrum, g_init) y_fit = g_fit(x*u.um) plt.plot(x, y) plt.plot(x, y_fit) plt.title('Single fit peak, different model units') plt.grid(True) Single Peak Fit Within a Window (Defined by Center) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Single peak fit with a window of ``2*u.um`` around the center of the mean of the model initial guess (so ``2*u.um`` around ``5.5*u.um``). .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(0) x = np.linspace(0., 10., 200) y = 3 * np.exp(-0.5 * (x- 6.3)**2 / 0.8**2) y += np.random.normal(0., 0.2, x.shape) # Create the spectrum spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=5.5*u.um, stddev=1.*u.um) g_fit = fit_lines(spectrum, g_init, window=2*u.um) y_fit = g_fit(x*u.um) plt.plot(x, y) plt.plot(x, y_fit) plt.title('Single fit peak window') plt.grid(True) Single Peak Fit Within a Window (Defined by Left and Right) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Single peak fit using spectral data *only* within the window ``6*u.um`` to ``7*u.um``, all other data will be ignored. .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(0) x = np.linspace(0., 10., 200) y = 3 * np.exp(-0.5 * (x- 6.3)**2 / 0.8**2) y += np.random.normal(0., 0.2, x.shape) # Create the spectrum spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=5.5*u.um, stddev=1.*u.um) g_fit = fit_lines(spectrum, g_init, window=(6*u.um, 7*u.um)) y_fit = g_fit(x*u.um) plt.plot(x, y) plt.plot(x, y_fit) plt.title('Single fit peak window') plt.grid(True) Double Peak Fit ^^^^^^^^^^^^^^^ Double peak fit compound model initial guess in and compound model out. .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(42) g1 = models.Gaussian1D(1, 4.6, 0.2) g2 = models.Gaussian1D(2.5, 5.5, 0.1) x = np.linspace(0, 10, 200) y = g1(x) + g2(x) + np.random.normal(0., 0.2, x.shape) # Create the spectrum to fit spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit the spectrum g1_init = models.Gaussian1D(amplitude=2.3*u.Jy, mean=5.6*u.um, stddev=0.1*u.um) g2_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.4*u.um, stddev=0.1*u.um) g12_fit = fit_lines(spectrum, g1_init+g2_init) y_fit = g12_fit(x*u.um) plt.plot(x, y) plt.plot(x, y_fit) plt.title('Double Peak Fit') plt.grid(True) Double Peak Fit Within a Window ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Double peak fit using data in the spectrum from ``4.3*u.um`` to ``5.3*u.um``, only. .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(42) g1 = models.Gaussian1D(1, 4.6, 0.2) g2 = models.Gaussian1D(2.5, 5.5, 0.1) x = np.linspace(0, 10, 200) y = g1(x) + g2(x) + np.random.normal(0., 0.2, x.shape) # Create the spectrum to fit spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit the spectrum g2_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.7*u.um, stddev=0.2*u.um) g2_fit = fit_lines(spectrum, g2_init, window=(4.3*u.um, 5.3*u.um)) y_fit = g2_fit(x*u.um) plt.plot(x, y) plt.plot(x, y_fit) plt.title('Double Peak Fit Within a Window') plt.grid(True) Double Peak Fit Within Around a Center Window ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Double peak fit using data in the spectrum centered on ``4.7*u.um`` +/- ``0.3*u.um``. .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(42) g1 = models.Gaussian1D(1, 4.6, 0.2) g2 = models.Gaussian1D(2.5, 5.5, 0.1) x = np.linspace(0, 10, 200) y = g1(x) + g2(x) + np.random.normal(0., 0.2, x.shape) # Create the spectrum to fit spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit the spectrum g2_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.7*u.um, stddev=0.2*u.um) g2_fit = fit_lines(spectrum, g2_init, window=0.3*u.um) y_fit = g2_fit(x*u.um) plt.plot(x, y) plt.plot(x, y_fit) plt.title('Double Peak Fit Around a Center Window') plt.grid(True) Double Peak Fit - Two Separate Peaks ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Double peak fit where each model ``gl_init`` and ``gr_init`` is fit separately, each within ``0.2*u.um`` of the model's mean. .. plot:: :include-source: :align: center import numpy as np import matplotlib.pyplot as plt from astropy.modeling import models from astropy import units as u from specutils.spectra import Spectrum1D from specutils.fitting import fit_lines # Create a simple spectrum with a Gaussian. np.random.seed(42) g1 = models.Gaussian1D(1, 4.6, 0.2) g2 = models.Gaussian1D(2.5, 5.5, 0.1) x = np.linspace(0, 10, 200) y = g1(x) + g2(x) + np.random.normal(0., 0.2, x.shape) # Create the spectrum to fit spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Fit each peak gl_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.8*u.um, stddev=0.2*u.um) gr_init = models.Gaussian1D(amplitude=2.*u.Jy, mean=5.3*u.um, stddev=0.2*u.um) gl_fit, gr_fit = fit_lines(spectrum, [gl_init, gr_init], window=0.2*u.um) yl_fit = gl_fit(x*u.um) yr_fit = gr_fit(x*u.um) plt.plot(x, y) plt.plot(x, yl_fit) plt.plot(x, yr_fit) plt.title('Double Peak - Two Models') plt.grid(True) Double Peak Fit - Two Separate Peaks With Two Windows ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Double peak fit where each model ``gl_init`` and ``gr_init`` is fit within the corresponding window. .. plot:: :include-source: :align: center >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.modeling import models >>> from astropy import units as u >>> from specutils.spectra import Spectrum1D >>> from specutils.fitting import fit_lines >>> # Create a simple spectrum with a Gaussian. >>> np.random.seed(42) >>> g1 = models.Gaussian1D(1, 4.6, 0.2) >>> g2 = models.Gaussian1D(2.5, 5.5, 0.1) >>> x = np.linspace(0, 10, 200) >>> y = g1(x) + g2(x) + np.random.normal(0., 0.2, x.shape) >>> # Create the spectrum to fit >>> spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) >>> # Fit each peak >>> gl_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.8*u.um, stddev=0.2*u.um) >>> gr_init = models.Gaussian1D(amplitude=2.*u.Jy, mean=5.3*u.um, stddev=0.2*u.um) >>> gl_fit, gr_fit = fit_lines(spectrum, [gl_init, gr_init], window=[(4.6*u.um, 5.3*u.um), (5.3*u.um, 5.8*u.um)]) >>> yl_fit = gl_fit(x*u.um) >>> yr_fit = gr_fit(x*u.um) >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.plot(x, y) # doctest: +IGNORE_OUTPUT >>> ax.plot(x, yl_fit) # doctest: +IGNORE_OUTPUT >>> ax.plot(x, yr_fit) # doctest: +IGNORE_OUTPUT >>> ax.set_title("Double Peak - Two Models and Two Windows") # doctest: +IGNORE_OUTPUT >>> ax.grid(True) # doctest: +IGNORE_OUTPUT Double Peak Fit - Exclude One Region ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Double peak fit where each model ``gl_init`` and ``gr_init`` is fit using all the data *except* between ``5.2*u.um`` and ``5.8*u.um``. .. plot:: :include-source: :align: center >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.modeling import models >>> from astropy import units as u >>> from specutils.spectra import Spectrum1D, SpectralRegion >>> from specutils.fitting import fit_lines >>> # Create a simple spectrum with a Gaussian. >>> np.random.seed(42) >>> g1 = models.Gaussian1D(1, 4.6, 0.2) >>> g2 = models.Gaussian1D(2.5, 5.5, 0.1) >>> x = np.linspace(0, 10, 200) >>> y = g1(x) + g2(x) + np.random.normal(0., 0.2, x.shape) >>> # Create the spectrum to fit >>> spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) >>> # Fit each peak >>> gl_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.8*u.um, stddev=0.2*u.um) >>> gl_fit = fit_lines(spectrum, gl_init, exclude_regions=[SpectralRegion(5.2*u.um, 5.8*u.um)]) >>> yl_fit = gl_fit(x*u.um) >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.plot(x, y) # doctest: +IGNORE_OUTPUT >>> ax.plot(x, yl_fit) # doctest: +IGNORE_OUTPUT >>> ax.set_title("Double Peak - Single Models and Exclude Region") # doctest: +IGNORE_OUTPUT >>> ax.grid(True) # doctest: +IGNORE_OUTPUT .. _specutils-continuum-fitting: Continuum Fitting ----------------- While the line-fitting machinery can be used to fit continuua at the same time as models, often it is convenient to subtract or normalize a spectrum by its continuum before other processing is done. ``specutils`` provides some convenience functions to perform exactly this task. An example is shown below. .. plot:: :include-source: :align: center :context: close-figs >>> import numpy as np >>> import matplotlib.pyplot as plt >>> from astropy.modeling import models >>> from astropy import units as u >>> from specutils.spectra import Spectrum1D, SpectralRegion >>> from specutils.fitting import fit_generic_continuum >>> np.random.seed(0) >>> x = np.linspace(0., 10., 200) >>> y = 3 * np.exp(-0.5 * (x - 6.3)**2 / 0.1**2) >>> y += np.random.normal(0., 0.2, x.shape) >>> y_continuum = 3.2 * np.exp(-0.5 * (x - 5.6)**2 / 4.8**2) >>> y += y_continuum >>> spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) >>> g1_fit = fit_generic_continuum(spectrum) >>> y_continuum_fitted = g1_fit(x*u.um) >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.plot(x, y) # doctest: +IGNORE_OUTPUT >>> ax.plot(x, y_continuum_fitted) # doctest: +IGNORE_OUTPUT >>> ax.set_title("Continuum Fitting") # doctest: +IGNORE_OUTPUT >>> ax.grid(True) # doctest: +IGNORE_OUTPUT .. plot:: :include-source: :align: center :context: close-figs The normalized spectrum is simply the old spectrum devided by the fitted continuum, which returns a new object: >>> spec_normalized = spectrum / y_continuum_fitted >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.plot(spec_normalized.spectral_axis, spec_normalized.flux) # doctest: +IGNORE_OUTPUT >>> ax.set_title("Continuum normalized spectrum") # doctest: +IGNORE_OUTPUT >>> ax.grid(True) # doctest: +IGNORE_OUTPUT When fitting over a specific wavelength region of a spectrum, one should use the ``window`` parameter to specify the region. Windows can be comprised of more than one wavelength interval; each interval is specified by a sequence: .. plot:: :include-source: :align: center :context: close-figs >>> import numpy as np >>> import matplotlib.pyplot as plt >>> import astropy.units as u >>> from specutils.spectra.spectrum1d import Spectrum1D >>> from specutils.fitting.continuum import fit_continuum >>> np.random.seed(0) >>> x = np.linspace(0., 10., 200) >>> y = 3 * np.exp(-0.5 * (x - 6.3) ** 2 / 0.1 ** 2) >>> y += np.random.normal(0., 0.2, x.shape) >>> y += 3.2 * np.exp(-0.5 * (x - 5.6) ** 2 / 4.8 ** 2) >>> spectrum = Spectrum1D(flux=y * u.Jy, spectral_axis=x * u.um) >>> region = [(1 * u.um, 5 * u.um), (7 * u.um, 10 * u.um)] >>> fitted_continuum = fit_continuum(spectrum, window=region) >>> y_fit = fitted_continuum(x*u.um) >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.plot(x, y) # doctest: +IGNORE_OUTPUT >>> ax.plot(x, y_fit) # doctest: +IGNORE_OUTPUT >>> ax.set_title("Continuum Fitting") # doctest: +IGNORE_OUTPUT >>> plt.grid(True) # doctest: +IGNORE_OUTPUT Reference/API ------------- .. automodapi:: specutils.fitting :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/identify.rst0000644000503700020070000000302400000000000021100 0ustar00rosteenSTSCI\science00000000000000============================== Identifying Spectrum1D Formats ============================== ``specutils`` provides a convenience function, `~specutils.io.registers.identify_spectrum_format`, which attempts to guess the `~specutils.Spectrum1D` file format from the list of registered formats, and essentially acts as a wrapper on `~astropy.io.registry.identify_format`. This function is useful for identifying a spectrum file format without reading the whole file with the `~specutils.Spectrum1D.read` method. It uses the same identification method as ``read`` however, so it provides a convenience of access outside of calling ``read`` without any change in underlying functionality. It returns the best guess as to a valid format from the list of ``Formats`` as given by `~astropy.io.registry.get_formats`. For eample, to identify a SDSS MaNGA data cube file: .. code-block:: python >>> from astropy.utils.data import download_file >>> from specutils.io.registers import identify_spectrum_format >>> >>> url = 'https://dr15.sdss.org/sas/dr15/manga/spectro/redux/v2_4_3/8485/stack/manga-8485-1901-LOGCUBE.fits.gz' >>> dd = download_file(url) # doctest: +REMOTE_DATA >>> identify_spectrum_format(dd) # doctest: +REMOTE_DATA 'MaNGA cube' or a JWST extracted 1d spectral file: .. code-block:: python >>> from specutils.io.registers import identify_spectrum_format >>> path = '/data/jwst/jw00626-o030_s00000_nirspec_f170lp-g235m_x1d.fits' >>> identify_spectrum_format(path) # doctest: +SKIP 'JWST x1d' ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1643306919.720045 specutils-1.6.0/docs/img/0000755000503700020070000000000000000000000017310 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/docs/img/quick_start.png0000644000503700020070000022160600000000000022356 0ustar00rosteenSTSCI\science00000000000000PNG  IHDR5sBIT|d pHYsaa?i9tEXtSoftwarematplotlib version 2.2.2, http://matplotlib.org/ IDATx{$Wy'eVݺVw !X0Xxq̂C{4; lUW]T7@ MV]wW^yUW]wuהz$@0lYx!صkݮ]`yy֬t]X^^6 YؑiwE [ovܩ޽{] ,˦ ?& d]#b .>l|waرc,..ZԩS߾}&U/V?/vW8A)D>?e W\~mW\qsyرcowrw:16 !XP'3M'`Tlxx<<< ݛfoo={G|#|wM"N݁bEC |~pԔ{"FŦ!{/\vepe^ ]v\pAE^-v\z|lR@?@|d φ>$GΈ@04y_y ^:w`r :hl(B, N$)I3xit@ MnUX@Q5"%+X IqP"=&PJ|,BNB 5)~۟vW H$ lBzB P$C>wrGJpxy]@wF&l3S@ BgT{0d3`po~u: E7?+f`` p!A p /CQ P~?}9>_ ~p@AYG&&`{RYP=UW> w?}Iw s?4C[O?vWGLsdkZnHۭPA/,@0UqL hlF7& u8kKh۫)iS,Wz)<liqdS`D@{(Iy'tpMGd, @nt \/;7$ua5WPўvおXHi0yod/.M3h=jt˲ hkoƒSQ?|NLwdצSk}w`ďL A.V,6;!L8mg@Y(ĩSNVI~0`$`SxpW<ܟxڑY媛|80@0( )t~u }Cv؊{3߻"+Q>3D,8㘶`:? oG>g HBghXQ塃 ԉZ5UZnYxps=^i:|;T!3i `c{<9}@fO6T*<L̲ $&,z 20^L$`}[}n-Iu@?OCL+[BgӮ2 lh*PgDIs6ɵ|rw9%'kjJ 0&zG2PhmP>c Afh&Q̎M٧SօǴk dxP>M{IZY{?<ѻU 8P 6s+:n>vpen^ RgJ,Ӗ-t8(&ઃ4nDI'RM)g?^76~(wnIӇаVu852L?kX. K:}So(*!r@fl6qL0Ui|8`Iꮧ?8 24F"z|{=MT`Lb#$g$s' =h},vrYJ p ?zݛ47"I`WLSǴ~hB`A =|> ѰMLnCi*G\A|;Ź|i; yi#=mCc}9Rgl:qlJ Ui/&dS❅cMM,ZN ZqTh#_t6hl?x[ YGr&*> UYZC<vU BgӮiU5;O2_i`~,7Jd:\p|NK:3p|%8q='~s|a8rڝ)9P}>6d)D|g|2 ?ySS7^HD܃!3:L%i`HIsS%Tmb2]5Ov|NAЌ8,s_/GD1SD yzo!$ڔMեڀ&`)/,DCpԺsWLSaie Rf4+5G8MNMM.&5$j#`2>c= |8 !Jw -n wu4NT ug0AtHf}i`f&l2q4Q }<ˏ?<^-^xg8uT A Hƛ MH,)R(xC4<&ে/|=?]@3 ,]%}#BtܺA01r+C;4E:9LBR;L=s¾H%  `+=hS%\wy/x^UtKV JM154Lj.>;>]n,!}nd*/}߉WP1 p_:Ahb 1)LX7)(4*4MYݹW*I'e?rNN Roؖo{|8RA 㐎 WT0eS*}Κo(y(qԜMO&MƬ $}qiC7H Է=Wi#׍[RnQVyPVzbSRhc!`QNJa}S~Dzyxn.uZJp/stE؛h&ET ܒ4>=W]; ʏӒ K&VEy\DP񣀧 625?5LqXM+6+PߋtdF̏ \7*8ca>8k 0MS[$gw4G&J Ӄ(3 !:SR|= ]]?'ܩpoM,cb, NKRg "Jur.jSC%",F#)@m&`Pǧ:u;ETӵ@ZCD$pe*ˆdɲL:tRb;Rw5@<R' %]nW>ܠy $1Na#g(` (ξR>neDz]1}6 9 <; V:}/b@SĢ;HuO$Su! pQo[@fN M8203ՅM7=f@T*ZẾ4 J x Hv PL߳:o4ru>_&i Eԅ6Z6+;H]}|'+d P!MyD \:1a `N: TQCpK > z_S 1㻎sp3nk$MFDm=݅wQ}K&MT  ͠K$ϿA`S j*spj칅NIp'Ց+H0kXM!I3{Yؾw ފR&i)$~_Ux~g_Xhp,ˌ\QJ&`,Fj 8]L,(zN0d:|XZ͵m.m43d40Z1u-ph< ^u̿z584O) 8B:58\H}5Z|5jB(>8D4T饠Nq@kbW(BVӏFg߆?v590(*ZrB(2V\S@8ľ`@+}J܆A S2 Z}ipm~,wj2WM&Lt ǵP|!3}c|Xp2}mhR`T9 1/=[+ْ*M&~qIbq.iٱp/OI_t-B2h8 fLHPcˑco f[M֔TV!=D#QBf{ H ~p\X!/~e&9sTZ U@~%`Plǎ~B^42WwB}1]#{ٱ(^͆\4~Y))yS)׬`3 `?j< abwwEʨ&`ȓ[||%WF8W* ` 8LSi _]0Jy - I惷=^f' LєHlxA3$f)`> 3wLwzU!/.=E}ޑ}c/k 8|@$ ̆GA'pLJbk T ,"F'dO#DmMZ\@BA`=A t/,}oBk+^/<+FMm7u? ^^}81LSEUQNA ]H]Bj xzZ3t)?38 8H$aHrB^ >;m^r >,0"C᳥L- Hώw@^EAMϷcE{S M%nH#rܟh։9`>J/uBčb:7$0#Sљ`1ύo6a;|§5|y QuaA aXpɛd%Q.dx:-X3C&/ udr#IUwdy\X}@Nu7 + (3 FQi&U$j̲ "fFɫˀ 36'+&zڌ@<^Y *nQĄ&nZ%߻P4}jsHmN^(p `b+u"$Sm&., Uڪ±wE5dƱ Qg|`J&iX"IÉC)H5t?$%/cXHي#Ԧ⥿|@w-$iy=5D̯G/j&5{'IMb&`tCXux@O}; hTǩ; 0#Y)I\}40LS:! N+^AB'S<ƟA57Y#EKPUGlei3_Hhw,u'W~ܳ'Wzdb4( 8ReU=WT$ psYLAQ  RBEg&#͵XuT@0M3((DO2SPtVI [9-3v}\"ZcUΣ?0ϟ!>z4dY_Lǣ1OBg>pUBOuyRIL&$4!֑GL.eU%y[Asi` QI2&(P ^GPcM!x^{nx׻}6xEՕ3-#vҰΨgbɲPR q9m&E؊wWvXjn['I0S#M} o"nlBg{?p U(`wf@a¦!kn~K᪫#Gfwp 7#<ӟ4{>ݩCe[o<YR>e_Es*3PuQ{ۢ>nD/Y@@ P#pÇ0 nf: xNϐDi${q\tw?p@sӔ_,墨dNL3*iJ ID|42c̑RptM𶷽 +^ aii >OX;ᵯ}-MoK.~~~T5h.ͷLK.N>L M^uu)]A%Fj!ic 4Loq8[ 43 ./w|~#;4&պh4ô"GyN ~O# Zc+S>NoU|:&@ w.R  _!mőZow8 zI\أ-ZϜ ~^NÃOX%phS(WȎuF. 5=l , 5nqS^GH$^x&`ҖS)c Iصk]СC}7OO/׽u^7;wTv]y_HUBK8Jr@@WPct* 9UHM+\8DІ 8 > KwZ$ إf}codeZ.LLlq]<҃$HAAj\=G~4"~E|V ℯHFk+0'8jm7QtJLOoy=v~ 8˲b-*zc`SQpw~G>?|s[nȹu]NR7ہ-?:meR*-*~5b(%y}}ۚ.en3gþ >v;eJB]0Mf"g3F 8TKJeY|R@;w]N> XͿOjC-9 {Z%8/ ?Jw _f ~E;_^ͫ/R*yR8~A3| .1R֤0_ 404MQ sυVN߇ .{^_Ux[ zի`ee~@<c_:|q6ŖmPq9X 8@ EE#UL:0שvQA [UDRiE³[Ewp6߆I.j?Կ%pAe&y |hoSN7;HR7/~>zٕ:mɏ[L@H8{R^.ʐ`@>)+y766t5y ~4Mo+ºjZ_YI40GU[H(4 &/ M2)We.n5+t+mz] &`rKQeZ &`_o>q 84EEKR]apZ?( ,yGf9sǦPZ_5 ɟIЇ>+++𖷼7_ 7x#\}pM7e]_~9<jEg)o+wM=:VyBJ F"QT"aۜ(L$ ,WHY%  P tZ1ty}j`x俕shA .+WŌ[SA r`&3#`QJMԲ0zpyq 11㽍#8go$mla*MC7ѣG믇C_j[U`3 w\}oZ0p`*;CVw9Gg4R/v(`^ *Nt, j8zn+7lA? SOS@\`f=k=3]Q$7+ :k 'Vz 3]?wh*?rz&3Dؙܳ.8p1),)WG"e-}0ɇ8@c ] XU| q,o ̙ا$`r4kkwan 77pzDipO1Txix; W&(ާ,rU (`3~UY n6=sqdYQ3Dڄ*' |\ P`N`lu'nm@ੵ>,?|>gz BRpUP!t5%WL&ʡMfY6\l(i` >v qsMS xz>[8GQ&];rbrw/7WO@ _ܼ!W(\si+<'WzjY*LpiRp;Ty|F`EޖI╙+cA VRHەC>TlYTY?ٷg>p?|ӳ@0/WLc`0*AgͷE3p<ĪDдrܻ./CXL FK*]|!eq͕\i gN;" E0 pAΝ l6NIQ9L2ctDQ<o,LYfiN^k R0=_!AaLZ*D򁮠|Vb[<$Aj!] ާHL3#j7'Vz{H@‚bHvuw#͕/hV[kD۷厤&`GߥyvpRN9~6s^7 1e޳Di?hrDYl~Z|")cINv:b8l&`_cQ!3P> [ƐmHGWߔ]vQUjw/G?Ɍs)"PBgqkUܗ 4^_JTi38 8OoյJȄ9N ;0k q*:0PWGmm,qӡ Hmwl"hg)QC `JQTeeQmXW[vl,0vb88P1pd\RMK|Ԍftt#~rT)TOreaTXwRFQTnU,67l,XqRbC%)SMs|;..CI}UM7=TFA  &uZ*?f vCY98`iX'A>aQ,)peUm0"e7'+A ۰á:K9+~ q(.)= u[X&`w$R~IHn48qxsAejmD\.A5!Pa$0LC(`(uX8a+NkN_?}n NɁVˢK 2BF&VdPiv-BtNq:-dv[0}U i,&=-#0 Uk%cQd?}yKJqf*mV w - Zo40K .Ԑ0 xz8iG4)ohn|;HM! 2רRpjaޱݕ0Aͱn|cvZNgm|xvEF XEHOU͕B{-9 <\P-c;Q+6K/K_~J h_0P40#ս? 5U hQ߫9O&9i`g^cXxJ}Z$`+Q>ڬF0M00+]]vihNҼ6f+ttE;mVC&R4Zvqe2 !QUa|EkP(1gYf܃P~>.:~m2ZU,.HA2$;<>D @!S/ @@c)HτELZRp}}EV8[-:A>I(y4.o6NF$gk;z܂2l ۯm9L=_TЅKF40R]Z <DG5$!׎~̄U!yuZx\q!hm[sNx/q.=F2B'Wd>^UU;A{ߞngYd 2Q =W˂+ 7 L* XCdS@7j"lQs-bs٢&i|S:-(+*n^rNBg S*σ'9ܩ4 wֺA:+o`ƾTzHL͛[SKLh×)8р?q\p~ULLuP&if24*gq$¦ӶRp>(Xb 'Av.L6r۠Y3$5[㚀3XGx>ۖ;m$b`.@g"y Kb8D3MAGJo IDAT/ظ@OR5yV4}| iDЮ'tuXR_vmǴ8j.DXv] z mP٦ؿZ)yR<7UBe2*xBAKIShuVΙjNwAv}!M~9l., >_o:[F(sa](fم.Y cFKOE8@WJM Q&`Z cptTܫ~g_c}&`}nU49W:*"XL% m!|-gY~`|w+FZp,BDm3S?<|@|☩UC{g -><MAED\@j0j8^ gnzl ]kfv ^ﳗТRBB * > (ӂM<$-j )&샀-., gL30O(5M& `'@$pDI#, ,xҜJ=|3@(zA+pD-r_ Ii??U&/,sDԛnC@lg_R@HBZ%L/.g@+=Kr:Q| &`ST^V0oۥ[hh&ctH*GwFC|R .t\ j `$if<7lϑgS?_}NwK2B|L V[TF}A>p8E @]+^[0EȏԚ1f.x `IɁ*E@,N:33Il#h?8S>!k] ^,-u5Q  U]~r!&m$b0O@0L6f<zhp40F"tK }^hnإ>M'h40u8ejܡW߮60>I}S9QRo9u3T_U7V l_E4>nped"BK"A=)\8@$k+ߪv< LE:T˟o a~BW! -`MV"WQRpKA&`Eȟٔ+^>8!$b-\ 40 .ϠӁ[ 8]>4ߑ}] ?f]ym& Fpb}kP-Ը,+ꇍ hwsIج?l.gOMy[~*'y(rؔэ7a &`[38t,Q>.}Ac~ Y M5?pe.HkI.\I=-\TLtًR($ jlA8p)x>z A8H^lnl&J&7e*Q-@K|`pgۦ4uh*0<ȓdEHS4vaUڴ&*ɦ'Wr>Q\ UشG:E@;))9JsțhT~܎chEmm&q9mSʯ_g{h`MA 6׊|\̿40ӁU2(B|,!dE2<]yBPV &G~40ֈ=)>f|-Պᵱ4DEЈ}y=& Qd v(*U@}٬ۧBH_Ô H`蠈5$ާN3]u;#<Iu$˘HϟZC2>Xwsq= `@V̾柮hQs[Fdpt+S)Y %HQZt.<| ڂPg,8} վ&U EQ[@J4()^<:0}H'rx2o7~AL^S Ȃ@RN"}34nUk8M.SĄZ@=ĭF8\>@|&W6Knjyut8٣v/I ?w:ٕ cE4 -:V+$Ae͸h!j(`bp;z߮:MĄMHw,38߾}&PVb%lJ}U@A\ {UubUV(ؕ |i`87KFdLx>6pWڏVUZ0ߣ6۠KV4;-ȱ@ÕǯlK[ڈ @c =FIV͇dWQb;D1ކcoQ&"4kɔ, KCADEI~RԲ 1.׍=ubA>|F@.S >>cڴ1-{V/(ƕ1h X54W d fZP&|e  yӠX-NBgeA eKAQURRSf1|G,zEKܙ梲*#!J >~Pҍ6>lb..0jO!- oND!ؠ|5M(|iBB+|J BdfDw;  z`b@}?Wzb& 90] CPlonN7$ܧp &!3|qxo WW(pfNu(MX43H 7H`e1=ڶ>6#DЊTMT+ CJ\nI\*(NܴImQ!MVHJ ^Q|}&O [b.@ x(`WkZѨ hS(O51 B?ao AS>Wɾ$ AVdAfu0xPЗW߮Iv*ǞeCRk.Qp`:+Гe+yn5L^||FRa +^Qh i`,#E0:(FeC`4]4$ #UA-zq2w40Dj a䴳;NoNL%i`hKlQؒfWMk`c=eZ,@S}M >!`(KT m>ty*y~2jt@% <2%l/!3 vO\6H'sH$p8 \*pR_D407Kcq&UxH\LsC `x}G֜cmO&'tMUuR^,.lE8A 4!'ٯ =BLt_IQ ׂTZq `ʔs4{]4U!٤A << ,8Ե4QQ`@hSh AatLT!pӎPWqN`VڴͿ `ձJ`?] `j( YES2+GuJ@kRX@g@[2<@ J1L`kRYߜKHU/pS3َ_ 62 !u}i` A F(ϦM2h3c@Rq۵_'dVAs% R"k|ކ^؀:S=0Y=G^axZjr-VFubU. =ېtْ!Qu0'p cwIgQ^s<5Xb3s3>E~B̿yv"By"h"IA'vmόs)o,V M,mlC^;N`+c)6O0wyp݊󙆢/xŇR3ps$Z ;?-SK:v ئ8@0m :jX,$ x8pE'mXM@ceWNfh@H EUݕmUmT7IYBV  3gV2̸iu3GQJfM | LAt#&;@RS[ lqm tuxI i*T)3{eMBǞ6G7%t pAQ@?|&`w5SAZfS>,j3}fhSwl q@M?Xb<8bufNf>[Jn(͔>1@BU奆JSYJp}Z&_ حd(7 0McQw('GM[@UW7Ƥ7$A*fX+(`&`R%`ff(| G1K*+0*7YJ(fh~xѸ_"6gtMX`AOft0"1;A! 8 cfHTz>EDA %] x b0`WXV զLf7L+D~q_޲jHQN[u}N[xt)E2I}Ccps@T8"߸`EI܃, E+C?ކ7(y沌C.*o&E%=wܬ5[`D{Sq\YB"hu>p!13.K(`p>!WV/.tg;ĶwO0YaP#lwK@R2i÷J bԊ$jЃ|klη5'FL4ux4Wu&)PA VȞs='0Cr_M =:9t3]$!0CRg DgtSp }cR&0sߦMn@Lx'u]tTi@ovJRRD!SF&a(`wA 'UbuvOK *]5 NH%T\uZe|:`3"\9i`[l h[: 6*N:V@Xtvtw+pY9.uWm`J /w|nXOsA &`#\bV&,+,.CydwiXڲ6[_B *FҊGxRO=NQ 2<Ǥ$;44:AkXnbFeR*I 2QRܚz8h?4f+"Y¶* s+I:|Rt{l\*e40kJI7@@[ڭ|فm>|35"\ )]ߴ`P HȺg.0lp@N< A8%qMF[ DOf`Oc!uT!$@Jqg~׭ozk#.eѩ|j$:1h"[=@ۂ&3VuB =6Qim 2]J+PȣݭA D#ev嘴i{*@J7ڏ,5Q0“ ] OXa|Re x:8p 8y] GEo*QUk8ip,rd2%)pƖ8fԷ`ihuz>5{AEarAWvI4W3Q4֑OVsl ֊%*ns\?%VBƃ@gUʧ-;Lc+?_j2jLEIPTկPZ0Xۂ|8|) u}A XO֝]4}}1k!ci! Y@o3ݗsX[@jiQ&4+cpY?b%Tȫ%ե A GR 5A?ڭ6| P0RP Y*1ۮ%&г}_|O"6SK6;7B#.)~=,CR<0c0E(`&`yyܮATyZ3L5nVB$`Oɓ6ԁ\3Hr%ТNq=k"h蹯4OYYdqn ATWHl `I=<Ϟ\VAK埅Rp.RE[}/2@إ-df,Öf[EYaj"B}>0؛2E'&`Z=iDA @]h-lyb+[?\fbTݦ;PE*0CULE'ٟ\P <ЂV4Cpd cf&P 8 P1ƕ+a\Y):-( Ffz}lyrPw!%ߧ,!und.E7yJ 0 ``uWzM@I&`$ t08zd ƕ7! >pq@+$&*4!3 pշz\q?䂠Uf5+9yh- 0ywx+%gT|Vm wy+Ӈ2 p`x?Qܰ ׊]I]xb)8lxX1K5cV괴f_Ll4j#!pBnIḝYClDӨ %'R p@#X6xtrRuZ~pu jVTT`vJAX,S\i={nS'un0<7ӧS:[+5 FKfCEP$I?~Y'i`,S_b>nm J GQ#ˊϕk1MgOsa3]H4=g@(?|QYA̻J lt?aK`aa.r{?sw.B/~qB>mQ R3H&ڐU4%u>R PTd*'Ji+&,ӊCxZɏaɿ+  4`eJ D + };P2b$5 )7Zfy') )qx5BI)8۳IB,Ha(!ՠt<a*V0IMYawZ6 Ĕ @s.KcSPW-h~ӟknK/ 9bݾSO=g}cpO磃(Bs1~ʂ@xY8̕(f%I,#<+$'WuZj" >ow+$0JXAآй 1guU%thE:}LZΣ] G_%g%TǦP;I]w>u={׾5_O>$o&}t]v\IQW hE1!H vxL_ζ<(0[-40Q-mcԷ*_1 k/x]~_w{TЌRíI7$͌:t1JAV9cT o,P`|j%b8׊GR:0gx ρW\cfqQdBsB"-8 #" KD8b2~d3x Ȉ4tZ%=LBiG5y o=p7;aΝݻ'"D*_fƾ}M>%P1 V@VQtN]0Q C a[?i`Lʣ9-N\ WjZsfؒ@ lQvh?yt@hpRpJz)鵡hֿNӄ^vX:?~ }WHGN+vI,L@h.- ~ [H1J;PTvǘKٰh ),>L.&bSs=Z>|pX /})HЗp!IF:8uo߾Nbp3P@_ @`%<V> Gl(hHEy M:xO:͵Z( 8߷ZVxj_WxǢF6R3-)⢩)xHj6@0e>~.~ ̏UlF hӑ{dcFrBG 0 w. % 8%YDZ)0^KW1/i'&bSNykoWߥi ~;\q}^“O> )yq X;v uJ L}^ry wO 9ε9FT1(``Y<(`K@Hˮ{xᔤTW~P]&`z $lm)8hy׸2 ,f7 o%ih`!xex Ei5}mwƷ?V FتM]|4h A߽TT&@dܩai`cg[0%6fq%SLŦ ^{-|cO~#vXYYQQo~Sۿo'N;Nx[nwcZP~<5oM"HRp<O9V9AO(G儎'Rᠦ`!NV9!Q6(š *7 T=s*< 1g| =OxOM&T `>B)2F;2% ru }) S'&VDPil% ^d]lۅM @W`J"M7= _=:t^WíުCy@{n z׻~/w;;:ʨ[D2nn3 ;,o JTA I`*i`r_1nOʹg} Jq~(8#U &` 'GifN\<0\gF& TZqh\ l Unq0l!ܙKh#r +j *@ @\]me8̫iz,>V s 0knņϱpzͭE 0=v@a$yMCkq﮸ Us*=V(:L84uHX]aJ2hM2BW-@ݵ-JZ` ]m4*v?N7qO4Isy[E5 QG\%D4%l%VkF|kQ # â6ZYM-oL"~->; `PWH./iA 平c4sE8 (LrP].a [T1Y|]UhCZ$?aiL[as g8V\%ik #,<خ-̎ρ,*#Fr،_/(c(u ~Yh侜._(lsѥt}2~_k.!$, `1\L$ oPm-ܝ>{J t%^Ji`, }f|Qh ]Я*3PNPBgVl8R WmA:X1@MфIkIW>'j*mϋxM b- "Ѷxp"qV&oYL-+pS=TA H0ZX' صMBV gcmɮ:Uu^66m̸1{1c BBHDX([D$3&Yȸcuw=U?k޻׻%={=jW]k[01u$*8@~7jȵGu%`%1)W1pJi6Of{Sx*cr?\.lǐ!U4r|e-MNL2*5 J%9VX!Q( !6L!U2ZZ)j"0ܮlI!?  LJ@ +o]7,1莉P,~.^g2<HGb%`\<ĔQ:H 8Bڰ%`Ge%P00f"Eh P3b@o%G9@zm* =#yc#h<.\Fs{S7{{ K\yh#8:Y@VnI4_h%C&n( &1.i(?jጠ `ې MRHup 0$ôn #P/w(!Qd.$$1\ЕsE _WN d"[iOF8ƈ% JoNJ7aCPBYS(6A"27 8b2HƥOr%L<`;&]<&;zhI 뼂=] nUk[LT~&l@|*\cQEVBU{~t)9"' n5M51$qzaMg=wD$ix]ZB_U+6R2@X+s035OcS Kg*PH !CxGlQ҅xON  9'S/NEXJՂiY"-)%Z.@T)1cX)~2l|cG/pnl80~{yv\(-ߦ/Pƣvv17apl( d>DFS%`koWC)ܕ9 rb1$qڸ-1>5T( +hV[Y5h/%J @zbV4K3SbH.)Ů"jW_$|e25PE&Q 1OdpqŧnFORurUuX1]gҕjqi x,*#g&7U1Qux҄ o?zzaLjjj PC'gI%[CxGX7v ȏ7Kۭ J<p%ºzPec600&'6mʕuH{*CZp@1 =-唀E*8!e1(UkwTt'30#bB봁k=uPFҗQ+K*}E5@r.Lضr7o2&Z9 X%KBMK$Ie:k2 N "QY44iy %[Cx+)"--!WՕ6P H$cLu&Lrq>tRpZ"Q x@j*P 9,}@DYyYX$r{.QL8v Mh!unCFH IDATCJ :I#(",N%!RͫvQTrpSh}[%`)Ԡ!i87FB:-dzlP< @AWK߆N 66hIrCZDVǥx8b`Xΐ? %' 2D"/I&( R֍}}Ս JX;ahO)E p<=vWEUqn{nt^x Z9X` x+82O71H{,0PKrFc/ܽ}` u#*O9ج>ԃT4I0]10Lt csj!}']I*Ep4vġ|Kb0zj/Y` CEbB8r>JPUϵMG%5{$st, EC"d ln}mԄIϏ"iybiiX>@ -i_DjuX7 QU㾺%7>Iǟz|f-8أII cحZAt\~Ky*p(}r-<%KRv ~M^&POahȚ rcԞpE{,XV׭zťbG1 q bcVp1m` On1bT嵢Ia#'|: (J9b|,RBm`44s[ .W`"C'F`EqzRҿq-&;dR _Ƅ^Q~5W_w%=HU$pP.f(팠#sF %/櫚)PyD yb1$qD9'@z[I fMDHsXRHLRX$7(Ir1RAK  Z`i'605Z)SD X_x%"Oq_` I*eL q4Xda874ߦAU,zeڭ$T n%t!{`_sUKpTnZE[O%.WUͶj>}~ mRuJ=,Hnȏ7$k+^l99e,$NJqTL2g^|rӐ gmV1oWxF~`@,0&D BD8t8^hJDQC'"@UOPmGi]pM(`\(#W^%!> aNyĕ=K.X0Tu lL.Jk^0!mi(8d!#$>StœpQ3[!MPNr%m'ɢ\YڽMl$Yp}ϫ/6_[<0>ZUKpC,wrln` M~aHR8c%Jrǵ%O#EE>1i@ok-x 8 ( .Zh[CxGL KV&2aaN  4|BzXGʉc9GU<\)'R ΛOn$ʲ$ z.X&H!X5;_ 6TsiJ XGh9]nzkS7<7Õq JASï9N o!2adcT棨CI_[HpFl`Vp/+0)=o]mmich+k`U5jPAC>=JCl?6 @d:HӀXHp(C#J W:MF!ZiP~&ۘw-W֞/ϘQ2({ /[L_N@,g rˎ:X|;@m:7J2&@XNMUOYTEɒ> s~S@=6uL+_87* < x:*]벓~D&F;蟏Y`J  '_5KdM.0T"\:@WǼ x| }[&@?*hNZ퍬*@\(dHdP&QP\QMx M.IF@|oY.=Ox,kQɻx.ߙVl`x TEV&*.i%`i-iz@2 4[E)# C-NCZ%" &p A#IDAw}@Xx5FpQQ60 q]?G+"矄ޟ=cHoKxAm"q|YU O_?A F4W.'=x=2 lߑe$O{ybM~ MZ<{K\G M;DUzi,*(}qX|$:UN!yIɏqFKn0Srݘ{d]9GdrA{֭$o1oMÑqzx@d/`׀GcǢ9d$nǔV|< .3;e]Zpqx,ԏY2WKo1$Y?,\?Y{( ]V|Tn+4l~VLN 8G[}=1B/8"ZLĶk y'ZX1P5VU_  +54)&>)}O7@&B@N>JuԲL$D c;8{u[2q8YVl"F/(%0+u!b p@r\Q:]יȼ11poXvزֹZ4riɿy= O]o.+kB~h%D6. FGSeܹ',Vp%E?x9'&W)>aN! nHKF$ JݏG޳Գ '/\kx8)'Vz}kϢ#du|mĪ6)+4Sc?}:|陛pd6Pҙ)PY95B+)ʌye\DZ륏dU7nQnĘ? DرۍUUo0&Ŀzؽvp״|@qc7*NXZ'g &TIu qCCu@+|D]g}A:h|,UEe7eA; m@ Yte+)ȇ/7PSq 8n Ni@L_`bTnK.hy06t߹@Ő DžBx5<)iNJ={ 9=X?6 \F{=OnbF~2MOқK,eud كwtT•aR$v: I(@HPlnǢ4&=t򗗍XR8fqwmZ kjLij߃N d_'LT9JY{HQ.絖g2ƸU>fD&_*Vs(rh`q8^ `dniɕ #hLTL2{vy@:^$oÀ$m.CΗ;r]@{kdV*UmH7\>;UZ򶤹sN/`KxN`-UJ_G Lh' !*`NOno?%k5EϳƗ;8Rz(Q4)XH'b[bSuGQ Ha Xvu?4fA;D ANI0Ļ~ɥXtiRs %ƐfAKRu*#+?q -^P*gE>Ƥpȕj {1@|/_oCA( RvhJ<Ư)?+_Ds,hr,Z{0`涖:#SZUҸ,a\s-4YMmKB16010ׂ V*ZX%u0!ˡU6K_}-d^1h0 4~^&Y60 7qeqS"m "IpJ0G4KmVQLviXX(Du0O=p "5m@>VzxpThSD'`PC6r@~w?/b1JĻ RR蘔} BC}L'&@Y@zN";d7K‡,Q n( X,1 . t|B17C{)\H`jtvCx|Tiܘא"n}cdL%Dfvڻ9F1Bs!D&#>YVl`詤 cBrG'ΑJ.!u\$YΘPm`n BeoN@R]1*`|eCU]ܲ9QmHLemP>"APFЁX{bPHϱDScp?f]EJ&'O,Bܧ>vV0κ,S'G!:#KEKHչ m"r@P '}[%`D;RJu PAԎ.}+8LB[ Q8 Lp 13Ȯ 犣J|!lAJ@lp`oD> &<-䶴𪪳8t~sCG,pX?6kL;Fhk]DCS8%`|8{65TUTJrdf20j-XN|+x `ӗ# )\8Xao[?zýV) .#<yXA&Tu䖀v@#vB) .9ucUb-D9^K2Z Lk D,0Lc4p[$cOu\Vˍ'l4 ̎c(fD u eސ֜m }.@&z[AհϜL}h#!V֕FihJXi-dx6 n;0#d -X67x ŧ|%H ԍcbCe XE /Z60\Rd/V@'D >ޒ.5 jqpOVvi ' `b~>TA(AGD ұ4FYPS.`(hkeS&07pe-,ttr6*Gh= ݾu,Uߕ_In(KZu`?\Y@%=8f&s;_|{M+7^ho n&%,`AA)ZBV hL,L0X 8[߅Ԕ:DC%/~?ZͱL 8v|v$k@dKK|L4 _DeuArXG&1J 5T8|ł)9"ҷ mBD$z`Pj| Z+z+5ba^ ʝz@09BdX0DE+1!yV2 ƶ_uM1o\kUaKv1Dq=Re h ߫]29.ߧQ #Y%`Rivt =xF# u>݀,+1=p#b%m|Q K\p$ |X"S'>@mn+x}Q-U'3 ͫ/T#ﱂU̵b-L}ĐF1<$%u}mC ͫ+K%,Ҥ˗,p"nt۬^-+tT>c$tDLG@ 4rKߋeSRh'0=זwVp@<|5[g DKBM+` :`HRt6gZCC뛸xYfw++P] mL` <\/Nbl3#gDz,q%;؄r6}y2DC;z= )/yAӗ(˧,,N">I=P=>%`Uibla5f->D \C'Yu0^,G iSݐ?f!tBnKܹDTNr<('xYdp^20@vCxMz 8 *y讳|Jvj2uQA y"@ٺHd@ʝ"{[W8p"c nS IDATF ص ddD#+" ]6Y#E)h E:ZykN 1=Ֆ/֮.3 斺nk CѨϣ۟!60{e vD[ED  ,/2=H{u>翧! `(hȪ Xc% tLs`BfPbE߳MPL5-Y?ĕr&Tc]`hF봂uJv`lܳ=@GEYW$ߥe^r"{<~氬ޟ/Vny8? Q#`{O͓y%q8 I8s;94: X bHOquwO>4,W|i/`Ka)%w0hUl$o3k2mR,`w"jh۬{&JAIj>ֵ*T 8y&%/+7ȹԑtj7yM_ONMc@HMeYRi\6{]Sy )f&zjIzԪ1δ8kKFcj%Ő׮),$>b>AW/;Ms^  1\h6 @FCrN. VTC4RY!pv0_|*R$BOkhCzeN51OX>X~5*c1oDc-OKo*rW"aࢪ 0`<y )nt}wf[>)/tyL~n&IC D >8pB\OHm`9'וD&hM@Ն% nHD epD xn ICķ_V[)ɇ`RE~')0Cǻ߲JM"k9>iu >WND-%A/`'=ܚS8y@&iFC$D s| |퓋1Y+eF&˭ng$B~"܇,ocg%`ҕV"ɍc@zRI@dYܴP^r|慹z|xӇO"ܿT# BZ覷"15zJ1N?@R>!OQf,?_40kBac@ )WT6P^0`(ZLN i 4OYŐ WB<@VTkV:7SV uV8Nd+lxAWwVJi/\;"۶@(A-Z)j'InF+8s~>%0&MuSomQ@,8+)I:aq0ɾkFl%R9G,m|+VEXWx`{n'P!}Q<PQ23rKzgpޑ|z39!֋!<e*U LS*!cplCXZJ{ښ 8F" *"9.B?5ﲇ,Ő"g!\uۍ$3*?Q>"=W~ᤍ#$y#60:p[*T5"f1sD  X4IcA5M/c7\;^B-6R/)M@{0b"\$;\jIȞtޑP] ):QW@rZe+>K+HMc*gъI,[Bp$k=GNGįŲ뭉37{vUw,9?p(R>T 8l}6ƾk 1hJ]y)Pjz~v"]O\o42E ӑ=J"B+RQ6DҎucj/V@,s@rKp-1 RI}Cd4T'@gCN@[H!acٛ qhg^$k"}J[&5Zڍ-$9"RAc`R R,}F-ΩTzqR@T2Ҕ]pfOq dU~˪q+g0ŗs&~ŝg!'3"+>ceC&#q ں @!|dxoa\fѰ, NSAw*`d\2`pi y-20`&ZDq )~TWlq>k$>ciA{˄ 7Fa;zGԃ c(圓em`jD#qv+`d=9YFвFٌ<-_<*#CCC QүORMfS~a!tYtU>@ضYUz/`QM"@%tO V2JÖr؂JSe>Pm'hEO}Y#\ PUc X T q4D+D 4Wj" N&VLkl`vCx!:"?FBR[q.We1Ҏw=U^c,i{ GAJq0J(#j'Q;( 0dr ßҚc3׬ūH@~\@|u@*@dĈ >}\׳ YwZj ASK+N9}'r@i.3L〠C5_)ꍩ@L?[FBUSXRxtpP ),.L@uFhB"iAK1K1IbHw"-yD3&.00Z@m\8RH(*\oEńGK $,2@daFJSXX}e #FZEn:|L'+D ȕ< U]ZKtW<ڍiWs4yI,>4D %С9QšP;>YI_\ӿS{ 8f>arq]B(b>[Eʪ`=/[ayY;}E#s^{ XyZq/+_]wMG]"pWU X"~v(>)r LV~ }!MsӫQ'. 'VWt gKq44N=0(KN9G٢|6MeV#@JS60T{ܘÃw9o<Fm9f-'E~zMoz|ӟG?Q(~~`#pY  E[(\#h>$@|_0( 6#?dYvYT9?U3!Ȯ 15iY\D߇/EqHJcdtZ|DষMx 8TU 99ӑFв= NhF|_9}1w;^W:oP;Ish-͊2gniF="݆?V\Sbc\K:gvsACD8ܹD(aLH !G9s%C+Nj&>y{}/|׽u𶷽 ~OOÛ=4?P bpV9mcM0c%Yxr}9Ö5QXI_@Tz"UW\*8*1:eqt3p9ru.Tz X+Å?[f PMڍB&t_*\>_zwQ*dAss~niwAS`p DŖE -\7qa"Cpak.A8k&%`pN} Ew]w^?A8<<|#gw --{m^@Q]*/V9 CKXr"ձfj @2@\ PPK$5.goaU7qZV 0_*0hH$^?QF UH˲_-z.B.u6dR(SC8^(48}-("b`c$ۧX 5+$q*hi .>P@E?Yv@"mp$@iXP.Cނ;=WtN]UbHw/pXg>xGkeY£> ԧ/"s=?Ypuo;p0{0Uފ 8 *m@1Rc-|yb"SQ%(r%XH yOȳ]۹;NZR@ xa,{|GF`hUMT{UCqv zYҒ'u8ke(7=/ڞP [aYpo̠Kq` u]);Vo=@"ϏV|)k%ڿ˳Ģ[Ҁw-j(.^ > UU+WW\zJïگ>&"O3\ݟ=Vn,D'uD ʢ"6^ @\,}K~?} \T8 AN?3%w߃FC'!Eas]wh4Wׯ^ {o/}K|߽V }<^Wf0Ͷ'MTOSblj d G SV^5ko ʅgA#KZ'JRQ+t(>^`2s?=dY:y>C@Ag6nƦiQXnlu60bD&?D O7,KTRT<LM;p b[>6)ɨ__py~8Gd%`Ӫ`xQ 뻾 >O꺆O|~a?ַܿ>V^de9 C-ɉj;ӯܷo v <_׫5Vn|Q*k V/?tevҜ~&m]bb7 7o EQޅ Ғ' uE]7 r;r]<0*j[ Fh2laZAMݕ->#zs3~0ktb\tiȴ6^ { xo|W~W͛w~xx^ײ_t xVFJ+˛XKxTl\|UG'm߬"j]`n?K+IE'0Q+Z y[&><ѿ{[ab#9HG^<öJ r K.W"Un۲v{ 7;FCW,L bI4p gAc" yH H;'QXSK'#ZLp$-LeP70WX<{QK~nN_w/e5* 8(w/gz y?!_Wd{ ҕ8-Gaʱ87|NuF\:cR ˇu1 yx]<w8P’Cem fc'JBfY%n>BᇍVMkvZD}uמ%S稀7L &-#m䨀8 \dZ\Qsj`\ũHB&'65u.IB"t|kC3LeXbGM!WKCx$~~O~a@*] hE2@#05h ߸`s#hcU3<^Aq`^ 8<|eB9PY(Gr_'@׬ճE C xJ_lAs`( fVp^ *Vj @1R42,^SЬb*`WPJb}\峭/L1{WH@ےn؃VяƴITbxɥ3Ɨߑ|/=.qO~a`c\:<k~V"1}=/̝:A9.kc60x\lNcQA@P8PBbvh60 rȱG{Y**O*`VNhRD΁2FƐH1+ԥjI&m++FdjN/C#Bg0Mi~O,!~[ 銛l`80#x$߇aƵﶯŌ,Ckf+㞐h=WwmX2t;W_Ws6k*%Mk4! z*OF!h~9|7qk— kn<_wg3R]pvOqD {P*i3bsp٩@EHEN-⤷ zٛ;ۇ&& PSkœY\ IDAT /~{ER}GLJC cMBn+P|Z"ˈPp:o aTzCj-D҄N eY "5l`𻓭x/CuJo** ? ͇x̢2h?)ߛ,L/;ֆϑXRdQ4D:CtTd061Szj_{/|ϷmǗC6OZ(F*rloBK/p.M1fF ؗ,92xoP&ee&hUv9."QC`L.Q6)2e=>e8 ONjH+y^[\<=O8GaJHԐZdB%sQcdXNL; ae%`[ gI XccL)GalR'q_4(>oSk" zq|K&E;ؕ, Ījq%%`|OTB]$ REˀ$uD 4W8z[Adz4,Vl#juCyhF#88fB97}20B+%k38Wzk-W j]7nM)yiLʼta#O^bHOqhQYV !cHNh60.B1RaY!ˢxp@MË A4d 0_$ ^!,ԮqfZyЮ">s4YSgrt<$@w "Yg!V#("UEZDԿ/L鵟 )g/X4[DT+dҠ|V81o*y7M8l^T3wCxroxaጯb .tl_\'9t h/`h6M)(ٰȇDTKqpY ͽ)[ o@rl%8PCeYL^FkkKQQkqjjp8 Sc2`cue! l9.TțX-+ Wth*_&90嬀\ NcHOq$[H0Um@?_?a<@խ|7o.\'y^ܕi?`0t@8Zæi@wD7˂qwQJ1lK J 8Le x9\ .,z?cTm`'r,"ⅢWWJ^aj`PёH(Q[ї8!ie {?^s`SO i~s\e쫡IlrMcȠC+hѿ.aߧfKeՌ y:;4"Q x'@Z~fUk@)@Ckw&abzz/t}a Ihv,~f-ѷRrsl`öoh{%HZn1*P5+_(P&[m [JSKh/Ul2h;0=G"9AߛsP$TƐPɨn땿 .]C10pNJ| /ud鉆 8"RuY@IEB]GB%tȌ+ V屍ރ n 0@sn+J@bjLUݰ*&/=rsKy5XnG;f>GZqgKL91i{!CS*8.:6?pr[=)]ߧb`$-@!CNEQ0!L5MKW~̝<{7 |%`4pUBO^qHDfhud"kE̲Õ;ܿ0Am`vSpݔ+-txre] pVXg!J$U l./%`҂ǎjlk604-{j5cc$n/Gz]YM(bBcO 2l $Ї(9lL"z'xf=!P>š"E ] x!RGdh; (\Q[k#lC[:Sh9/+;k1SnD 9%`OJtq ~h{6$!<š#`!1Hl`v$MMi~nr7&6&J004=xL0Ac &)k @ ~cǠyaXW >^1`@siE>8^'[F VӉ%$Qڿ_ʿQ/!ǐʠ3;rO;5Ԙ}&ReEUh'\~4^If@Z Zن?5`hpA'YDS%[""WD-WN xN,Ł71m" ,$M'>|4mM.ϯހﯮ,?{`3c@`3ˋ)O`4{H@`!اEVp^Nohԛ#jvƘEQklX:mHSÎF'4˨!,&r ǐgNJ#] ™u )QހO1]&}|-pe$Ax™Ii^ ~{a8%yXƣȔ\ڿPCjQ@f&@#f"m,+(Q~(gU h3wCxCC5pdC@$#bPCw<8YDdciBOLst8>|:FLOĵVYIP??ݎ]CJi X=^Um[֬A+Fͫo} !apԬ} @% Mq1Lxe!خ'vѿwTZ+>$mطO{Őp4p AxOQ8F2B}wqЇߪnH/#}J2|yVg>Jڽu /@a'?޹]@QLH{·|c^"e 8e ;7JSD1t]ǐ;(iQ;MTqş*S7S+ұ{hoxo}h;H릁g_Xk`qJTJ^Q,em`G(o }̗!7k^8;^vap=$oB0nزRaWbwv<*0k^Uy=fA{c `wk` xwY g x:3|J!榴<S>DbC#hRԥ;z&gY-I0ǿ.x˫V@$Z>d$r!x|̣Q^`D= X>KbǐNcⅸ, J0DJC;_-A:_P5~cUeQ0! p{YFpv;0O C:etiW@CD 3tcՄ  &~V/`c 9}|Hh@]|@wxJNgN/`NvnۉW Er/tk)o%JwP|ܱĈN48HL)2?Gvs"E] Q~[QG@^-CXbf芍X3piz[nl??_Uzl+!-*PG D T3p$ bG-x&D uT"ՕPŒ3u9CxJHX xT7u<)Tz"u3%~8q '7eE4h®%uCl:3h^f-KţBxcېKX;pxj9\lƅPJ<@CxJ @oWDŢ(&yN}ǺV+xaN8Qi"P"J: !Ӱ@濗iX}VtY^Exe&AweFE8m/qhڱM:nUE @{)yh XL-!)y 8n%|Bp7 ;x>/T5&Zi/UwiG/U#mHF0*= u1ϗ;fh*` Dv3@ +\( cHOiY @-A,yR.x^CU"+ݜc 8n(1@:m@WoL#(N󥍪Y}]#Q =ON Vw-#ne#\ Ƞwf2"@̓T@Va _/`"\1L  Us>Lmo#W u WF. ]n/" mI$hp*ۺu h+m`/ᣟ*<ث+cO'^떀gFt9O4٭ |R?@LZ0>Hr, f_|_E'jKY'sjEty>ؕO" YgH]pSM X zgU7Z9vnOC+=z\d@ O,b S\8^ 9~ZWϟ,1Ս/ pOiX@ LMNL(%ԎXDɽTP `׶^ )4M&FIK|V--7:u)WV㾭RIT^Kx--V}o[ ,CAf K-` NGcà*-m {](tD 48P3aA,-tv8pPrc ˟ah#D [,G%;'!;7rz81{T7VͰľC"Lp[!8کw}i 17 `N l`K@-='b~~b d͜$2E8X7/M@./Kf1$4,p2@rXxrΒO IDATd'K,*竚 @N >qT l)qX|<@L..@YU(`ό6vV~}u"y$Xn=% ,܏myf'F9ڱSHC-aңq4tpt xJ4;`>S6eVhFДShT) !4_Ua>=mqXàf `%Ơ5PQ׿^y9/H*0qDnR"1fpkMN5?T<Īné.E8rd8MC+@ǣ*{P\yOҲwj =uJn4z#4 jST<%`zc̙?R:}ݚ̴IdY3̱9jc""I eDMCϷ%] a߷zb:OwJ BtA}tYp0 X@Dӎ-s1kLE 3Ԭ[+Jnxas7Uv`wMʏ׻N C x?1Oi"eP*n+CWIDn KUmwF 6,PdTIR9}[v~Brr! oc@݃o@^;&jpS^Hx[lciǖItpC:otYw+[Le(mQM#Ch>Fzp+/E '@{oYWYR%p9Hlҩط/0]6Xs_JlRChRr%`qh&wgtڍ3l INBLDtaP7pc5Ji(*}Y? <&l)xU͒Wy$,⹥A[E,zNtˑp|@`f R`9;z"D c U:ZȚYfdhǘY"bl5EױV'.PѝfsK8g=Qvi]9dj_pp[ N 6e`60j@9 G舕{@$=UKC`f7^r~y{=L7U#<#Kk-=dŸ,aYU*n P"!m^ |=7U oMG>n@N$Ck~@K]7;ٸ ^^ ) o%k" x@\gi5y" '<D@)VX*`;W9`'IJ;KhVpFPN#jD  |﫯ۍ09F%`p$N՛;V8wX/) ͥ#5xVa38LT@[oK&FF xOЀV0GJ웰rDU*W{.2 k/ާwh;?RY,"mI?n UY^>@;?gKâc@OimQ b1 Vp~KK,p` G-*#rݫtYnE"eFкArP"Fm<_\}UKr{ )wA-m͑Vw(%q-;|+_~Ge)=Ő@n@&e ț4tldEQm/׌3;쨄EJggc;7DJ\%0id [T[7('/S:s?hE0fn%-%h7tXy^ŐHD"g83s7O/Вi lhmx%`yroWiwXFo/~>pN Ot)j()t7UqڡQ Lp:rMo @,>IiH0P>#-q'0Vx#:ٛ0ٍ&j!_b4[sJf>Vg[%`ʷg x.T#R} 0!{w]GyiS:% ,7=x{۟- *-6_T!<S .wξ|n'}$*^K_p0q/w soC,! 3MhiUAhri0*6Z98-60!p*M/' Yg XҾVszݾ4'^s^IcW%`\'8L >EU>=&?mCo˗/˗G߱}]ub X3EPPZæ+ 9p##i0@Vp"U&?{K-(o <0J(J;AI\ZDzR]禃 E~c=>^:xO?O~?O} |A>׾o>%`HkB=2Kd%jhfg o''o'(Ɖ`+i_ mȳK[v/` }+(6r ܗUDw`F秾^Z}@8dOпƋ&|z׻|'5~pxxGoO<#Ç?a>Oyz@DrSJzWZ|#d"{ cU7;3E|UpLM[};ee* )GH_frKPv"YEK,?ȸM/s6@qh -8$EX,3 <,GO}SY8::r wܑgn먺cp*]1Y [:9' NjDA[]t@uQ<ΓEe+#G960cxB~I&Ro2GJeN  hlGeb[]G\'獃= uiy3^"g}+Wx/+WŬm ,1a>߯_ހ3@);WyI=lQïk+ ;6x U]@"@UbWn,yHщt+C <&#`'^TN C}}T,{BrD @ <~'v{Fe(|En{(o{ .^=;S{=wF{W{·#E8I0A܄jn$F&Ozh" =!OT^YI `PBx H+y]:HKȺal&uƒm: r8#,`/hW^e_zg޹IQs;{vKPDn7&ވ $jEV4DH"Bu[*Adݝs{Lٞ3>K/aܸqY]jەO?4a6 FUg>Q"0gj}NʱH3+'3i`av@$Ӓk=%ɠN y7" X$9`&ĥa0`tj%:75]aGM߆QnhXLt_X &MRp[~=l۶ 'O9%%%R % &f7Ec&ྠBGɀ`fRf@T>Fi`tKFM$ @@ɿ=~suc߼ X;lM`dM-`kQF>9&`^fm1rs7ƝQ .#$/|`ꪫ0ydL2<:;;xbE0h [pwcزe 6@EE**.L{OJ9JM&|<ߩX8ٓH*^40YOU+?%il*r]*'>H3Q}KO>Ӄw_p H;'euɷpHOݫքjghovÒ^YJ J[9FÒ+yf@^+`E|#^~8|0V^6L0۶mSCߏ;?#%\jg͚5Xv]l%`ߍՓ>PE?=wU)+ Ճz} F믿_{}Cy;`$HHBoRW'pu'N1 e ͔zH8aqB~gdA :i`2ž93NI8J̙-HX=nnm)99>ݫ׋EB(/I z&BQ fFڨ Z43bC7(0R/|3䪙kv$) 0i"JpRɜPG}E HH z6 vP WU&Pk /Tc40|@ A%ܘUw} $IꙀK"!%)&J >Z![)8l6;zBvӮ-3Gǂ M#\W@?{jQՔpD7:mUQ-a-v'J\BUTi` r:>o3Ђ@S ld u(>nn]oՓPEg^,M?h@,o/@~ ϯ(`E55Ekw((A8 iͷv`Y|LшGrŽk0yD27%n)8u;{j+pi`xS[9 }3(`p$<8-|yHNhi `{Ia5fg@܊6'[PPrZLYU .) 5P'`_d1((1h0n s}@; 8g;0 XW>NT>nS@%M ̻QXE13p9 BJT5W3O!PPXa-`b^' LN?}էb.R|FY?;J8zCs37]dhhuM@*G`QjPCSi`w7e!S}؎(rS 0d Z%#Q%}3]XdV8r;z=()]\OE: 8},] 8bg'Ĩ-um2*|/9W>F&DxeV4}QJ(͘7!+>KÞ&`&` %@AQAlv|Y5c'ө |H,DN 4  4W~yӯͤLU: LyHtiu5@yVJ F}jMbj@d&`+t[i`u~% [W>~VdrM#F?! ->LsܥIӒHeQZʳdi`EwB?\!К=횀%)5nKRY0A Fd97/p40V5,i`$`L,`)覼ϧ' 8[@{C"lLvF&`q(`A< 0եQP)6 h8I2k4 |Ł@A(ITmݺڻzV$ %N$EZ+P-*>&jk+@2LYeJ(WyQkf/5# )BCu;o =t8;[fи v6GfHy,&U.QêkK@AU .c#xvc HzMo`s`gl%FWFуL/t>(`o@Ncbµ)P(d2f2{jFO.~?aϲJH0PeZ@WO}Gׅ)6OaŴ>~I|͒ilx4Vj T4b 'VA] ĽqTƣ{KNʃ [ɇ#_<˱3Q]ރ3/R)8q PPzK,z;V;Qϰ#1M kk0xI=@c U-C.pȂh9mKX%(`@4NMc\"i;k*Ո9 ӲW( hi4B!J8r`%tbIb]0s7aE3͕`0}L6 IjFkGu&`(`OM:l02 qI.x*&MƜq7$``|~>Z8pNx8W") $D@~@%$]di`tL|nZpRpR(ۨ X讶dKi`I}͹kZ>ICkq3<7K>0~E"*Î*HY s@7nF~c'ds"[lJ)A RΈGxaIh@y&t~PCW C3|b5) Vlڤ^5p2 oZ,tWOZAаK?\}@$$dp/5"^J]*ԕ I\̹h4Fi`<0@l@(&̈97CĤIpUPN1vP5Tn=}f*$⺿[u}5 0WRCЊFBBRuӏIє9$0D!oqDfP[7SRg2ȩJ "jbȃXW @Y**? abs _/^Pi\2pp 8#Ww4bCN۶aJ{Ou1pSKn5'Uq >a90ִU&.o @=a}灟_hg o^N,M$MUi7G8܎(`S{*G5V@v)R H͋Z~HT۠c旒wa%4W 8_6XNmY}x xaPC&`aur!X0)D!#[Qc  @zJXK> vVG"&؝*Ay,wx[E1'(`\$d@Q0*si^' U76' P/Li`+/(133#H'kKu% H\5 N Ip*_Rd&`YNuvn2`x_k:CnCh"@sFW` Ӥ$t*dT-3m2nYo[ kYPKΌyM&#݀sMEJ% ^$m.8OW(LxX tכS|mѿ0& l~mkݝxk?sԠ,YAGgRpҡXtbH'15vص(@Lyńo@F6T[O]ӂi`t˒VRpin6 cQKDڏNTE6[}x D>@(@ti.Ӣ]a = ZT[&A4 ?]L^G>Qź*!+7ѸN)fܬ!A0¯(&`Dw/<|+[-`ϧ}ffxHJ ~^jʢ8k`w - iK3@{B:.]J)/fQDkQ5XW 4o6եZWƽ=7V%* ;y1Y]40Ia6rFTP@??\#5ς@Qhڴp~ F2d#c#D@&9JnwS'MLЊ 4A2b]%t@ ?-եf;t"hKMtTbTS5lNQLb4E Hsb1:&!!˶|+J0~p5*ьK")0bφu:=v^ d -s$( H^[XGe<vv ! HަIxod|.LV'7,A ~$}5$3i\4BB(@nb"5V6ue8٭kzIm@m{ , Kjӈ D) :N[Hi &C/@Qi`RBjY1g$`K^w(H͈xg30'NŢ,Ip9"yE%j R)8Ib!Ā@ff=+ X1VFiQ.$[ $RDVH~ő 2x^FZirU0yA V CCup@O[[`ݺuV~/#СCBCaCC%%T  MX &M+K&xWҢ?---`AA_h`ꪫ0ydL2<:;;xbE0h [p7 .ދy'w<^  Fqa^mmm0amۦz߿!.[iӰer-qggg  \AeYJGGގ*CA h AAa   AA@  A AAD   "`HA0H$ ,vGG=! ,lr- f{BAU?j T .$8JHhhnnƧ~25~1}|}|,hjjB(Lo8A( UUU>>>>hj{   AA#vڵ^w'cƌDih~hu(  "`  "`HA0H$ $AA  Ď;|MMM$ >~gF]]$Iݻ38u:աp!9ǼyPVV`Ŋ-ѣ0rHbȐ!XlUIO||38cƌ'?Q<;}Ώ$IغursӃ+Wbر(//GSS-Z8z(JTUU_~Xd N8:7Mq477cÒ%K0|pbĈXf U7Æ ]wݥ:_7~ma1ڵk1j(f›o:G{ @tvvbx ߟ>}:n6n&<غu+wH$0oh?_H&կ~??6n܈o9_~Y5&M*gnvUobiӦe܋W_}5ɓ'bYgz>׿bذa={6>#XTD ?3ݻW ;ǎѨuV?O*˲,r(ڔsyJ>}tFb1SOɱXL1ն\ x㍆2N Gv3x뭷d'|"˲,orΟ'Y$?eY_555Zr| 0ӧOs=Wnh|wu0qDlذAf9|_bvwwGEuu5ƏP:mAikkC,˸6^h`#G;P[K/kʼn'l2zj:t({aʕسg~iM6aј6mxSrJ,\P)8ֆ΋D"U݋ÇWaMM 7|G/~{G9VQQ{7 ? ,>kn -[ sjkko`ժU8x >M6aΜ9۷oWiU_ѣG^6FƁ0sLL6͔ԩSǩgԩž}gTU]])N9rq8i&D"lڴ EQ PP&Mh4W^yE9g߿---- cƌqv`[X =xٽ{7jjj꩝RillyR&9i٦/2TﷴرcعsrW_E2Tر===9۷oȑ#09#͘1&M͛ vޭ\^bf|ZvލP(h0 26oތE|ęC#ɤ{Q$\ N8zڻw/vލZ 2Gg߾}E]$OSU h{ʲ 0a\QQ!Ǐ7n('?_KJJ38C^bޮщTeyժUrss<,-k_ .+**䪪*yUӧOKJJAwu+sbFo?3*ۘ8q"6m L1cd2}]udHLLc ʕ+c7nIJetr d2/^lq}oF???{e\\\ѣGcݺu(..ŋC&ZL .h+))3ƍu9s^'|nݺaXvk4h={~ 3f%KsNS՟oBB>~Iܹs...HHH@>"K.!""YYY9s&{=ʕ+زe b=z4°h""336mڄ%KX}#"DT'[N =zuȑ#KHH[n9 yWTVBǎN U^[F)|@-.[{"L:UuSN\\\~UYWFÇk_tЬY3aҤI͟?_ :uJA(++v*Uc޽{ ֭[ z]DWNNhi3χ{t8wR[ ؐ?GгgOlٲ郑#Gjڵ+d2=>m6d2:u npXXoߎWjuVtR8tP*""3fÇo>u <@YYxbb"///t?ԩSF.]3b8;;􄽽ng,ǿDuRDYYK ;vDF{᫯W___$''gϞhժ^z\FBB \|駟УGL0|{ño>n޼ӧOAW_{nx{{k׮zYr% gHNNֺfŊDze˰aܻw=L5zh0nu.//Ǿ}Pdeeٳ*%''#,,Lzw걜U Ç/?">@:pppz]\\a^@`` <==1~x/FU(߿?ѠA_TfBΝg?ÇǺu0j((//7xoxx8 -qwOݻw۽{7jN:QFϣzirssïHDFF믿Ɲ;woYa:X>UK߉'PtHHH:tHg_ZZ 00ФZp!˗pqqpBH\ DuqQ.7KӦMn߾sغuиqcڵ ӧOW￯*]vŶm۰j*Nn{";;@lWPZR\\4oزe :vh8@Ekڴ)bcc兎;d2 DEEIې!Cp|ᇘ3gܹ&MSN?~|!,, ZA/88...:3\ ={6Μ97xyyyIUyww^D䋄ֹ"ݻcxhraaaزeVJhh(bcc6moݺ5?˗#66VBqq1|}}1x`߿ 6PO?O>>f%#""":cl  a$"""1 DDDD60@""""ÅpssL& AݻwѬY3I!##^l$ODDdѼysk?U0Vj3t[ihѢ-bU; Qc÷l㛈Ȇ1@""""HDDDdcl  a$"""1 DDDD60@""""HDDDdcVH)D̎sڏR[`W̻#Wn#vk?N@"""2*ZRr$ a$"""1 DDDD60@""""HDDDdcl  a$"""1 DDDD60@""""HDDDdcl  a$"""1 DDDD60@""""HDDDdcl  a$"""1 DDDD6N+W"00NNN BRRkW^Aqhܸ1qkAヒf͚A Ù3gDDDDVUg͛1gHIIA0b>11&MBBBѲeKDDDk-ZK?ѣGaÆݻ5jLa~W^XjXǎ1fTyRDƍcʔ)͚5Ü9sk퍏>/BeaÆ˃vW#-%%%8v"""GDD&QXXRxxx._7nhP(jbk:oݺR ooo޸qIeCxx83̘4lPjѢDTdZA9ϢEwa۶mprrh_f~""""볷r\e.++Kŋcؽ{7u>%פ2  V- B||x?ᅬ;wwZUfII kL"""N@TT&O޽{#88_}0sL)S秞h"ظq#ԭpuuL&Ü9s`mm۶ł 쌧zjHju&N0999?>233ѥKر4=l\r%JJJOj;w(**¬Ypv킛[}."""Vgx`ͩcȶٔY~zjOd`7G a$"""1 DDDD60@""""HDDDVw=k?Ma$"""W\!6VA0@""""HDDDV%ڏ`sl  a$"""1 DDDD6J9 50@""""HDDDdcl  YR0U-=ڏ`sȪNZl Yy DDDD6j_7rG-HDDD5"MD|O:hGy DDDT#8uP: DDD$jL5uLHDDD)O2 DDDd=F tH2 gcHDDDgdp tH2Jk?HDDDQrZ$<3 U협 ~i9Ap` \n߃rD+b$"""Q9/vheC DDD$rӯ(?`$"""Q\եD, @"""9]k DDD$*YT;1鷋{2Dd `Wgʕ+'''!))gΜO<d2,[Lw}2L#G ""5il<&~ٜQՉyf̙3o&RRR0h 1iih ѪU+,\hܹ3233կSNIjrx#c $" ɶ+..Ƒ#Gp ={D``e-]=yeUV!&&F>}O>_`l#""do Ofc4D?5B pmUV?3gͭJJJp1 VYSSSѬY3(  ,@VU&Q]5tU!jpdd$|I!..wENN]B⭷v!>>2oݺR ooo޸qگ_?_qqqXz5nܸsZ/""L)bbc( Q[#""uV8::=ߪU+j SNř3gar2A9Fk׮F֭QQQz{gqDDDfH+904Dm|饗 ?Meeeܹ3 V嵞:}YYY:₮]"555SE&i+U< ck|YDEEq .GHHhV\\s5 Z/""H2ҐlJAA6mڄGE'** 'OF޽ iii9s&`ʔ)S.))ٳg__~'N+ڴi;w.qlYYYS鉈j'A#Xe-2 4$ ǚ5k? 00gϞ޽{1`˚0arrr0|dffK.رciiiӘΞ={/^/Fhh(׮]äIp-xyy8t萺L"",fde/UV4D-ڵkQPPI&a޽;иqc˝5kf͚*ԩhFM6Y,DDDuۤ@@F@)x k?>rQ5HzYf[NVICI ֭[^{ O """6zU, V{ʢDo/|qݻCܹsGꈈL%eZ\O,+ɫ/&20L Bhh(BBBtR%""*˵[lߥ.t:UR20Ґ|@777̜9FJJ 틅 J]-M*)+"1wd/-J0>sZ!z|ꩧev˖-ŮLZ}˷MGifw_~hRm@i۷o>M6EDDVXetIՊ1{^dWZ1v$ :$*y;v /^Ę1c?m۶ի}]]%U37tf.mZ{`JxUc $ؼys̚5 qqq믿T :?3gHU=[α"ݰfn _%oj/.- 'AƏ 6 ;;k׮\.GrrrMTODDd^50^,bU E$  ٹbuW"66:uBDD*UDDD/qeH^cDX]Ғ022ׯo߾Xd "##j*%"""˔z,řU@=MI?A{իX~=>3%"""J7MqyvQPBvڅqիW +J(' X6mDzz:ʂT%f{*u ` h*%Y7oΝ*Z{)UDDDdP yHA[!,'|Dff&w>>tP;VjȀR-C xh\/ČrJMجY3DFFb:t(|||W* e8sWW@o*w^ >ܡ9s u%&zƍ쌗_~?|}M|[_.FE_#i5hrAs/`&@)°d"99zŠ+닰0|'tټ2#0vY!Xd7Rr?\O_Pv]; b~xWsʥ$ sΈơC~{A׮]ѥKl߾]ꉈlJIPTݪd.fu?%J2OfF=#Z{"'Zx8V.=$$ʼ1c ̘1î]P(jz""zX K9Z=\Ⱦ[ @ ^#+F ZH=.Ͽ-`رFDDDRY sD_<\6 IDATnVEaq}Bl9^g7^X!ˈKKKꫯM6۷/֭[u͛bWKDDdt@th {*ﶔꯇ,N?ݜS=~X~=fΜ+x>DDD3l_)hoدiCu(ҩH} f5 0}t1ӧOڵk@kPzfMnВv-ׯK.[Fbb"1yd(""""˔IdZ^2v=׬Y3ٳGԩSŮ\̺o\FƸ?ShSSʳ r&#`nǸh]w?#ظq#u\Įf)1y}g nkZUCLk~Kj!o=}a׮]bWKDDdMW?HITOX.hLj)ѻ1|p}}} LDDdJ+*E&bg`q lXl_Z62;\~@VV+ L}嗥^Z{*BI\¡GzL:D}D'o o6M]EL'Y\nfΜ GGG4iDkLHDDdg=ٞ&ߣE# TKI6x;S3wI렪I͛y!::vv\H*nЫe#l5@^ ?+)Z|%BL8ᏈHb%&TfS+.v^Q;HΞ{9lݺU≈lNFnV Y\jZ V 8&&FΝ;ѵkW888h_tTUY\LO_g>y=&@U9]dpC@gQ}U,LjO[gVnν\:-Vm&hfAtR]ӦM ""ZbvR Hļah(GvjL&LsR39 1( 0 sTI#rkuT [kٳ|r'""j. 0}^׾vp@Ek<]V@& ȑ#سg~WtYgȶmۤȪsޮ@'F99ޯ,^|eeeh۶T888 @JsNFL!4 w0gխgF.{1oT'8Ke帒Se]>c-&ߪ&(u륳vߔGGD'o'j,]8qTYV%A3u_c Weo~=$ 2 w9R)DDD֤LݦM%]T@C-"5濺A8h h=R 8PjNh^+ U1\P\_՛k $h" <۷ǠA3={HU-ՕWp[Xk<ҺYȵ `m%Y `Nbݻw1e?]tZ"""+$AV&^BluK]- 'wʎ{ ~W=z4|||ЬY3,X@*jⲇ]y""&ia6hWn3z7'CoL^H5V/Gw!  pY ""Պ5ϭJ.]Nqx 8 ̮ۘ )dXt6WۉZ/GL_‰'֭[#** (`0,Q]r7N{#onzZ+ kv՚Q]r;7qNms^.!=K r;zkGP{XqGn!:OjQ d>`JJ ?RCB.CXr%~tԩye<ZO3W{NKƾݱGoѢ\sr[ -,m%[|/G1GFF;Ǐ6l&MׯcxWz"""-l˦O7]=zZUX2 p(zNSV.:A߇;}],ZΘ7o;&#i)p{ $V5c&lL*GP iJWWPM.༼,bccq5\~x0f#GЮW '""񕔕DzR+ɗr]xf|)SAZN^rwm*Pw>s:ljN oL%7U ˴WxuHK :'N?Zl'bС/:t5kz""Q [Ob̊tw'2*MƨʤՇlkS ny?i%Ʈ IIIh֬ڷo^ziLURRcǎ!""BxDD4}e#??_EDDz}޴['Iنcu:٤F3 CuUKT׭[T*7nܸQe޳N""T]&:4v?.R5>޽$:-??-Z3Qc(ԧ}誰Ǹ^~(_yvZ{#R5H 77=.]? ??Ӛ===!uZ沲tZLei B""Y(6o'{ef/gS5,5Czɶ=CaI9s2G] ?ڵG}ŋ#77hqttDPP㵎#$$ĢgL""y%e_CQy+Od]_R@bAO ƒk}lv ud0** ӦMCjj*G}]֚5kvZ;w 0sL)SBeII N8'Nׯ_lj'pE$"O37}K5}{Uc~\=,*Ӑ 1{h[Q5]5YQ&YѣG_3{Ƅ #33]t;HKK,=RZx1/^P$&&T&~KWʔro%IJM6|mzz:8 fDDdc,itxjr h޸^#h^c1xͩlkGD VBGܹs:c'k,ɠ,Hx2yKæ8""Lpv߫[M(la{.`TgGP9{EOv:ɺjH<;fc*o'z5Ƿ{nVS]cXA{FhZ[o7m=:+ªKf#:w@c n~p=$fO6hйZ;l^Ɵt5[S!C*v!@""3J;GՕ٣b=m+.aE% +^ ^H*ArҬgdhi7w8".fMd <)UDDTOeaW̺g`OcJ+QR&_7 ߡ:ow'A!/hm?H׳gO?~\n8Ȕfi)4C٣sξn=;x:>âYTAwoH= ,~ ,\ ӦMԩS1o<%"z*}sS#ȿ1>Sok9mk9fgFUU<}@$˗cȑ>}:oߎ ѣԉ3<2}U1VnkjlѸNVg00I'DDD`ܸqXj/0A0yٖn3y5UWU :='sa'?+;0R 'ҥKƯ8꫈īRgq횵KTFv؈MVeЬn `G_wLH5M=z 00'Oİa`Ϟ=ضm+UDDTvV(7+O|u*`?{}hnc1T$ +WĦMШYN!!!HIIA^ꨴBRqrOg46V [e,&Ns 0@j+/s^v0u ᝚bǩrSi}'M&;Hnj^& <,Y"UDDT˽Ė7D49,m=#o L f"fԚ%Fjd37kFoxڸ^~vIך Tˉ<޻_T/|5R1vktbv3>nߧOZan&_kN =m&L͛7Ůj‡A>\smlge 9jL_S9aӽL֜%]=Vn۱cݻ'v5DDT jt閉՛%lHڎTH49)p Se0'@L3˗~lf_Č*jy*5sZH A iӦA9s&\\\۶mU/Un(M5%(. lZO8uT\)4 ';ZcL5vY ^'"&""QvZ>=첖x6v6=l,Ic{/0Ƭ8`QYA ' V"z{X ? 긋Ywqꆤ|he99ͩDk~-s|pq7=8 IDATģ#""ܾWؔx׳~*DX|x⛃WHtwyK QL$%@":ڝB (ڏa1 . { +Ub0QF'y`/g#HDTVK@Kw))(V9[9)HJ DDuLY-|e(V'3>&x1sqsk@}5iZ4DRb$"CV']6y} 4`ty'I\)'3zNKCڠMSWsNrc닑|ef[I@& Q=F}qnӉ0x ym {~ZǽzoOBG_w[ߨb$"C v^cez=x)GgӤR*~|i@oʁD Lċ(SnT r;x'> \RVO ثe#XʾRǠVVz"iq@""+|&{Mٛw jj  JA'PgonaI|i쯛vZe_5BT8ؔk{BGfKqT˜VeM\.6jSs]NF E2YK_p~8~M)1= _컄]Tݘ?s!9}of0'P}HDd% Շ^3c՘ءiLfߧiuA^ѹ9 [O|ۙE|9L-V|"ՙ+WD`` $ԩ :uXӦML&z_ʏ@DVP\&]oݿ>1L=W?쯛w,D2eymSW~U_\ i@&@Dܼy3̙7|)))4hF4'''c„ (233կ;LǘYc*s.;qx%w<&oh;6 Eʩm8lEK.s=;vIJeТE ZJ˖-ða: ::CŲe˴S(Q<To9O0#GD.]~_۷,3::yyyWzz]%DdےR'/"FZZW=\f>th.87Q$[U i܌ETe`<==!uZtZT|||̺|}}T++ (72'"gGyEm}\8`d `-{ŢG|j|eHOš1w5d+j} #u<>>!!!z ֹ~׮]5DDedaI}[aC;6 4=233ѥKرNomHH6mڄz o6Zn͛7_Ir9N:#772d6o 777|F"_.fL 17oP1Bs_w'5Ce`Tc/ddBu[!#ޝEU 30.* J* ⒢e*e=jj=hRj?{G˵E+3pWM]q eGaȎ y^9{=wN_ν>휐[u}wD&M¤Iʬۻwo!C`Ȑ!eƫjDFF>yfJc:'p]7E OAūkb"Vw*zS1ݩ@T &$"jl"+)>e+KUx kA9dbo_8 iL`HD%ֿ]=n5*ZƬIl_W #jѫy6oZ)x'V*ilT{% )gpLPWD/ED Q <8mjR^0?ʍ3WnN&<[20Z-opXNmѥ3Dd8=+f#'m@V%5p3M-/kFV9"j=mź'+3$,JKW)fg{_ `znA2"jUӃ'l~~ikOHe&d7"dž 5J+WF 7Ii'k3\qf"j<QSTťZm-!O]]OzݻZ:\ƥlSDx@"2xB^G]vm]k QoH$F_iDdrd/m:BDڏRK"jk'+3y3zs5$A~>?PV"GoR"jWRkQ񛔈MVCRWXO"ϗhXdRyDDTu<LD..UrL ,ɮx5KQ0$2 )Yy8'[ə&_.V/Ft,UoQaHd@[ܪ)B[ÐNnp1D& ?+JC%ʭwTȿ4DD5鐯!l2n툘˩^6 b?KK̢9[!rz0R2`f*GVf8'k3[(/ Z쐚R?7kXq|n:UfZFt qw(0$j`3I4josv[o#̺ O;ݷ5-UpQW{ߕ),!~a_M݄jf5w_#qF_U@앻}RD+ 8R\9UnkpU#USdkaWxcDdi@5wB.ȳhlyzz!;_W=͞7rK*ey;MDTS]TSg`6Ezn!Zt87_׫#zuOx4AK _ߗ[EZn!&2'^;kOWT q?)vN Fׅ{ƒ5DD@j8|.fp-,fv_xi k)ԦLLͬKk h_/ap+.q²!tRRz"z t 31|E r[yvXTmTkG8X=>*l`_EXa|'=w 9&dNLʃk ~!R|v8t.  USxڡ@\fqk#RsQ؝-+cgN鵥J3sʍ51ρ1bHkÑD3%1("d2&2DO{/vnGTP)L(WTS EMBMJw^+ hFSeb鞋. SR6С}ttyQ.G^L[Z3dr1OkDtvC\bf{we ˢ/a|'ن.9#]D][Կms ?eni8q,쿘]TRLD "#ױ5\IͩQgneiְ./wꇃרC`;5 J=-%+E:O%-sR8IPOw]3my6n6X6#\lU\mmFd&wyo' 9uU|7sR\;wQjHIM[n-,p)E)&JOHGEa+]M^酷zJjl=.v*fW|kܻ`1ʹP魑gZE`p6oo?rKD1lE:$eLoƬ~7w_@L\LɆ &2&b׹F mk:yJ FT7x;Z0#"j jpٲeOvaɒ%ѣG7oƻヒ˗/ ~!, !0w\Xiiiҥ k׮J ft'Vh[5㺷k7 <8u WcRQOsWSskOJĹZ}/R@qN ~W" ~6m-[ݻ㫯B߾}wwR񱱱6lϟСCqAtR|ŋc͚5hժ> ϟe-QRMM̕hh !^pQ#5;M4Hq0O}!h mQRR_]%"*2.]cǎX|T֦M 4 .,?l0dffbǎRY>}`kk7BL6 3g}&LPij/U uVcBwM}๎6y ے XuSB[0Z\]H"X+~TQ} ĉx{2°dիW޽{K* !!!RX[jz[usM0Ksy(:4ʜU7li_bsM09jù[ȘD"8::;::"99m+߲b_^f{MHiJܡ;;6ID!xf>@g+ǔ q?kpÛ} 7A|I4 ? ;s) (~mfq_uE&I'=}8GDd "|.&is…;wnu\#]W5; n=r份`"Ji&wkK&I㲷P"5óEm&DD @{{{R})))fprr0ɩxMd8;;Wwyo>33nnn?Ji +38_`khmrV;J:uݻwލnݺMPPP]vI-Z^LAAWn* VVVz?-^-kVfL0f7ѣ X8q"`̘1puu:u*G!<<?3pAŧFM X`4 FQoIDDDT &6l޽y!)) ؾ};7oHLLݺuæM0{l·~+3f1i$i!]vDDDD`lr =>L @""""#0$"""22L @""""#0$"""22L< !zz UՃiLAVVͭ{BDDDՕkkk.?IDATF cNN۷aii LVi|ff&p }!x2^Bdee&&y5g 5kV p p Kc/c{8^""""#̙3;aLr9z g p ̰pBDDDddx 0$"""22L @""""#nݺQF8qT/9sZ={ٳzmaѰ5Ft3g $$j7oQ?<<< JL<^{ 077qM61`: bۇN: /VٳgEPļy5T!ř3g͛Ox$޽[B!&N(\]]ݻE\\ ~~~B !jW8{n"L"ʕ+BшSrJajj*~8d;;;m6qU d)c СCE۶mž}ŋ/͛7}v1k,yf@zu5>111B. sΉ B!>\fΜ)|ru:prr-򄵵/B$$$z ?Bl2amm-򤘅  aS ///Dzz0556moݺ%LLLΝ;_&&&֭[RƍJB!f̘!Zn &]5.ƍ+{ĨQkrss\.۶m+f50'u9>C}OXX>|?POW֭[^xʕ+W"99{T*BBB5t"tz1!!!PTRLXXn߾k׮Q6^XnƍL'NPo\\\7pqqb/kAQXXXGx<سg.\ѯ_?kZ-`ffWVqAYWSw6n1+W`Fdd$&N_| 99訷T Rm;88ŔF}POHOONj/T*Ջ{x [[[(JK"55q9s&"""кuk" ӦMCDD5D qmaݺu8r8f \]Oy1TNC`` ,Xٳg|r3Fdz !JBڲYj7WWon:lذڵéS0m4`رRkbܸqpuu\.Gǎ1bI1ƧP `58;;m۶zemڴAbb" @FSRRzpΝRmz1e+눊+"999iiizcJKPή6z뭷och߾=Fӧc…;Pyyya߾}ƍ7pQE.ǧ_`X ݻw.\͛e{nCnAAAѣG#G ##C/fzKڵ ...kV^ _*ԩLMM+)) XHJJbvJN:I1%xSS}ZCT*$̙åj(22Rϟ/Uw}1eѤIVų>+b_./jhҤ2eRBw^ J˗/cj233ԩS033b֬Y"??_X OOOT*::={DHHT^PPXK.f̘F~z}mذᰰ@nn.BCCaaa@>}ʝ={6V^˗ٳ>}:F}͚5 }?Bq h׮aÆI͝;Cӧѯ_?9+ۿ?KgeeǨQЫW/%|g ɓ'1i$?6 #..̙3w;vܹsX|9GDEE!))I:={ܹsؽ{7m81<~8n݊X!Я_?Jbҥشiv܉{۱}v]+VKs8p@#50+VsssQXX(233Bw6mݺuBo>@\|v;agg'߲e999B!222_BZJN'm/jB1vX.";;["""B!Dtt _UB ??R fϞ-2Lرc+Ub /:u9r^LŨQ:N888˗ !X|-+Wɓ'B 0@Keիz;V8::|… 8tT*jB^Z.]b&L 4ʒĄ ? QTTTf_aQ_IDu%449998vЪU+888 $$Gf)m  !!jCNNѿ( lݺÇ͛aii޽{N8K.y"///_.τW^z+Сww ?ۙ)))߿fffWZQFIG`Ʀd2899I;<:t~ΝꫯG\\zA[n#oJRz9( tE*Ν;'i4xyyI ?3Z N|JGD hٲ%5kh!$$-ZСCSO=%msu'NѤI3tZ۷K;rHܹgϞEtt4F)u/^Zlcmm]mBR!11T[UTݤ(Wj*#>>N~f̘UVU֭[ϗJޭ@ӦM/bݺuXdt#Ƀk۶mjqݻpڴiS>Gǎ"LDhh( h1 { display:none; } *********************** Specutils Documentation *********************** .. _specutils: .. image:: _static/logo.png ``specutils`` is a Python package for representing, loading, manipulating, and analyzing astronomical spectroscopic data. The generic data containers and accompanying modules provide a toolbox that the astronomical community can use to build more domain-specific packages. For more details about the underlying principles, see `APE13 `_, the guiding document for spectroscopic development in the Astropy Project. .. note:: While specutils is available for general use, the API is in an early enough development stage that some interfaces may change if user feedback and experience warrants it. Getting started with :ref:`specutils ` ================================================= As a basic example, consider an emission line galaxy spectrum from the `SDSS `_. We will use this as a proxy for a spectrum you may have downloaded from some archive, or reduced from your own observations. .. plot:: :include-source: :align: center :context: close-figs We begin with some basic imports: >>> from astropy.io import fits >>> from astropy import units as u >>> import numpy as np >>> from matplotlib import pyplot as plt >>> from astropy.visualization import quantity_support >>> quantity_support() # for getting units on the axes below # doctest: +IGNORE_OUTPUT Now we load the dataset from its canonical source: >>> f = fits.open('https://data.sdss.org/sas/dr16/sdss/spectro/redux/26/spectra/1323/spec-1323-52797-0012.fits') # doctest: +IGNORE_OUTPUT +REMOTE_DATA >>> # The spectrum is in the second HDU of this file. >>> specdata = f[1].data # doctest: +REMOTE_DATA >>> f.close() # doctest: +REMOTE_DATA Then we re-format this dataset into astropy quantities, and create a `~specutils.Spectrum1D` object: >>> from specutils import Spectrum1D >>> lamb = 10**specdata['loglam'] * u.AA # doctest: +REMOTE_DATA >>> flux = specdata['flux'] * 10**-17 * u.Unit('erg cm-2 s-1 AA-1') # doctest: +REMOTE_DATA >>> spec = Spectrum1D(spectral_axis=lamb, flux=flux) # doctest: +REMOTE_DATA And we plot it: >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.step(spec.spectral_axis, spec.flux) # doctest: +IGNORE_OUTPUT +REMOTE_DATA .. testsetup:: >>> fig = plt.figure() # necessary because otherwise the doctests fail due to quantity_support and the flux units being different from the last figure .. plot:: :include-source: :align: center :context: close-figs Now maybe you want the equivalent width of a spectral line. That requires normalizing by a continuum estimate: >>> from specutils.fitting import fit_generic_continuum >>> cont_norm_spec = spec / fit_generic_continuum(spec)(spec.spectral_axis) # doctest: +REMOTE_DATA >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.step(cont_norm_spec.wavelength, cont_norm_spec.flux) # doctest: +IGNORE_OUTPUT +REMOTE_DATA >>> ax.set_xlim(654 * u.nm, 660 * u.nm) # doctest: +IGNORE_OUTPUT +REMOTE_DATA But then you can apply a single function over the region of the spectrum containing the line: >>> from specutils import SpectralRegion >>> from specutils.analysis import equivalent_width >>> equivalent_width(cont_norm_spec, regions=SpectralRegion(6562 * u.AA, 6575 * u.AA)) # doctest: +REMOTE_DATA +FLOAT_CMP While there are other tools and spectral representations detailed more below, this gives a test of the sort of analysis :ref:`specutils ` enables. Using :ref:`specutils ` ================================== For more details on usage of specutils, see the sections listed below. .. toctree:: :maxdepth: 2 installation types_of_spectra spectrum1d spectrum_collection spectral_cube spectral_regions analysis fitting manipulation arithmetic custom_loading identify Get Involved - Developer Docs ----------------------------- Please see :doc:`contributing` for information on bug reporting and contributing to the specutils project. .. toctree:: :maxdepth: 2 contributing releasing ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/installation.rst0000644000503700020070000000245100000000000021771 0ustar00rosteenSTSCI\science00000000000000.. highlight:: shell ============ Installation ============ Stable release -------------- If you use anaconda_ to manage your Python environment, run this command in your terminal: .. code-block:: console $ conda install -c conda-forge specutils Otherwise, the recommended method is using pip_: .. code-block:: console $ pip install specutils If you don't have pip_ installed, this `Python installation guide`_ can guide you through the process. These are the preferred methods to install Specutils, as they will always install the most recent stable release. .. _pip: https://pip.pypa.io .. _Python installation guide: http://docs.python-guide.org/en/latest/starting/installation/ .. _anaconda: https://www.anaconda.com/ From sources ------------ The sources for Specutils can be downloaded from the `Github repo`_. You can either clone the public repository: .. code-block:: console $ git clone git://github.com/astropy/specutils Or download the `tarball`_: .. code-block:: console $ curl -OL https://github.com/astropy/specutils/tarball/main Once you have a copy of the source, you can install it with: .. code-block:: console $ python setup.py install .. _Github repo: https://github.com/astropy/specutils .. _tarball: https://github.com/astropy/specutils/tarball/main ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/make.bat0000644000503700020070000001070500000000000020144 0ustar00rosteenSTSCI\science00000000000000@ECHO OFF REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set BUILDDIR=_build set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . if NOT "%PAPER%" == "" ( set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% ) if "%1" == "" goto help if "%1" == "help" ( :help echo.Please use `make ^` where ^ is one of echo. html to make standalone HTML files echo. dirhtml to make HTML files named index.html in directories echo. singlehtml to make a single large HTML file echo. pickle to make pickle files echo. json to make JSON files echo. htmlhelp to make HTML files and a HTML help project echo. qthelp to make HTML files and a qthelp project echo. devhelp to make HTML files and a Devhelp project echo. epub to make an epub echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter echo. text to make text files echo. man to make manual pages echo. changes to make an overview over all changed/added/deprecated items echo. linkcheck to check all external links for integrity echo. doctest to run all doctests embedded in the documentation if enabled goto end ) if "%1" == "clean" ( for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i del /q /s %BUILDDIR%\* del /q /s api del /q /s generated goto end ) if "%1" == "html" ( %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/html. goto end ) if "%1" == "dirhtml" ( %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. goto end ) if "%1" == "singlehtml" ( %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml if errorlevel 1 exit /b 1 echo. echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. goto end ) if "%1" == "pickle" ( %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the pickle files. goto end ) if "%1" == "json" ( %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can process the JSON files. goto end ) if "%1" == "htmlhelp" ( %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run HTML Help Workshop with the ^ .hhp project file in %BUILDDIR%/htmlhelp. goto end ) if "%1" == "qthelp" ( %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp if errorlevel 1 exit /b 1 echo. echo.Build finished; now you can run "qcollectiongenerator" with the ^ .qhcp project file in %BUILDDIR%/qthelp, like this: echo.^> qcollectiongenerator %BUILDDIR%\qthelp\Astropy.qhcp echo.To view the help file: echo.^> assistant -collectionFile %BUILDDIR%\qthelp\Astropy.ghc goto end ) if "%1" == "devhelp" ( %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp if errorlevel 1 exit /b 1 echo. echo.Build finished. goto end ) if "%1" == "epub" ( %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub if errorlevel 1 exit /b 1 echo. echo.Build finished. The epub file is in %BUILDDIR%/epub. goto end ) if "%1" == "latex" ( %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex if errorlevel 1 exit /b 1 echo. echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. goto end ) if "%1" == "text" ( %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text if errorlevel 1 exit /b 1 echo. echo.Build finished. The text files are in %BUILDDIR%/text. goto end ) if "%1" == "man" ( %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man if errorlevel 1 exit /b 1 echo. echo.Build finished. The manual pages are in %BUILDDIR%/man. goto end ) if "%1" == "changes" ( %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes if errorlevel 1 exit /b 1 echo. echo.The overview file is in %BUILDDIR%/changes. goto end ) if "%1" == "linkcheck" ( %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck if errorlevel 1 exit /b 1 echo. echo.Link check complete; look for any errors in the above output ^ or in %BUILDDIR%/linkcheck/output.txt. goto end ) if "%1" == "doctest" ( %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest if errorlevel 1 exit /b 1 echo. echo.Testing of doctests in the sources finished, look at the ^ results in %BUILDDIR%/doctest/output.txt. goto end ) :end ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/manipulation.rst0000644000503700020070000003707200000000000021777 0ustar00rosteenSTSCI\science00000000000000==================== Manipulating Spectra ==================== While there are myriad ways you might want to alter a spectrum, :ref:`specutils ` provides some specific functionality that is commonly used in astronomy. These tools are detailed here, but it is important to bear in mind that this is *not* intended to be exhaustive - the point of :ref:`specutils ` is to provide a framework you can use to do your data analysis. Hence the functionality described here is best thought of as pieces you might string together with your own functionality to build a tailor-made spectral analysis environment. In general, however, :ref:`specutils ` is designed around the idea that spectral manipulations generally yield *new* spectrum objects, rather than in-place operations. This is not a true restriction, but is a guideline that is recommended primarily to keep you from accidentally modifying a spectrum you didn't mean to change. Smoothing --------- Specutils provides smoothing for spectra in two forms: 1) convolution based using smoothing `astropy.convolution` and 2) median filtering using the :func:`scipy.signal.medfilt`. Each of these act on the flux of the :class:`~specutils.Spectrum1D` object. .. note:: Specutils smoothing kernel widths and standard deviations are in units of pixels and not ``Quantity``. Convolution Based Smoothing ^^^^^^^^^^^^^^^^^^^^^^^^^^^ While any kernel supported by `astropy.convolution` will work (using the `~specutils.manipulation.convolution_smooth` function), several commonly-used kernels have convenience functions wrapping them to simplify the smoothing process into a simple one-line operation. Currently implemented are: :func:`~specutils.manipulation.box_smooth` (:class:`~astropy.convolution.Box1DKernel`), :func:`~specutils.manipulation.gaussian_smooth` (:class:`~astropy.convolution.Gaussian1DKernel`), and :func:`~specutils.manipulation.trapezoid_smooth` (:class:`~astropy.convolution.Trapezoid1DKernel`). Note that, although these kernels are 1D, they can be applied to higher-dimensional data (e.g. spectral cubes), in which case the data will be smoothed only along the spectral dimension. .. code-block:: python >>> from specutils import Spectrum1D >>> import astropy.units as u >>> import numpy as np >>> from specutils.manipulation import (box_smooth, gaussian_smooth, trapezoid_smooth) >>> spec1 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> spec1_bsmooth = box_smooth(spec1, width=3) >>> spec1_gsmooth = gaussian_smooth(spec1, stddev=3) >>> spec1_tsmooth = trapezoid_smooth(spec1, width=3) >>> gaussian_smooth(spec1, stddev=3) #doctest:+SKIP Spectrum1D([0.22830748, 0.2783204 , 0.32007408, 0.35270403, 0.37899655, 0.40347983, 0.42974259, 0.45873436, 0.48875214, 0.51675647, 0.54007149, 0.55764758, 0.57052796, 0.58157173, 0.59448669, 0.61237409, 0.63635755, 0.66494062, 0.69436655, 0.7199299 , 0.73754271, 0.74463192, 0.74067744, 0.72689092, 0.70569365, 0.6800534 , 0.65262146, 0.62504013, 0.59778884, 0.57072578, 0.54416776, 0.51984003, 0.50066938, 0.48944714, 0.48702192, 0.49126444, 0.49789092, 0.50276877, 0.50438924, 0.50458914, 0.50684731, 0.51321106, 0.52197328, 0.52782086, 0.52392599, 0.50453064, 0.46677128, 0.41125485, 0.34213489]) Each of the specific smoothing methods create the appropriate `astropy.convolution.convolve` kernel and then call a helper function :func:`~specutils.manipulation.convolution_smooth` that takes the spectrum and an astropy 1D kernel. So, one could also do: .. code-block:: python >>> from astropy.convolution import Box1DKernel >>> from specutils.manipulation import convolution_smooth >>> box1d_kernel = Box1DKernel(width=3) >>> spec1 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49) * u.Jy) >>> spec1_bsmooth2 = convolution_smooth(spec1, box1d_kernel) In this case, the ``spec1_bsmooth2`` result should be equivalent to the ``spec1_bsmooth`` in the section above (assuming the flux data of the input ``spec`` is the same). Note that, as in the case of the kernel-specific functions, a 1D kernel can be applied to a multi-dimensional spectrum and will smooth that spectrum along the spectral dimension. In the case of :func:`~specutils.manipulation.convolution_smooth`, one can also input a higher-dimensional kernel that matches the dimensionality of the data. The uncertainties are propagated using a standard "propagation of errors" method, if the uncertainty is defined for the spectrum *and* it is one of StdDevUncertainty, VarianceUncertainty or InverseVariance. But note that this does *not* consider covariance between points. Median Smoothing ^^^^^^^^^^^^^^^^ The median based smoothing is implemented using `scipy.signal.medfilt` and has a similar call structure to the convolution-based smoothing methods. This method applys the median filter across the flux. .. note:: This method is not flux conserving and errors are not propagated. .. code-block:: python >>> from specutils.manipulation import median_smooth >>> spec1 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49) * u.Jy) >>> spec1_msmooth = median_smooth(spec1, width=3) Resampling ---------- :ref:`specutils ` contains several classes for resampling the flux in a :class:`~specutils.Spectrum1D` object. Currently supported methods of resampling are integrated flux conserving with :class:`~specutils.manipulation.FluxConservingResampler`, linear interpolation with :class:`~specutils.manipulation.LinearInterpolatedResampler`, and cubic spline with :class:`~specutils.manipulation.SplineInterpolatedResampler`. Each of these classes takes in a :class:`~specutils.Spectrum1D` and a user defined output dispersion grid, and returns a new :class:`~specutils.Spectrum1D` with the resampled flux. Currently the resampling classes expect the new dispersion grid unit to be the same as the input spectrum's dispersion grid unit. If the input :class:`~specutils.Spectrum1D` contains an uncertainty, :class:`~specutils.manipulation.FluxConservingResampler` will propogate the uncertainty to the final output :class:`~specutils.Spectrum1D`. However, the other two implemented resampling classes (:class:`~specutils.manipulation.LinearInterpolatedResampler` and :class:`~specutils.manipulation.SplineInterpolatedResampler`) will ignore any input uncertainty. Here's a set of simple examples showing each of the three types of resampling: .. plot:: :include-source: :align: center :context: close-figs First are the imports we will need as well as loading in the example data: >>> from astropy.io import fits >>> from astropy import units as u >>> import numpy as np >>> from matplotlib import pyplot as plt >>> from astropy.visualization import quantity_support >>> quantity_support() # for getting units on the axes below # doctest: +IGNORE_OUTPUT >>> f = fits.open('https://data.sdss.org/sas/dr16/sdss/spectro/redux/26/spectra/1323/spec-1323-52797-0012.fits') # doctest: +IGNORE_OUTPUT +REMOTE_DATA >>> # The spectrum is in the second HDU of this file. >>> specdata = f[1].data[1020:1250] # doctest: +REMOTE_DATA >>> f.close() # doctest: +REMOTE_DATA Then we re-format this dataset into astropy quantities, and create a `~specutils.Spectrum1D` object: >>> from specutils import Spectrum1D >>> lamb = 10**specdata['loglam'] * u.AA # doctest: +REMOTE_DATA >>> flux = specdata['flux'] * 10**-17 * u.Unit('erg cm-2 s-1 AA-1') # doctest: +REMOTE_DATA >>> input_spec = Spectrum1D(spectral_axis=lamb, flux=flux) # doctest: +REMOTE_DATA >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.step(input_spec.spectral_axis, input_spec.flux) # doctest: +IGNORE_OUTPUT +REMOTE_DATA .. plot:: :include-source: :align: center :context: close-figs Now we show examples and plots of the different resampling currently available. >>> from specutils.manipulation import FluxConservingResampler, LinearInterpolatedResampler, SplineInterpolatedResampler >>> new_disp_grid = np.arange(4800, 5200, 3) * u.AA Flux Conserving Resampler: >>> fluxcon = FluxConservingResampler() >>> new_spec_fluxcon = fluxcon(input_spec, new_disp_grid) # doctest: +IGNORE_OUTPUT +REMOTE_DATA >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.step(new_spec_fluxcon.spectral_axis, new_spec_fluxcon.flux) # doctest: +IGNORE_OUTPUT +REMOTE_DATA .. plot:: :include-source: :align: center :context: close-figs Linear Interpolation Resampler: >>> linear = LinearInterpolatedResampler() >>> new_spec_lin = linear(input_spec, new_disp_grid) # doctest: +REMOTE_DATA >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.step(new_spec_lin.spectral_axis, new_spec_lin.flux) # doctest: +IGNORE_OUTPUT +REMOTE_DATA .. plot:: :include-source: :align: center :context: close-figs Spline Resampler: >>> spline = SplineInterpolatedResampler() >>> new_spec_sp = spline(input_spec, new_disp_grid) # doctest: +REMOTE_DATA >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.step(new_spec_sp.spectral_axis, new_spec_sp.flux) # doctest: +IGNORE_OUTPUT +REMOTE_DATA Splicing/Combining Multiple Spectra ----------------------------------- The resampling functionality detailed above is also the default way :ref:`specutils ` supports splicing multiple spectra together into a single spectrum. This can be achieved as follows: .. plot:: :include-source: :align: center :context: close-figs >>> spec1 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.micron, flux=np.random.randn(49)*u.Jy) >>> spec2 = Spectrum1D(spectral_axis=np.arange(51, 100) * u.micron, flux=(np.random.randn(49)+1)*u.Jy) >>> new_spectral_axis = np.concatenate([spec1.spectral_axis.value, spec2.spectral_axis.to_value(spec1.spectral_axis.unit)]) * spec1.spectral_axis.unit >>> resampler = LinearInterpolatedResampler(extrapolation_treatment='zero_fill') >>> new_spec1 = resampler(spec1, new_spectral_axis) >>> new_spec2 = resampler(spec2, new_spectral_axis) >>> final_spec = new_spec1 + new_spec2 Yielding a spliced spectrum (the solid line below) composed of the splice of two other spectra (dashed lines):: >>> f, ax = plt.subplots() # doctest: +IGNORE_OUTPUT >>> ax.step(final_spec.spectral_axis, final_spec.flux, where='mid', c='k', lw=2) # doctest: +IGNORE_OUTPUT >>> ax.step(spec1.spectral_axis, spec1.flux, ls='--', where='mid', lw=1) # doctest: +IGNORE_OUTPUT >>> ax.step(spec2.spectral_axis, spec2.flux, ls='--', where='mid', lw=1) # doctest: +IGNORE_OUTPUT Uncertainty Estimation ---------------------- Some of the machinery in :ref:`specutils ` (e.g. `~specutils.analysis.snr`) requires an uncertainty to be present. While some data reduction pipelines generate this as part of the reduction process, sometimes it's necessary to estimate the uncertainty in a spectrum using the spectral data itself. Currently :ref:`specutils ` provides the straightforward `~specutils.manipulation.noise_region_uncertainty` function. First we build a spectrum like that used in :doc:`analysis`, but without a known uncertainty: .. code-block:: python >>> from astropy.modeling import models >>> np.random.seed(42) >>> spectral_axis = np.linspace(10, 1, 200) * u.GHz >>> spectral_model = models.Gaussian1D(amplitude=3*u.Jy, mean=5*u.GHz, stddev=0.8*u.GHz) >>> flux = spectral_model(spectral_axis) >>> flux += np.random.normal(0., 0.2, spectral_axis.shape) * u.Jy >>> noisy_gaussian = Spectrum1D(spectral_axis=spectral_axis, flux=flux) Now we estimate the uncertainty from the region that does *not* contain the line: .. code-block:: python >>> from specutils import SpectralRegion >>> from specutils.manipulation import noise_region_uncertainty >>> noise_region = SpectralRegion([(10, 7), (3, 0)] * u.GHz) >>> spec_w_unc = noise_region_uncertainty(noisy_gaussian, noise_region) >>> spec_w_unc.uncertainty # doctest: +ELLIPSIS StdDevUncertainty([0.1886835, ..., 0.1886835]) Or similarly, expressed in pixels: .. code-block:: python >>> noise_region = SpectralRegion([(0, 25), (175, 200)]*u.pix) >>> spec_w_unc = noise_region_uncertainty(noisy_gaussian, noise_region) >>> spec_w_unc.uncertainty # doctest: +ELLIPSIS StdDevUncertainty([0.18739524, ..., 0.18739524]) S/N Threshold Mask ------------------ It is useful to be able to find all the spaxels in an ND spectrum in which the signal to noise ratio is greater than some threshold. This method implements this functionality so that a `~specutils.Spectrum1D` object, `~specutils.SpectrumCollection` or an :class:`~astropy.nddata.NDData` derived object may be passed in as the first parameter. The second parameter is a floating point threshold. For example, first a spectrum with flux and uncertainty is created, and then call the ``snr_threshold`` method: .. code-block:: python >>> import numpy as np >>> from astropy.nddata import StdDevUncertainty >>> import astropy.units as u >>> from specutils import Spectrum1D >>> from specutils.manipulation import snr_threshold >>> np.random.seed(42) >>> wavelengths = np.arange(0, 10)*u.um >>> flux = 100*np.abs(np.random.randn(10))*u.Jy >>> uncertainty = StdDevUncertainty(np.abs(np.random.randn(10))*u.Jy) >>> spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux, uncertainty=uncertainty) >>> spectrum_masked = snr_threshold(spectrum, 50) #doctest:+SKIP >>> # To create a masked flux array >>> flux_masked = spectrum_masked.flux #doctest:+SKIP >>> flux_masked[spectrum_masked.mask] = np.nan #doctest:+SKIP The output ``spectrum_masked`` is a shallow copy of the input ``spectrum`` with the ``mask`` attribute set to False where the S/N is greater than 50 and True elsewhere. It is this way to be consistent with ``astropy.nddata``. .. note:: The mask attribute is the only attribute modified by ``snr_threshold()``. To retrieve the masked flux data use ``spectrum.masked.flux_masked``. Shifting -------- In addition to resampling, you may sometimes wish to simply shift the ``spectral_axis`` of a spectrum (a la the ``specshift`` iraf task). There is no explicit function for this because it is a basic transform of the ``spectral_axis``. Therefore one can use a construct like this: .. code-block:: python >>> from specutils import Spectrum1D >>> np.random.seed(42) >>> wavelengths = np.arange(0, 10) * u.um >>> flux = 100 * np.abs(np.random.randn(10)) * u.Jy >>> spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) >>> spectrum #doctest:+ELLIPSIS , spectral_axis=)> >>> shift = 12300 * u.AA >>> new_spec = Spectrum1D(spectral_axis=spectrum.spectral_axis + shift, flux=spectrum.flux) >>> new_spec #doctest:+ELLIPSIS , spectral_axis=)> Reference/API ------------- .. automodapi:: specutils.manipulation :no-heading: ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/nitpick-exceptions0000644000503700020070000000420000000000000022273 0ustar00rosteenSTSCI\science00000000000000# Temporary exception of inherited astropy classes py:class astropy.nddata.mixins.ndio.NDIOMixin py:class astropy.nddata.mixins.ndslicing.NDSlicingMixin py:class astropy.nddata.mixins.ndarithmetic.NDArithmeticMixin py:obj NDData py:obj NDUncertainty # Links that might not work yet, depending on the astropy version py:obj astropy.coordinates.SpectralQuantity # Classes that now live in astropy and which don't want to expose here py:class specutils.extern.spectralcoord.spectral_coordinate.SpectralCoord py:class specutils.extern.spectralcoord.spectral_quantity.SpectralQuantity # Classes in the SpectralCoord/SpectralQuantity methods for which we can't # control the API links. py:obj SpectralCoord py:obj SpectralQuantity py:obj SkyCoord # Super class for Spectrum1D for which we don't control the API links. py:class ndcube.ndcube.NDCube py:class ndcube.ndcube.NDCubeBase py:class ndcube.ndcube.NDCubeABC py:class ndcube.mixins.plotting.NDCubePlotMixin py:class ndcube.mixins.ndslicing.NDCubeSlicingMixin py:obj ndcube.NDCube py:obj NDCube py:obj NDCube.plotter py:obj MatplotlibPlotter py:obj ExtraCoords py:obj GlobalCoords py:obj ndcube.NDCube.world_axis_physical_types py:obj specutils.Spectrum1D.crop py:obj pixel_edges py:obj pixel_values py:obj ndcube.NDCube.wcs.world_axis_physical_types py:obj ndcube.NDCubeSequence py:obj sunpy.visualization.animator.ArrayAnimatorWCS py:obj ndcube.mixins.plotting.NDCubePlotMixin.plot py:obj astropy.wcs.wcsapi.BaseHighLevelWCS.world_to_array_index_values py:obj matplotllib.pyplot.imshow py:obj matplotllib.pyplot.plot # SpectralAxis inherits methods which have warnings we can't fix here. # FIXME: https://github.com/astropy/sphinx-automodapi/issues/101 py:obj Quantity py:obj a py:obj n py:obj axis1 py:obj axis2 py:obj data_as py:obj ndarray.setflags py:obj ndarray.reshape py:obj ndarray.flat py:obj ndarray.T py:obj a.size py:obj x py:obj order py:obj subok py:obj arr_t py:obj inplace py:obj a.size == 1 py:obj args py:obj ndarray py:obj new_order py:obj refcheck py:obj val py:obj offset py:obj lexsort py:obj np.atleast2d(a).T py:obj a[:, np.newaxis] py:obj i py:obj j py:obj a.transpose() py:obj ndarray_subclass ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/releasing.rst0000644000503700020070000000343300000000000021242 0ustar00rosteenSTSCI\science00000000000000.. highlight:: shell ==================== Release Instructions ==================== You will need to set up a gpg key (see the `astropy docs section on this `_ for more), PyPI account, and install twine before following these steps. 1. Ensure all of the issues slated for this release on GitHub are either closed or moved to a new milestone. 2. Pull a fresh copy of the main branch from GitHub down to your local machine. 3. Update the Changelog - Move the filled out changelog headers from unreleased to a new released section with release version number. 4. Make a commit with this change. 5. Tag the commit you just made (replace version numbers with your new number):: $ git tag -s v0.5.2 -m "tagging version 0.5.2" 6. Checkout tagged version (replace version number):: $ git checkout v0.5.2 7. (optional but encouraged) Run test suite locally, make sure they pass. 8. Now we do the PyPI release (steps 20,21 in the `astropy release procedures `_):: $ git clean -dfx $ cd astropy_helpers; git clean -dfx; cd .. $ python setup.py build sdist $ gpg --detach-sign -a dist/specutils-0.5.1.tar.gz $ twine upload dist/specutils-0.5.1.tar.gz 9. Checkout main. 10. Back to development - add the next version number to the changelog as an "unreleased" section 11. Push to Github with ``--tags`` (you may need to lift direct main push restrictions on the GitHub repo) 12. Do "release" with new tag on GitHub repo. 13. If there is a milestone for this release, "close" the milestone on GitHub. 14. Double-check (and fix if necessary) that relevant conda builds have proceeded sucessfully (e.g. https://github.com/conda-forge/specutils-feedstock) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/spectral_cube.rst0000644000503700020070000002104700000000000022105 0ustar00rosteenSTSCI\science00000000000000########################### Working with Spectral Cubes ########################### Spectral cubes can be read directly with :class:`~specutils.Spectrum1D`. A specific example of this is demonstrated here. In addition to the functions demonstrated below, as a subclass of `NDCube `_, :class:`~specutils.Spectrum1D` also inherits useful methods for e.g. cropping based on combinations of world and spectral coordinates. Most of the functionality inherited from `~ndcube.NDCube` requires initializing the ``Spectrum1D`` object with a WCS describing the coordinates for all axes of the data. Note that the workflow described here is for spectral cubes that are rectified such that one of the axes is entirely spectral and all the spaxels have the same ``spectral_axis`` values (i.e., case 2 in :ref:`specutils-representation-overview`). For less-rectified cubes, pre-processing steps (not addressed by specutils at the time of this writing) will be necessary to rectify the cubes into that form. Note, though, that such cubes can be stored in specutils data structures (cases 3 and 4 in :ref:`specutils-representation-overview`), which support *some* of these behaviors, even though the fulkl set of tools do not yet apply. Loading a cube ============== We'll use a ``MaNGA cube`` for our example, and load the data from the repository directly into a new ``Spectrum1D`` object: .. code-block:: python >>> from astropy.utils.data import download_file >>> from specutils.spectra import Spectrum1D >>> filename = "https://stsci.box.com/shared/static/28a88k1qfipo4yxc4p4d40v4axtlal8y.fits" >>> file = download_file(filename, cache=True) # doctest: +REMOTE_DATA >>> sc = Spectrum1D.read(file, format='MaNGA cube') # doctest: +REMOTE_DATA The cube has 74x74 spaxels with 4563 spectral axis points in each one: .. code-block:: python >>> sc.shape #doctest:+SKIP (74, 74, 4563) Print the contents of 3 spectral axis points in a 3x3 spaxel array: .. code-block:: python >>> sc[30:33,30:33,2000:2003] #doctest:+SKIP , spectral_axis=, uncertainty=InverseVariance([[[4324.235 , 4326.87 , 4268.985 ], [5128.3867, 5142.5005, 4998.457 ], [4529.9097, 4545.8345, 4255.305 ]], [[4786.163 , 4811.216 , 4735.3135], [4992.71 , 5082.1294, 4927.881 ], [4992.9683, 5046.971 , 4798.005 ]], [[4831.2236, 4887.096 , 4806.84 ], [3895.8677, 4027.9104, 3896.0195], [4521.258 , 4630.997 , 4503.0396]]]))> Spectral slab extraction ======================== The `~specutils.manipulation.spectral_slab` function can be used to extract spectral regions from the cube. .. code-block:: python >>> import astropy.units as u >>> from specutils.manipulation import spectral_slab >>> ss = spectral_slab(sc, 5000.*u.AA, 5003.*u.AA) # doctest: +REMOTE_DATA >>> ss.shape #doctest:+SKIP (74, 74, 3) >>> ss[30:33,30:33,::] #doctest:+SKIP , spectral_axis=, uncertainty=InverseVariance([[[3449.242 , 2389.292 , 2225.105 ], [4098.7485, 2965.88 , 2632.497 ], [3589.92 , 2902.7622, 2292.3823]], [[3563.3342, 2586.58 , 2416.039 ], [4090.8855, 3179.1702, 2851.823 ], [4158.919 , 3457.0115, 2841.1965]], [[3684.6013, 3056.2 , 2880.6592], [3221.7888, 2801.3518, 2525.541 ], [3936.68 , 3461.534 , 3047.6135]]]))> Spectral Bounding Region ======================== The `~specutils.manipulation.extract_bounding_spectral_region` function can be used to extract the bounding region that encompases a set of disjoint `~specutils.SpectralRegion` instances, or a composite instance of `~specutils.SpectralRegion` that contains disjoint sub-regions. .. code-block:: python >>> from specutils import SpectralRegion >>> from specutils.manipulation import extract_bounding_spectral_region >>> composite_region = SpectralRegion([(5000*u.AA, 5002*u.AA), (5006*u.AA, 5008.*u.AA)]) >>> sub_spectrum = extract_bounding_spectral_region(sc, composite_region) # doctest: +REMOTE_DATA >>> sub_spectrum.spectral_axis #doctest:+SKIP [5000.3453, 5001.4969, 5002.6486, 5003.8007, 5004.953, 5006.1055, 5007.2584]A˚ Moments ======= The `~specutils.analysis.moment` function can be used to compute moments of any order along one of the cube's axes. By default, ``axis=-1``, which computes moments along the spectral axis (remember that the spectral axis is always last in a :class:`~specutils.Spectrum1D`). .. code-block:: python >>> from specutils.analysis import moment >>> m = moment(sc, order=1) # doctest: +REMOTE_DATA >>> m.shape #doctest:+SKIP (74, 74) >>> m[30:33,30:33] #doctest:+SKIP [[6452.6131, 6462.6506, 6481.2816], [6464.6792, 6479.4128, 6514.6099], [6486.7277, 6526.3187, 6567.3308]]A˚ Use Case ======== Example of computing moment maps for specific wavelength ranges in a cube, using `~specutils.manipulation.spectral_slab` and `~specutils.analysis.moment`. .. plot:: :include-source: :align: center :context: close-figs import numpy as np import matplotlib.pyplot as plt import astropy.units as u from astropy.utils.data import download_file from specutils import Spectrum1D, SpectralRegion from specutils.analysis import moment from specutils.manipulation import spectral_slab filename = "https://stsci.box.com/shared/static/28a88k1qfipo4yxc4p4d40v4axtlal8y.fits" fn = download_file(filename, cache=True) spec1d = Spectrum1D.read(fn) # Extract H-alpha sub-cube for moment maps using spectral_slab subspec = spectral_slab(spec1d, 6745.*u.AA, 6765*u.AA) ha_wave = subspec.spectral_axis # Extract wider sub-cube covering H-alpha and [N II] using spectral_slab subspec_wide = spectral_slab(spec1d, 6705.*u.AA, 6805*u.AA) ha_wave_wide= subspec_wide.spectral_axis # Convert flux density to microJy and correct negative flux offset for # this particular dataset ha_flux = (np.sum(subspec.flux.value, axis=(0,1)) + 0.0093) * 1.0E-6*u.Jy ha_flux_wide = (np.sum(subspec_wide.flux.value, axis=(0,1)) + 0.0093) * 1.0E-6*u.Jy # Compute moment maps for H-alpha line moment0_halpha = moment(subspec, order=0) moment1_halpha = moment(subspec, order=1) # Convert moment1 from AA to velocity # H-alpha is redshifted to 6750.5AA for this galaxy vel_map = 3.0E5 * (moment1_halpha.value - 6750.5) / 6750.5 # Plot results in 3 panels (subspec_wide, H-alpha line flux, H-alpha velocity map) f,(ax1,ax2,ax3) = plt.subplots(1, 3, figsize=(15, 5)) ax1.plot(ha_wave_wide, (ha_flux_wide)*1000.) ax1.set_xlabel('Angstrom', fontsize=14) ax1.set_ylabel('uJy', fontsize=14) ax1.tick_params(axis="both", which='major', labelsize=14, length=8, width=2, direction='in', top=True, right=True) ax2.imshow(moment0_halpha.value) ax2.set_title('moment = 0') ax2.set_xlabel('x pixels', fontsize=14) ax3.imshow(vel_map, vmin=100., vmax=2000., cmap=plt.get_cmap('flag')) ax3.set_title('moment = 1') ax3.set_xlabel('x pixels', fontsize=14) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/spectral_regions.rst0000644000503700020070000002742100000000000022637 0ustar00rosteenSTSCI\science00000000000000================ Spectral Regions ================ A spectral region may be defined and may encompass one, or more, sub-regions. They are defined independently of a `~specutils.Spectrum1D` object in the sense that spectral regions like "near the Halpha line rest wavelength" have meaning independent of the details of a particular spectrum. Spectral regions can be defined either as a single region by passing two `~astropy.units.Quantity`'s or by passing a list of 2-tuples. Note that the units of these quantites can be any valid spectral unit *or* ``u.pixel`` (which indicates to use indexing directly). .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr = SpectralRegion(0.45*u.um, 0.6*u.um) >>> sr_two = SpectralRegion([(0.45*u.um, 0.6*u.um), (0.8*u.um, 0.9*u.um)]) `~specutils.SpectralRegion` can be combined by using the '+' operator: .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr = SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) Regions can also be added in place: .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr1 = SpectralRegion(0.45*u.um, 0.6*u.um) >>> sr2 = SpectralRegion(0.8*u.um, 0.9*u.um) >>> sr1 += sr2 Regions can be sliced by indexing by an integer or by a range: .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr = SpectralRegion(0.15*u.um, 0.2*u.um) + SpectralRegion(0.3*u.um, 0.4*u.um) +\ ... SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) +\ ... SpectralRegion(1.0*u.um, 1.2*u.um) + SpectralRegion(1.3*u.um, 1.5*u.um) >>> # Get on spectral region (returns a SpectralRegion instance) >>> sone = sr1[0] >>> # Slice spectral region. >>> subsr = sr[3:5] >>> # SpectralRegion: 0.8 um - 0.9 um, 1.0 um - 1.2 um The lower and upper bounds on a region are accessed by calling lower or upper. The lower bound of a `~specutils.SpectralRegion` is the minimum of the lower bounds of each sub-region and the upper bound is the maximum of the upper bounds: .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr = SpectralRegion(0.15*u.um, 0.2*u.um) + SpectralRegion(0.3*u.um, 0.4*u.um) +\ ... SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) +\ ... SpectralRegion(1.0*u.um, 1.2*u.um) + SpectralRegion(1.3*u.um, 1.5*u.um) >>> # Bounds on the spectral region (most minimum and maximum bound) >>> print(sr.bounds) #doctest:+SKIP (, ) >>> # Lower bound on the spectral region (most minimum) >>> sr.lower #doctest:+SKIP >>> sr.upper #doctest:+SKIP >>> # Lower bound on one element of the spectral region. >>> sr[3].lower #doctest:+SKIP One can also delete a sub-region: .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr = SpectralRegion(0.15*u.um, 0.2*u.um) + SpectralRegion(0.3*u.um, 0.4*u.um) +\ ... SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) +\ ... SpectralRegion(1.0*u.um, 1.2*u.um) + SpectralRegion(1.3*u.um, 1.5*u.um) >>> del sr[1] >>> sr #doctest:+SKIP Spectral Region, 5 sub-regions: (0.15 um, 0.2 um) (0.45 um, 0.6 um) (0.8 um, 0.9 um) (1.0 um, 1.2 um) (1.3 um, 1.5 um) There is also the ability to iterate: .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr = SpectralRegion(0.15*u.um, 0.2*u.um) + SpectralRegion(0.3*u.um, 0.4*u.um) +\ ... SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) +\ ... SpectralRegion(1.0*u.um, 1.2*u.um) + SpectralRegion(1.3*u.um, 1.5*u.um) >>> for s in sr: ... print(s.lower) #doctest:+SKIP 0.15 um 0.3 um 0.45 um 0.8 um 1.0 um 1.3 um And, lastly, there is the ability to invert a `~specutils.SpectralRegion` given a lower and upper bound. For example, if a set of ranges are defined each defining a range around lines, then calling invert will return a `~specutils.SpectralRegion` that defines the baseline/noise regions: .. code-block:: python >>> from astropy import units as u >>> from specutils.spectra import SpectralRegion >>> sr = SpectralRegion(0.15*u.um, 0.2*u.um) + SpectralRegion(0.3*u.um, 0.4*u.um) +\ ... SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) +\ ... SpectralRegion(1.0*u.um, 1.2*u.um) + SpectralRegion(1.3*u.um, 1.5*u.um) >>> sr_inverted = sr.invert(0.05*u.um, 3*u.um) >>> sr_inverted #doctest:+SKIP Spectral Region, 7 sub-regions: (0.05 um, 0.15 um) (0.2 um, 0.3 um) (0.4 um, 0.45 um) (0.6 um, 0.8 um) (0.9 um, 1.0 um) (1.2 um, 1.3 um) (1.5 um, 3.0 um) Region Extraction ----------------- Given a `~specutils.SpectralRegion`, one can extract a sub-spectrum from a `~specutils.Spectrum1D` object. If the `~specutils.SpectralRegion` has multiple sub-regions then by default a list of `~specutils.Spectrum1D` objects will be returned. If the ``return_single_spectrum`` argument is set to ``True``, the resulting spectra will be concatenated together into a single `~specutils.Spectrum1D` object instead. An example of a single sub-region `~specutils.SpectralRegion`: .. code-block:: python >>> from astropy import units as u >>> import numpy as np >>> from specutils import Spectrum1D, SpectralRegion >>> from specutils.manipulation import extract_region >>> region = SpectralRegion(8*u.nm, 22*u.nm) >>> spectrum = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> sub_spectrum = extract_region(spectrum, region) >>> sub_spectrum.spectral_axis Extraction also correctly interprets different kinds of spectral region units as would be expected: .. code-block:: python >>> from astropy import units as u >>> import numpy as np >>> from specutils import Spectrum1D, SpectralRegion >>> from specutils.manipulation import extract_region >>> spectrum = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> region_angstroms = SpectralRegion(80*u.AA, 220*u.AA) >>> sub_spectrum = extract_region(spectrum, region_angstroms) >>> sub_spectrum.spectral_axis >>> region_pixels = SpectralRegion(7.5*u.pixel, 21.5*u.pixel) >>> sub_spectrum = extract_region(spectrum, region_pixels) >>> sub_spectrum.spectral_axis An example of a multiple sub-region `~specutils.SpectralRegion`: .. code-block:: python >>> from astropy import units as u >>> import numpy as np >>> from specutils import Spectrum1D, SpectralRegion >>> from specutils.manipulation import extract_region >>> region = SpectralRegion([(8*u.nm, 22*u.nm), (34*u.nm, 40*u.nm)]) >>> spectrum = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> sub_spectra = extract_region(spectrum, region) >>> sub_spectra[0].spectral_axis >>> sub_spectra[1].spectral_axis Multiple sub-regions can also be returned as a single concatenated spectrum: .. code-block:: python >>> sub_spectrum = extract_region(spectrum, region, return_single_spectrum=True) >>> sub_spectrum.spectral_axis The bounding region that includes all data, including the ones that lie in between disjointed spectral regions, can be extracted with `specutils.manipulation.extract_bounding_spectral_region`: .. code-block:: python >>> from astropy import units as u >>> import numpy as np >>> from specutils import Spectrum1D, SpectralRegion >>> from specutils.manipulation import extract_bounding_spectral_region >>> spectrum = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> region = SpectralRegion([(8*u.nm, 12*u.nm), (24*u.nm, 30*u.nm)]) >>> sub_spectrum = extract_bounding_spectral_region(spectrum, region) >>> sub_spectrum.spectral_axis #doctest:+SKIP `~specutils.manipulation.spectral_slab` is basically an alternate entry point for `~specutils.manipulation.extract_region`. Notice the slightly different way to input the spectral axis range to be extracted. This function's purpose is to facilitate migration of ``spectral_cube`` functionality into ``specutils``: .. code-block:: python >>> from astropy import units as u >>> import numpy as np >>> from specutils import Spectrum1D, SpectralRegion >>> from specutils.manipulation import spectral_slab >>> spectrum = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49)*u.Jy) >>> sub_spectrum = spectral_slab(spectrum, 8*u.nm, 20*u.nm) >>> sub_spectrum.spectral_axis #doctest:+SKIP Line List Compatibility ----------------------- `~specutils.SpectralRegion` objects can also be created from the `~astropy.table.QTable` object returned from the line finding functions: .. code-block:: python >>> from astropy import units as u >>> import numpy as np >>> from specutils import Spectrum1D, SpectralRegion >>> from astropy.modeling.models import Gaussian1D >>> from specutils.fitting import find_lines_derivative >>> g1 = Gaussian1D(1, 4.6, 0.2) >>> g2 = Gaussian1D(2.5, 5.5, 0.1) >>> g3 = Gaussian1D(-1.7, 8.2, 0.1) >>> x = np.linspace(0, 10, 200) >>> y = g1(x) + g2(x) + g3(x) >>> spectrum = Spectrum1D(flux=y * u.Jy, spectral_axis=x * u.um) >>> lines = find_lines_derivative(spectrum, flux_threshold=0.01) >>> spec_reg = SpectralRegion.from_line_list(lines) >>> spec_reg Spectral Region, 3 sub-regions: (4.072864321608041 um, 5.072864321608041 um) (4.977386934673367 um, 5.977386934673367 um) (7.690954773869347 um, 8.690954773869347 um) This can be fed into the ``exclude_regions`` argument of the `~specutils.fitting.fit_generic_continuum` or `~specutils.fitting.fit_continuum` functions to avoid fitting regions that contain line features. Conversely, users can also invert the spectral region .. code-block:: python >>> inv_spec_reg = spec_reg.invert(spectrum.spectral_axis[0], spectrum.spectral_axis[-1]) >>> inv_spec_reg Spectral Region, 3 sub-regions: (0.0 um, 4.072864321608041 um) (5.977386934673367 um, 7.690954773869347 um) (8.690954773869347 um, 10.0 um) and use that result as the ``exclude_regions`` argument in the `~specutils.fitting.fit_lines` function in order to avoid attempting to fit any of the continuum region. Reference/API ------------- .. automodapi:: specutils :no-main-docstr: :no-heading: :no-inheritance-diagram: :skip: test :skip: Spectrum1D :skip: SpectrumCollection :skip: UnsupportedPythonError :skip: SpectralAxis ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/spectrum1d.rst0000644000503700020070000003010000000000000021347 0ustar00rosteenSTSCI\science00000000000000======================== Working with Spectrum1Ds ======================== As described in more detail in :doc:`types_of_spectra`, the core data class in specutils for a single spectrum is `~specutils.Spectrum1D`. This object can represent either one or many spectra, all with the same ``spectral_axis``. This section describes some of the basic features of this class. Basic Spectrum Creation ----------------------- The simplest way to create a `~specutils.Spectrum1D` is to create it explicitly from arrays or `~astropy.units.Quantity` objects: .. plot:: :include-source: :align: center >>> import numpy as np >>> import astropy.units as u >>> import matplotlib.pyplot as plt >>> from specutils import Spectrum1D >>> flux = np.random.randn(200)*u.Jy >>> wavelength = np.arange(5100, 5300)*u.AA >>> spec1d = Spectrum1D(spectral_axis=wavelength, flux=flux) >>> ax = plt.subplots()[1] # doctest: +SKIP >>> ax.plot(spec1d.spectral_axis, spec1d.flux) # doctest: +SKIP >>> ax.set_xlabel("Dispersion") # doctest: +SKIP >>> ax.set_ylabel("Flux") # doctest: +SKIP .. note:: The ``spectral_axis`` can also be provided as a :class:`~specutils.SpectralAxis` object, and in fact will internally convert the spectral_axis to :class:`~specutils.SpectralAxis` if it is provided as an array or `~astropy.units.Quantity`. .. note:: The ``spectral_axis`` can be either ascending or descending, but must be monotonic in either case. Reading from a File ------------------- ``specutils`` takes advantage of the Astropy IO machinery and allows loading and writing to files. The example below shows loading a FITS file. While specutils has some basic data loaders, for more complicated or custom files, users are encouraged to :doc:`create their own loader `. .. code-block:: python >>> from specutils import Spectrum1D >>> spec1d = Spectrum1D.read("/path/to/file.fits") # doctest: +SKIP Most of the built-in specutils default loaders can also read an existing `astropy.io.fits.HDUList` object or an open file object (as resulting from e.g. streaming a file from the internet). Note that in these cases, a format string corresponding to an existing loader must be supplied because these objects lack enough contextual information to automatically identify a loader. .. code-block:: python >>> from specutils import Spectrum1D >>> import urllib >>> specs = urllib.request.urlopen('https://data.sdss.org/sas/dr14/sdss/spectro/redux/26/spectra/0751/spec-0751-52251-0160.fits') # doctest: +REMOTE_DATA >>> Spectrum1D.read(specs, format="SDSS-III/IV spec") # doctest: +REMOTE_DATA Note that the same spectrum could be more conveniently downloaded via astroquery, if the user has that package installed: .. code-block:: python >>> from astroquery.sdss import SDSS # doctest: +SKIP >>> specs = SDSS.get_spectra(plate=751, mjd=52251, fiberID=160) # doctest: +SKIP >>> Spectrum1D.read(specs[0], format="SDSS-III/IV spec") # doctest: +SKIP List of Loaders ~~~~~~~~~~~~~~~ The `~specutils.Spectrum1D` class has built-in support for various input and output formats. A full list of the supported formats is shown in the table below. .. automodule:: specutils.io._list_of_loaders | More information on creating custom loaders can be found in the :doc:`custom loading ` page. Including Uncertainties ----------------------- The :class:`~specutils.Spectrum1D` class supports uncertainties, and arithmetic operations performed with :class:`~specutils.Spectrum1D` objects will propagate uncertainties. Uncertainties are a special subclass of :class:`~astropy.nddata.NDData`, and their propagation rules are implemented at the class level. Therefore, users must specify the uncertainty type at creation time .. code-block:: python >>> from specutils import Spectrum1D >>> from astropy.nddata import StdDevUncertainty >>> spec = Spectrum1D(spectral_axis=np.arange(5000, 5010)*u.AA, flux=np.random.sample(10)*u.Jy, uncertainty=StdDevUncertainty(np.random.sample(10) * 0.1)) .. warning:: Not defining an uncertainty class will result in an :class:`~astropy.nddata.UnknownUncertainty` object which will not propagate uncertainties in arithmetic operations. Including Masks --------------- Masks are also available for :class:`~specutils.Spectrum1D`, following the same mechanisms as :class:`~astropy.nddata.NDData`. That is, the mask should have the property that it is ``False``/``0`` wherever the data is *good*, and ``True``/anything else where it should be masked. This allows "data quality" arrays to function as masks by default. Note that this is distinct from "missing data" implementations, which generally use ``NaN`` as a masking technique. This method has the problem that ``NaN`` values are frequently "infectious", in that arithmetic operations sometimes propagate to yield results as just ``NaN`` where the intent is instead to skip that particular pixel. It also makes it impossible to store data that in the spectrum that may have meaning but should *sometimes* be masked. The separate ``mask`` attribute in :class:`~specutils.Spectrum1D` addresses that in that the spectrum may still have a value underneath the mask, but it is not used in most calculations. To allow for compatibility with ``NaN``-masking representations, however, specutils will recognize ``flux`` values input as ``NaN`` and set the mask to ``True`` for those values unless explicitly overridden. Defining WCS ------------ Specutils always maintains a WCS object whether it is passed explicitly by the user, or is created dynamically by specutils itself. In the latter case, the user need not be aware that the WCS object is being used, and can interact with the :class:`~specutils.Spectrum1D` object as if it were only a simple data container. Currently, specutils understands two WCS formats: FITS WCS and GWCS. When a user does not explicitly supply a WCS object, specutils will fallback on an internal GWCS object it will create. .. note:: To create a custom adapter for a different WCS class (i.e. aside from FITSWCS or GWCS), please see the documentation on WCS Adapter classes. Providing a FITS-style WCS ~~~~~~~~~~~~~~~~~~~~~~~~~~ .. code-block:: python >>> from specutils.spectra import Spectrum1D >>> import astropy.wcs as fitswcs >>> import astropy.units as u >>> import numpy as np >>> my_wcs = fitswcs.WCS(header={'CDELT1': 1, 'CRVAL1': 6562.8, 'CUNIT1': 'Angstrom', 'CTYPE1': 'WAVE', 'RESTFRQ': 1400000000, 'CRPIX1': 25}) >>> spec = Spectrum1D(flux=[5,6,7] * u.Jy, wcs=my_wcs) >>> spec.spectral_axis #doctest:+SKIP >>> spec.wcs.pixel_to_world(np.arange(3)) #doctest:+SKIP array([6.5388e-07, 6.5398e-07, 6.5408e-07]) Multi-dimensional Data Sets --------------------------- `~specutils.Spectrum1D` also supports the multidimensional case where you have, say, an ``(n_spectra, n_pix)`` shaped data set where each ``n_spectra`` element provides a different flux data array and so ``flux`` and ``uncertainty`` may be multidimensional as long as the last dimension matches the shape of spectral_axis This is meant to allow fast operations on collections of spectra that share the same ``spectral_axis``. While it may seem to conflict with the “1D” in the class name, this name scheme is meant to communicate the presence of a single common spectral axis. .. note:: The case where each flux data array is related to a *different* spectral axis is encapsulated in the :class:`~specutils.SpectrumCollection` object described in the :doc:`related docs `. .. code-block:: python >>> from specutils import Spectrum1D >>> spec = Spectrum1D(spectral_axis=np.arange(5000, 5010)*u.AA, flux=np.random.sample((5, 10))*u.Jy) >>> spec_slice = spec[0] >>> spec_slice.spectral_axis >>> spec_slice.flux #doctest:+SKIP While the above example only shows two dimensions, this concept generalizes to any number of dimensions for `~specutils.Spectrum1D`, as long as the spectral axis is always the last. Slicing ------- As seen above, `~specutils.Spectrum1D` supports slicing in the same way as any other array-like object. Additionally, a `~specutils.Spectrum1D` can be sliced along the spectral axis using world coordinates. .. code-block:: python >>> from specutils import Spectrum1D >>> spec = Spectrum1D(spectral_axis=np.arange(5000, 5010)*u.AA, flux=np.random.sample((5, 10))*u.Jy) >>> spec_slice = spec[5002*u.AA:5006*u.AA] >>> spec_slice.spectral_axis It is also possible to slice on other axes using simple array indices at the same time as slicing the spectral axis based on spectral values. .. code-block:: python >>> from specutils import Spectrum1D >>> spec = Spectrum1D(spectral_axis=np.arange(5000, 5010)*u.AA, flux=np.random.sample((5, 10))*u.Jy) >>> spec_slice = spec[2:4, 5002*u.AA:5006*u.AA] >>> spec_slice.shape (2, 4) If the `specutils.Spectrum1D` was created with a WCS that included spatial information, for example in case of a spectral cube with two spatial dimensions, the `specutils.Spectrum1D.crop` method can be used to subset the data based on the world coordinates. The inputs required are two sets up `astropy.coordinates` objects defining the upper and lower corner of the region desired. Note that if one of the coordinates is decreasing along an axis, the higher world coordinate value will apply to the lower bound input. .. code-block:: python >>> from astropy.coordinates import SpectralCoord, SkyCoord >>> import astropy.units as u >>> lower = [SkyCoord(ra=201.1, dec=27.5, unit=u.deg), SpectralCoord(3000, unit=u.AA)] >>> upper = [SkyCoord(ra=201.08, dec=27.52, unit=u.deg), SpectralCoord(3100, unit=u.AA)] >>> cropped_spec = spec.crop(lower, upper) #doctest:+SKIP Collapsing ---------- `~specutils.Spectrum1D` has built-in convenience methods for collapsing the flux array of the spectrum via various statistics. The available statistics are mean, median, sum, max, and min, and may be called either on a specific axis (or axes) or over the entire flux array. The collapse methods currently respect the ``mask`` attribute of the `~specutils.Spectrum1D`, but do not propagate any ``uncertainty`` attached to the spectrum. .. code-block:: python >>> spec = Spectrum1D(spectral_axis=np.arange(5000, 5010)*u.AA, flux=np.random.sample((5, 10))*u.Jy) >>> spec.mean() # doctest: +IGNORE_OUTPUT The 'axis' argument of the collapse methods may either be an integer axis, or a string specifying either 'spectral', which will collapse along only the spectral axis, or 'spatial', which will collapse along all non-spectral axes. .. code-block:: python >>> spec.mean(axis='spatial') # doctest: +IGNORE_OUTPUT , spectral_axis= Note that in this case, the result of the collapse operation is a `~specutils.Spectrum1D` rather than an `astropy.units.Quantity`, because the collapse operation left the spectral axis intact. It is also possible to supply your own function for the collapse operation by calling `~specutils.Spectrum1D.collapse()` and providing a callable function to the ``method`` argument. .. code-block:: python >>> spec.collapse(method=np.nanmean, axis=1) # doctest: +IGNORE_OUTPUT Reference/API ------------- .. automodapi:: specutils :no-main-docstr: :inherited-members: :no-heading: :headings: -~ :skip: test :skip: SpectrumCollection :skip: SpectralRegion :skip: UnsupportedPythonError ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/spectrum_collection.rst0000644000503700020070000000717100000000000023351 0ustar00rosteenSTSCI\science00000000000000================================ Working With SpectrumCollections ================================ A spectrum collection is a way to keep a set of spectra data together and have the collection behave as if it were a single spectrum object. This means that it can be used in regular analysis functions to perform operations over entire sets of data. Currently, all :class:`~specutils.SpectrumCollection` items must be the same shape. No assumptions are made about the dispersion solutions, and users are encouraged to ensure their spectrum collections make sense either by resampling them beforehand, or being aware that they do not share the same dispersion solution. .. code:: python >>> import numpy as np >>> import astropy.units as u >>> from astropy.nddata import StdDevUncertainty >>> from specutils import SpectrumCollection >>> from specutils.utils.wcs_utils import gwcs_from_array >>> flux = u.Quantity(np.random.sample((5, 10)), unit='Jy') >>> spectral_axis = u.Quantity(np.arange(50).reshape((5, 10)), unit='AA') >>> wcs = np.array([gwcs_from_array(x) for x in spectral_axis]) >>> uncertainty = StdDevUncertainty(np.random.sample((5, 10)), unit='Jy') >>> mask = np.ones((5, 10)).astype(bool) >>> meta = [{'test': 5, 'info': [1, 2, 3]} for i in range(5)] >>> spec_coll = SpectrumCollection( ... flux=flux, spectral_axis=spectral_axis, wcs=wcs, ... uncertainty=uncertainty, mask=mask, meta=meta) >>> spec_coll.shape (5,) >>> spec_coll.flux.unit Unit("Jy") >>> spec_coll.spectral_axis.shape (5, 10) >>> spec_coll.spectral_axis.unit Unit("Angstrom") Collections from 1D spectra --------------------------- It is also possible to create a :class:`~specutils.SpectrumCollection` from a list of :class:`~specutils.Spectrum1D`: .. code:: python >>> from specutils import Spectrum1D, SpectrumCollection >>> import astropy.units as u >>> import numpy as np >>> spec = Spectrum1D(spectral_axis=np.linspace(0, 50, 50) * u.AA, ... flux=np.random.randn(50) * u.Jy, ... uncertainty=StdDevUncertainty(np.random.sample(50), unit='Jy')) >>> spec1 = Spectrum1D(spectral_axis=np.linspace(20, 60, 50) * u.AA, ... flux=np.random.randn(50) * u.Jy, ... uncertainty=StdDevUncertainty(np.random.sample(50), unit='Jy')) >>> spec_coll = SpectrumCollection.from_spectra([spec, spec1]) >>> spec_coll.shape (2,) >>> spec_coll.flux.unit Unit("Jy") >>> spec_coll.spectral_axis.shape (2, 50) >>> spec_coll.spectral_axis.unit Unit("Angstrom") :class:`~specutils.SpectrumCollection` objects can be treated just like :class:`~specutils.Spectrum1D` objects; calling a particular attribute on the object will return an array whose type depends on the type of the attribute in the :class:`~specutils.Spectrum1D` object. .. code:: python >>> print(type(spec1.flux)) >>> print(type(spec_coll.flux)) The difference is their shape. The returned array from the :class:`~specutils.SpectrumCollection` object will have shape ``(N, M)`` where ``N`` is the number of input spectra and ``M`` is the length of the output dispersion grid. .. code:: python >>> print(spec1.flux.shape) (50,) >>> print(spec_coll.flux.shape) (2, 50) Reference/API ------------- .. automodapi:: specutils :no-main-docstr: :no-heading: :no-inheritance-diagram: :skip: test :skip: Spectrum1D :skip: SpectralRegion :skip: UnsupportedPythonError :skip: SpectralAxis ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/docs/specutils_classes_diagrams.png0000644000503700020070000044273200000000000024655 0ustar00rosteenSTSCI\science00000000000000PNG  IHDR@JsRGB pHYs+@IDATxUnV;EBT@PBA$E@@%$%2s?|{=;;/("("("<POPE@PE@PE@p! E@PE@PE@PUfևTE@PE@PE@PX"("("(s*E3C*"("("}@PE@PE@PE@@碙!E@PE@PE@PT~:U7nsΧC\R [n͜9s˖-1>ۺu+mwͰ6O 'UoA$,7SX-@߿k!S]9s6mzԩ)TgU_~Ǐ~?;|\ܾ}{ҥ .䓱c޹sp,X}8^x: >|C:kYfMFXԄaÆG32M,RMRH!Cf͚լYӾ1a^{-q"p=VFH$ɻk rl5bk"l"fSrYfo>u RQK/=z4VX+W̖-]?вeKֿx tz+Q=(#ʭ/腜eʔ':"(!@ .h_5~_xeOȉ>}э#Ƹq<2>6m.\X_1h߿?UjmsQFN:v!VXyy~0_JEX)ݻ %$ܥ ;$ɣW^\2OYG$$|2C$l^2c2/ a?f Ljժ{]l/("rb8m4GuI-@,~7c9<W&-4L<'P߷ok׮CΞ^Ɉ[oc,.8g2ATVqK/snܸAG)>3< ݥ546w<>nAӧO'ʼnGԨQ-Zob2c;zZwBT Q3#p}FyB Tꫯ tD{3XƏ_bClB (f$wvr#/_>w*xvڅ믿>}zfr!)mK,pq->}4ijG&MD&MDeϞHO2ӦM3gNvv$.i XL4uT$TRѢE3E.]:vmam۶-t1x ʕHu֥Fmذah'N([Gsg SPʒ%KȑMu$~-RL*P&iپʕ+eʔ )l߼y3ҚI&usm(/p5Ġ @{-_YWHEg諌 oD`LX4 XF׊zySw$ƍ+y V\5yp:Mċ17iZHS̤̀Yn nݽ{vbHSC].5kք :/ 7  h0<-&# zi_SzuDvŋG/_ Ś6:c,D[~_}޽{@GZ$AZ")91G`~;<ɯuޝ t3X84f($y ^@ҁ>K )-PowNF3Q}ȑ^"\XhL E3o%_+wFP> (>zM-F@ݗ0Ӗ\z ,YT,3D31!{}sL84 @䘈Bן"zٚ'`B,R3 H^zBR||е NS+bD9=1t`2yEnJX`3 0 SQ C%D0SmLi&FΒ)Nl"iYtРAH]v1(N*KCbŊ/X%Z5C02wQ¢ncȒwh`/2\L p#$ܛm@GB_^L[r@Baa;[j,P23lV G7?}~NJ(3jZeQ{0MG[L jLP0poܹTF$.1Q`d[>[bCX]fL-7 0ٱQ2T_,WIBDD?O6م˔MdqıU,9-IF1k AP8h S @Dvc/ ?3rɅS035D|(ˊ;7 !Udʔ "6CSmf3ڹ(PGxd7 yҝ􍠎r އz[*HAHBz" [^.YkkPrp@aln,GXkÄsRnJv@%t]׿SS U`1Ndn\iZ< u.m0N8p9J4!9AE|xp')ã=#ıY:ci Ih]&7qAAa|Ԍgd|'߆Ȍi1@}sNdڄ< D=fN/̳' X,Kc4[Ye)"bh{;|ǁ!-/͌$t!+6i?`,`bA." (`lQKq UnF.% H{SQZGoa'{x (cQM4a6 !Y,7c(v `q %c?Lp@X`? ySri gt n_0v'/}C} Qδc,K Mp`e3ih2iQ3)~A%P/tGוgp/M@d)ds:u? }Th c)ʄ̨kT" G >B- (vBȹg8#Dِi lBy-|04 ~e (;Q)\6x6XPHK76%QK wBKs{`T41xF8\9X13 _MA@L?9C<>,DBYR-~~r໐~ht'~'|/݀E|"v_PpwBߋ:"`&`'~w2433ׄS :XܥU"#&ʎ1JƄwH7 IT[EܒL9^gTe- 0<ɴW9K$yqf\&y&–T2<$Evs)EIxX)l?jp`vE>;UHL[/vB_N|Qriրp%a:`(Q,d`e](axA+ H  y.fsQ &޿бYld![ fTz:0Sc]Q/"TCl2Gf,aJQi;DQ)4ӹū|y s.;`NL!(Ǘbe\b_osN <5[9 ՗"0|;^Nv>w9ˬk?N2_c-޿3l}bwx}sgze) D2uN)@ Ld :at@$ rFJuCp8jh**H9#N.hq~=<ÆF̶qbEb3  8" 62CL#Lv쇛{-& ?c ["؈-0RΙXY/Dj&U.,&- Di~c64~e046'{2b0CEmEFV@D7pvi! ecT(qׂZ#+X[sи>d#@ar `e$z(t /ar}$hJp6q b"%V 7gzɀUAӆ=x +%-mAX/ * Q9κLͽE4Ǫd9Mh2PbvÇ_}6F_a(ffee˼a!-|FVs˝؝ G9`"g?:<2oq>@K]._n@ ӏ.S|itbR !:HHBN"0_m*M_vmn^ϝ9'R(2uN)@  4S%fH=?ͦ=THV,K a5?ј.(([ e-z (vAQ4^L D؜W c/>{tQ{sp%' ' Y6rIRBorcdƀ0Sdٜƾ3itZh\̌}bd^"#/m:۳,¤ EKAY1Aweyg#S@SGHU4T9и^ h}(etQz$Y[:11ގ]% DBk*&\ ~.o+` 4w*Pý7:k!/t0{ 4X C"Ps1t $ XlRH AXjբ'hs;j1HƋҷꄅGTz*+oӏ1<#kccb((YzHKad@' C~`W:i1%:GLjDǂ0]o.G @Fg^yŌ9#X.}i*Ŕt x}U$a_ ?x{B@7n`}9%N) E*O.^~4Lw#͆ܒK YTdt*69Tvna~i!8n2~) tlenH|\QCXx̎TUƹŔ'D6, ;~*.(5`1دų< <~he!4 Q`KEՆ=!y[e~lN 9R-Md#u @ wIX8sS$G4HB"l6VS^Ãpe~&&鮆Zz8꓅.J'[cBLS(ʅh2B@2:b_Uqv0l(@!]L|qH^,(*`Hqx~48E*ܞi<8M̏zh)%{,Ǘ6{M,}> &q8=v@tF!q eO5+ ѷY@K'O{ߠ@G9E>h"q5'xSO "݇zj{9tT? _ƥ\SEM!$C@9B j>vob[< ھ7AEF;H ,KY9o TnvؚVpt8D G>N`>s0piB\~x4CV02?f'-ۡUHW3)˜s[&C8I A7Z\:ܢF:f(A%H?i@<)|RKHWg-?n`~,#ݼ,XEٞ"x20hw5 b]lFB^> S! !SX3BfD =tS=q ֹsg<-qř 8s9E֤̊ ,X~c{-1 zl%X3TE@@B&qsɎ〄gQIvWbǏiG$ ϤDf/xaPK$6"0 O*m(#@H*=]UR;xl:HFuek(qvE@v?O(! !jId ٟ-_KS|G푓"N@=UI y, f;a!%0}K 2p' {@G{3&Gh=&= .[2.ᯈ\ej9@K4H"T`P[E@PE@PE@P"?E"("("(7tQPE@PE@PEiB@৩TVE@PE@PE@PPo4"("("(ӄ*OSk"("("(~# iFE@PE@PE'ݻiӦիW_p=9'oypP3gܬY3/lzKPl$ M+"("( ֭Kʕ={]1޿^y?߾}{٥gƏ_>|xNkѴ"j֞("(",hRI0rIH6(Yd1o>}bŊ#FqZ2(!gGVE@PE@PvFzcǎL<˗%m&ETn("("-VXqB %LwgΜɓ'O%6_ĉ+Ι3'fdr:tHٳgɒ%8T)RD(Ǐ9˗XbvQLM(6?O("("!pΝE"_~3fv+8Zh܅@$f͛/nuJ&M;utMиq#GKM( ?' ("("!0x`4[[3g1UV6Qv{zmp۶maK,Y/^hѢkԴ""t(G.~z!~n%O;s"("<>h%߾}_2;`(SL?kذaPv[oE*J1cx ޸qcn` rB@@?_O("(" 9r?zRB9ŋOy8C )Rr.})lΕ+"\! s"("(O x5~Qe9q`TV5c Zj]6("l ю"("(a1pW^uHiSE1B]1AlӒn߾=i$K0#gUէSE@PE@xd̘Qt"َkd2{q[5`޽{E\pax83gUՖRE@PE@+`ӧO;oKl jڨМȋt:u]\9nݽ{w˖-Ty(|3*"("<tA?#6l(Qĵk&N8g(~#t_ʭ;wH*B:;wC9CȆCulݻgrAٳ' A!4+Wdqɒ% 1AS󀀺@?Ϩ("("'֭[97f̘F [d~t1HĊpKCi,Ǐ$Iwy#9s허\ 62ej6~NP sO1@*E@PEi@ɓ7nt됚 yQ͞^D&zd?gB^5kVs05=z4vx>JjTmTkÇ+U$J'u/*GBWE@PE@P0@f"D/5{ [K.{Z"("("(O mb;!_b6_9t4aW ڴE@PE@PE@PB3l.A{s[\(_@ἡ[֦("("(@"`ƍ,u`.7tUЭPkSE@PE@PE xNڱYsdN>W\M1m߲3UxUsۖW._ɖ#KIM!v;yK'M[W._={7H=re6!셟c>lݴMǖr_=|5‡aYfWCkK/s[\"("("2ώ<껱f7P #U+D1YO1 Y ZXK5ff Z.Bzn>hTr9r$)aׯXXuF~Ѽ%3ΩRb:U%OSIΛse(OΚ#bVQa~F (]rY|[w]0g(͜6gPb ܶvlm~M+"("#v{0@ؙ T*62Gp߭tڹ \ݤ\cG7WPU؄ϝ9X\Qn1xPw%CغLʶt@y})x;HΩKK.#]A֋xzKPE@PE@P'&9`xUr0/ņNԉ宻+D$vm!Upldq+uP^S:}KC߻{{t}K9{^Q*"("("xv୛hF}8v6G֬\'b}%]^UTY~][0ޖteI2ܿ8RKs+A/;۷]{}e1 B1/޳r7br1bn߼L~9 U"("("8ܽ{wvyB4" <#.JT-T^çwS&H&Ey얎)RJ,],Cl/eV,<%K/]'!-Zj͊iҥzږMۈgPwaZvm]D^! /E~ )$o<-ԣK}_|ﺟdDzl;tkLj}hė~=ދZ~NLjayҕ? jJbē:r"("("ܼqj:OA:Ah)>e,Lh9u-}دۀ]6]JQ>}YE@PE  >µXE XX]lڷr+LhWd-D46j> nݼ"UXs4m=s2 D:~QE&<1=1Ǭx"k4kWE>=T-`ӔVԲu) ¼ZйXS2(P@qP{AϢE:}څ0ZKGj&Au&p/m 5>A+>N5l S(?jy) U6S 1ĉ]/,8i0G٤IPQ4S@Ţ:V:q{>bkKiE@PE@P-syTɳdǯzu+!b&>7,2hVUTVX{ /Pb E@PE@P{߻Q!ܾeg(|'ſsFu?(^ Z:p8}tq֔s .;;Cn9p-E|v"(_MryPnp󖷉}lxeSB}˗/޽YΝ;w).z*S@PQA?~ȑ. 0\r깛o#ǘ-[ f͚'ObrEfz饗ʕ+g|ҥժU|rh]MSz;psGk׮G|@Hux[t? ݡCz(@0"(B<=˗D-P8/i܃ Uz>z^TiRioE?폠qvn݃2kDWsqI|\Ѣ5͔)SK3g֬Y1F4KFςlLS>xy?A/+Rȑ=ωVվ$u1bEGV84a׺s&\#֬Wk^GXKx^_銀"`#Ĺ\֩S&%E{&˘6mh7AvүJʕOγak+AvJ.}/w OkyL5p͛7MUޢl sKyѽm̭IDx1|2EfMlٲŋNZ"ڽcOd3gPǎ߽sݻ0~p&%lr?eBm<GܹsgW._y9xpE+'L {l>fW޻{ қ[ mN͖#KI DQ9zӡ# [r{zaZRh~ifh{xA@`/-EByƒ+8 wV'9 u̪[n}駃 →, 2L>=ܫ}ʙ.7E (dw1c:etoBzŒ(U5̵D`-rQ)Us4s(?w=Oױ͞1Gx{ƠRp\ ik_nټb_-:~(CG|`9 Qؚ}ܘq?jK9q׀ש2|Шݾ}D>=yiNF!S?׸ٵߨa|95 ѵElþ|PJ^(8^R׬$LR'P bz+Pʡ " prϥsg/ׁlT> 6'4|ڲ܂s۷o'j񆝫\~Kkrȼ~XI_8~!ܺ~篿5ȑ#B9|m6d GW\ vlr7rC_'Nwkʙ3gǏ ,vW1b,c/,_JLśӦM[r͛7M jܸq{y][ʾ\Cg/7 QwB@27󏿼L|v&Swml߽K2_eKÆ_ ݀hiҥeYhoVzc/jEr9?I0x%C.^Y]fU缥| E,w6$WM  /VaNvg&llOZq SeRu_mftW/[%ٔoW,^wJ*>?ΈVZmذ}ũsoZ8(wľ}ƶX0վr<㧏=ܯVp︼+?eOBɕ+Wa0 %]~}QE0&M̞=i CI (ؤ6iL&\.9>xM =oO={3Qc$ŋWPs5Gմݫ?ʱ~!> Br>uYD8e|%q\6>Lƅszq 4S2eΝ{ֽ*E*tK>;Ȝxw-oI<;D .1Ֆ\\lbM]̣FBnCp_{y}Vz󫯾6nu\ PIw~Z:sg}^OҾ>m2d)ć~8~x¡![fxK{`>g ]\MÏN׮i~èmB'Q21b̙3PmsW@XCxNi82/7W{hkx<>də;Gtkxyw0σqz^x}IL'D׍?z%~l+8OhS'NOeo5Fӱuֿsyj/Am8)+.{*!F?g4'D\*9u{wR )_Z( ĕAjQq}}ɮ<;jvD)s}.~1cGO˥PqV@vrŦ5Zcׯ_qh޼9j5j:[Q2KRi'QFX"S'N B~I3Zի'u^!Qv-pFN:*LAz[VoeJ>%w5kֻwfnGWzZ7"{cd\7ݚ/_>T8,xO^jߴiӆ oB©M1֮]=ϖjT_!LwM<>뎣 "ސ._P|?D%+N]r0`_)o2MB#-\~ӧO'_eSa62_zE2Y SN-ȼU7GdZjޅ~/2oիWO%UvEY/` ֡SrV.N_2P-LQt`ڗ2N4KB~3fW? Σff/ZAåf(x^m P*G]xl~?S۬Oٷ4)PY׬\#گF\%`O<}q{'7 pԱla458H,oٖ-⋮+\lHFbNmM2{ Q~Kiz֮EGuhPHbƌauč WlN_ݏ7u~%֙,2sfljdb8}siAvcdϜ)kFZdiˉȈ E 0ƠCK.۰eП"FyI5øt]v(K"3M+<4#3 [B'"srp5*-f<@djn(X‰=׮]c?aI3KN,ݾÿ Nڦ}>X(i/DҤIYd17P\̛K,8-D9ˤ4Q^?y8~K)@Bıs¥k6bŎE0 (īPMl+4`?NlTCiۡt#ώu{` PRD_!K⸫uvle7 Z1QJ>0Af9P\g-'6Za4A+xXغ'> h ʠ#`wLBȾ;>xrʼne5/Udц FsBT)̼HxUgJX5 xDL}ҙk0l/{n})ȅ=g%4̹:Q?Lc5k1bCXˮQNF G=Q"?6;YG *p}'!fG\gΞ`hŊHsn J2K-Ί}_b.q?{9s|0o+XL&]Inzԩ#v~nN ,ȼEW^SNaC+2gy%\WWdn۶mXѫ;XJ˙3'.Wݴ Ro`p.-TboW`kWO&]{QȈ7zk,Կ`r+^qeUf5!o<“$MASG͕%{_ ĵ턪n-x(wj Oze׺vZaS@P p捛} ȝPkl]7o"&Fۼq+gh+ծaݴ']ׯVV["X ,83eryJ`abSQ~o8 (Ʒ,YXaeI m!K Tw`a+ъ}C횸ݥOĈ˥oؿQ"avfՇ}WҢ8TpNdǾ/޹ťݍ,UbTd 1aQbٳ $t )w&0Rl>ɮZZjŊr+]>e,$02NJ#UքRSxAdv}zOfQܼ !JƎ[QD7Q%Kĵ's^s 2+8^7 mvuqˆi2Ș/I'['̛ۡ4R?pƊ` T<! Tǯ>]E޶tݮ&SكENWv~s澯}JQNuhݕӉ%ήm/K+dΌkTyP|pmlqY YRU}A)0߰ɓ~!2Y$V=RlF4 rѡl6s(S@V|Ξ:hi/ZN5#ϕ7;u}?gaH_ɳZ**ZhdQLe~1GRݧ}>*_?o[az#WneHb_(kĸnxtpq)\)uHfʟ[OFt='QDIKՅ;zQ[LX& $ G@]dF3eNrJ32,'|_%=?KrEbJ5=cEҾ_rP%GJ & [h1W2oY%~eP:PK zKcP4C,:\rPfMIy9>v@iAsĬ2ILi6OQIbGQ8vhqy7{jߥ2HpnNӷ[A?3ܙ 'MH!P1 sؿUyH )N8|%hF@oeWu,ִ{kӏwHwS.8GVy88Yi1q\B""Onݺm_<9d/ZRN@<,9* wQ*(zT GvysNAMTaϥ((L RsRmÙ p$_2ӦMK10el#cdSҥK=Mw=j˜e\|̮]"~̧O`-°q;4Ҿh_k/0 u҅+%*+8:G"vP^f{Cq}R;gb;ۗ 3C?sI )_ziE "еgG|ednNKEcķBU1Ҳ~ppTo`վuު.T>K}wLAהӿG3'CyĀ>C~>5+H˗ds2bZE >jG7W%?Bn( RNvk_z9lʽfɜ O_Xx?UHxqzIJ}xc-'v%+ nM7B15l# pk)܎U$.# ?Vvm+vL8T_qެvqrh`.sP|,Uva2J Nq? ;+lfiF|.ɰ~˒ *l75r?esCg/ՑNk*uOо^]틩D~Տwn pn g`AtH"-o߾/9w<|j˼#4D'j0չwrQ9(Fӊ@Dco,9FDNSO[~o58]^,r2qfϸ)?o!h%{&*< %3:$KԔ"k1HEj砻hZe_K2a}Nx(q5G Ϝ6/_/]{t+Ƙƍ#v &d TG<L;gJ#i׹k7~;]N_v1’_8]5iDV۵h zk?E,<#ت֪,̎I')R0߅%>R峊=_"pu $|T@rY`m՗qnW3ǚ1 V+6`/D텭GYl[c߆g AsܔQĦ(I?O5Sc ۱u'\2fN-[H~\A6zg d0s>$&ָQFb`ʥkע5(p-AƔf*MJ'bH.հ?~h_\&38vzoV|[.</[սso.=;b.X$ UShYa[ (Csqc<~l>D$+ͬ*U )YYd?fLq~5tXӧOY#GP&83)A(":?yfkh3$Y۸mFqBACN'A|WgBSlӭ[7>/=?E'z(L$YDwd~"䄏j:ܺN"/G .Tr2|D!oyVGs K օg7;iݫ%TEXLȑ# :tɒ%QsoJ2Sl~;YMSyӖPaCflq3.X!5}R++yw|q;スb >,۱2S* K1UӾDݻ7[#$X 2~Ȓ@s.:Is0Qtrc06k1<_':9k yKg>emF :?3➽s*} d-> A Sqx )R%{p[cҒ@s ^(ܾ=UXAk+`eD4PX0B>6G v`,){@ŠQlK(f BKsxV34LV|O}2o=/@ [5Umd|DusZo>}e#. =%Aa‚ <~ 6Vp[(Rߍfˌ,pbD$hb|?KB{]4oh2+U riӧ5ثa-4FC{HpR_Rn6% \']q_s/G)Oe*qqcK1e.mkp8k3$LN&Fgߒ49)EsYx܅d.N3⌊6bM`.%5H%F6dkÓzcs2)&EKJB?G_!2GH̙3'L+F,W@)/K*^IlgH쎛J&MX^w|-n8;96(GrD3"iBx荆}칞_zU#:N#Zr*yhwjhdyÈvpŽdO$LG׮ZߤGsȜ5#H}:Ō2kdN<#,!q l36a.^upy=(^t7و{s[}2>{[fbq[W[+E\ң2mڴ} \P?zu߮xv`wKEAEqc'N<-ZԔ}oHRn*rd4g (Bj0"-m?;-(EM!k$*BdҦ)eBl-"ٷ*$ lgg{\wfܹ~s=y9v:a8n /Pqкx8''&.]՜|l:c/ p& ֭͒0'qɳϼWA4V\^:&;V*% <۷o{nVO"M __|1_Nl)EƳPULD dN+g0bݞ2Yjv gE&ӚU7Yf9l25zž6x-X$L%/Eظkl˳L̺y mD^k׮?<޼lީGِ@җ1/xĒn*u/^qWD@D@\dv ?7P)-ޫg2ƺjD^ghq~ɴX.7ALmd<أϳ)ϻs-8Ii"`Y2@FdԩI 5j`}k̿U@3 ]D@D  0r_/Yy_L8 Ez ''MV:W؄}+\pncogl-\<=҄'N?D^VW~[úb;0Bm@( t »bE^S:v5?} amúvK.؞xͬά`\Mă7ou'5otO._ )۶3֯ue_PUSS61 th۩^_V}I m #V%kջ(iΣ'  4hd-qqW& Co@MYP31U݊X\m q7ŐXPszrS'~8u2F3xqsg閭 i ?ߵ{&N??r0?Զuj缢zF_x-Xܱ׶PBD@D@D@D@D@…p T[J۶l˛?E[s7/>Y6j/E-x M%k<X8Ecei[w8pYKq͗׀zuÇ{^_LkU %${:hxd^;-]9$.:NtM!vE@D@D@H ::jvPU@b y0NzVֆ%SE@D@D@D@D ( tX^BIeOh@t@ä߿7lpM9@V:Q]" " " " " "@)J]P|BQj4@QD@D@D@D@D@D@E@8P$U@HˣƉpH& җG @T=" " " " " " !M <ij@88(" " " " " "aF D@D@D@D@D@D@p8\%QD@D@D@D@D@D $3P pJj@ Hg*1H1\lhbbRJ"trj+" % Q:>P˦Oǁ:DD@D@D P*6T @˕R;E@D@D@D@D@D@2D@8Ct@+vdp`p! .WJE;xÆ ;wPB޼y]gYv9rv\rTTsW||U .\r9޾}ʕ+ /˓'6E@D@D@D@D@D W]vժU=z}wꫯӧv;vRJW݅; ,Ȯ&MPg"E#G2~G.袺ujoF$5֠vgQ E5'VLK@`"zٳ.\:[W_%&&3۹k,34/n֬TRk׾K1Ϙ1c{E|ʕ{' .wVv餤o666r۠ gٲeׯo +8tLL #f_~ƍ *t-/^ ̫TrLWԩ?~g7o|w83ݻts5k2x믿C-[^愄h7'.yꀷY9uAow@6'L^V-']d 7ЧOΝ;{-tgsҤIO=T%\%|3ۖp"N+ K?u=H,g>_~]?z(v]rz!g%7dٳguFnfFBQ_~ŀrxMȏc,Jᅯ݅|뭷3/2mڴq)#Gs9hxYH {irAxνW\qUgZ73fNkٰUٕIm}d>gΜUCe(N+UuOf(- "sWD@3@<%K"opf[hҥKoڴ c7 40۶m9Ѩk֬ر#=^z;w!1ޙeLx 4c6o|ܹ޽{wޥK9s LfM~ya v֙tFh0mMAAmݺwao&t~X" #x7޸q6m嶅 .5ID@D@D dg}g-?xT{瞋34F'2 z{&soa{ ]k/`go9aHW$wu9s&9g4S:!HGy79{f [${`ԃ\*tmaѓ?&'LLng#Ɲ8;XqD@D dFtPuJ[Z^= ,Mgnok6j-`&!̔E{P;thF$$[}|bMn6=_.9rW_}0믿$h5pe]ܤI6Xq.`b3/n$i6s^`g/w6%'mv:ydיu>#6:9OЊ+??eS3S!di{8 7&Ϡt0Gbx{Y+Yh$QvD>a{֭( s8)׺m۶-8ςLEkt^/lx5ӹ&~ƍ{smdfhO~%cyJ@ Hg`&D!T>V 8bEV*B,kZ`!xG}٦@j jZnsejg> s؂I7lؐKFEV?2Z?Pw (1|٢ZQK_9w}s9iiWx0eL&lUos ORL馛^z% Cr8w3, n (2><8my\6h$>ؿwl.+r.[ly=9$.Vژ\܍: {'S_ીYzO?4M.dz۷'TamehOzP̓5j԰iIJ@ ֠K(MsK&m+5̦1dLl6}3Ŵ[nL^57z驾 \hr0-O 4+L:eΰo?d\A3/ADܢIL6̦f]v5^1k0jXh`żf~R2syČ-6s,7$3lUDFwh-*s9e3/̕e3{QWc#7+qk7Q`GKSsjAN[UDH9Z9 .^!g4֚&Qɽm?_:t0Iklp&yMն24- `ԩr%D@2@~3 _D@J@`B4c.nF"V8y%>Kx$;voH_Dº_Y|eO!RCZ׾бkA0`%R誓9vg=fX伛Kf^_jjÊT'kIL,u(ܹs]8[ps%fٸ[3gQn -w}6i& _tq,.|4K>Iwen3"О+7U32ؒZQ{d~ ,J9,H#tesh_\) FWs> FR5nby%39Od\jSD@D@D@B@8 38k&gجdc?3'/? 8Qڬ灡\ ӾLwk Mf9c!f&osЫf$H_XL fڶ:M&RX0?QÁ ?S9@:̱4]b˶Nͬ2a]5i r}f20)\믿Pe.aq ь)` |Ě*f̙c&AhḾ㭍}`lv*/u:gk"6/ "pusq"+uRm?$ee0bE]`Ӛ2eaeNf~\@brwPek ] 3jhj"$L2vqygV#z1nSf>J]5B Z'mC6Si6ʞ(U7L@IDAT68e]:OU@ ĆWk0ݼ<Ӟ`rKE]^i-L͍XC]Z naz N7.|If,;c+k<ϽCp]1h+^xۢ{ XK8L;6b&k1( p2h3 YY[j49JҗUE̎'\\ᢰ׈U:&3lOYzL9M0UwmPp/"Zqjڽ0#8s*2u*!" " " ! XMЇ;R|Ƣ8bv!-\,aby(YM\uY&*F/YRΠqpuF+! v/ ?d&"30NbOn|Q繘b< oߵm`hՆ*rfqh[&*Nk9i97`ҥig32/gL=z`!GnlΎ{03Qυ>>XM<o'0s5QCZ*]QbZI mHjobV" " " "Aɞz2?.JiN4aBLŽgi_YLUͮvuX9[ת OֲfGhhӉD@D@D@< Tl:3S9" "w֭3;֦W_}5o޼rڻwojeR˿9OE@D@D@D@D@D HmٲeTTԻロ٬jժUs:sis9B-" " " " " "$C^z饵kϟ٬aÆٶm[]&/O6o,s?L&fSc]vƌ3g\b3Y" " " " " "$C >#4 z۶mӧOlc:tpYgլYaÆ_| 7ܰi&Sk׮W^yO?wIdɒ| ӦMctҍ7nԨQ+W3~Y3PxYru|=ZgSZ勀d- >;*p¨s㩙!C4id…}N~Gp\\Hoߞкl"Ṯ_|ܹs6miӦԑ޽{>|Yfmذ@bAOF͛7O??W|yq87A"תU omgv4 !MqrFpM7q)`46K^۽b&{ 烆 /lݺ5 80 &]f<={*8,Xs]s5 u1Wdً>HyWj}_e#nB/f͚9|_hOP 37rrvVi xX8sGy41WB-{<׭[wݺuV!Nh5իW=z`>/LY}LۮQ=jnCQW\kfʐKLql9%lJLL2jժ^OJ4ZHܹsZ4K/D_б(1a"m5aSJ=̓^bh{9UM ,HڴwL&1L5q>c4r(V58_;5x ^xDg6=6wm6flgO>kF2,າ\ofRZj\)Ra2]*1g2ѝ38VB{*QC-cǎb2pTBD@D@D@†@fO2Vp2M(fL3cccm= X-5Yvqq`=$ǙtpnѨSGSF^#hfga`IIxDQ#.&cҴׄ7ԏ:M462iQ(@On2QJ`xgy&+?N{ԐUWPj6KFi 8=K{dr'pgg-f2znNSXMUM<\h(Fs#C&}wM{E@D@D@D hbv&(MSZti,9S\&cRZw=u\z.r︯wi?N1gJ@+eK0F<]/rg+)*#i1T)yN؉$8Wc@5xg#vQk귕WF,^uJO0p6pK&>stN{j5? 3v4kỼ/ =MMl'Of`i @hHchv bZR_dt,U(S3/8-FdE;z=KdY&!{m6^<#6ze3MlI;aVq]>MYDOLf|ڝi&p&&7dou/. c;,E1ƒ|IkːhѢ0bB񜑍:&7!RYIBDuuYvE3>DqvO3Ӧdfz*5~40D΄jNm 69ݣGO&@bD: =ی<$F=Kr.0aS9,ћ f)|a})C͌ kD(\Pb-5x*{7s}2"i6-d6M p'̟pgD 9iL"4Y6}NgN)#0.S3G-ó>zƮQHdg)aUƸ4*Ԝ³&ȳ5zyRN68~g)믿bTD\MF @q&_ י`/' 6]p6ifC2s=#:d7!1/㶍O}r: /A! -0."J"ܻm_͝^凶m7/\0G<'$plbB[0w8>~0qn7;CU" "p\Tp5+۽W bemۺdXby\\8ÝJ@X8eI n^ 6w]#~l x׬rU}A}{N8ٽ eڹ Hd9 V$/svcqmR̕T48緁g@'EG@kWx|1^{ٓ?ٽT2{v{dKK t+ Ǐ'  N t? = g% CD osm}YN;/xkǧ_~_׋̘*J>=E_גǎ=z466hLģGOݷ*`8>~0qnʙ;&5E@")!a̛=?g|Wr)6&G0y,gа/Z7Gs `Hر ΁!t?ħ Ϳqcs%~,RD@D rHߵz ljUf:h}- z'?+KIG=<ĸ@%SVM=1v6 'W=: 0A8dwZHz<wMK)w=(W_|)־V~̒sH>бi87w!3Ǔ 9%UqBS ␑Mot E@D HG5=#TV*?B+~1_q/ͱ@q5ܲ/jJCV!xjm}hAunZ=9Kb!q3ߘbzH% W6_ }ΝNnߺŹIzݽuʟ9}?;6jk_Êxwj'` \|c!ɓ_|? z M礤d0N" "Ls"B8׿Xwyɫ8+oݜԶoίܳ&jѻ;;[& NMY8_҄:|08r$H\ܧnˣGG8#Vs!A 8w<YN"̕һd-SJ#kۡgϼS&|xOzM[ںeKTh\_sŋ>1eBɫJ?Tjm>`̫]:Q[el߸XI>t`Ϟ=d+mo_|ұO`0WDA }?8C~7Q(ޣs$+..*w8 X@84"c~W\R]LLNvL~7Wf5yzk`Y˫WpoC:uټOXii? ɚߘqSU8r};S&K<#m{caN{@ AqEɁ?>)Wt|1Yhް;z.-k~2O)Iѧ:lpS;)6[ o0w6ox%]t̜E?ȓ7=s }أmf|С''߽{78w,c)aSNwyrh !A!Gr' \r%71..ZKzlav}}R~b }̝ Se70ϼ3#0.X{1L>qI98%:u<:Vs-A 8wਘĤ\ǎcxڐԻD6 ȾػjFu۲NO%" " "QQg伨_^i"" A% T:}]6EQ/,Yy&s\** 'Aˡ!zнE@S+GgKdխ "$,K~8z$" " " " " i {ݷx+~#ump 4**" " " " " G`fՒu[Z+WkyݤGyY)!m۶^Ts+Fog}g;ڟ TqZ5mp_<5}E?_ʥxW8Ԯ#" " " "~۳{u{ݫV,˒'{?7x߷޼g:?zOz7V>#*ps} 0sdzJ`;'5U;mP+ jWDi nrMemWqvW* ]>^ǭ<A,9s᤾Γ Z֘=EU2-j$C튨=" " " " " ˖\=&'9 }kr .ԏ\T+*?bϴI=3$LDnM7[j7u9n!ߞ]h9 ׻ixg[7o"+gw6m3gֆ$`}ΰ$w(>rpX>#J\zu7~XYM[?D3f&hkWo{gXSҹ]tM"oH#N*\?9|x-9. 2ӏgj^x[911qw=ϲ?r^!<{  ; ֖1OǺE/(VtySK[ Oew9˟ߟ,ᄑ.ʼ9Awӆ\vcR1P5~ĥ.#/˗[Rebb8IYwș+ u*!j_{z{Fp6lͯ'&$ |I43Njǧ3aͷֳ$Zn8k3_iFыj⩌g SfʕK~nZ̝9aUMg4D7{mY^3Z4tk>H7rީ=>Fit=SdmM,kGjk}'eo^w[ƱDrvo*_5-lضe3VAFYc6u/ ^xfҸS-7iu2̜Z伢n)O<x`!heLwj8 {/(?Ng$%g`O6v&hD?o˖RRk=55Q| `]G5(0 `-_j΁/3}.b/*[4TRD@D@D " /_>G@3Gt_u:9+J%|٪Ì2BtnpIvO)bp@v#94}{tx0fJUe+TsZ׿9OLYtЇ>NUjH 8\j|]51oVk>uV|-/z̢>=|]2E/V`A|hVb3Q3~6L>ӱs[|qŕH٤%*,b[%T6 CayۯXY}YyW`3hcmBٱF&{ko ,ȱS&|`ylڂ(mn$'fiہ͞7rCAe3e\x_D1l[yorܻgϔ_^rd^]B7 1~ƥD4y,iH[7sNN|y[\\Eof^C5]{a$#? @q` :]Ggn$7l|oi<<{cNmجɂa<:lֶjظUde V!YӸ~nuv!_O]Ul fOJ*nq s?|5xm ^p)[IJ2Ej6~OLu8ܯ'*7ߚ8Q.24-q 10z˭qfcN}p/*%sy_.4ΤGNa̟br%!><kaKWMO@?@j^Qv_;z#^M2{H7j~~9h ;j/0oa_~ oc/iVf&>D;6~kBox9gD-Ó$V]07َm_(~4n$~nDZ~xkO~93xyG6[ QEVr,H0БFɍ+(Ok|+9̂hrI+kkl?|1Lsg1r;UJ]3z{{-I& 0I)3OF&vTUCK$ֺ>5`op;*nm6q2gQ>GL>k]^Qxk~&3׺4kDIUO6w/m&55AH0f -[.ǧ g2}3!)z6<26^~? *̧m7_&BP#PHk_~ޔƉ,N,SD˿)g~Θl&vJ:w1}>u_%yTshtwϮs`aXZuZ'󄝇.SM܆]Ō+߿~]{JRyv7n0^߱ttyx%(i;+10GX?չ" -[qƂrg:MCvҌ%85`$#0(ƨkQpaB:p'p8^+n-^ L<2 ?}'k׻^[$wueIy/U,v5z˓p|e؄2C36  $+{v^8 ]Œ)laFkԫm~gQ`;)ԥ|1`˻slQIU+U֓ݓ3g̀ޙ6}2m{v֪lKڄN*h},.0dWKvp?? 37n73E9!qҸN};s0N" " " "|Fo߯?;*\SW$T5lP?f `EL%Bcg/L1]To:k_4#Ux#ayM8 ;m;eʕNNw ֩]&IKjeΧPh'QhF몽4Nʳ!W#M}Hmp*ZiVsfLA04my?i|ky?3NvXq?˛bL:%ݻ]]y]|,es^g1QHԢ%; 4C,'' *"O|/xHS"gAjF摃#=/?=Ơ Igf֦8u.~'4&@ӭ53^Erc̛uС x ߜ,38|${C%Lі|>%GhƖ{7׎ə]Ls0thZ1頻# Xܕ@"ԋKUb2)`g7_,*"oZ\4<3E/Y&hRzSp0"/M"Z4WLVMS{*WnU z:rɝ;I5nc$hXxޥڌY-hټĥӦ/NᆙUsB`EYg1s,H7ʙgK 4LN(b`b!V_ޠUg9fWZo?[C&%p4=B1*at82]LĀ΁Y&~˟.^J$z\fV&wZ|&y=Oxpkiؤ01nY:=أګ#$l%qJŜxXNp9#F<!IնS'hꄠuLW6k\;p  g>!299b@IDATUe" " " "B0=G DurvHczz/11z`uC)b!K'%[M0פXjL(+Jx̨{&Mx˕$~Ϝ<@R󲫘Mc|f$-Ib@ 熔Y>8־#`& U1ʈ6_OhAfνS*2)q]]ڼ$B̚:IkV,W_D@D@Do.˽V R_:‚LRc=أ.LgGߴou GWC&2f@$nL!{c<|F;47^JĸdFm 4mjA 1KCs8.W[5_V] 2ɫb7l7эԨ۠/\l)M> 2n=-7Լ%ر6c}ϝ6{эƱ >|xܘQm<qsm>^opgSnE ?g[7WvL`P.>~b-.ǧgIXclBE,-4ږ@Jy \j^kdz@f!}D1SfxZ"AGm f"0]EiĘ#UY˂`o4d$L&7 #5m1iV$m_/Z.}ɓ,#)^f H|TLӀ ;ʚx'iXNPxA' i1} 5)" " "7#0tp$<4pk$%S'3j7ݤc=֚s/1)xA!#fs Tq߅RWZl*ҟj#cA=[kbMOeDDׇwE9sbӼfk,TCo;SA;>0mϊv,lkǧ0cA?srM n{Ø9x(Qo؜cW!Ujy/sDk"ڴqҟ亼7 8{ gy>/v`EMXɔz"2)dƸj8?q>|oyZ32Awт,7(/dq|Q4+J^evi,XMG4^.<$Ss^=-` b~k"Lӵ 4]KF(s6OEڹ&23]MIΙ1L|=z4y |uj56cL믾Xk]{n+4CvW`k݄ =0g`o" " " C̕;_g̕;yp2 nxc)W LW=s8Ǔ!k2vq{w!wxtj&jL~C@xDQ0S˝G$L EWn<Ц y3x!O4lG8:9˽ЉN[-|[OxYddz C {iSAd£ \¼r 8O_[9% ,dOWDzv٬lςY5lp\Lz ۷]xq fCn*06绾\n$etca:o$ޟ A}pgcO{vc+7)=ʤ` `Qy5*|l/҄Jk˳\j*UǴD1.2]"R@M̜1 }s.c6~ 0u co 'g'Jv)4lw\>i8أ(7|j)!Fqˣ-p3| /s+V6W(Xf}PwCTŧ־dӏwWӖ~qivok|<#`qO?-Y V~3ֻ΁@`7W~Fg]h˦ (2tbap]9OiMtXi]bB2w9F3 >7=نh Y<|\e[) Ol&0_1w̷6>" " " DXw dzA&5#,%Z؈qM7ݽVf)U>V|oěTT] ;o.Ӡ8eܕ}bXʰ[Rk(YY)w.sRzL~k~cE``0I=^l|q,!S lm6X%]4mI&3Ptt)}\z[rI;@bVm:A؄Oq?ƸB`dѬ{k;l$\sr֮^;9fT/8_jC)" " "<+|{iY7n<g'GS99\MZ:)y,.A;4DV:C|I냜#KH%l3Qёag9_3RD@D@D 07Ef͆e,'  ;6yY>,1F lJ@8^~zCZ%" " "|un ~uF"^UkE@D@D@D@D@D@D }$MG 0`j@x5BG@f8 ~ AlMX j@flª_D@D@D@D@D@D $ĄD+8~L$j  `I2@雘; TCE@D@D@O+::ע#E@D Hg}@&$$;vw `SD@D@27&&&gΜ9rӨjdǷ"StZ7>> l8u /c͓'bG[" D 8 ~h,&M(FI1uFD ~<|zɶre~S`MD@"@H X%/;̂ȗ\/o+/sWxM)Wd7e偭-IS Т8%M2!*!1*!ԃj)sKCr8#&wRT4_}x%=/sAM*D_}dnc*WȟCG{~']w[^"پ[׮D_hg_u3)t- э#?I Ǔr4?3r<#䍚pg>tU 8{BBCRߘ\X!{Mk.MsE*$}-wVM ݯĆM[Ի] "'uE/jxjNժTlXVXKwmi={`[۶;x-/+)I)%N.IQJ]$ZTvѦHh%{}1}~nOsogΜ3gN߷c󾄭yKVt;ObY}@A8{WN~ ^}޿Ծc7Hpnv$$(}#|ޫ)JbK5T -Ilu٭1'[;#C,,BA>^ Hb褬}^(]@ y֙s.YG=ʀMjud,< *ׂ+VhTOJOZ7n]:fߧV>ckVDžC8LxZZ!N9~}>.VX15 *?u%$ې眾{Uಘ%˖ӁTR̙#L\Zᐷ?P7@;w릖^Y'G -y -հ=᣿|z+[h}3.ovQFzbJ*pnLR'vӽ- ױ/{qm^x{9F&y֭vlݷs[|[]{msPN i mtvF|a?M<#):&7_ o/l?oܪDb=ץ j-_ocBrUlٶ*(P!y=VM(|R%dGn~w0D!>._(쌧-JaJqkR>+3B`}v[7oZv-vnϻnw<9pJBAA}@㤽!8߼yI(wϾb  F]#pUs/gN>+?ERY:ٱl/=SO:cZiEGL0roqfǝw%GVt_ iTzqWH/g~:GQ_QM1k*<}z9o+vCo~R=bX`wz~s55&?nmK76oG|NJg\i+Y]|ŕ(u!s@2}6_ b㘤W/+.۷oߴiӶmې|uVڗm߮yy{7S(!OBV^opA8T|7(̄M<{ți#kD S_(ذM{.8 /#+:-JT-Ώrܷ&c…?>JྀWX+/z 1<@iү]+|6.^G}-apK^]^0z J&ttFgfe˖7@ e5k֠ݷkG.?ݲ.Oy߆-_8"ᐊf(P+RPBHE`Nѣj~V)"Ԣil@7Ɉp02ip}hʗ-,6 ,4Jxi 4V\=EL.GRՏltIϿNwVr(q8Y*_@f/7ov'oܻu i@8?^؋ ,Ϳ F} ?9&wtfgafB}@㤽!}q8b/ǫg]תF}9/bhѢuߏX߶C;ouM͜J7mTSs딩OcK2QǓ[¨,pñI F1Ox17\{^;`ǟ,^Wg(~/n~٧л|^NW '\д5b9"k3OݰiGݏ]vcK*ݣj6}|Ьa>W.$QBiw~ƿ\cDZ퐠dǻpl {br2Bc_;t]{֩'RW`JG:1-7tQ'pB㵍.?2O /֧cӁ[`aUvsAƻ8ETJⶖ e*^IXD;8̩"$0 Tة6x=\5?blf+D|=5G#E.5.2مsNQnzUd^H! G)u9~OCmP& }yc6nɨqש3!I/GyMZELB$LaK+,c 3}BEI e+N;1f5޵~UW4x'ZbVy.^Xn= @fni ڦyknܞmtQ<~3g_G 8r.<Π<;'}m I([׏G|'qT{o8}Ͽ$;0u )]Y`ˮues֥3H֏~^=랉}DJ" ry,N*I'$⳴Vg_L=%K0xIbAan_֨GG0)'PxK) >ϓ͂!VXcٸysn<0{bzC  Hkxv69t6wl-[`ײId+! B@d(|)KEb#dc(x /)MĝOF>աS< 7Ϯ[&˲+on_qSԭI<JGF yi+w}^t~|E?X #}cF'O5J@Jh S"DS)ZIVt})r[%ʏu,aⱂfyR;Y'``YoGy % ^xNqĹś?Z`ˎ{ƶXbI #f؏y)b9c֬~m[[^_gBx U @EhW+ 'z5 1g[b;o $Xj&MA! B@d4sڬ9nd9Ƹ$QXr^sx[eW?I" O:I~*+2^ݺ7iᶯooN[Qh0bpǧ_tcc,1zW~S, oWEs(Øc?=itN-%mRt#v'(ٶڷ3e*-j}n~G…1YXZ׮闩o`r M;f?kK@((/=r4nswRLoo }dGgϧ(YXʀ?k֥'֬ʩfCɚNcl}Xx̀Zub݋̏qdϏu:H.D4/ ! B Tr{}Ϛ"o&o[,YS*ak/0V%L_U2EaКѧ,4); c1Nxsi 'Fcu1UfFO4u5 Ec>Z56Tr8g r"n3)NG8 l5k1 za#r'B@!  -R|Yu%LH`{Nwg^| yb8@~4b SN _S=6p%GD] x| kMDbH#^)ݣCĴv"Sg]y]HlM@7QKoG(2QFe&1]\4dʫ Qw~7c [wL2:&h3B@! @:N#y☓NI& DyV&}ztmx.Ɲ ~:jW߱1ǜ+/VTg[8r[F!*ZKQ6s+<P)X@\w\~NX dȧ<,a.  Sg`Va#~IQ@]$ ԭrO#(GUDwe+]ui+۟)5Q^ Nr!eKO}*~mQeSfmҴL1@wV.n=p1YG-L{wqMEөXLa{f8qFdZn=ڇkp@ -]kWLv?n mn% je$B@vTL'^}9,\zuVw>sLKѸV? rq;H{fOr]-oV(K~ -<[pgSJ'+~vx5#|rNp穐|0z9g0û~W_|=4?ۆNBrUy:z`'{V!*bs?@:m%g!ܒzwNnma+U(o\!3w-tqͳaӦ&N^W{m|+Q<ޏH=-4~*V~F.|5G;2?07xz#얷o'OXp|pV.B@! Ȗ ,XImoG?.]h(C/ KhGz9r.rزsq}-.ëg#I+ sR~d)2Jmq ]dR/`WZပ5I";E7ad`'5E+5Fѣ*@#0bjUi}9Xr1y ׳[ڴ7uydCD0e }=M!#`(){n2PČP&=vrpR|(aJnkvTXx֬lkr=]Bw.Gs%&}ֆ2,O#޺u(49%̠ VlB@!`DEة;s~9VMwf5GAU@7WmA'OØ5O>8… |NV2WifwvF[죡B@! r/lV/X@=9h/Eu-B(C?thP޲%L"qBg/ _6gwF'~s)oO1KV@@pVhA! @růip6}f;@De:x?1zx% }33N>abi EϽ^효CX!:Yyc2CPɃ/XRp{N|,lӨ`B@! G.lĜӕ=ɃHbqSM(ӏsSU ^I! B@@6UvP(_mge% oU4868d2pec%GC,B@! rHplQ'UB!2Y޼e9 L8gevQw  ͏~)WTnF<#'۶qs`}bEP@u+B@1M!;s0)CltIJbHrg^G^gTq9p}V~o)wtof\v|IoӫUܰd ckVX~f˟?_ߞ0Þ]_=vp EJhc}uB˙JuiΧc7| uaM^lKfǵ+~|ڗ_X_w?*-[Q0jB@! r:(#@dsեrh\WmE_Pn98LC~LB@!FJ.]dI`d` !]Wz̔]긡r<|0`f5/%T6:ͧշ&5ktO6cvHҮutu_O B)U_p f@?z奿{額IysfR.p.vl`aÓz}ڄ̓\G"#÷+bix)9~8|E2cVQUӱ d`B@! 2?#(QÐL͡܅M\]WUV22d]R6T+, DMGfF\γEEM$ ɷ݆h'@@4z:ؑ'jf00YL>ľ_ h*UD,@>%Wc}'+l7B@l@2e f`>gUᅀ)%rwlM+Vc1ի+/SfF3$۝;wڵxr۴Ys ;mc o_1&۞x=`TU2-ۀGo&dD8WOSt }<`a">ʕ]dٻ>&a+5o<7_K{ċ;_AAnb+V,]Ndj'}>k˭!oG 93H2D̫J|6=^?!1~wquM-IH?p- Z0M448{oԴ% y'ijר\lλwAvTu%Q@! 6j嫌@onX`HBĘ3+.6GRn>oq(r͚د4o=`Ajv{*ծcƗ^s8ӈ#"|A-Z7?.W?,U:zٞP [H'"c,JSzכ !٫}{V8&OyyԲɕ%vذqS\.ϿڇՄ pgyr._gח^ԋOv}8SNp4&zɨqa2}s;iTqzuTHx3@apqڬj 'Լ7o/-O"\|YZ5517oHjj8lcǎӥEli<2P_KX6ޞ2bV%-g#d\@ތVB@! ȓCa?K^QHa$:Nͽvsyr:ѯZf-Տ<˜ %-R=|¹׮0w2 -_ ,$ ꪇPQvڽogZ7k" l߾JH)y [wҺ]h@,޽{oTL)m$q7Wƴ Hعz.Ψy-=F4_Z~ZB@! Y`FduK b@\ EqDb}s4H`kY͸>2r[N%ޗ~)]į,O省bܧ1XpNJ,)} ! B@! ##)Seu:hF]pm3ia>}>OΘ9}S5Un`/~>7NaK)-腀B@! qF ӂ> YvZ.Q߳RyGyj꒓ k︉xvL86o^|xv ! B@! r-8pw⥧_'8Y,8j*B@! r9Vm 8뷑J(B@! C?]B@! B@! rsX:B@! B@! qQB@! B@0$UuB@! B 2#X! B@! aHa ! B@! @dș(V! B@! @f# pfB@! B@ 8.0+! B@! l$gv (! B@!  2B@! B@F@pfB@! B@ 8.0+! B@! l$gv (! B@!  2B@! B@F@pfB@! B@ 8.0+! B@! l$gv (! B@!  2B@! B@F@pfB@! B@ 8.0+! B@! l$gv (! B@!  2B@! B@F@pfB@! B@ 8.0+! B@! l$gv (! B@!  2B@! B@F@pfB@! B@ 8.0+! B@! l$gv (! B@!  w%.)dذa7|pŠ+(PdXdǏ<~Ygeò@BB˜1cvYBf_E*W?@FӧO_T/_̺,eJ2}&NuC9$"ز#]R@|G2Ho*Z-JѣGwSO-VX2=B@d{R[|9#o9pYfŒ$lڴ{駟X"Ǭ3+6<&O?ᅬ=U)w믿fϞ}v?2?DlوF #Uwڵ=ܨQR4K=3KTB@@͛7?]|NCd*SO͛hѢ.\̙s7O%ɓ'OFX/@#6J FVvdx|DnG=!C>l={s鍊nCeR^|y|G͛7(z  gqI8}(E'dH"4Xne˖(Qav~y{1U?>Xˬfڴicز#3)✎h(@c ȑ#y1?>%-u%*Uw8+*/! @Hf02ƍ7 ɤ_:+SNsY_x&pX?YGɎOd…1_n]R46~ҤIXp X*>az /+lРA,IB>wttʕJY7Vx?ƍkv[6bdF.<枭s t3<󪫮b}'mĘsWϛ7ヒsN!Oٟ9|p L1o߾J,ς7\X;!QժU_~eA[nAA$sc  ;t!v_}|JJ%6k̭| ɲ .[[ha;]YORDAZ?(4Qcǎ*(GyW_}pzL \$իWw%ز#]L /XgcӥKXB B n$c" ._>aR1?clٲ ,` kP$G!ܯ 3;`!NLS.訣b"H./L&4sS?hɝ2r}, o6\װ-9c]"1$'Pe9A)q}`f 1svai,LȫDr)9 yA5eZiE>hJ 0 |!}@>z$S@yrR)l4 /B–7 ĘbIQ"3oԑ-A(P<Pš b9 ^d^za1RM6 E S͛T2oNnDJH&6zRat{x?Q*d;D0*B@(EV[q F6*D;L;|3~ie.+^D G)Htn{0DK粰 |,%Gzg~28 w0e1sbGOj>SxIO}aW~m?SʂiflK#-;i#:Bdjذa y.;N83ﴡ "Э[78?$:0>Ԏ wKtPf @쬶,sc8hx/+&|O@{b˥rl\dv hذa K3t@d  [-xQ>&/x OiN8n:u/!@2_Y j -8qȨ.u1Kob:oAn]rI5,5t6Xk}ݻ7Dq{2eʯ/ ] CJ O??,7*_ز#-yfktFM'Ď[E{6bjAA ?ĀaV䊁ZCVC &i&PvEy`~ngLhwhx,됐g;tb RXbAQ9㆔m&a{B^@x\ &V}#*H:р*fOK3Z[V@_"t#/ZM، ,%ض TÇus\T#L,R p1bU`a02(o)${j83&\h ? `A_tz yO`[Eز#-[7ZR֭S6JO ^v: 8|9%z%/a0\3|f-<s0"渴Ƶo+eNvj,'Ao-:@dGhPD]NMA"6B)^hՉeI2Y7 ! rT_Fe壂M#`: ɨK̟aV[8\z;v,1(TMaaGDҶm[g"H;፧L:3$mJ`c8 gˈ+SQoEۜzJEh$Ӆb(XՈt"2 l>h1]rQ fiSs1wd^k|vv:[vUOkZ;A(:vA螲]&f8 sRf&4mzd/()X$4ZYIH7iY#];ߐG>&B!O懓-$ɲ5%?-b2m&G1.k@b?]L W{- `ūGo͒X8gZ> j_ͼTnqä17om;&.`bcIcH%TTli꟎LPe$Gh##lx'1&+;;ph82]؊M/p`~O/d$P5>7l擉>bΊ .dˆ.>t8D" V(o/[s}æcƇ-g壤B '!#&cES?>r&?.Z⑬cF[,*CCLgk05L|*웂DSn]*+sa~&|#V9Hܾj۱MQˁ=)J'!G̿]$tVf}NE5A&f&0rqG#s{ R`Xhpح]2G40QR` &fg^`[}Q'3" i0[ 'Ü+t0 0t<j=&^LyeꏋdɆ"`>VQlIgW ` BqOK!}TĖ73Ŝmw@VqF0f^f 7S""<,$~ o4B %]JfXws7JT<Ӛ_({kF||汧McKLYA1Ogu=FZX%# cwZ5w٥2TusF!:&߸_чCw*+of$ueazEu+k}LO`3o>ػ3bv$Uma> _ E1N䶭ȒM%>@$S#v;^H7A4=#p>'`m2 W?)!GO,x"A ʌ%!|Iǎ :B,;؆ 3 3M7u.Tli8—F,Ye#rY>@`]8о&35Uv1]~)-'LUS+U#zf6Hc<ޛFN*)ٶ}^@_HK$.̰kԜPo yC,-l3JE$`VHA{{hZQH Hi ~@ͦOU#&O{$#9P!Km b*xc1Nr{l.I˒5O6-~RƶPռ[es,0bf ˱+`PD?7(X^BSH1WPeNݨ agf&_@ӄTֈMθ")F!g 9m>xXn# __#_n["9N21r`J #4glXE\ݝ7ȱn۪=ko@ǐ!ۗҹ}22a&gR9vs5MeNҾI™]`NMf~ Qhtg[J8d-cnb[[?zu^TLCDDk_9"Ay3 c>K!cakqow` $sx17H zcPCUSԎ.U\90'ybmV!Fl\Khe¹R4iiʹ &F4He0ڦq3WSb_g Sּ؍P g/ɌGVcd_z!k20Kء 2mġ*Q%u ߈kT/ZF5gNa! @A ',-hL袋|cRL KthDD# %|p΄LZfh4#:]s#X=!wav „jvJ/C wɐH$(쩐vȘSz 3K;?1/Le ;"=|dfle oYL$·(C(/Yh9RΙm9UtLz|ϗ&[44O0FǔڶGI`S ( (āGHL舒v!Gl UҁOİ Ml1mbFanj!Y#XV+_anlQE4,8[VS=OTush 6I%5>t] Ȏ1۴8U~7sT04v"OLHړ7זޅE+]pxLQ;1̦ >JZN5ʸa4-tT7i1d r1˺+Vaa|i)>|V$恺p^R 0z1>~s;t DS719),;>B<"44FX`$I*n+mZ3#rPB ' XVUx@B7sEGɧQs;l=FsGc$p{#DGyNqL&Ri#+PK*qs$dW[].~v ߓOtT1  DAԵ}]1Pwy9zHq yh`e `;#$ifr$`Jo?O[F`rOY,X tug,\cǎ e)v[IvHJlfE`] T8&<\dTY"땣1@P  K0x"#KhS+fT:ܗ2Bn删d  [jͬQ//! 3Ͱ +x\_`( rO̼p4,m@w>MfY*|Р^ۀ`j!߯Ɏf jr q~cd̖-Iܢ p5n9zbf],o;>\a!iqCA(-$ڴhhVgg4S*h1:rጫN,Ce9; ! rVa>E`V+n#Ԙ2 sPl ;̈_ Ü\B4Ns.#rda%ޯHYJQxr MswL‹c$gm_Dx:~xʏ~QbSpvʋ6BJѻ@ĄDs4gtDp"û h h@E|ѧ8=!ľ~ }8:+, hRKti^ 4:/$[Gwזۤ(T2F `J ϟ2ȏ!LDc VpOOQy,nlƞ c;:`ª }/jl7%)܀=GZ W 0F'?} @cpT$"lHQJ[%$Ҵ;ph0 OC%w(y8.UZauC`4\.E,PDa"eT/Ih7Z0^X2kCvQ8V!CeRcX7m$%FTQ܆-t Lv\|\MU-YFE'FYE1R 'M;`{xԧge"b;#Y+/! @H Vʡrqa @nꛛ`'ɸE:la<ҲeK_fpTw!疗M1@c.f^ pΜ 8pمqI8 !H)ۇ_LBT|8`gWRKEb)4Khp! .W_*e΍{m`tAꎫ 4.^tD MsґX ! i [y9Rdq0ΫG/mZǮ`6>ia9V يϞ=q]#g4j^W1m2۬ҋ$6Yn_[R4B@A( B@! B@!ĪB@! B@~ B@!  UI! B@! > B@!  UI! B@! > B@!  UI! B@! > B@!  UI! B@! > B@!  UI! B@! > B@!  UI! B@! > B@!  UI! B@! > B@!  UI! B@! ȗ,87}[VFr/޾}Ԯ]9 ! @lٲqFvQB@dSL.P@"E4òΦ b \\ٯf/J`Loݺ=~URB@dCu{);J;w< " !{{>Vo9ĄaH'F8-QPPPHf|𣇚G5;,SXlӭK`Ɇ9=K?ksOi'o%,bD`P>^  ¡^?|~ܹk׮2 -?Y@FWB@!  kLYT! @ tJwꄄ}=sltgMgNjo]L:H8X!1)n gGW! @@@p&7sni0ݸe zL\\?nƒ}e4}q nݽ{{}ar{n#CF8"ྒ)z/w=!|U!}cKv?n uyʕ~M럑 Lũ?;d`߻'!a[΄vؽc;\  ^4>؋ J@e³knd`~::B@dw$6Qkn֨w)KO>턋/opҩ'dBV!&$$N+aO`m6ܾ)7ڸ~"c6m"Cp@8i/pH늮C͟? {veU4 !}۱} 86eɲujUz+THFLk6mdd`=ڱyڼ[o_j+;׭Y*k7p 4>4Nڋ ba'_|};$<- ]d3Cf͘tҥKx[-ut5n-X[lٸq#ў[7ϻmSš5k\aoڹa5lZY#>q^6ɿ@Eپm]! C]d'xO￳ү!^dA;:v7_~_OA2,Ϥ@?Z8nW=f~ ?rN>/4'XKqiCF|@|=k|b:?ȭwg@޶\k=w5kbRζӽ>iVM{1g]Nx2W͹=FT/IX*_V0F̞%#rݳ{Ƕڼyb=۶nw1۶!A8h|hA8mD#O/_0> ! )y+ұK7MGn>۶Un]T:,[7i~%W6nGY+o&;#*\h¯c+~b_WxO)"caFJ4:Qʓ=e̫5Ԙ>]ˢq.f" `]B8"Ł/rޣv4$YXiK&/}<Ν?2*fT䫌.S,~aL]y㥰e愿[A7;0fKΖ}]t9.;{{M524r](SO8KdLZ!)t}ef'v"}_s%s[^a! B #5QB "/+.7j\9 _Dq?cnWr[o%\7l#_xp~hnکܠmمNVƫӲ%ˈ{@Ⱥ(/;q{8lS]H%B@! B@d ^oFwy+/z[|]n}GLM޺uE& oXIS|Jb]=уBz`\Li O28 RKϟoㆍӸ [$uAH&`۴`B@! B@LA ; ^};awo*>CIn:Z)q0.ʕ%v͚#P$jÆ_}33ryK\H/F `[w~+;RlS]x%B@! r'*5wZt+dNU->:_y$n9"$nf/+W9Lƶnߺ(?aFMIѤ">O>lbE?;g S6+ہG}{Cg `(F%E!RlU@!6EKL_CHJ*7w?R|S8Hz -MO8z;m޴xb񉅜+,fs}!({H3϶ѯv`тca<ܥX"_Sh~K>K4c\c6vlC]%KojSڄls'렺{*9 suS;llbnh6Aq}@"(avBl@H,4=+"{ܰY}y80ͦw͛7sE$L;HͶ@-IӠ'g(?lT80XK8x[k"OP.}گvsY_T)4yw{x,A|G KC b$Ly{p]Nol=D 3ahu v4e޼vIBb^ 8HQ>;)/6 1MgE@Ab .T?f̩X>ckedذwOUpB| O@3MEsew3gl̪MKgϜ{"{Vjl=mj5~gAA5H-Kq@ ˕''t/44)Q=%QbZsḲď'62_aϪAZ7ؚ<6z7Co^ |ܹs.]|.~Hp!~'EP€Ò+޸tdkT,0YP|XCI?)8_<ӤM?iЬffC\ɯ t$Ұ_]R_SM0Εz `֙|ءu .o^u!1W/_8HQ>;)/K_y-ʕ+dGs{, 1L4IK 9.?Y%J=w3ΐ^hY{-X;A7o_vyo5Az8>Iy0x~b)Gϊ@#0k~]͙;lj>ԏvT@a-^mW} \#9Q\ԇGϕӛyNewð1?}aImd[bl1KM`!cT(}}qMQ|/oY/W.] 2IfҖ/fスM<'v jmҤI1%Hɦ{5J-d̘1mڴ斀]Dwh| G^UjT>ٲq۬QaBjh~2c7ІŽ*Z.w۬"j^,ݫ\rd/X/@~;?D8%Yb^)ȣE *Ξg=Ihq!N_l~d/BWE@PX^UZ0`WX91JW܁!ק{>e VakϱGT@Go< m"("(1lh1_IxjX$U._~"3g!gEm-l|gތ2d͖eȧ*S'N@c!aMs)"("7~q\FC}OF_?ƅ>-03nVjZU =|&8bw]F\ U 5V.h"4͢(.X~/w;t=_:uԊ+n cYdž)S޽sJ>FbΞpbmÑo8!tr~[gZ :u)SB>MH(az5h\Vpb7E  ̞͐֯2F*a@@ p@,"~{ϊS&7^v=ɓ'wbJ&pZ{8i\fc[PΪU벦Wہj%"x.ܺtҌox*ݗhs VtY0ѽnhCZ]hQNۥ(8qI_K"E ֕pܖ͟#K|[wfȘB/y$nߥ-3{QS*5ѻ"[le+&m&C_?/G+I$I3;Zeʔ$ gSGNϑ#DZcYTd֭[P^yz$B,\pҨ)3$)+wڰajԨV &xpOCSK'~} RnTRA'z$Zc$얄+"wvZ< P{̩iҥɐ1dahI4LliӥMa\s^8@|kR_Jy 1^pL$kIs̹mwdΚD&[/{MC_jk:D͚cG{ 0F)S8zخ{xhג-!Mc0\Ze˗n߹qO[sK#oP JFPBAo%E S/_# .^=͛h£G~WB)Ի oذaŊK\}or9*qDn}tP| 1X]8~-}]bݞP0F|ot0aƍO<~K 7z0vq%L qesz,^"\?,Y2/d#F/[n}4ozb%43[8wѠ> ~6ڽ"/V L"'c߷rNC\z}Ex:!ܷP]:dcG|9ɣFٯ$~dj%N6+]^}Nl٦kJ{DIAuܤQ7OoҮc  Wh}23ם/ f#"jfB<\&K *U-ʿ.Zޠ^fdVx(4Bn?vrOU*y/BʏaksFPVezwcg.Q瘚uRB9s>I#F eznk/_>˒6K1s ^PD<}4$KZ_;y<|U!+6w\CFFqe˖k5kuьu X;g̘1mڴ;D엒luPm_L4K'ٳ/{ L]"k3q2uIO*e]a.t[¿f{￐ V. [uWxY.m}+Ϟ͌Y2Vjͽzv(q/g#*S.h_jHoa N<ه+d132l>ðhZK)QwE2Z3wu5}~gHаyiK h&wıR,Y3ts~VܓԤ(K|Ƙ5y7ƍ`A>?Ι6~ T6['Ո?=*GMw5j"aÆ^zdVXةS~a;[7<3ճٲ=7=vΝoe߾}۶m+Vc~رP*N_[~d ?~sC.Vd%e{8JEG.ĔT$x6LxM[Zh̙ҢS?t/x. ҸqF nݺ6oH`Y^ϡ.\`,kna1uܹs[F݋IIFH[ڹSEOjgҮ]_䬟{% "M)`ŇGCXIG=$ߑ#G,N=tL8,ٹuQ\aHӂnA?p*;pL:$bs]Nm'OtiH5/J_+A4oWPlɤc|qP[rIǶ{gunr}zסEcʿ`jU1SԡGV.c(_ [iy͝7-yH;_3$O,T:9kVU}C`iRwQ.Rzܤ)u$勗C))kg+Q/&m"־2l)/7}kxlo1_)|-:b%0~jφfj._\ta?7lX!5*MhNjcЪk.]$ MD 0G0[PV֯|غ-k?\"<=}ODbNsk uQM۞Ȟ a^n׊{wU,^0g|jԮfMeꕫXfo?Y5D.\n l0a v~~9F0gֈ-[t׌O_7^W~ V*.^͛?]7ӧX>|.JVZArJT#Go!i?*&:gΜs̞={sN zE! ؑ,WRᅲr I~{z^xaƌ`.C%o|W. ޶oU_n'w~nݺ?`"G1h f(:._ܥK~ <4;b`ٽ}3kXϔPVyKe][ɬXynAuUUJqo*T£J$ϨH"dL_d^K-[.{|׹oJDI◝ި\ri=C rZ]y}MAϟ?/wK;Gܯc߾GM$^ޗ-wweB䵞2Vr\哦*rWgN܅yvF9JmWA׎ێkp|pׄl|ƣG?t䴴@:GwJs\ھjOWmްfB{%?q1rrwUV߾}ۥ~DOt7ߨtď'OYus|yvvْR ?0*VHx$Ѽq͛7X4x'#hpz#g2'͚5[`AuQ&O5~駇x7ϝPVʕ+C2j]*s <ӧ ٽm_f=_:~]mٶ:w@C {x嗱hs_h|KT=vXyʌ~U&͒%K(Ӑ|ʉOUףAIrݪe^LJH<$0`@֬Y?裓N=Σ/f~7˗M.(QP֬YsU.t|r80 K6g$f*yإ= N= l]xi6m 2а~rjx8s Xʚ=sN݉c6*᫈da$#k׮~ʕxƋ042-#MkdF>UM0uT w?!V3ڳ SN6mP\5|#kT ŮV1`N-kRɺYV5Z`9sug~PتE0ݕu⅞9<Ƅsw *Քrjʽ+K'fur|mᭃ)urϟ>)_qFGM{9AEh\Yijd@H˚1ZhEy$-;Pbͮ+Z"dO+WK|7bXl- 3mIJ0^L~{`v֢'pP `5p9~?g$8O֌$͙11(`gM`0#=x_kZCqsCT40&|"W\0{)^0ofϵ`RXɢ˝ۂ9g;9+ꐟYG?Wy12{.*8bLE[СC {DFb(e~\Xo 0YB\ɒ͛/ؔ8ǂ7UFry>}`lS^git&MhG^f)Ж XJ,_tFhtjN]i5y2 ¬nq4E;t݄7mڄf~ V~Dm< +vǷ9͌5*{vfZ } pr6r'HGp062CYIv:jdfFc[_٫A"}ǯ_ |9HJW]/ xS~`"j{l~x16~WEe矇+Wn>=K/޿欵ՋtW-3um۶5=Ӫ]2oIXƖۿ\-zb^TJ!1%48 ]@h qo!f bio~|WOw % (U̪t1L=܆}_~"2y@DZpߏq.Ź1C_ҩG{%A#+7ov?)S/bM.Vұ2\2[L25?7<ҏ|jRsI.?W&^@p0QlX``/|dyDpB\aB3o’_U"y"Xbc "%N\†0ċwZ#|h`+4#? wP3#6i-dO$_'.+x5Tw$(%86YTU) !-B LnHz1E=eS<]v0@g]#O8!Y.򇜠c-1on۴ -+Wp.gΜA噓g,Dž4p6`~ɏ>ɚR"!(,mx$hԣ_H.fM/X\Zq ȓ.a2lEԻ0I^J#,8 ʉ;.vs+rȐ)&\(3PJ+n=!H(}N̝S9)y=s2gʗc&Yb3IÚիWHK S'|zt l2>쳼 rtgsJʚ&]b5BFܥgtߔ"8}cx潰 aG)`5Xl?8b_ lf3,YId9LW<|hM 9Wd|8|GuNV{ۭ(ظs׮wh /նd'Hu!3>cb n̟,'93x_T\"=[_tK>Yp~Ж/Z]oݠf3hr:p^J}NUK7ܕū?ƤzUAUX R6|E2i`R5>/4hTjr!CCj=I8yş,M4ǚ& C: [ZB of 3&Fɒi?kF +`E&&Fi!di)SQ1!su$EYY&I%Vtc\vc$B&I \$|6x|MY RHɒ%e_9&͉*UB{)@)WHH1f&xrG ^8< <́ʄE,hԨ\ʋK&F|90~ݻ7:޽'"\v ~+]]6u+V8q $iUxaNl2߰h_2$df.Ymϗ[b5|Err,ZLe!DV, `m5>Ax\3q̔trμY󙵓_9xur*dY8WkKNaw5z`V i?DEp嬿? Z.|>R!(;|VG-5hPmpXIH]h\ EW4jyN~;?L[;ra/3gnD;1.+dɓ~4d+8zp05񒒗 *g^nF\&!4k]8#n};oE5͗uhӅm3wqnV٤4!X5|HQ|!fw؋ϪTSnXu,*wمo#|:Yh qI zu,mCwEi"{,Rf͙Y `,[D2]tk\UF{b peﶃ'KO=^80%s$oM-Er;C2tmŒ?Yi }bǥ1v΃31/߻`D"F;(ULRM3駟֗b U &̷M& Hh_ҝzjNĖfﭠ[{$p8xHv~R$%MSgͥ0-[.mg\,y[9Qtoy7n`.m}xobLv Qծנ6Ɲ\`߶:Ç0__ds3ώ62i$ѱ܀};t}Ȧ-ao6r[2וeSY2|\KQ|  WL u]]ɞWbBLE)SH4 VV?X?NPΟkHpNk% rbd5oyս=5,@`Ň` <'O>x==7X&W'9fqj?xҽYZw\[lQvq|Oz*$l[R #Ud"PP".6C*'J_hRU( Bʸo6C&{^xW_5JiS]xkaLg]JRhޥKރcq_:.B,]o86lWzuɓ'7UFJ|?Ѓ?/1,G }щ_y|P%1 3@\Bt8EK>鮨.T\S4iҐ(ttr$l}vd1oe'2w5b`GUWUnظy`@ ?ج1d(sjg|aCDBןObO*nj=,;8_:|d|=Bد%-motot~ܭRxRϿi>CUp\ȞğB,K8s<`q1rB#܅:*]} ƛ"g8(?oMy;w{FJGn2JjMk~.Y,tׂUtWՠ55,:vwxk019x)3$%\zژZ43lZ|諭ժt赛Xg71iN_&>{ܸ~_2۲굪PZjO!=F&&^}Û[!zxZzkoYxͼ! Ygw8-I@`s9xL2 ZؽD2D2L/NKf9㶎3vl W.]ay'~7}Ĥտ!J a FTCN\ϋ{%g7V<UC߈Tf%Mv{(\oA g8dI[ _UKauǕh2x~w~=ܖt5 YgQHOl3ov <6Gaf{*YlpނmlχL`h:xcU9\,|͗/A-Kq|p%-[kw%Kk)U,_ϓ7D2kF>xWb' :n&J,ĕuFq 3d[/]BEq={?mc̭"sZdaK?0ES:8cuh"+^8svEF /ecƌ?IGYEU,X%˦Ğ@6*)s`Az%$3Olw훡vl)8N>[J-L.ZpB j86"0s.!x&g0^"uh]zɟL3B=ku%Ž%fQ ޻7_֯ThA{ұ[;yl#y~n^z%$ٔlק[@q3wmFQzCl) 1S~/=ű)֌s]kx^{Nm>5odp.pTZ뙋bê{wfΚ5wVILwl#@ǻx[6T2NoS#[2ZF_?IV0ŬPD6l~9Hq!xGwM"l{W0/g9[ڦ [RH&]5*UڷFj9s. wlӜa?&%By/@y!<ר~Y{vUX@i   !e#~p?a^fҩ^Gok׮0u֜ s(<^%يTɁ 5jWxM'd+c~w`8\cio:TZK [q ga2O;%|#EHZw HiO+_1רIҥZ\iٳ{ؑbjuf~ҧOϞEjB2HWyJL 3%ph%k:UɫׯC'~Y`kz=X_LZ%cџ `8L:taowqelaa֬Y$߱u6{2\4KkѢEd9Cz/7523Osr%kgڗr<]O_] \vrlwdnvve~Ij=|:3C% lĠ `fLx+?d\3 @ƛ~숯1LvwPJL!Of;Ӡ5qT . Gn |mw%R9{U p~XX{c-Aq+mݨ;Y\ c1;hܬ=t Q<Ź2*{YRv\ peae H8a@!i". .\:W{w,101]j#wY}Ca )~2Ug/F~pʥ9Klvd_Ru]֣XBz ʭ[gf 4X "a&1'L.B& %7LBoYޟ-ʲR6E sRsػҹk(9U&Us 2( ގ;tNqXߡ q4yeڳPOV(Zmk}d,13":u7eY KvP-g6F6uLKO.Lz kWC++Uoƭކd֭82]4u:cbJ3?o>]03%y͹azgc[ &>n==s_O?Dz/æw[eV(B9NNQ 7DFY3/*sOfah V!:bE#̄ wKl\.{g]jD{+\z؈e}Q)XL1y+f".;z1b%-g<6oV;VQ"9 OT JcոsG,|y3,f3)*3/ 3Lφb.!rߜ8Dd;dw)Rł\HB&+:XKòiS~!ҪE7i4C5ہuu1r|5C\XYKቧag/9m[pu˃*:j`1G-<`Q-J2NCs4sNF }Ÿ[/جD1WL9[9!aKí@?o˷-{K,2uT &-mg**_ EΜ9ןY`.y|s6Mfy`Z9en퉹Oa]O[Wcb$`d}G |*g@Xf$wyYي5֦޽{y@ۚ>gUdf-+Y?];sNi8p^ I`p4TU>7߯Q!&o:9?\Z}js&Y(x?^ٶmrSL3eer~i8 5k t3:9=9n +Fb HCpGcQM١})f襋=}>  d4$)V,]XuK.|a 0{`k^+hɯص܈Dmyx—/]fǠl90kbP a:};,Qprqņu40!ZȖ=+o&L {,S8[ϖ/7Gl_TP\wX_tCѭF&4_8!T _6år839 ]nNؾ2xasQ?.ݥɔ5;5ݥx*u2 L9|3H'MlQ ::'E9HL2rz.A ۇnB1]hE [< K<#Y@] p}Im2Sk9EI}k:k-.CB99ʥ,Hv3`M4LQYnor~)% .P9Z;_,c&'6@IDAT`)k,Y3DLu*OvF$ !T!Ù Vhv@vd4wQxTXn\XȒx]gj5YW5n`kѺ)~׊,m~׺8AUp: <{\B@0;}8x5iKwj&=ǎXZ3x%[7eK.=+hӦm8g9U{\8s>4%W Gy1'%osyTI0FGV+ˊ/[=~7㻯d*+C/D}{nHfK*n"}irmz҄|YrOyqً62e8/f#&.O=ؿ!C5,A\olMo~;MNY$3iY[~[/Q;lcvj+Iy_@eVa(r)oװ!1j=+ ݻ^k^okE@M{~b*d9sͥ(@,B 5?wڙSgO_Vp~u.w.ӰVb䛻t:~]ƦjRѭW̏5VglzNX1EmH+Z3avnj 6^jުMllسϾA! yan}2jVqlcdIE~t8汶K9Cܸr pߏ]Um[ZX[džXcOD#0`yW,`RwEH{֭b4*Պ]0dŵku"D1QMy/{|9s /{{6RGul")%6Pv~ cl ceSGE*UG--mpciTP`9%J V*\@Pb;CcFU๺}s9qV/rb)+]Vqy׹%ǞK4gJ?s=*#K~I-{./R(C| AqPc}D ճl vsiE@P؀\lȨ:62*"JIE6b `_6[/\Uk"("BSWAEwP;2D9ס B; vZ,m(". {uTZE@xT?*b>RpoeF5("8#[:c1"c( #4䌸-cSE@P|It_{ E@#؎^Gp]s@z SgGX<ܵdE@PE !zsJ(۞}3\T)m<_ti"Zsxݔ9sf2Dkg׮]ϟ'zĉ[jXPE@PE@PE knݺUPaĉ6/\PHr͙3v֭[O>$պ ,O<94iBҥݻ7$;[k.[luiݺu!&L"("("(1_#O?4.]ԆUo%Kn_6[l)Rȭ? rթSO?3wGGI:| ꫯf̘1xTRc+\/E@PE@PE@Pbf3Ϡb A+$ᅴH ,[200^Bk5>%JDK.O)SԩSӼyl߾] ѳ"("(>mϴK(q_#˛7C6lPreD!iҤɘ1޽{Y \`Ask償}YN}Z(P^zH +q3?Bs^aU|F"^Hd<dOǏ7BVRE & k`0~{O ^r%5j@Gڵ†Ν;BQ^zu+pm% \sȁzy͚5/25rܹ+B]2dw-|͚5mF]l:уcƌxY 9q6dv1J1ް?RE@ |TҰaSNe`)섄*P\=Cv*wCݔKgazꏭLlkq[.#Ji@K,BlK̆Ltό,K) %^B 5|pV2}tD,?GٝX+V(w@nb$8?`"L[p,. aRn@!Xf&iv2C`M,$;=lBSTxQs^&='1_h(Tb|_ iE "H W3*1V`LPw8^NvPS@t.%!C=3!bc,rmiO2ejhPY_a)I3/2Ynj-%\70^/"y`9,Y%f1n:w (?;H#}'|Yn*_ՈcvX=7p޼yn.v"X.q16\YS,G͋,uc@J^|Mdd*, 4FI=ks"yNI!C0gU\ēi&Cr]مXk/E$& qqg 8v]k2 E<ml(8+'6F7߲6YNW\œٙO+-[< 3oժ|! 9ۤIZ, /׊[x*pQ?Η瞓ϟxKwy)&S"c3]W*xfҟ}Կ`]kኀ"&fa' !ƽ ֿ2Hހ.H$g*h]fDː>& \Ja@!ੋ6~7~E,dޔ*e8iF03ve| -1 cI Bu%c8 bQyӋxlV%bJ-1$Q?]e YThRW %ǏGӎi:t:?`L FP,Y&K7c.ɫa p@u♘l~ر= x}xY01eIÕ̙3yʆ.Tcǎw`]o5iR&C$ɛZbB3$G.Bx>4&ZL!`{<i80l b@AZxh 6o!KQX0#> Lv"0s<>V3$_6Ⱥux{: >B K!=`(} "D$< /QS R_fl e‚h2E RxKO{?Цs>{yŤ맾qZgx/_%(Cp<3GD/am#W`e-ۼ8HF \qaj2dw$w0+|8*AVM\ƣO@ČH1ec'@,qs%#qeb)eo$Q԰#4ؖz)7Թ٥KXC#F)H<,qr)ʽXy b cz-Aհ8m 1ibF6wY DD3M oAxt(XDž<,Hx K.EUK卓K!ĠX;R- g@.2&ܢ _$ئDR,kCӘd&:DJ7&=oLH e̴ @$du…rW76%l\Γ'Ox꒰˜i|%g`= !eJLyu5x-I!U2x 7o"y]o)KgWtr‚Cc&2{m:sĊ)"h&NN?!V /N*c&9V@4 .a$C @pfd?kMq*a&lBciJC. oXIu0ϓ`!2VXB&ݕC-Պ7CzYMO ,&wW2B )@a&*ܥ7bΊbA͒I2DwUid F6Ct51` [I ۳Vg0FxTb!5Qi.x#Gx/ =d>X2C)(t {y7V!L*xs!P,9C]԰tor oJf[H:32:6@SL񕓢  4ggoDRK`ܸqh /x6T!XK[YÞQ%%_ X&R\`#w*hJ5D=mPtc9^QP@ ~ddqZ>Tc#3a_nͰU`}bD^&(aМeC^Y؉)5Z>iV2FӮ];b0gu pqbtLG 0ŘzbBe`4I\R#Ek0mA0 F*CU>(f-xQ(0Du+\('e0%kE Xr3@IMK켅XH8.3p< r@ϥgS:kpKɈ7s:Lz%vs`W$Q#nKku®VȌI,5ǁ3% o5s7 XmM&Oɭ#@]ȭdbZ*@SLͤ7+DFnEg-o!|yʦb-1d # Ğg`CZ5l&2L#]gA5si--TЬ5D=|A閿Q֨(Q`(4XVkEN=Fo[`f%a#f,N 1e!xb" oKIa+kgc+&oXQJ?סrQ)e|ĝynqG kxFX+4~V#EĢ2rYD"D g92d% V\0ݪ0[c-"2@g̘2ZJ[ XaMzl2!ECo)3]b6F 2 I U1]&>2׃&130N^4P;gV| K;s~nɥ,chF+UE pjBx%FLYm<;6ha4:^%@l;dK,fɱ%ԅ b IcQTal %f QF4 }< `nzw6ε&zGvi& ЙCY &+a^jvaÆHE.^e5&5ȅEޒL 1w]x):R៶[./ae@rhҥKmnY$=“s(3ỲYy/m`Rfg95$&cZV2^6(C)U mљH\jASr(g-.#uZIObUn!6a,38` 5dd;= Vo%6 =dcI-3%Y̴Nѓ㭀< zb x֥CG))=wYZP%Ь:!›4\fHÊ@"Pލ11cF>|!uE0Z"(Q@G%P#J聍!XKuw :%lԀη\X6feŒ-8̥ ``Y wٞod!*w0EEV vFқxRCUU*91sAF"*!hٶ208Xm gm엔R<-3u/s;GR:ch)\Vl][  9LF[Ef2j@$xdֆw_ 9":-VP"Mv63[`U!"f@g&kxNDɠ(q޾N:vW^yj"`R1e[2+"(~T4}X}1zH;!vXPN!Rt,ѷ^E /+MX^@,E@NVp"$jvo?0M("(!= 4J``5?M(⇧+"("6XE_VJ`]65":ǺGgc cSTVE@PE ,u4X *d'GZ"D j`jQw{ѐ"("D!F SW(lV(@(8v{MDhXр"("DN)S,16,EY"E@PBE@ piGFf b[΄]rG.Z3("(@LEElK±#iT T.E@(O(6޾}͛W^qX9*"(",/7Yd 0PjE@P %QuܪH!D P*"(q! &Ľ&>N[qm"t' 6vP ˭.yE@PE <2[p]x/ŢuB*"( TL'9&:Bz("(@4 A{hw i+f_#V(!84~n=LY]gGTwn]XE@PE@8`Zm"("S32ʥPTU.E@PBA@ p(G_Vo-w7Q/֨("Y`-,pE@̣׺/_.޺qEi6CPE@~QڶUon*"PQ[ry;ڹsXNPE@~9Ǐ8qdɒ`1hEgP3w"f7nܺu.\FjKE@P`-qOVE@%c) ̺_70w'ڵkQ&V("eyGQ|q\zJD, `EAĆ"H.J/"X(E.w$$ ^ݽ\Kr9~ξiݝ߼7o {!!rFDۮ(A࠹[,R}8xDPE -iE ~(n?/ %GL0떿WsQE@PZ_嗀nTVPR JS̭ u%Luu E@PF@H/6p`:vݵv" %qҡG (%s۷%Kɋp$\l+X"uq"(A׶߯AFm"(d +W>p߿Ϟ=P5kܰaWGoIrj&KajEcƌq^n͈bIlKS!o-QPED@sXoucSm"xG@ w|K/7n?c=vm v݈,XbŊ(ˆ>)[ڵ@Μ9#D][˖-7o^0QTXN o\PE@ p]W"8({}=)Ss7*Zcro߾/իW7o|޽r!CaÆk׎S/.K,)U|_~9sL{8ڵk߾}4 ~n+*RCM[E("( j7P %r_~wy%0^P-ZGq`ޛ={>FѣG~P9r۶mY|-[g OުU~8_|[ly뇫G}4ia 3NE@PE@ WD@ pVjnݦL7, kĉp{R3f͛+qwZ*Tjh0j~ K.O>$gȿm۶sI2l*E@PE@H^XE@H2\۝aÆaaaX& f[ج1+V^_|[n9zldm\r+*S_(Q -;v /:4FPE@PE@P@@ p.\[Jv?rX&wV .P.0NOl>h`JZ@ـSE@PE@PE@&:& q_|E*ȑ#b.~VM}&*Tm*0`kժ,80ߥKrK"("("hc`X(FVfOT,sAˮUj)`bk'|ɟdf͚vY[dF2s$}^C Bޢ [Q%v *wM}b{ NP@F@ p`v~-޳i 0tO+VlѢ2d. + >+Wܻw9Mٳg{٧OKOIHn z 3iӦe"480+REJ=!DJ/l$sK(lΖ-5B ˗/GA8Α#U0CF4ďo ab4ƂH""E6%{]X1q-M5k5}efSqS yS&NaV;QF Ngh%R5s$ckqMk}^֢VgO`&~O ƍs`c}d.Ƶ6<0lf6Òfɒ%SL2d+NѷR+ܘ t2wJ0jc"`c&e˖ {bG|J,)פʚ5+Iɒc1WSnbr_J[Ыބ1Z%U֬%l29VZem n1.SKQ)H,^],]k\0D%F33FȨmN:#L*$!J Xu@jӧ[7di S4,,9Ēi9T Oe9hXӾ P x*X'J^:2cOǨ;;wX-۪ܵa;+ I~zUK/Ԝ)'4蚰sf nܼ Zξ+"L^{B䉿w,#0yA`KрIqN6&ݻw[ zi }HBn$:t(O q ]JxbT 7^n|Μ9L`oy̹w/eֱ˥k2W.vݗ^=smn*F$ÎT߇(+Wܯ_?%ƣ݂Ӻf3N>/<B14/"_ȟļ0Ln&zWG%,#y)Vs@ ;ۺǴ'ni dkq*(@ Nd-"IK6d':$6"#kB5ßd,TTVj@ȯ'f0)Y sC .5ޙmc?F@XPCT$-z"!Ry2X4PDZ JWlua+ڌb°.'Ek&!aƯ&cA5,k_Yo=6JoBhufl$T7j؀Qb"`ѢE,#' 'rNkXSVB1ekGfM[MgaL< 5ބq \fh>9U`SMx{B @n4 *&gO:mҋbmR$ڋHKGb /C:L.xt7֚;c{(>C3=ǚ/;8[M|\lt`H/g"$/U$oUtE/0b|j E-C$uRG+%CK̆(U[δ؞i. dwư6`w = ce֢<7E3f³bO#(Xk21aCq6.66黼-k[ѺǞb'`+)86c\/̒5EbrXE}%KiB$bgrJOCg]S!) E4 ;vY=it|: nnm~^)RX_llM{m|ű.בm4O֚{k<Ol!3{w߱f"IOKƯ2^Rѓp6GbzIP?"؏`jV*A(p~c{if|&+,l fi& zllJQEke3H Vz&XA'3Nb1NB-,X1E͈ 1M7fFK 9=/yu(?={ 6 S6Y70XZӄ,T:8[DgKgP&D6V2Bw21s)Xδ* SmLۋ'e݁\ ݶ*c^ZmdK~y^/ }70g7mI/,_ Ы!R%/;#y@gY a.-[#!yjZE@P_2[K-]V2c ,iģlP;m>V((|O$18 Rc/2LĠ2xc`gl>- +&Ģut&1F2XsXakJ׆ lgs_zY5Y]e|#rhʩz釾kZ#$[hY̪ [#,dz:}xuOxz4 CSS"RrJ`޺׈RLNK$We-pa"諒\rԭ[irAqԌasj.ڂ xj50f%Je:#ږ {qξ<ZKKg><|\%[DPdD@ p2E'  =q)6agFR VPܙ<(M&M3rݼy/bN.Edu$ `&JE5Ǫ]43 1D@ Y8v.;NQ1ƃ Ί0`Qʀ ?O j0_R c2Ə5z@Y1(l-u]nQ04v5h́- ̤0b/NǏwV×{Lev,Owv0j(N˂sC,Ye-t˖-&ĸn\FpE'Acd,,;y2"2!,gnf!8km{cM@d"RZAt V TV# skV ^/r7FF{ձf|DrǐL<ZKK/Hj&"@0"GPH&AirkNQ2v< _rqr©u'YC>7&-8qBZ :@p`ZxoƎSg8_bL& *,ĜF@<ʲĘT%1f$HpVS{*CCòB15)pUt€N::M*Ou/˜Ԓ$$N[*T bŊិG7#c+!OmQl2"s@$Җ<{l qK '=6tc)Ι\WV`M$Ԗն&p>Y"Y%!-&[rɨ|1Fod5 nb+K' ] W%+[>6E^S ր"j\3 Fk% : jv_NaBjKVsɉ3LP+.U39r`~D kf*R g΢m57 %|i;%3Aq@kNm%2HJZOϋIh+V ."I?.x=㔕[aTE YpC#|K w;^dEM'n?Gfmllb 4$BSFl2$%0,κ=+< :VS([ -Z4+&ۺGÑ$3idS:5Ep T01bQlh{\rօmي0p&~*V2Қx&p>YȠփ9M*(0~6Lm mM0nc|l5SfE&Cm&VsՉ^' `k&3$q[ r>۶rMMhgMBn' Jr,knvoqf$E [Ml^-mȩ k"(ɂ@*JE?УXT{ 3vcnSid\@* }Q<5+Zo>_a~l| 56`dաCסu1Ds7 lXĚ0U<}pmӤϒ.c43N!UiR}kv1A@/Urc9K*(@R"^m-KH:X*JeIx*4iVW̪`*qj4CҖ(Q~Ib ;ۛ%j[y/uʯV#E@HA(NA7KOAZEm޼l,"ĽEc׊#bXNdf\\fG>AbQԀ1k?䪼7ajjE@8- )"8 8_A ] ~^ܘxy^I8ΉQ SP$@?;$AyZ":@PE@tZE@HlTkv`z+"(ɇ{4 ZnhɊ"(N\|5wg-,("$ 8mfL2A1t-TP%I("(@"+W.P_hxsx' PhZ"t([ב -0%TV("35k֘O3x7 $ޥkBE@PbEx_ȅXTAXn˺d%7޹iBE@PE@HBz]7 Y'^hZE@HTLyJAoɯJ)7W("펵P:VT@P%ɋ4l_ T("ԡtʹWZSEE@ zf^\PE@Xԡt"J'("("?:O4E@Pg@ :y"("Cr"\K pw/Vq]lM|iE@P,k&$NR ZOE@H8QE 0"+W$Ԛ)"(A|][qs`A#Y@ 8Hnd7o^z9戎VwV("f;ߴ1N9.E@qP|R~.\/DX9p sZEE@PB7]tc 2U\Y[܈(c?2fL Cw/]vy8SbsΊ"(@JDˁ5kV>oJlYP+Jhz~o˶*\4SDsْ3/]6r F >w\hh(Qk=E@Pg]@ )uZE@P:wΛw3v)+FEh4RjVPE@H@^ŋLOжhE@!H@>B*W^ֽ֊ּ4EkMM+"()]8WGJZE@?Fޓxt֛;w<`ړgrˇRaQ3oo9%8s|rњ4iٳWXsSNa~k&"$#\@o͛ gSU/&ZLK6`8(\AAqwo-az@NWP@ Mp[/4ycO~:{#.[~aYS'?ӼQDJ׿̕${O,.Q^]bݯ)+u!HsJ 2υ=ߋ|͖7]b8t*UE PLw3m1߶z X`o'>ӼUwE2:/u-_Y\zԩs.LPP?sA<Ȱ3gΘ-!"(AwC۳t|Sojשߵ=:ݷ˗a(5o)"1|YQBz͖,.҅'N/?}t'zsgT@q8(~{R!~8 bz%/䦿"JS=]0w6z/^Re1q ?BYSF D [ )xMp}1ϰbdh!!s. YP?sA<␐bxiLN>m'*"d(Ny7<ڰwԩ+aN~0lQ?^2⑏IbL\?,u)}c=wkQ&+,YFb"#]TWυAq"#.]Lr -JE@JRABذ}!feC`ؗmvIN_jhْl 31/s* Wmɇټq1-rG %Oby2z=^F1,3.WE@PE $_֬Yɓ/_z =MQlٲ?ܹsgɒu,Jh"$..5n3>Sf/ԩ5v F%1R;j>a)׎@닳+n`+APW8GOm_?wJM8QPGE Pĝ_HsCNZ|Jqza[N гK,Y[ڶUӍN06rj}/Z`u[VqPxSAXD!f.mԹ|G"7J(Tk-ٵD4u-|S=sT7xT:|/t(Ƌèwd_V@VW7@qPCȋQQb]$C_E@bͽ)[O>܌&5{A->ȖVY+-_f|Wt_mO k_ :|Y9n,dAqHNE6]HȖHqPlKvȎ׫ 2̊'XHG_E@@ yP{'|nJ>jMKiӹ~Z0gָeΒʷLtTA#$]zyO촴nLzfE9-uFUqPtkYAqH a.z.lxxFU`"(NIwvkR֬8N_F<@Q p<@$qFիfMI-Z{br&zW\5I (\ȃ8(gJ274c(@RE@PE@P>+"9j&&JjE@PE@PE@piSY_itB8FPE@PE@Pѭ^ihڴg%JRJS+"("(@JDܹ ٻ{}3cLfcp8C~I-8E@PE@P8!y۷nS*d Ut{V's~?6[{9tЗ 5X|?kkI/81E@PE@PRݴokmގm[^m]斌DF߷ :dG ̷|Ѻij5"+ݼY\&͘WB%uG3LJv-TPE@PF [_^jzb\ykwUCK-OjuO Q&qEXj}Z0f?ɝxF=%~T3TE@PE #9urh@fO5KOgɃǎ*z[‹9sS>~Bd$9!}{r5mK|s"2t˖^rW硂ģQ+,ҧkt#M("(78 !ҦM&M78A!]QQ])9 >:Y?{ڷodӿuR,4 0:]۷oOiVσTG8|[n͞f/b];]/"G;9w.T1rؿwO,oSI 3į4FPE@PRYdɔ)S ȁ+nݼ1<,l #ھr"EKT3fxehվ=Ahx"_ltUQ.0*Ν'[y"nٸ.ɛϭjۖiҦ-]\[n52a!ƏPh62Tcm/z[%K%  =[tY!'ݰnmK)G?>vm[ϟ(SbۮK`dc떂0޳kݦt ʔh;r8E"gΒKMpL㱨 @}kB"i-ҥKZMZNrS!'Z{|W!cF~5c1b5xL;ҧ[֯rG q{[7>\V3S̈m۲% DцM25~_~:jnk"=5ɪN/dcs;P&|{N]tux@Re˞c+tyJdsgM=Y@PǞ1YKѿFtxy;<[n-oϮS'A#dz#0!^TG_3ؘ/nEK>,|?q3eJH{ӧOϜO]6*ڌ7_&IJU&HE@PE@z%04}Ph{~_/7jzKdIZKѹ=/lgS4g\Ę*ihޏٹ{ᢷxOǞ?̝=76sg"$f꤯6+OqAQ4n79@?Ԣ#.=Y5 SVRNGDMeVq)=BC8cKlgZ|kS%y6͗,yѣlٴOG)aIM@*S~ڔ ٱm^%2:N{׶;iR}$>˖prD)m{nΟ3kEǮ|Z/5oL6Mkp~K&MQֺǞ|5nRN~_+#d-#͗[v++m-u %/8g:$~G~6/~s0Sx<ڰ1iY; =[CuG$y_҇ *2?f&v'DT5!*0-Ɍ_`]be[mۭ3fu? &`=ݼq*ɛ7'+ކma,o^L7X-m2x-C=UE@P`B W\b`pRrU˗A cNPEˠ[B̝͐Q0B!<|pct⷗B#5[6`d7ix3~fZZ{>m&k7NnT>Zک[oe#xlٲ}ا&)ǁ>‹"Lm[5#t3m$,3>ݬeaD3&~矌he+>$ReHذV-qǶĭ_\&=u=N+DmظϾ6FBGñ٤V vT|._N1Oig7kRKҮ#ӴEǏ;`L swwe*BMB w}M3&;>ݿ'IaV[tܺe#h?71LO$,,~7W_plح|#1s%\%-aˑ(<内:h–s01k+f5B䬗r|3gc\=Yaxg-\Kc qP*p"FA}vNϟzO`kR.˰rů?H>S'OpMa9p j6o+QؾXJ.%)͡,LHMp@pLvppə9i8;-\<m[=;K# {gNY y}Z#ι?p`GC^0gV^},@G? fĒȶ\J\I`@D5LWzK)JL1 (?q4("([ȑ\[:y2cbiue!0|/J4Yxg`4BiÀXi'+ S"Kg9ea!A*BwyT=ee Ju6@&qY潆^`a8r {a0]pXS\%4X'Zcu0v>0F lX8Xmw=~}7g)w6l"ũ:? W0eŜ(l l&I69 xЫʔȩC}wu-U6zxf3s* mި⦑"(@P"+=ɸU֭(;!=@'~h/-]Ӈ#?mKcȀ^_# ز&O%k(L|1v2 ǿGaT衄|,\$>51熿BP,* UfVİC mT0#=gX0gJZ6UM)~bӆ0 <(BϜY4G&v&awaQ6mbC 'CȧW'.m-3pUdq`\yֶNr}M[Uܾ!я y[#Y%24XRJ LdЋ❛?'PEDDLj4l9+K ($d6 =ܲX#b< poNt<\X_|:q5HfL@ޣO@eu;1xo9>$FY_K x2dsI:tY1b6m. 01ނۋ68/tux9^tWA/@a"Te>N/Xk"("寬_0 8XF7i\Fͻɼsꕂ K{׭]b>t*.D2b2DD;D&%c!m0̔)svR.[d)!hsFqҒ+1㢱Y2+ztn֑l4ºt9]t3l6H)O1G[z x}'kB( j BrisG/ 3d`<`dvƒ ӥsUL~/?36_-q,u2˭Lcn!"Oi:Fx-Tu=ǐ[8 Ú9a|L 9|c5@`6[EeHZOa Gx,49CfRc+`$3.3P3bf2ykS z/@X|SًW>"OyM=7$AvDžuĚ3~ey֬W݆y8?9'y=Ӽ%&.NXڵi%˔[xlu]{} Y5xzC5O9 aQuȔK@ظ"ESuk"5;r;qɛ`~3~,60*eA鍞iQhW&XdAoc} V[1[[7RhE@PE #b9Rz'ޤ5~[ +Dj nƷ&x__ԿքU&Y4t-]aʺe>/ X̆ -H~e뒚k8%t _ZN+;m]7C*Ц!RNiS~g-PXuMG GZ>-}ڶ(pZY`=`]vz]3JNXNYM_dn" Mu&p.k(ɁY 6|!$RE@PE !p`=!8-3-i> 7_6E%l#[jb4E2{!FIKv~= I/fn^|$MdA 0Ux浶|/_d7Y kE3aaK&X[66שոGK ^bv °Bmr|f*J{Vm߸;9*V`g.zcimBxYfz&ciǘQUq!%srV?e&+F.Cܤ("(C w_C8<F|SλqӻI{eWB}[Z96RT֬eucG%~KB,u&AMAZ,+^.۲iz`L>V6TT8,ͽt& _p_⭤{o;ӓ&O7WY\nR6Vt8rdD[+6.H~\S"("$1cƀ׿-e0v?f)7tlj{LkeXA@m$~bJxg-wnNe.64gJ_GAxg37sbk|w63˱xd$N8"("/pP$vW4E@PbE h uR>+*p>otx#6qQ8E9kl];HNZ>|k$gչްu ɕUK{2~E س&Y\BrK^_h4RVOKWE@Pn p]x/MF |EfokKE hpⅶۼ8kUD:jTk{Zz"b)sVPE@P|D͏m+XGTLP%A##U)N^謯)-.޹ţ~Ib#:^>Wx׬Ū]Tbn>u }ŇU@qhB}Oʃ8 xuU,|^>/h&"-P6<؉gBC˕ 9fݦo-_v֫8êvۮ{̟7O*ph-{߁;w%22f`j 7v|9ɐ <|4SFE c>$i޻c^")LL2sS_$]K.!"sk3VBw<,WӧO7{o"m_Qvv!~h{;[ib; ,Mj̞0jCexP%%f歵}vږ?8gnkVŷ?tD*ױESV&/Ux/UJt~MQҞLZLёQT@q"IQ}O{!Bq4G+@WP!rkz;z3kC`qliRN*-k߁O'|{h!koٳeիT%GgҫA }?<{RPCӧʖ.^L_e_PA ~arْ%E=X; +q[| >iTjrK79?ZebXXB0blfMԻuIB~Zi/+ 6F>bYDC.Y {|y3-8g>6jÖ[5mb$nj܀_1|XQBz͖}҅'NR.M!$k5:ԕ3T@q"IQ}O{!BZ" xg@%x>0h=~1{[L4Ed|yr{ЏWl˦GCbJt6,ui.]'\ KCLVg՗@jxXo>G_J*"LΞ-˝SgJ,- IxO2gC_z5]:Jx+Zeb D|9-&I46;g ?޶s/xMY1&Ʈݥܹsm/G.ED9:յ+"DDU!h'%pq>sS}8\=>sftT0UnXzg3M 9o̐?֫Tx >沼U:DJ^{Tm:t$WҦ[5a iki}l|8zOx텦{u^I햿9Ttnb2tS68s uƧ>E(!_%$*U F-lj?<*:E֭jMKV^wlTbXbk? ޚkTe ROϜ }Kak QqR*8ޙF{۶^Gbm>ٗ"-u̷uFPqPtkYAqH -_E@z,_W/Rj}Q65\+u7c񲕜=|[B߼Wszm{unW,WF4«֬;}&铏iWY385 ogV|:O] L'fc<`\ܦnmu)-P{+}?h OA뭛ir@}piƯK xԩSCӜ>K}lٰP 8(\AAqHwϥs³d` 2L xw?V#K"|4vMIܶu30n.̯@Rx|8F(5/5ѣ}0W%>?oEx/^g,83Zehzw#NV P /= {+ blEmArg}=y'NVTZƿJ/ʇ XzٹO"mq[37(|uPtw")[/4KO8qth !i.^!Iw[P?s!O8>"+YΆ^ڥ/~˺zcYǟko>-M);t8U-(!u.VqʁQsy-<}N ` aYQ(8s;m״/-Y[n y371+SExj8[n\/B&Oafp`rlX\NO.$T@ Aq"Z1 o!80)ܥа47ui]X=` 7/*q ڔO"{&}eW],b`b_)Z`W#KL18qIr`lR^C\ \DTyh+"DD vJ 8 }O"M/TDFfp-l/Clݹ-+)'6ݲcwyrs7^ Ɖo/ΑػX[l|Ȅnںy^X>fݦ" Lt GE]4 PTDZFfG}ٵw?l*ť+9ck[LټmgBdhp\b9jJ!@ڴm'ZZeJVIH -WvUd[羻LeV+ZHw? \Es*$IUMAbO^qim8yT eOqI6;odgi_Fώ]5PS&UogEOdɜ };nofR%<U@\n?Vm~M p(k `k,(>% (,- %%H4"ݽ4 Jt7Hw#tҠ  Ғ}0 .skg9g]VJ "(")(;/md&M4QD_/ugo$I$Isdɒ%N8N8p(Л7oи]w =_dƭ ةER~%VoܲiْE] H%ds6R  sȳ)ZyG3(5v-Tn6(ae(] δ::K l[%7)yp?-;߸}￉W-+ MիuyB֪ZP~w(k6o+3/8z`L]?TK1.5u;ac$pևOkE-?ibSg"9i =գ}JZ>҃s!<n޹I5dUs?^FoKy<8סE@PE@o"35X2׆3IfAeY!6a:Z5wƢ^|NַjhU8pQUeΐeH  ys~{Ķ+% ڇi1D)C:Qq%=8"7v󎽟/^9GGk_Jz+/t;ElC_x6. *.Y_l\S^(ރ!iwo^Cnv!֬qG@P#aT>n<~Ho$|9M}k0C`VHMyx9`څXe?c@ .C4j66{"cubD36M Y% Ò*ٳZ_/&,4s*ב.\P[t6Ou)9kĠK#ь5|QASyzjok@5l`MC$ bYFP0b<3QE@PE@`: -o~rsC筻 DS)h %?zkCYLa[7oܤn Ė}f2|fjrhVp ~6dw E*Ңa] [vϝ0DiOR p) /q;+mQ̖*!v?hzH0Om?_EoUc?~o/ʔdWߦac?,f\< ٰ_F$ {4Kn=<^gΐΰ_ZYmθa~)ʒǪSCuE8ug-.&K )[5Mȁ窍Am,ǼiT r|4yX0iU, Ⅲ#!lJu3 x/Q:4QcK`WkoٍZ\Ԣ;pž}|hnM*glWojXp vؽˆWrY*GUE@PE # |KM'E[))E7Yo*A>q%<4ove az+7lAAJ"{0 m[݄ vʛ|kjxD#`l74m$G#qIV`dŋrH_6ks'{Umƾ`kѹ灮HzZJyBUרv5ި)м@RCh v'\Oxu* ,5έ)'>na3wbnyփ>y"lbdvGmQP pq:mE@PE@P'$8XWwӽ3}> 4aHgn=7S Ldf?7U̓$rp:!4sgiӤ25΅IߢS(aP}w&/,Ժk`2`$R!~ O>- aH?w=uɝU2h Ng[qk- Ī[wse_-ny8ZV(E@PE@P^W^lPD!"*c{\W̙dc*L VcNJeډ^rm~sƣ*;w20]o&ud}7ԋū\&`kk=2*_6-vh\ Byw!~e5&׍Oو/r7Rp>~ SkJb2}.YHK?N>82cE¶J#M))"("@1df S ۠V?Ãq)GO!efy)ИVG]B&)xJAbm0%=lP,e7u< T[O t4㉧z_,(  1zpPs/M=SZs F,ΛoR&!ln1P75 ^>.;!2c]q ݸE`t.2WV]tC;e{2 fJnG6ݍt=zLK+r/:t2˥#&||" 4_S.+%opվIGeK Y%:"qר^({ڛ"("Dx'48<=vPorbJBp&L'[&̜/%5 )Wcʡ쉌5wfBc}6BRp">~2Vj%̖TF+6lF2ֳשF!H&5XhwdiƴcŌYnsgeɘ~:Zco+.Y BV+Qo)AA' #.Fv\̀xە5Wj vL5k?KrA^eQ ;W,x|h۠fPdlm"A_oⅽ֛eW+[nӖkNS?i4=;5hӕMc2wd< X|mV=hR739é8~.`tC,a6f8Z[{q_!!Cp{aD0Xr:qrNcoah~ K.ff-3,Hdܢic /VMCSX3o*6z{ƴiS$_8֯ H,bݹw\Icb h>]zuleM ;?ʢM(uo8vV"J#<:x5 [pXxx G"lZPnqs.;n7?iXֹ>.!3b#9P*f,Xƨ\[|(4$@H?<ګZ0۷~$ _ccNIa˒9=9ܣc-C"ΤoU8Dk`aߪIɓ^pokm2}p/D~`9769BTe+*C|c#}2\hH?G>B*j(@}U0a5+UGO: 4p>$1w sC],~8Ə~1^ ?s;~, ~]Pu]% Cte=  ;Ac&^&x&IuAy\>%|sO&a7\Q"("DN1by=O 7n\^9o$f5qReff ~چBݬ3l1۱Byn\gcMCTD &2(;,7{p]r=V H[XbKCR5jpx4DHJfrA`"Zݘ=Z6CN.0?w01-fő%.bAs֘yz`녣mfnp:L8>CA f;N"("D/ 9Yd'^.0F^ݯ@T8r!F , G [gE/øШ[[ry iGHJp…iSHnс"E* XD9ަjޤψHF2kNo\۬^-XJZҏUJo+7j/>HBFMcF4gA!=eN[A9WTF"$G ШuVc*_H&>>'L8wK}!q|ϓ=+{ FPE@P%-~m7Zh엡˙ڦ:0h ?hQ5vG,z_`h/M2r~֝cc6,|aO8<І"("g*Z(NUP"#XVnؒde4QN;(a攏OP,2O[µw&A_8mIi+pU!⇏ n[L!`n 3Oh$. w9ǡ=;Sԟٓe3TNIWrfNVKկQE,'cWE@PE r"+ZyY)"DG,m?Z_$^>pEadN g1$ESh[Ja%Ymwpz+Y}k 2A|QW՛ҥof+R08 Ju3_̕= 2m sEwaxqP?:gk62% p9.$ceE@PE@K#˕r~Cu"gѝD;rHQD``%^֡",ͭ2wj+Ԗ9nFd(Aot+W~>ѽcpfO݀Q4&}?e&%^wٕT 焺{mn.[d`+GrML鳎XҺl"("H0ap`GW(L) Z#BU4ຬ;"L X>PXRy#؜+#0WCS> =\z mply ɧ˖>ܝeheV^J.o BvAuZ("D?̂8Jرc&) `|)Б޺sN0۸q}<~,0?D7]N@وKoUp]qK̘+&C嬗 S=󻬕k؄0-XlCHe3mgk"X-Omҵd52吝"("$J(A8b(a@xkN0fz&frT1a qSVFV)[j [wA{[wĂXt>PǺ-;U.!!!3kv/ģ'ϐRF2ĂzAщ~䫰P?O҇HS,ԨJpD2^Nhyo'{K#{ǩ50մcG$KJ;~3YʅKYK\f=+"(@TD`XrhzyN,Yĉ)cl_VK(777iӿ9rL"S|}:0c[gE&a*G~jԡ%Ip^eKluy0%3Mxͦ|0n^v:VϝұC[ 4 2B ֘IDAT@<%U (ZM,b}9~*J zoQr 5511F팪lweեSL΁z2 2ў[␍U hY)0yE@PE +YW{- Q7yk7ny ->m KyJ YvoXzϏ1]#i_8/;f?ݼ_p-:~pr`jXPMpEyE Im7n6W&6+ p "\gXc*ۄ*STzXI-=xhĤ4԰gZ]ͩust 4hAPE@fZp`ޠ -]^"(.AB^?Ox>.v]e a+y.Tj͸z%(p `k26+bz+i{ȸiB}mzx9e/H?$~&aVJD#־{/BgWAmb!hME@PE ,YSڧ"D "4bWl,aDCY^ҿr$+e }[v&]S=jm+6lg= b;f&gE@PE@%ב|+h]*(QKO:"KEt>y?m {lW[D /3NY1pڡɖwbcAQ5JcO)`( mGoB)Ց xuS_NM`$Ve{t!ӅL258'-?6"8W[PK!37 ppt/slides/_rels/slide1.xml.relsϽj0wW3XR L%}C:"$tr߾m~;?/Š  &Z& ?ׯw\0X1SMK=%U a.%}(fYDNƘ,̓Jhn8:5ͫ{)!qǎ }F.t-'*KoF;uxPK!.5 ppt/_rels/presentation.xml.rels (AN0EH=qRBN7 $&$myL!j$UuyWN-zRȒV4ފǫ;`H//V/eE*G,І9NRbxR[ol/t0Q&TVɶuJ|g&hGz66c +iOC, *_7fxэIXAk|  vܛøa?>PK!"M ppt/presentation.xmlj0{:┦ǠдJc*KFdc#EݔAWt~t >!+2_MCXˊd1wSHY)g$CG"E3o)7LqvJ5sϓŎX^0mxJ}M`2IW Y{{VE[''PU56<$ޓM,9S ZuU%"gy/ŊS haT1+ \x1?>9u[u7 N?8f(ITO<ĸ"Ҫu5U-nz$QGJ kaGkP0ic=ЩנL_ (r@?o~w;¡5*߳x5oev lnY|O~snӪ+JDgcMN/̮:6p[\&|! $(䅠=;hMOu# {>bD=?b@iG@ M+0T,4 ;LJ{@xIX@$\?ƶӊ*C7*CwMsgwyƟՅ G[t\*xm oD42zS t_ZPK!ppt/slides/slide1.xml]nə_`ߡ,|ԡ+b=3y XɖD"&% M ػ߇'ٿIj3"%Jm}aC\_G%>`:M^6"}dXчr~~={53{2U>:_,f򢘿 tZ] Y=z1>b49Z_hP,Jr\,lmvfU9joukxx>糷UYoVwUo(0,G?,?'Wgū"}³eׯ`?ӵz o[J8TM6G|_य़LȣeokX>[ԉ$Aڽ5L2D-W$ iV<({Vc \ͬ> ̊"Ȳxjn8=e&jun=McfȻuOղDj}b#w$ 0Q5(3Lc31SnLQ$4wZqeA3!+=Ѷm ]]^2ՠ+j6b!ו{@z!~_\rr6/T~7lt]h-o.I V>!0[nZVUEEiF18N QrŹjhZ2!B)AÒK> iygˋ7as=q儅)ﴺZXoViq|yŗ[퐐8G7/~_/%pWL&Y>%4.qk+ "a9 B)B2N~,kz[4'E@{,]I hb|@y1`b#=׎G!bh-|Ӓcܙb~Ӟ}đ\@;D 9b /qL  h*nϑ0&>E;6oh+ii94b@XA6ibY tGi|Ct#kwj!%]uR aFNaPO ϑG (+iqP\oKK__R_?B{&lTH Hm<@%}55@$nҝ~dT3RaEo%̤ND)0_T{M N5W6:I['K,>(L]}r*"V=3 [q[ty}ov>z"4 M!BDYJY_ a8}yiޫ[ģ7@J5C@c%GPiE5AbSG Ӎ!Ax׃^z]$ F*ZW%"x@s<6>Ș0a%w=X.z|zZ|[ᓠPR^9D941HP4syk0:mI M=>ӡiKKխ-H+ȁ `ggXGes=h>$|oeǧ֩WIL Td(#E8AV z @z- bLr[+e žio Zn̘&~_lie18Ϧ27ٻ]oK6ܴhifK:{ ְzMbmjSzA*J[xY#gmr)ݚ5b'Fy'?(#ٔ9R1Ui?WZϱ&8z1qч?j,gRAAӴӧt( (]} i;2:66 ٜK QB:lFtHL',-îҾQ;rS*Eq[XWTXQNFygĈSg8p(R"XJ '~xǟg;Ψ{81Iq pӤiOWotu;O:ӷt 2W:L(en&)E#eB m]Pɔ~{S'PL9.܉*F"ٵrL:S+ JP~uʓʝ QJC'P9BucUI6ImyW}Ė %ˑ$ x@&yKmGUHl;bЬbFbLieeA[ fA1\#xLhCYupcV1#'V]Ro_-nUG7r+&5 G8khTL Nkq67_^蕊U*Ėn ,')6 Yxsw  ㄲǧz|: >mEgM)H@*Z Q9Ò(Sr{xкrl4my 3؇>n(c3&Ѧ`b [i 2aeClodeM?Xle v_]촂^~p7SÆ`tD ϪhXoo-G 2a\Fύ(odɲo3G!VCƈ!f o%饫t|$]]̀Χ6%nA yέqw-|u!`rh*禒uz ^0wGajdem6cPL7i(`#sOȮNF  1Z)!Q/#xM(kzBu@ڠp8h8> k F!-#bfV0ҙmRTs?d(0Ɇbd|1~މo|-9#9#(d( ƹwz')pnR9/t4Y$K!aF4HXmq}{U4k/͒7Eog_PK!ђ7,ppt/slideLayouts/_rels/slideLayout7.xml.relsϽ 0]&4uE$ۛт}\MxRbZV ț`5ܮgIÛr\h\xpEQ{g(]m(')#;+b cdkH)  MN]XnKѮ"@ks%;\4q )jx43 Im].mf6 mQ639Ci2kj>3w7l׮M̕ݑeYxϖ3 *D¶YuV68_2].˜'my#L ;=KrAy^ʊϛ|k^lcC EUMV_`ZYeh{2ff}w.8Wfݙг?l΄Q mD1hoX{/nv]Zc;3ĝZj 0a0Wkkzu*)iNz9ܔQĠQ4D >1.l;ׄQLs;de.ھ]7y[<;tY͞fs|?0 QDlMr<: k |HߝhZ`L<xe1n5MF+J䠽q`;ɿ4N:ؽ-@?W亁$اx6j ZVy43yX`^.S*S+o`+?5xQJ}K. 5,1ՀֵڳuGN~Ve9_UïT9z<QYB Gシ{ דDb4ԣP˱G://xfP @ď#Jq8 yk!W#Oj:UuI~+-Ȏcs s'AII.Jh&i3җ/ dr*a?i~p&8HS-00qCDC`צQDU9+/ւ鮊qų|qh9hқ,ٟwNbcmrBCsLSJ YRAo3$=/D* n|Xןw_)8IbF>rid4>hBEN' gr7g^f(B9Nȧ> "Fƶk$!ZfzEio? n)7j'BA!><1PG<&Nлځôӵ_R$;(M︞:>XڵBm%gFk'XuJ鵉 }S?PK!iGFnz"ppt/slideLayouts/slideLayout10.xmlWn8}_`A>3)f4)b 龳 m)uZo~Nd'qE`E93^֕/Gi&o<6]d(0^`Uc_y8eWZ3vlf+^xGqi}캲m{V׋Cַe5o"x$߯ʮкC:{ѫ$:#/ `\Ua4RV܀2gqRO 5E΅^vs.Ph#i/iuoeRԪcȻR#|w쁹*}`5m`Ty5b8dZ,#feCԎ(R8AJ,&wVco t=n2m.Q֣H&*+?l ʂb"'p`!=/_nK)fB7Ig*7$T{Ջ,v]GB ( )М$~'=?}1{s}=qn.'1| l۵C'"!)3._/>Au]4E]FDQ8 I8>g CiX` ځôӵRDb{φ@=A!;[ =xúH/H,)?PK!hZ$w85!ppt/slideLayouts/slideLayout3.xmlXn6?` hY)QAºm6`%:(MVkm'IIsiM` )ꜣ#u䗯pїm3 ׶xE\Kj>yo:I_g]KKh6WRv'+^EF[fR]K쓊]Wr]ߩYأ8Ŀ].˜'my# WL*)ZwHN^1޷!Ne7 2b}rUa5V kwKra݅\Ϛͯ[t8ݜ ,tvl6fqj)j=jXW3[v;z_I+jzmJv8{Y t.JYqK8h-ʙ9PD Lv#  CMQo7Or /I_пi]ۥ|(IcNG:51iBx^Y@(0TPn(hp|'"?V*4o[L=vQSRhnɮRL=H 0mK ݑFD .CD^Emq?QȚ|ժaZr!+n ]lI*[=s?'S~1 siw&2ضu*~_d?鿻AS0o5(ޑ6(ПgQ S8R]JDQeBFJ+V-Fe7;ah;a.4d $K$V_ 1EQX(eY\ n- we+Κi@vrR Mq($m冏Un8h꫊C49MC v^nK)fBnܿ4Er?jzNFn]Np321qp 8LAq1!zzP5{s ΂ Lb84]@rzIo)J뗿~paekډG1@qp`dz'c/ xv][;4 @Rw4Цq+N_xúwڼci];s?PK!QD!ppt/slideLayouts/slideLayout2.xmlWn6}gVEɒQ%q6~"ѱVI(ڵ[om/IIqd78@ä{9^5exWжX[jo+ Beޖy[ߜnyi) {-e7wX&_݊&Q8?*vUk1jU,Ŧa@sUOh1h`1wI;-öتG>Sq˺ڼQWc% ݕ`L[v{VUjq/F3nMǹ7fJ4Uɰv [qc;i`q-kgZjpa8h gHΒ㼗GQ-ϔ"gzCp( hB0Co$/7x@iS|%_1ILMg! B}x(MB.QV|Z3;>w[-WDi^n-2uۭGIInxi:,*;˽^Zf0׽\}CBQbY /rYRWKrd2뫒V(:!Lt ;N7?ߺy] A3DAf )&S/F1$3sʠ*w(oۢ~J]!hBGp84\5۲Dcīu%~ HFшj+'1f!pH 2$yr[I1M.T&ɝpO?-DJf4_(cQDY I>QG97؉Lg& {{+<"tVyw_| vL_:SMo!Qb p@LP8!axNQixe]8g;D3虛c\[,uۼ4憔Nr0=Ч߳PK!57דC!ppt/slideLayouts/slideLayout1.xmlXn6?` hY)QABa@sDBuEI}q$#))4u`4x(pQWƆl ٦-|f~:K5^dMUmf%;(lM݁̕eYX#-[^gBV/,dۮUgecCe5kY *~BqK={?%qjE)*f:o4e*&0UY0}8cl~ݼ;zeƙ5^f;֍S7;XZr#)Tߖcȇ|;NWִjHv9h*g?:^Ly93) IbvA`ȡ 9 ݃3M$-ޢ.sRz$/$#*o1AN !@7vqdSz'"?FJoVr6un,CmAV9ŤQϵo9`w"lK5{![cV}\\VL7THu5[)3U CS_*.'UrրOa9qUeR^0n[OZQ%x˙Е/Rg|p)J s!@N$^H<~t*R{^FC+t, =T?i#mgeSHW}.)v4raE[TL<},Fhg =ţ!݅Vx#4BCǃ p&;QnXGloSV#6b+'qGl%՟_ȡL_Ƥdq&qZe9[U!rިY0@)Ep(Ѕ ^$!z}.y^er2:>jkRuu1IB@~L|}Lq r5 ɡ(kkNBSxS]F_bYs}*l$Ф7i3}$mwߨ!"( =0MlD^b)rm/4[7I? R^Kq7'opLc*WG\=b l?|͗{v'ÑemԲo ]Pl"%"]L(mdvu^]t3O{vwQDCL) u Hq rڑN~akK_GlJk!-^kdm?fFHuJC6D>s/PK!w(6!ppt/slideMasters/slideMaster1.xmlZn6yZH}Wot4"K*EY-gCJ$& FFivYhkƛ*':zg+*Ulֈ̒*Dc?{_")i(q2BѨIl4漢072p˯GO~bM-ԻSW19*? dH]+%W P-j0u?LƷsW0ws$iVhiKLt#"z{Կ`4x\U~9W(vQ$)[TEZt:oD݊8>bb02Ïgr#?)>d!?[%LE|>'i?!; olvl >{!Gujgڶ\?4)-GP'q!챨Sꬍ̃Q ܦВ؉Ba`q ׷M6&5=FT ߠK{u7|) =ǘ65bj4"5"cOݺ$2UaCtqV[xݹcZZ]#(K4,i0JҔ8AO=ecBz )=0ڼ*Q{ Ydso]-LM;oMM',kZսP,~KH`T'RiYѠ +[LyuRV ܚ p R\8Yl6.0WUE_>KC}Pv5[m*_ 0w[[aCkI &hGĹ xv G!K;頔Q0 {K;Ҿ &rgڟ {K;-'"roڧB.qn6=ǕX09'.ZVp|˲{"NlчdNٳ>hUڟ gǦ@7NڳuOIPlS甤w;as~QQ~6/PK!_Z!ppt/slideLayouts/slideLayout4.xmlXn6?` hu3.À JFю"@_k{>HZski }ԑ޽_7X;5m-XYSiEޖyZ:577˻nQaKaHm?ɧBnbY}Mޜ&[%/禮m{VW9gyUЄˆb:2~Qu[i/7CNf+._J@P^hF^0#fn;唪^wW'ܨJaiZÍL_+ݱnM?d=jN)ۨKѵ0`-kk\jtИi%jjqbhɫ%q L $!Ȑbxj6&?ʑ[лgSl. %xRE%rQ0 @:(]8z0s. 1#V|I[ܮ,`[|R{4mon5aF-r&[=O^ĦS: .s%WڂO-0!AJ^Pn6MvhFȿ3?8.X]ʅJ]qvqBO$!RUHI^U8wp'1 !]@O(EcDž,VdS1is;тQxflboxO,~Hl& 8@by`8 EjV=IO4뎚MrAoR , "Mj@ ?f$M^*yip_m4u' ɰ um\ D)1UC|R2e7"i^W oG'-Ory!7-cLQee$,='~7$+m.o/s.n#S(P#Գ*qٜ{;.1a0&61(LJx_rY y Lb@|*ȳA K Btu VF뿿} .{w =3 ay,cD*t܏;cD>\U6}C[ml82S˶xI)C"tgrPK!ԥ !ppt/slideLayouts/slideLayout5.xmlYn6?` xYAºp6>"ѱV&N"@_k{>HJkݤ,@~9<߾;/rg6 |NU&UvqyUɅj'~m~|Q-;GcN;Yt]3BqUͫ;9M||\N8+'f|%*eʮiTwvVoV70vuZ{۝UgnVNQ:e\莠*ڪoQʴկM}T6v‡ad&NËaXlczc،wMaz7݉&>u9Iߙ{cEttcQUomwq1coѢeN>K|I n@"Gy̆t'it8-j޽IbǨ1M'4@CI# BAA$\ p?6@<>񑈶ޯOSV(kՈL ;)`[91Uibb2" @@0~Q0㜸T@fzq>2q%qn2G9&zӎMpo*S'W+oh7aEl8zjt Yro_hJ*YA Ց+H]dbehm2dO+)A௕k%ZI/*)P%E^hf` g0"D @ ?w%u=jj#VSfز1løSb{N!/%ubC =, s篦Ү6zص[))Β `ѹMX.ZtK;smwzi(Ab>~wbhsv{enXS_8Pxˆ3mlGyT}m` ՝=؋.y.};5:a vu?x%wJCV 蚣mUygrTVZ\i-˨7C%9׻74Ҫ+`2Ťf8̯-[leWuw(IZ.-wTW5&<3Aq>97Jq ;Dܔ ؏  ᭶E)Ajf>BK)Ugh'j 0=2fz #8͑G MCN TLN@ ( V'(۝^}FMzK9uybIEuA>jl j 73iHa}ڌUK W>Aqip.p,Sga: Lqa%߉(r H͓E!82.^{n>*3O5[@o'o$z}'.^1"GӔ&3ٓW3F}b^k.k ӊ흔qzz: poEO%\#wvi;KxJ쩀əg&kf(qD%ܬPZ)~e٣O8fHU8L\yE2Yc7p? =f&tz|_~E mgv, P"PZ 5r|_0Vߨak:^H`3kӨ9޹?PK!n[ܬ+!ppt/slideLayouts/slideLayout8.xmlXn6?` xYSIa}p>"ѱV}M]E8}8i3k^/_Y/x⾀GYl2ljq<ɯl2?MtGǓtg QntU*jw]S7ҢC7yɖ@҉2*~Qekak;+3nJUm.XOV;9Q+ܩJ݈Z*C!N`BG=o:3s\S3 [ïl]jGD wO=N6nf;"ف U _˹(d)Q&^ڌ]q<9 i * G%g=Nmȭ\UuM勬FX)I]2JE(T37`qTjV1;. ѷMwF utA bk}%KN|ƌvʬwFؔr`n:lk=R]i-u/β&^P.*pQv~V3:5LuI5xw>!O;qD^HuK9foTQ$p` olf9[SczVX4e&BԢQcЀyb6#0?I-Z[.hπѻ\i{co@b },bX P!ށ%X P!فX P!݇zBTa-@>u[}WMy$G_{Ŝ~&7}\dM;XFbQtYvPFrc1 (Zꉟi$g.I35qcL$C<}ekb"-:hv|?o|\*TJS0XԹ:4oQs7KH5>wn/P:w%lžYawK[M`$Dy­Na }b#H塞c =?R1}_IƩwJ*y%sqpS@bu!>>„ӗ\+ 7+y~sJoV#$'H bN<4$ QH\i(JjىKi$vW2*EZOS[;u~vv)c6Ϛ763! $A2Jc}8m.o/NmܞϕڷRE.7rKp 1 `F|f qPSH0~z2W{S=n*3yG XAK2QnL%UvV_Bu1Su C AC(lD{u}福ym>m GR!Ctx5Gյ^ە1Ie{dnژn>PK!x^p!ppt/slideLayouts/slideLayout9.xmlXn6?` hY%RAB76XP%9$uVkm'GJ%K"aQ9<պ*UESey6YQ_ˤΒS&Wg=/ٶgbDZKܞud7*=\¤N fKyS溽*8, -&@w[:H*e)y]ϐgaY yVHu˼3܀([]4v]ηEiOE)3xy%ԏlP R( qLŞ^`}jE>kmvԼ-r>#]X۸1v v##37GgG0G|ls<9`G0}QC<x=S-8{3YjLL-U.إXjDQYHh"R3yP@O=}1c6"<#E6/rkPa0J{}SYנdzZ2at oްY[3T9]|"b-Fw@;I8r14\(9%a7C 0\U:|?OGyrHl )>q2*husϞ=ߟ?z˹vP^??|ۏkNjYǝ?NЌgOLhtܑ\]2ˌ G"JaQHJbhq֚j(vӳ;O&x$ F.F}ShV}OwbѴKIړ=s;s{,]wG9hK8.ɸ *wN{PK!ppt/tableStyles.xml I0@Ὁwh}-CQ$ +w*!@he/?JXd45ݤ{c@qqi` yߥ+LiBz[Jfܣ#l-3k=YLjo\I67؍AFa-Z[l:dOR,4p ұ(ݪ_6?OBЕ[G % f\a9WP7\Z2o,q4"A">v]!3g zhn۹E}5lG 䳕Ƚ,J!\&rbpO|Tw\s h=ZC=թQ=m-(BӬ`c1[\ :D<#OGI2KŚ[=[/PK!xgldocProps/core.xml (Mo0@e lh7K~zhHjkO`[c¿a6ymG@ޒt0RzK~UxC"繖1dG a07h,W`Ҏ %[Fr {-!ĚZ.%Gڂ{NGal#9*XfHAw4]zֽY0e79ON-`~=)sj5Vz+,`^JgAt^5. w.\TF޺.Ј_ |  A!jUi;9U8= A, 'PYf~l!:<{P50 <Y>!K< oPi̒t'qUiOl}qHg8( 5g~|vKG_4:? Ǹ _<{fSc"k_DgGPK-!6 [Content_Types].xmlPK-!kхQ _rels/.relsPK-!37 ppt/slides/_rels/slide1.xml.relsPK-!.5 ppt/_rels/presentation.xml.relsPK-!"M K ppt/presentation.xmlPK-! ppt/slides/slide1.xmlPK-!ђ7,ppt/slideLayouts/_rels/slideLayout7.xml.relsPK-!i_!,ppt/slideMasters/_rels/slideMaster1.xml.relsPK-!ђ7,U!ppt/slideLayouts/_rels/slideLayout9.xml.relsPK-!ђ7-["ppt/slideLayouts/_rels/slideLayout10.xml.relsPK-!ђ7,b#ppt/slideLayouts/_rels/slideLayout8.xml.relsPK-!ђ7,h$ppt/slideLayouts/_rels/slideLayout1.xml.relsPK-!ђ7,n%ppt/slideLayouts/_rels/slideLayout2.xml.relsPK-!ђ7,t&ppt/slideLayouts/_rels/slideLayout3.xml.relsPK-!ђ7,z'ppt/slideLayouts/_rels/slideLayout4.xml.relsPK-!ђ7,(ppt/slideLayouts/_rels/slideLayout5.xml.relsPK-!ђ7-)ppt/slideLayouts/_rels/slideLayout11.xml.relsPK-!:b7["*ppt/slideLayouts/slideLayout11.xmlPK-!iGFnz"p/ppt/slideLayouts/slideLayout10.xmlPK-!hZ$w85!4ppt/slideLayouts/slideLayout3.xmlPK-!QD!9ppt/slideLayouts/slideLayout2.xmlPK-!57דC!%>ppt/slideLayouts/slideLayout1.xmlPK-!w(6!bCppt/slideMasters/slideMaster1.xmlPK-!_Z!Kppt/slideLayouts/slideLayout4.xmlPK-!ԥ !Pppt/slideLayouts/slideLayout5.xmlPK-!6w !Vppt/slideLayouts/slideLayout6.xmlPK-!k !Zppt/slideLayouts/slideLayout7.xmlPK-!n[ܬ+!^ppt/slideLayouts/slideLayout8.xmlPK-!x^p!dppt/slideLayouts/slideLayout9.xmlPK-!ђ7,fjppt/slideLayouts/_rels/slideLayout6.xml.relsPK-!{C] lkppt/theme/theme1.xmlPK-!brppt/presProps.xmlPK-!yuLtppt/viewProps.xmlPK-!uppt/tableStyles.xmlPK-!pu%MvdocProps/app.xmlPK-!xgl(zdocProps/core.xmlPK$$ }././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/docs/types_of_spectra.rst0000644000503700020070000001162600000000000022645 0ustar00rosteenSTSCI\science00000000000000.. currentmodule:: specutils .. _specutils-representation-overview: Overview of How Specutils Represents Spectra -------------------------------------------- The main principle of ``specutils`` is to work as a toolbox. That is, it aims to provide the pieces needed to build particular spectroscopic workflows without imposing a specific *required* set of algorithms or approaches to spectroscopic analysis. To that end, it aims to represent several different types of ways one might wish to represent sets of spectroscopic data in Python. These objects contains logic to handle multi-dimensional flux data, spectral axes in various forms (wavelenth, frequency, energy, velocity, etc.), convenient and unobtrusive wcs support, and uncertainty handling. The core containers also handle units, a framework for reading and writing from various file formats, and arithmetic operation support. The core data objects are primarily distinguished by the different ways of storing the flux and the spectral axis . These cases are detailed below, along with their corresponding ``specutils`` representations: 1. A 1D flux of length ``n``, and a matched spectral axis (which may be tabulated as an array, or encoded in a WCS). This is what typically is in mind when one speaks of "a single spectrum", and therefore the analysis tools are general couched as applying to this case. In ``specutils`` this is represented by the `~specutils.Spectrum1D` object with a 1-dimensional ``flux``. 2. A set of fluxes that can be represented in an array-like form of shape ``n x m (x ...)``, with a spectral axis strictly of length ``n`` (and a matched WCS). In ``specutils`` this is represented by the `~specutils.Spectrum1D` object where ``len(flux.shape) > 1`` . In this sense the "1D" refers to the spectral axis, *not* the flux. Note that `~specutils.Spectrum1D` subclasses `NDCube `_, which provideds utilities useful for these sorts of multidimensional fluxes. 3. A set of fluxes of shape ``n x m (x ...)``, and a set of spectral axes that are the same shape. This is distinguished from the above cases because there are as many spectral axes as there are spectra. In this sense it is a collection of spectra, so can be thought of as a collection of `~specutils.Spectrum1D` objects. But because it is often more performant to store the collection together as just one set of flux and spectral axis arrays, this case is represented by a separate object in ``specutils``: `~specutils.SpectrumCollection`. 4. An arbitrary collection of fluxes that are not all the same spectral length even in the spectral axis direction. That is, case 3, but "ragged" in the sense that not all the spectra are length ``n``. Because there is no performance benefit to be gained from using arrays (because the flux array is not rectangular), this case does not have a specific representation in ``specutils``. Instead, this case should be dealt with by making lists (or numpy object-arrays) of `~specutils.Spectrum1D` objects, and iterating over them. Specutils does provide a `SpectrumList` class which is a simple subclass of `list` that is integrated with the Astropy IO registry. It enables data loaders to read and return multiple heterogenous spectra (see :ref:`multiple_spectra`). Users should not need to use `SpectrumList` directly since a `list` of `Spectrum1D` objects is sufficient for all other purposes. In all of these cases, the objects have additional attributes (e.g. uncertainties), along with other metadata. But the list above is exhaustive under the assumption that the additional attributes have matched shape to either flux or spectral axis (or some combination of the two). As detailed above, these cases are represented in specutils via two classes: `~specutils.Spectrum1D` (Cases 1 and 2, and indirecly 4) and `~specutils.SpectrumCollection` (Case 3). A diagram of these data structures is proved below. .. image:: specutils_classes_diagrams.png :alt: diagrams of specutils classes While they all differ in details of how they address specific multidimensional datasets, these objects all share the core features that they carry a "spectral axis" and a "flux". The "spectral axis" (``Spectrum*.spectral_axis``) is the physical interpretation for each pixel (the "world coordinate" in WCS language). For astronomical spectra this is usually wavelength, frequency, or photon energy, although for un-calibrated spectra it may simply be in pixel units. The "flux" (``Spectrum*.flux``) is the spectrum itself - while the name "flux" may seem to imply a specific unit, in fact the ``flux`` can carry any unit - actual flux, surface brightness in magnitudes, or counts. However, the best form for representing *calibrated* spectra is spectral flux density (power per area per spectral spectral axis unit), and the analysis tools often work best if a spectrum is in those units. ././@PaxHeader0000000000000000000000000000003300000000000011451 xustar000000000000000027 mtime=1643306919.720971 specutils-1.6.0/licenses/0000755000503700020070000000000000000000000017411 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/licenses/LICENSE.rst0000644000503700020070000000271000000000000021225 0ustar00rosteenSTSCI\science00000000000000Copyright 2020 Specutils Developers Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/licenses/README.rst0000644000503700020070000000056400000000000021105 0ustar00rosteenSTSCI\science00000000000000Licenses ======== This directory holds license and credit information for the package, works the package is derived from, and/or datasets. Ensure that you pick a package license which is in this folder and it matches the one mentioned in the top level README.rst file. If you are using the pre-rendered version of this template check for the word 'Other' in the README. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/licenses/TEMPLATE_LICENCE.rst0000644000503700020070000000317300000000000022504 0ustar00rosteenSTSCI\science00000000000000This project is based upon the Astropy package template (https://github.com/astropy/package-template/) which is licensed under the terms of the following license. --- Copyright (c) 2018, Astropy Developers All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the Astropy Team nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/pyproject.toml0000644000503700020070000000020600000000000020516 0ustar00rosteenSTSCI\science00000000000000[build-system] requires = ["setuptools", "setuptools_scm", "wheel"] build-backend = 'setuptools.build_meta' ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7489011 specutils-1.6.0/setup.cfg0000644000503700020070000000331600000000000017430 0ustar00rosteenSTSCI\science00000000000000[metadata] name = specutils author = Specutils Developers author_email = coordinators@astropy.org license = BSD 3-Clause license_file = licenses/LICENSE.rst url = https://specutils.readthedocs.io/ description = Package for spectroscopic astronomical data long_description = file: README.rst long_description_content_type = text/x-rst edit_on_github = False github_project = astropy/specutils [options] zip_safe = False packages = find: python_requires = >=3.7 setup_requires = setuptools_scm install_requires = astropy>=4.1 gwcs>=0.17.0 scipy asdf>=2.5 ndcube>=2.0 [options.extras_require] test = pytest-astropy pytest-cov matplotlib graphviz coverage asdf spectral-cube docs = sphinx-astropy matplotlib graphviz [options.package_data] specutils = data/* [options.entry_points] asdf_extensions = specutils = specutils.io.asdf.extension:SpecutilsExtension [tool:pytest] testpaths = "specutils" "docs" astropy_header = true doctest_plus = enabled text_file_format = rst addopts = --doctest-rst asdf_schema_root = specutils/io/asdf/schemas asdf_schema_tests_enabled = true [coverage:run] omit = specutils/_astropy_init* specutils/conftest.py specutils/*setup_package* specutils/tests/* specutils/*/tests/* specutils/extern/* specutils/version* */specutils/_astropy_init* */specutils/conftest.py */specutils/*setup_package* */specutils/tests/* */specutils/*/tests/* */specutils/extern/* */specutils/version* [coverage:report] exclude_lines = pragma: no cover except ImportError raise AssertionError raise NotImplementedError def main\(.*\): pragma: py{ignore_python_version} def _ipython_key_completions_ [flake8] max-line-length = 100 max-doc-length = 79 [egg_info] tag_build = tag_date = 0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/setup.py0000755000503700020070000000364600000000000017332 0ustar00rosteenSTSCI\science00000000000000#!/usr/bin/env python # Licensed under a 3-clause BSD style license - see LICENSE.rst # NOTE: The configuration for the package, including the name, version, and # other information are set in the setup.cfg file. import os import sys from setuptools import setup # First provide helpful messages if contributors try and run legacy commands # for tests or docs. TEST_HELP = """ Note: running tests is no longer done using 'python setup.py test'. Instead you will need to run: tox -e test If you don't already have tox installed, you can install it with: pip install tox If you only want to run part of the test suite, you can also use pytest directly with:: pip install -e .[test] pytest For more information, see: http://docs.astropy.org/en/latest/development/testguide.html#running-tests """ if 'test' in sys.argv: print(TEST_HELP) sys.exit(1) DOCS_HELP = """ Note: building the documentation is no longer done using 'python setup.py build_docs'. Instead you will need to run: tox -e build_docs If you don't already have tox installed, you can install it with: pip install tox You can also build the documentation with Sphinx directly using:: pip install -e .[docs] cd docs make html For more information, see: http://docs.astropy.org/en/latest/install.html#builddocs """ if 'build_docs' in sys.argv or 'build_sphinx' in sys.argv: print(DOCS_HELP) sys.exit(1) VERSION_TEMPLATE = """ # Note that we need to fall back to the hard-coded version if either # setuptools_scm can't be imported or setuptools_scm can't determine the # version, so we catch the generic 'Exception'. try: from setuptools_scm import get_version version = get_version(root='..', relative_to=__file__) except Exception: version = '{version}' """.lstrip() setup(use_scm_version={'write_to': os.path.join('specutils', 'version.py'), 'write_to_template': VERSION_TEMPLATE}) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7221413 specutils-1.6.0/specutils/0000755000503700020070000000000000000000000017617 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/CITATION0000644000503700020070000000072300000000000020756 0ustar00rosteenSTSCI\science00000000000000If you use Specutils for work/research presented in a publication (whether directly, or as a dependency to another package), please cite the Zenodo DOI for the appopriate version of Specutils. The versions (and their BibTeX entries) can be found at: https://doi.org/10.5281/zenodo.1421356 We also encourage the acknowledgement of Astropy, which is a dependency of Specutils. See http://www.astropy.org/acknowledging.html for more on the recommendations for this. ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/__init__.py0000644000503700020070000000304400000000000021731 0ustar00rosteenSTSCI\science00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ Specutils: an astropy package for spectroscopy. """ # Packages may add whatever they like to this file, but # should keep this content at the top. # ---------------------------------------------------------------------------- from ._astropy_init import * # noqa from astropy import config as _config # ---------------------------------------------------------------------------- # Enforce Python version check during package import. # This is the same check as the one at the top of setup.py import sys __minimum_python_version__ = "3.5" class UnsupportedPythonError(Exception): pass if sys.version_info < tuple((int(val) for val in __minimum_python_version__.split('.'))): raise UnsupportedPythonError("packagename does not support Python < {}".format(__minimum_python_version__)) if not _ASTROPY_SETUP_: # For egg_info test builds to pass, put package imports here. # Allow loading spectrum object from top level module from .spectra import * # Load the IO functions from .io.default_loaders import * # noqa from .io.registers import _load_user_io _load_user_io() __citation__ = 'https://doi.org/10.5281/zenodo.1421356' class Conf(_config.ConfigNamespace): """ Configuration parameters for specutils. """ do_continuum_function_check = _config.ConfigItem( True, 'Whether to check the spectrum baseline value is close' 'to zero. If it is not within ``threshold`` then a warning is raised.' ) conf = Conf() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/_astropy_init.py0000644000503700020070000000114500000000000023055 0ustar00rosteenSTSCI\science00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst __all__ = ['__version__'] # this indicates whether or not we are in the package's setup.py try: _ASTROPY_SETUP_ except NameError: import builtins builtins._ASTROPY_SETUP_ = False try: from .version import version as __version__ except ImportError: __version__ = '' if not _ASTROPY_SETUP_: # noqa import os # Create the test function for self test from astropy.tests.runner import TestRunner test = TestRunner.make_test_runner_in(os.path.dirname(__file__)) test.__test__ = False __all__ += ['test'] ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7261004 specutils-1.6.0/specutils/analysis/0000755000503700020070000000000000000000000021442 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/__init__.py0000644000503700020070000000035000000000000023551 0ustar00rosteenSTSCI\science00000000000000from .flux import * # noqa from .uncertainty import * # noqa from .location import * # noqa from .width import * # noqa from .template_comparison import * # noqa from .correlation import * # noqa from .moment import * # noqa ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/correlation.py0000644000503700020070000002414600000000000024344 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u import numpy as np from astropy import constants as const from astropy.units import Quantity from scipy.signal.windows import tukey from ..manipulation import LinearInterpolatedResampler from .. import Spectrum1D __all__ = ['template_correlate', 'template_logwl_resample'] _KMS = u.Unit('km/s') # for use below without having to create a composite unit def template_correlate(observed_spectrum, template_spectrum, lag_units=_KMS, apodization_window=0.5, resample=True): """ Compute cross-correlation of the observed and template spectra. After re-sampling into log-wavelength, both observed and template spectra are apodized by a Tukey window in order to minimize edge and consequent non-periodicity effects and thus decrease high-frequency power in the correlation function. To turn off the apodization, use alpha=0. Parameters ---------- observed_spectrum : :class:`~specutils.Spectrum1D` The observed spectrum. template_spectrum : :class:`~specutils.Spectrum1D` The template spectrum, which will be correlated with the observed spectrum. lag_units: `~astropy.units.Unit` Must be a unit with velocity physical type for lags in velocity. To output the lags in redshift, use ``u.dimensionless_unscaled``. apodization_window: float, callable, or None If a callable, will be treated as a window function for apodization of the cross-correlation (should behave like a `~scipy.signal.windows` window function, with ``sym=True``). If a float, will be treated as the ``alpha`` parameter for a Tukey window (`~scipy.signal.windows.tukey`), in units of pixels. If None, no apodization will be performed resample: bool or dict If True or a dictionary, resamples the spectrum and template following the process in `template_logwl_resample`. If a dictionary, it will be used as the keywords for `template_logwl_resample`. For example, ``resample=dict(delta_log_wavelength=.1)`` would be the same as calling ``template_logwl_resample(spectrum, template, delta_log_wavelength=.1)``. If False, *no* resampling is performed (and the user is responsible for a sensible resampling). Returns ------- (`~astropy.units.Quantity`, `~astropy.units.Quantity`) Arrays with correlation values and lags in km/s """ # resample if the user requested to log wavelength if resample: if resample is True: resample_kwargs = dict() # use defaults else: resample_kwargs = resample log_spectrum, log_template = template_logwl_resample(observed_spectrum, template_spectrum, **resample_kwargs) else: log_spectrum = observed_spectrum log_template = template_spectrum # apodize (might be a no-op if apodization_window is None) observed_log_spectrum, template_log_spectrum = _apodize(log_spectrum, log_template, apodization_window) # Normalize template normalization = _normalize(observed_log_spectrum, template_log_spectrum) # Not sure if we need to actually normalize the template. Depending # on the specific data uncertainty, the normalization factor # may turn out negative. That causes a flip of the correlation function, # in which the maximum (correlation peak) is no longer meaningful. if normalization < 0.: normalization = 1. # Correlate corr = np.correlate(observed_log_spectrum.flux.value, (template_log_spectrum.flux.value * normalization), mode='full') # Compute lag # wave_l is the wavelength array equally spaced in log space. wave_l = observed_log_spectrum.spectral_axis.value delta_log_wave = np.log10(wave_l[1]) - np.log10(wave_l[0]) deltas = (np.array(range(len(corr))) - len(corr)/2 + 0.5) * delta_log_wave lags = np.power(10., deltas) - 1. if u.dimensionless_unscaled.is_equivalent(lag_units): lags = Quantity(lags, u.dimensionless_unscaled) elif _KMS.is_equivalent(lag_units): lags = lags * const.c.to(lag_units) else: raise u.UnitsError('lag_units must be either velocity or dimensionless') return corr * u.dimensionless_unscaled, lags def _apodize(spectrum, template, apodization_window): # Apodization. Must be performed after resampling. if apodization_window is None: clean_spectrum = spectrum clean_template = template else: if callable(apodization_window): window = apodization_window else: def window(wlen): return tukey(wlen, alpha=apodization_window) clean_spectrum = spectrum * window(len(spectrum.spectral_axis)) clean_template = template * window(len(template.spectral_axis)) return clean_spectrum, clean_template def template_logwl_resample(spectrum, template, wblue=None, wred=None, delta_log_wavelength=None, resampler=LinearInterpolatedResampler()): """ Resample a spectrum and template onto a common log-spaced spectral grid. If wavelength limits are not provided, the function will use the limits of the merged (observed+template) wavelength scale for building the log-wavelength scale. For the wavelength step, the function uses either the smallest wavelength interval found in the *observed* spectrum, or takes it from the ``delta_log_wavelength`` parameter. Parameters ---------- observed_spectrum : :class:`~specutils.Spectrum1D` The observed spectrum. template_spectrum : :class:`~specutils.Spectrum1D` The template spectrum. wblue, wred: float Wavelength limits to include in the correlation. delta_log_wavelength: float Log-wavelength step to use to build the log-wavelength scale. If None, use limits defined as explained above. resampler A specutils resampler to use to actually do the resampling. Defaults to using a `~specutils.manipulation.LinearInterpolatedResampler`. Returns ------- resampled_observed : :class:`~specutils.Spectrum1D` The observed spectrum resampled to a common spectral_axis. resampled_template: :class:`~specutils.Spectrum1D` The template spectrum resampled to a common spectral_axis. """ # Build an equally-spaced log-wavelength array based on # the input and template spectrum's limit wavelengths and # smallest sampling interval. Consider only the observed spectrum's # sampling, since it's the one that counts for the final accuracy # of the correlation. Alternatively, use the wred and wblue limits, # and delta log wave provided by the user. # # We work with separate float and units entities instead of Quantity # instances, due to the profusion of log10 and power function calls # (they only work on floats) if wblue: w0 = np.log10(wblue) else: ws0 = np.log10(spectrum.spectral_axis[0].value) wt0 = np.log10(template.spectral_axis[0].value) w0 = min(ws0, wt0) if wred: w1 = np.log10(wred) else: ws1 = np.log10(spectrum.spectral_axis[-1].value) wt1 = np.log10(template.spectral_axis[-1].value) w1 = max(ws1, wt1) if delta_log_wavelength is None: ds = np.log10(spectrum.spectral_axis.value[1:]) - np.log10(spectrum.spectral_axis.value[:-1]) dw = ds[np.argmin(ds)] else: dw = delta_log_wavelength nsamples = int((w1 - w0) / dw) log_wave_array = np.ones(nsamples) * w0 for i in range(nsamples): log_wave_array[i] += dw * i # Build the corresponding wavelength array wave_array = np.power(10., log_wave_array) * spectrum.spectral_axis.unit # Resample spectrum and template into wavelength array so built resampled_spectrum = resampler(spectrum, wave_array) resampled_template = resampler(template, wave_array) # Resampler leaves Nans on flux bins that aren't touched by it. # We replace with zeros. This has the net effect of zero-padding # the spectrum and/or template so they exactly match each other, # wavelengthwise. clean_spectrum_flux = np.nan_to_num(resampled_spectrum.flux.value) * resampled_spectrum.flux.unit clean_template_flux = np.nan_to_num(resampled_template.flux.value) * resampled_template.flux.unit clean_spectrum = Spectrum1D(spectral_axis=resampled_spectrum.spectral_axis, flux=clean_spectrum_flux, uncertainty=resampled_spectrum.uncertainty, velocity_convention='optical', rest_value=spectrum.rest_value) clean_template = Spectrum1D(spectral_axis=resampled_template.spectral_axis, flux=clean_template_flux, uncertainty=resampled_template.uncertainty, velocity_convention='optical', rest_value=template.rest_value) return clean_spectrum, clean_template def _normalize(observed_spectrum, template_spectrum): """ Calculate a scale factor to be applied to the template spectrum so the total flux in both spectra will be the same. Parameters ---------- observed_spectrum : :class:`~specutils.Spectrum1D` The observed spectrum. template_spectrum : :class:`~specutils.Spectrum1D` The template spectrum, which needs to be normalized in order to be compared with the observed spectrum. Returns ------- `float` A float which will normalize the template spectrum's flux so that it can be compared to the observed spectrum. """ num = np.nansum((observed_spectrum.flux*template_spectrum.flux)/ (observed_spectrum.uncertainty.array**2)) denom = np.nansum((template_spectrum.flux/ observed_spectrum.uncertainty.array)**2) return num/denom ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/flux.py0000644000503700020070000002701000000000000022772 0ustar00rosteenSTSCI\science00000000000000""" A module for analysis tools focused on determining fluxes of spectral features. """ import warnings from functools import wraps import numpy as np from astropy.nddata import StdDevUncertainty, VarianceUncertainty, InverseVariance from .. import conf from ..spectra import Spectrum1D from ..manipulation import extract_region, LinearInterpolatedResampler from .utils import computation_wrapper import astropy.units as u from astropy.stats import mad_std from astropy.utils.exceptions import AstropyUserWarning __all__ = ['line_flux', 'equivalent_width', 'is_continuum_below_threshold', 'warn_continuum_below_threshold'] def line_flux(spectrum, regions=None, mask_interpolation=LinearInterpolatedResampler): """ Computes the integrated flux in a spectrum or region of a spectrum. Applies to the whole spectrum by default, but can be limited to a specific feature (like a spectral line) if a region is given. Parameters ---------- spectrum : Spectrum1D The spectrum object over which the summed flux will be calculated. regions : `~specutils.SpectralRegion` or list of `~specutils.SpectralRegion` Region within the spectrum to calculate the gaussian sigma width. If regions is `None`, computation is performed over entire spectrum. mask_interpolation : ``None`` or `~specutils.manipulation.LinearInterpolatedResampler` Interpolator class used to fill up the gaps in the spectrum's flux array, when the spectrum mask is not None. If set to ``None``, the masked spectral bins are excised from the data without interpolation and the bin edges of the adjacent bins are extended to fill the gap. Returns ------- flux : `~astropy.units.Quantity` Flux in the provided spectrum (or regions). Unit isthe ``spectrum``'s' ``flux`` unit times ``spectral_axis`` unit. Notes ----- While the flux can be computed on any spectrum or region, it should be continuum-subtracted to compute actual line fluxes. """ return computation_wrapper(_compute_line_flux, spectrum, regions, mask_interpolation=mask_interpolation) def equivalent_width(spectrum, continuum=1, regions=None, mask_interpolation=LinearInterpolatedResampler): """ Computes the equivalent width of a region of the spectrum. Applies to the whole spectrum by default, but can be limited to a specific feature (like a spectral line) if a region is given. Parameters ---------- spectrum : Spectrum1D The spectrum object overwhich the equivalent width will be calculated. regions: `~specutils.SpectralRegion` or list of `~specutils.SpectralRegion` Region within the spectrum to calculate the equivalent width. If regions is `None`, computation is performed over entire spectrum. continuum : ``1`` or `~astropy.units.Quantity`, optional Value to assume is the continuum level. For the special value ``1`` (without units), ``1`` in whatever the units of the ``spectrum.flux`` will be assumed, otherwise units are required and must be the same as the ``spectrum.flux``. mask_interpolation : ``None`` or `~specutils.manipulation.LinearInterpolatedResampler` Interpolator class used to fill up the gaps in the spectrum's flux array after an excise operation to ensure the mask shape can always be applied when the spectrum mask is not None. If set to ``None``, the masked spectral bins are excised from the data without interpolation and the bin edges of the adjacent bins are extended to fill the gap. Returns ------- ew : `~astropy.units.Quantity` Equivalent width calculation, in the same units as the ``spectrum``'s ``spectral_axis``. Notes ----- To do a standard equivalent width measurement, the ``spectrum`` should be continuum-normalized to whatever ``continuum`` is before this function is called. """ kwargs = dict(continuum=continuum) return computation_wrapper(_compute_equivalent_width, spectrum, regions, mask_interpolation=mask_interpolation, **kwargs) def _compute_line_flux(spectrum, regions=None, mask_interpolation=LinearInterpolatedResampler): if regions is not None: calc_spectrum = extract_region(spectrum, regions) else: calc_spectrum = spectrum # Account for the existence of a mask. if hasattr(calc_spectrum, 'mask') and calc_spectrum.mask is not None: mask = calc_spectrum.mask new_spec = Spectrum1D(flux=calc_spectrum.flux[~mask], spectral_axis=calc_spectrum.spectral_axis[~mask]) if mask_interpolation is None: return _compute_line_flux(new_spec) else: interpolator = mask_interpolation(extrapolation_treatment='zero_fill') sp = interpolator(new_spec, calc_spectrum.spectral_axis) flux = sp.flux else: flux = calc_spectrum.flux dx = np.abs(np.diff(calc_spectrum.spectral_axis.bin_edges)) line_flux = np.sum(flux * dx) line_flux.uncertainty = None if calc_spectrum.uncertainty is not None: # Can't handle masks via interpolation here, since interpolators # only work with the flux array. if isinstance(calc_spectrum.uncertainty, StdDevUncertainty): variance_q = calc_spectrum.uncertainty.quantity ** 2 elif isinstance(calc_spectrum.uncertainty, VarianceUncertainty): variance_q = calc_spectrum.uncertainty.quantity elif isinstance(calc_spectrum.uncertainty, InverseVariance): variance_q = 1/calc_spectrum.uncertainty.quantity else: warnings.warn(f"Uncertainty type " f"'{calc_spectrum.uncertainty.uncertainty_type}' " f"was not recognized by line_flux. Proceeding " f"without uncertainty in result.", AstropyUserWarning) variance_q = None if variance_q is not None: line_flux.uncertainty = np.sqrt( np.sum(variance_q * dx**2)) # TODO: we may want to consider converting to erg / cm^2 / sec by default return line_flux def _compute_equivalent_width(spectrum, continuum=1, regions=None, mask_interpolation=LinearInterpolatedResampler): if regions is not None: spectrum = extract_region(spectrum, regions) # Account for the existence of a mask. if hasattr(spectrum, 'mask') and spectrum.mask is not None: mask = spectrum.mask spectrum = Spectrum1D( flux=spectrum.flux[~mask], spectral_axis=spectrum.spectral_axis[~mask]) # Calculate the continuum flux value continuum = u.Quantity(continuum, unit=spectrum.flux.unit) # If continuum is provided as a single scalar/quantity value, create an # array the same size as the processed spectrum. Otherwise, assume that the # continuum was provided as an array and use as-is. if continuum.size == 1: continuum = continuum * np.ones(spectrum.flux.size) cont_spec = Spectrum1D(flux=continuum, spectral_axis=spectrum.spectral_axis) cont_flux = _compute_line_flux(cont_spec, mask_interpolation=mask_interpolation) line_flux = _compute_line_flux(spectrum, mask_interpolation=mask_interpolation) # Calculate equivalent width dx = np.abs(np.diff(spectrum.spectral_axis.bin_edges)) ew = np.sum((1 - line_flux / cont_flux) * dx) return ew.to(spectrum.spectral_axis.unit) def is_continuum_below_threshold(spectrum, threshold=0.01): """ Determine if the baseline of this spectrum is less than a threshold. I.e., an estimate of whether or not the continuum has been subtracted. If ``threshold`` is an `~astropy.units.Quantity` with flux units, this directly compares the median of the spectrum to the threshold. of the flux to the threshold. If the threshold is a float or dimensionless quantity then the spectrum's uncertainty will be used or an estimate of the uncertainty. If the uncertainty is present then the threshold is compared to the median of the flux divided by the uncertainty. If the uncertainty is not present then the threshold is compared to the median of the flux divided by the `~astropy.stats.mad_std`. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object over which the width will be calculated. threshold: float or `~astropy.units.Quantity` The tolerance on the quantification to confirm the continuum is near zero. Returns ------- is_continuum_below_threshold: bool Return True if the continuum of the spectrum is below the threshold, False otherwise. """ flux = spectrum.flux uncertainty = spectrum.uncertainty if hasattr(spectrum, 'uncertainty') else None # Apply the mask if it exists. if hasattr(spectrum, 'mask') and spectrum.mask is not None: flux = flux[~spectrum.mask] uncertainty = uncertainty[~spectrum.mask] if uncertainty else uncertainty # If the threshold has units then the assumption is that we want # to compare the median of the flux regardless of the # existence of the uncertainty. if hasattr(threshold, 'unit') and not threshold.unit == u.dimensionless_unscaled: return np.median(flux) < threshold # If threshold does not have a unit, ie it is not a quantity, then # we are going to calculate based on the S/N if the uncertainty # exists. if uncertainty and uncertainty.uncertainty_type != 'std': return np.median(flux / uncertainty.quantity) < threshold else: return np.median(flux) / mad_std(flux) < threshold def warn_continuum_below_threshold(threshold=0.01): """ Decorator for methods that should warn if the baseline of the spectrum does not appear to be below a threshold. The ``check`` parameter is based on the `astropy configuration system `_. Examples are on that page to show how to turn off this type of warning checking. """ def actual_decorator(function): @wraps(function) def wrapper(*args, **kwargs): if conf.do_continuum_function_check: spectrum = args[0] if not is_continuum_below_threshold(spectrum, threshold): if hasattr(threshold, 'unit'): levelorsnr = 'value' else: levelorsnr = 'signal-to-noise' message = "Spectrum is not below the threshold {} {}. ".format(levelorsnr, threshold) message += "This may indicate you have not continuum subtracted this spectrum (or that you have but it has high SNR features).\n\n" message += ("""If you want to suppress this warning either type """ """'specutils.conf.do_continuum_function_check = False' or """ """see http://docs.astropy.org/en/stable/config/#adding-new-configuration-items """ """for other ways to configure the warning.""") warnings.warn(message, AstropyUserWarning) result = function(*args, **kwargs) return result return wrapper return actual_decorator ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/location.py0000644000503700020070000000533300000000000023630 0ustar00rosteenSTSCI\science00000000000000""" A module for analysis tools focused on determining the location of spectral features. """ import numpy as np from ..spectra import SpectralRegion from ..manipulation import extract_region __all__ = ['centroid'] def centroid(spectrum, region): """ Calculate the centroid of a region, or regions, of the spectrum. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object overwhich the centroid will be calculated. region: `~specutils.utils.SpectralRegion` or list of `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the centroid. Returns ------- centroid : float or list (based on region input) Centroid of the spectrum or within the regions Notes ----- The spectrum will need to be continuum subtracted before calling this method. See the `analysis documentation `_ for more information. """ # No region, therefore whole spectrum. if region is None: return _centroid_single_region(spectrum) # Single region elif isinstance(region, SpectralRegion): return _centroid_single_region(spectrum, region=region) # List of regions elif isinstance(region, list): return [_centroid_single_region(spectrum, region=reg) for reg in region] def _centroid_single_region(spectrum, region=None): """ Calculate the centroid of the spectrum based on the flux and uncertainty in the spectrum. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object overwhich the centroid will be calculated. region: `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the centroid. Returns ------- centroid : float or list (based on region input) Centroid of the spectrum or within the regions Notes ----- This is a helper function for the above `centroid()` method. """ if region is not None: calc_spectrum = extract_region(spectrum, region) else: calc_spectrum = spectrum if hasattr(spectrum, 'mask') and spectrum.mask is not None: flux = calc_spectrum.flux[~calc_spectrum.mask] dispersion = calc_spectrum.spectral_axis[~calc_spectrum.mask].quantity else: flux = calc_spectrum.flux dispersion = calc_spectrum.spectral_axis.quantity if len(flux.shape) > 1: dispersion = np.tile(dispersion, [flux.shape[0], 1]) # the axis=-1 will enable this to run on single-dispersion, single-flux # and single-dispersion, multiple-flux return np.sum(flux * dispersion, axis=-1) / np.sum(flux, axis=-1) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/moment.py0000644000503700020070000000466200000000000023323 0ustar00rosteenSTSCI\science00000000000000""" A module for analysis tools focused on determining the moment of spectral features. """ import numpy as np from ..manipulation import extract_region from .utils import computation_wrapper __all__ = ['moment'] def moment(spectrum, regions=None, order=0, axis=-1): """ Estimate the moment of the spectrum. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object over which the width will be calculated. regions: `~specutils.utils.SpectralRegion` or list of `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the gaussian sigma width. If regions is `None`, computation is performed over entire spectrum. order : int The order of the moment to be calculated. Default=0 axis : int Axis along which a moment is calculated. Default=-1, computes along the last axis (spectral axis). Returns ------- moment: `float` or list (based on region input) Moment of the spectrum. Returns None if (order < 0 or None) """ return computation_wrapper(_compute_moment, spectrum, regions, order=order, axis=axis) def _compute_moment(spectrum, regions=None, order=0, axis=-1): """ This is a helper function for the above `moment()` method. """ if regions is not None: calc_spectrum = extract_region(spectrum, regions) else: calc_spectrum = spectrum # Ignore masks for now. This should be fully addressed when # specutils gets revamped to handle multi-dimensional masks. flux = calc_spectrum.flux spectral_axis = calc_spectrum.spectral_axis if order is None or order < 0: return None if order == 0: return np.sum(flux, axis=axis) dispersion = spectral_axis if len(flux.shape) > len(spectral_axis.shape): _shape = flux.shape[:-1] + (1,) dispersion = np.tile(spectral_axis, _shape) if order == 1: return np.sum(flux * dispersion, axis=axis) / np.sum(flux, axis=axis) if order > 1: m0 = np.sum(flux, axis=axis) m1 = np.sum(flux * dispersion, axis=axis) / np.sum(flux, axis=axis) if len(flux.shape) > 1 and (axis == len(flux.shape)-1 or axis == -1): _shape = flux.shape[-1:] + tuple(np.ones(flux.ndim - 1, dtype='i')) m1 = np.tile(m1, _shape).T return np.sum(flux * (dispersion - m1) ** order, axis=axis) / m0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/template_comparison.py0000644000503700020070000002571300000000000026071 0ustar00rosteenSTSCI\science00000000000000import numpy as np from astropy.nddata import StdDevUncertainty, VarianceUncertainty, InverseVariance from ..manipulation import (FluxConservingResampler, LinearInterpolatedResampler, SplineInterpolatedResampler) from ..spectra.spectrum1d import Spectrum1D __all__ = ['template_match', 'template_redshift'] def _uncertainty_to_standard_deviation(uncertainty): """ Convenience function to convert other uncertainty types to standard deviation, for consistency in calculations elsewhere. Parameters ---------- uncertainty : :class:`~astropy.nddata.NDUncertainty` The input uncertainty Returns ------- :class:`~numpy.ndarray` The array of standard deviation values. """ if uncertainty is not None: if isinstance(uncertainty, StdDevUncertainty): stddev = uncertainty.array elif isinstance(uncertainty, VarianceUncertainty): stddev = np.sqrt(uncertainty.array) elif isinstance(uncertainty, InverseVariance): stddev = 1 / np.sqrt(uncertainty.array) return stddev def _normalize_for_template_matching(observed_spectrum, template_spectrum, stddev=None): """ Calculate a scale factor to be applied to the template spectrum so the total flux in both spectra will be the same. Parameters ---------- observed_spectrum : :class:`~specutils.Spectrum1D` The observed spectrum. template_spectrum : :class:`~specutils.Spectrum1D` The template spectrum, which needs to be normalized in order to be compared with the observed spectrum. Returns ------- `float` A float which will normalize the template spectrum's flux so that it can be compared to the observed spectrum. """ if stddev is None: stddev = _uncertainty_to_standard_deviation(observed_spectrum.uncertainty) num = np.sum((observed_spectrum.flux*template_spectrum.flux) / (stddev**2)) denom = np.sum((template_spectrum.flux / stddev)**2) return num/denom def _resample(resample_method): """ Find the user preferred method of resampling the template spectrum to fit the observed spectrum. Parameters ---------- resample_method: `string` The type of resampling to be done on the template spectrum. Returns ------- :class:`~specutils.ResamplerBase` This is the actual class that will handle the resampling. """ if resample_method == "flux_conserving": return FluxConservingResampler() if resample_method == "linear_interpolated": return LinearInterpolatedResampler() if resample_method == "spline_interpolated": return SplineInterpolatedResampler() return None def _chi_square_for_templates(observed_spectrum, template_spectrum, resample_method): """ Resample the template spectrum to match the wavelength of the observed spectrum. Then, calculate chi2 on the flux of the two spectra. Parameters ---------- observed_spectrum : :class:`~specutils.Spectrum1D` The observed spectrum. template_spectrum : :class:`~specutils.Spectrum1D` The template spectrum, which will be resampled to match the wavelength of the observed spectrum. Returns ------- normalized_template_spectrum : :class:`~specutils.Spectrum1D` The normalized spectrum template. chi2 : `float` The chi2 of the flux of the observed spectrum and the flux of the normalized template spectrum. """ # Resample template if _resample(resample_method) != 0: fluxc_resample = _resample(resample_method) template_obswavelength = fluxc_resample(template_spectrum, observed_spectrum.spectral_axis) # Convert the uncertainty to standard deviation if needed stddev = _uncertainty_to_standard_deviation(observed_spectrum.uncertainty) # Normalize spectra normalization = _normalize_for_template_matching(observed_spectrum, template_obswavelength, stddev) # Numerator num_right = normalization * template_obswavelength.flux num = observed_spectrum.flux - num_right # Denominator denom = stddev * observed_spectrum.flux.unit # Get chi square result = (num/denom)**2 chi2 = np.sum(result.value) # Create normalized template spectrum, which will be returned with # corresponding chi2 normalized_template_spectrum = Spectrum1D( spectral_axis=template_spectrum.spectral_axis, flux=template_spectrum.flux*normalization) return normalized_template_spectrum, chi2 def template_match(observed_spectrum, spectral_templates, resample_method="flux_conserving", redshift=None): """ Find which spectral templates is the best fit to an observed spectrum by computing the chi-squared. If two template_spectra have the same chi2, the first template is returned. Parameters ---------- observed_spectrum : :class:`~specutils.Spectrum1D` The observed spectrum. spectral_templates : :class:`~specutils.Spectrum1D` or :class:`~specutils.SpectrumCollection` or `list` That will give a single :class:`~specutils.Spectrum1D` when iterated over. The template spectra, which will be resampled, normalized, and compared to the observed spectrum, where the smallest chi2 and normalized template spectrum will be returned. resample_method : `string` Three resample options: flux_conserving, linear_interpolated, and spline_interpolated. Anything else does not resample the spectrum. redshift : 'float', `int`, `list`, `tuple`, 'numpy.array` If the user knows the redshift they want to apply to the spectrum/spectra within spectral_templates, then this float or int value redshift can be applied to each template before attempting the match. Or, alternatively, an iterable with redshift values to be applied to each template, before computation of the corresponding chi2 value, can be passed via this same parameter. For each template, the redshift value that results in the smallest chi2 is used. Returns ------- normalized_template_spectrum : :class:`~specutils.Spectrum1D` The template spectrum that has been normalized. redshift : `None` or `float` The value of the redshift that provides the smallest chi2. smallest_chi_index : `int` The index of the spectrum with the smallest chi2 in spectral templates. chi2_min : `float` The chi2 of the flux of the observed_spectrum and the flux of the normalized template spectrum. chi2_list : `list` A list with all chi2 values found for each template spectrum. """ final_redshift = None if hasattr(spectral_templates, 'flux') and len(spectral_templates.flux.shape) == 1: # Account for redshift if provided chi2_list = [] if redshift is not None: results = template_redshift(observed_spectrum, spectral_templates, redshift=redshift) redshifted_spectrum, final_redshift, normalized_spectral_template, chi2, chi2_inner_list = results # noqa else: normalized_spectral_template, chi2 = _chi_square_for_templates( observed_spectrum, spectral_templates, resample_method) chi2_list.append(chi2) return normalized_spectral_template, final_redshift, 0, chi2, chi2_list # At this point, the template spectrum is either a ``SpectrumCollection`` # or a multi-dimensional``Spectrum1D``. Loop through the object and return # the template spectrum with the lowest chi square and its corresponding # chi square. chi2_min = None smallest_chi_spec = None chi2_list = [] final_redshift = None temp_redshift = None for index, spectrum in enumerate(spectral_templates): # Account for redshift if provided if redshift is not None: results = template_redshift(observed_spectrum, spectrum, redshift=redshift) redshifted_spectrum, temp_redshift, normalized_spectral_template, chi2, chi2_inner_list = results # noqa chi2_list.append(chi2_inner_list) else: normalized_spectral_template, chi2 = _chi_square_for_templates( observed_spectrum, spectrum, resample_method) if chi2_min is None or chi2 < chi2_min: chi2_min = chi2 smallest_chi_spec = normalized_spectral_template smallest_chi_index = index final_redshift = temp_redshift return smallest_chi_spec, final_redshift, smallest_chi_index, chi2_min, chi2_list def template_redshift(observed_spectrum, template_spectrum, redshift): """ Find the best-fit redshift for template_spectrum to match observed_spectrum using chi2. Parameters ---------- observed_spectrum : :class:`~specutils.Spectrum1D` The observed spectrum. template_spectrum : :class:`~specutils.Spectrum1D` The template spectrum, which will have it's redshift calculated. redshift : `float`, `int`, `list`, `tuple`, 'numpy.array` A scalar or iterable with the redshift values to test. Returns ------- redshifted_spectrum: :class:`~specutils.Spectrum1D` A new Spectrum1D object which incorporates the template_spectrum with a spectral_axis that has been redshifted using the final_redshift. final_redshift : `float` The best-fit redshift for template_spectrum to match the observed_spectrum. normalized_template_spectrum : :class:`~specutils.Spectrum1D` The normalized spectrum template. chi2_min: `float` The smallest chi2 value that was found. chi2_list : `list` A list with the chi2 values corresponding to each input redshift value. """ chi2_min = None final_redshift = None chi2_list = [] redshift = np.array(redshift).reshape((np.array(redshift).size,)) # Loop which goes through available redshift values and finds the smallest chi2 for rs in redshift: # Create new redshifted spectrum and run it through the chi2 method redshifted_spectrum = Spectrum1D(spectral_axis=template_spectrum.spectral_axis*(1+rs), flux=template_spectrum.flux, uncertainty=template_spectrum.uncertainty, meta=template_spectrum.meta) normalized_spectral_template, chi2 = _chi_square_for_templates( observed_spectrum, redshifted_spectrum, "flux_conserving") chi2_list.append(chi2) # Set new chi2_min if suitable replacement is found if not np.isnan(chi2) and (chi2_min is None or chi2 < chi2_min): chi2_min = chi2 final_redshift = rs return redshifted_spectrum, final_redshift, normalized_spectral_template, chi2_min, chi2_list ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/uncertainty.py0000644000503700020070000001355500000000000024372 0ustar00rosteenSTSCI\science00000000000000""" A module for analysis tools dealing with uncertainties or error analysis in spectra. """ import numpy as np from ..spectra import SpectralRegion from ..manipulation import extract_region __all__ = ['snr', 'snr_derived'] def snr(spectrum, region=None): """ Calculate the mean S/N of the spectrum based on the flux and uncertainty in the spectrum. This will be calculated over the regions, if they are specified. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object overwhich the equivalent width will be calculated. region: `~specutils.utils.SpectralRegion` or list of `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the SNR. Returns ------- snr : `~astropy.units.Quantity` or list (based on region input) Signal to noise ratio of the spectrum or within the regions Notes ----- The spectrum will need to have the uncertainty defined in order for the SNR to be calculated. If the goal is instead signal to noise *per pixel*, this should be computed directly as ``spectrum.flux / spectrum.uncertainty``. """ if not hasattr(spectrum, 'uncertainty') or spectrum.uncertainty is None: raise Exception("Spectrum1D currently requires the uncertainty be defined.") # No region, therefore whole spectrum. if region is None: return _snr_single_region(spectrum) # Single region elif isinstance(region, SpectralRegion): return _snr_single_region(spectrum, region=region) # List of regions elif isinstance(region, list): return [_snr_single_region(spectrum, region=reg) for reg in region] def _snr_single_region(spectrum, region=None): """ Calculate the mean S/N of the spectrum based on the flux and uncertainty in the spectrum. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object overwhich the equivalent width will be calculated. region: `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the SNR. Returns ------- snr : `~astropy.units.Quantity` or list (based on region input) Signal to noise ratio of the spectrum or within the regions Notes ----- This is a helper function for the above `snr()` method. """ if region is not None: calc_spectrum = extract_region(spectrum, region) else: calc_spectrum = spectrum if hasattr(spectrum, 'mask') and spectrum.mask is not None: flux = calc_spectrum.flux[~spectrum.mask] uncertainty = calc_spectrum.uncertainty.quantity[~spectrum.mask] else: flux = calc_spectrum.flux uncertainty = calc_spectrum.uncertainty.quantity # the axis=-1 will enable this to run on single-dispersion, single-flux # and single-dispersion, multiple-flux return np.mean(flux / uncertainty, axis=-1) def snr_derived(spectrum, region=None): """ This function computes the signal to noise ratio DER_SNR following the definition set forth by the Spectral Container Working Group of ST-ECF, MAST and CADC. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object overwhich the equivalent width will be calculated. region: `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the SNR. Returns ------- snr : `~astropy.units.Quantity` or list (based on region input) Signal to noise ratio of the spectrum or within the regions Notes ----- The DER_SNR algorithm is an unbiased estimator describing the spectrum as a whole as long as the noise is uncorrelated in wavelength bins spaced two pixels apart, the noise is Normal distributed, for large wavelength regions, the signal over the scale of 5 or more pixels can be approximated by a straight line. The code and some documentation is derived from ``http://www.stecf.org/software/ASTROsoft/DER_SNR/der_snr.py``, and the algorithm itself is documented at https://esahubble.org/static/archives/stecfnewsletters/pdf/hst_stecf_0042.pdf """ # No region, therefore whole spectrum. if region is None: return _snr_derived(spectrum) # Single region elif isinstance(region, SpectralRegion): return _snr_derived(spectrum, region=region) # List of regions elif isinstance(region, list): return [_snr_derived(spectrum, region=reg) for reg in region] def _snr_derived(spectrum, region=None): """ This function computes the signal to noise ratio DER_SNR following the definition set forth by the Spectral Container Working Group of ST-ECF, MAST and CADC Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object overwhich the equivalent width will be calculated. region: `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the SNR. Returns ------- snr : `~astropy.units.Quantity` or list (based on region input) Signal to noise ratio of the spectrum or within the regions Notes ----- This is a helper function for the above `snr_derived()` method. """ if region is not None: calc_spectrum = extract_region(spectrum, region) else: calc_spectrum = spectrum if hasattr(spectrum, 'mask') and spectrum.mask is not None: flux = calc_spectrum.flux[~calc_spectrum.mask] else: flux = calc_spectrum.flux # Values that are exactly zero (padded) are skipped n = len(flux) # For spectra shorter than this, no value can be returned if n > 4: signal = np.median(flux) noise = 0.6052697 * np.median(np.abs(2.0 * flux[2:n-2] - flux[0:n-4] - flux[4:n])) return signal / noise else: return 0.0 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/analysis/utils.py0000644000503700020070000000127400000000000023160 0ustar00rosteenSTSCI\science00000000000000""" A module for internal utilities for the analysis sub-package. Not meant for public API consumption. """ from ..spectra import SpectralRegion __all__ = ['computation_wrapper'] def computation_wrapper(func, spectrum, region, **kwargs): """ Applies a computation across either a whole spectrum or a bunch of regions. """ # No region, therefore whole spectrum. if region is None: return func(spectrum, **kwargs) # Single region elif isinstance(region, SpectralRegion): return func(spectrum, regions=region, **kwargs) # List of regions elif isinstance(region, list): return [func(spectrum, regions=reg, **kwargs) for reg in region] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/analysis/width.py0000644000503700020070000002224100000000000023134 0ustar00rosteenSTSCI\science00000000000000""" A module for analysis tools focused on determining the width of spectral features. """ import numpy as np from astropy.stats.funcs import gaussian_sigma_to_fwhm from astropy.modeling.models import Gaussian1D from ..manipulation import extract_region from . import centroid from .utils import computation_wrapper from scipy.signal import chirp, find_peaks, peak_widths __all__ = ['gaussian_sigma_width', 'gaussian_fwhm', 'fwhm', 'fwzi'] def gaussian_sigma_width(spectrum, regions=None): """ Estimate the width of the spectrum using a second-moment analysis. The value is scaled to match the sigma/standard deviation parameter of a standard Gaussian profile. This will be calculated over the regions, if they are specified. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object over which the width will be calculated. regions: `~specutils.utils.SpectralRegion` or list of `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the gaussian sigma width. If regions is `None`, computation is performed over entire spectrum. Returns ------- approx_sigma: `~astropy.units.Quantity` or list (based on region input) Approximated sigma value of the spectrum Notes ----- The spectrum should be continuum subtracted before being passed to this function. """ return computation_wrapper(_compute_gaussian_sigma_width, spectrum, regions) def gaussian_fwhm(spectrum, regions=None): """ Estimate the width of the spectrum using a second-moment analysis. The value is scaled to match the full width at half max of a standard Gaussian profile. This will be calculated over the regions, if they are specified. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object overwhich the width will be calculated. regions: `~specutils.utils.SpectralRegion` or list of `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the FWHM value. If regions is `None`, computation is performed over entire spectrum. Returns ------- gaussian_fwhm : `~astropy.units.Quantity` or list (based on region input) Approximate full width of the signal at half max Notes ----- The spectrum should be continuum subtracted before being passed to this function. """ return computation_wrapper(_compute_gaussian_fwhm, spectrum, regions) def fwhm(spectrum, regions=None): """ Compute the true full width half max of the spectrum. This makes no assumptions about the shape of the spectrum (e.g. whether it is Gaussian). It finds the maximum of the spectrum, and then locates the point closest to half max on either side of the maximum, and measures the distance between them. This will be calculated over the regions, if they are specified. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object over which the width will be calculated. regions: `~specutils.utils.SpectralRegion` or list of `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the FWHM value. If regions is `None`, computation is performed over entire spectrum. Returns ------- whm : `~astropy.units.Quantity` or list (based on region input) Full width of the signal at half max Notes ----- The spectrum should be continuum subtracted before being passed to this function. """ return computation_wrapper(_compute_fwhm, spectrum, regions) def fwzi(spectrum, regions=None): """ Compute the true full width at zero intensity (i.e. the continuum level) of the spectrum. This makes no assumptions about the shape of the spectrum. It uses the scipy peak-finding to determine the index of the highest flux value, and then calculates width at that base of the feature. Parameters ---------- spectrum : `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object over which the width will be calculated. regions: `~specutils.utils.SpectralRegion` or list of `~specutils.utils.SpectralRegion` Region within the spectrum to calculate the FWZI value. If regions is `None`, computation is performed over entire spectrum. Returns ------- `~astropy.units.Quantity` or list (based on region input) Full width of the signal at zero intensity. Notes ----- The spectrum must be continuum subtracted before being passed to this function. """ return computation_wrapper(_compute_fwzi, spectrum, regions) def _compute_fwzi(spectrum, regions=None): if regions is not None: calc_spectrum = extract_region(spectrum, regions) else: calc_spectrum = spectrum # Create a copy of the flux array to ensure the value on the spectrum # object is not altered. if hasattr(spectrum, 'mask') and spectrum.mask is not None: disp = calc_spectrum.spectral_axis[~spectrum.mask] flux = calc_spectrum.flux[~spectrum.mask].copy() else: disp = calc_spectrum.spectral_axis flux = calc_spectrum.flux.copy() # For noisy data, ensure that the search from the centroid stops on # either side once the flux value reaches zero. flux[flux < 0] = 0 def find_width(data): # Find the peaks in the flux data peaks, _ = find_peaks(data) # Find the index of the maximum peak value in the found peak list peak_ind = [peaks[np.argmin(np.abs( np.array(peaks) - np.argmin(np.abs(data - np.max(data)))))]] # Calculate the width for the given feature widths, _, _, _ = \ peak_widths(data, peak_ind, rel_height=1-1e-7) return widths[0] * disp.unit if flux.ndim > 1: tot_widths = [] for i in range(flux.shape[0]): tot_widths.append(find_width(flux[i])) return tot_widths return find_width(flux) def _compute_gaussian_fwhm(spectrum, regions=None): """ This is a helper function for the above `gaussian_fwhm()` method. """ fwhm = _compute_gaussian_sigma_width(spectrum, regions) * gaussian_sigma_to_fwhm return fwhm def _compute_gaussian_sigma_width(spectrum, regions=None): """ This is a helper function for the above `gaussian_sigma_width()` method. """ if regions is not None: calc_spectrum = extract_region(spectrum, regions) else: calc_spectrum = spectrum if hasattr(spectrum, 'mask') and spectrum.mask is not None: flux = calc_spectrum.flux[~spectrum.mask] spectral_axis = calc_spectrum.spectral_axis[~spectrum.mask] else: flux = calc_spectrum.flux spectral_axis = calc_spectrum.spectral_axis centroid_result = centroid(spectrum, regions) if flux.ndim > 1: spectral_axis = np.broadcast_to(spectral_axis, flux.shape, subok=True) centroid_result = centroid_result[:, np.newaxis] dx = (spectral_axis - centroid_result) sigma = np.sqrt(np.sum((dx * dx) * flux, axis=-1) / np.sum(flux, axis=-1)) return sigma def _compute_single_fwhm(flux, spectral_axis): """ This is a helper function for the above `fwhm()` method. """ # The .value attribute is used here as the following algorithm does not # use any array operations and would otherwise introduce a relatively # significant overhead factor. Two-point linear interpolation is used to # achieve sub-pixel precision. flux_value = flux.value spectral_value = spectral_axis.value argmax = flux_value.argmax() halfval = flux_value[argmax] / 2 left = flux_value[:argmax] < halfval right = flux_value[argmax + 1:] < halfval # Highest signal at the first point i0 = np.nonzero(left)[0] if i0.size == 0: left_value = spectral_value[0] else: i0 = i0[-1] i1 = i0 + 1 left_flux = flux_value[i0] left_spectral = spectral_value[i0] left_value = ((halfval - left_flux) * (spectral_value[i1] - left_spectral) / (flux_value[i1] - left_flux) + left_spectral) # Highest signal at the last point i1 = np.nonzero(right)[0] if i1.size == 0: right_value = spectral_value[-1] else: i1 = i1[0] + argmax + 1 i0 = i1 - 1 left_flux = flux_value[i0] left_spectral = spectral_value[i0] right_value = ((halfval - left_flux) * (spectral_value[i1] - left_spectral) / (flux_value[i1] - left_flux) + left_spectral) return spectral_axis.unit * np.abs(right_value - left_value) def _compute_fwhm(spectrum, regions=None): """ This is a helper function for the above `fwhm()` method. """ if regions is not None: calc_spectrum = extract_region(spectrum, regions) else: calc_spectrum = spectrum flux = calc_spectrum.flux spectral_axis = calc_spectrum.spectral_axis if flux.ndim > 1: return [_compute_single_fwhm(x, spectral_axis) for x in flux] else: return _compute_single_fwhm(flux, spectral_axis) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/conftest.py0000644000503700020070000000217700000000000022025 0ustar00rosteenSTSCI\science00000000000000# This file is used to configure the behavior of pytest when using the Astropy # test infrastructure. It needs to live inside the package in order for it to # get picked up when running the tests inside an interpreter using # packagename.test try: from pytest_astropy_header.display import PYTEST_HEADER_MODULES, TESTED_VERSIONS ASTROPY_HEADER = True except ImportError: ASTROPY_HEADER = False def pytest_configure(config): if ASTROPY_HEADER: config.option.astropy_header = True # Customize the following lines to add/remove entries from the list of # packages for which version numbers are displayed when running the tests. PYTEST_HEADER_MODULES.pop('Pandas', None) PYTEST_HEADER_MODULES['gwcs'] = 'gwcs' del PYTEST_HEADER_MODULES['h5py'] del PYTEST_HEADER_MODULES['Matplotlib'] # Use ASDF schema tester plugin if ASDF is installed from importlib.util import find_spec if find_spec('asdf') is not None: PYTEST_HEADER_MODULES['Asdf'] = 'asdf' from specutils import __version__ TESTED_VERSIONS['specutils'] = __version__ ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7267532 specutils-1.6.0/specutils/fitting/0000755000503700020070000000000000000000000021263 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/fitting/__init__.py0000644000503700020070000000006200000000000023372 0ustar00rosteenSTSCI\science00000000000000from .fitmodels import * from .continuum import * ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/fitting/continuum.py0000644000503700020070000000764500000000000023672 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u from astropy.modeling.polynomial import Chebyshev1D from astropy.modeling.fitting import LevMarLSQFitter from ..fitting import fit_lines from ..manipulation.smoothing import median_smooth from ..spectra import SpectralRegion __all__ = ['fit_continuum', 'fit_generic_continuum'] def fit_generic_continuum(spectrum, median_window=3, model=Chebyshev1D(3), fitter=LevMarLSQFitter(), exclude_regions=None, weights=None): """ Basic fitting of the continuum of an input spectrum. The input spectrum is smoothed using a median filter to remove the spikes. Parameters ---------- spectrum : Spectrum1D The spectrum object overwhich the equivalent width will be calculated. model : list of `~astropy.modeling.Model` The list of models that contain the initial guess. median_window : float The width of the median smoothing kernel used to filter the data before fitting the continuum. See the ``kernel_size`` parameter of `~scipy.signal.medfilt` for more information. fitter : `~astropy.fitting._FitterMeta` The astropy fitter to use for fitting the model. Default: `~astropy.modeling.fitting.LevMarLSQFitter` exclude_regions : list of 2-tuples List of regions to exclude in the fitting. Passed through to the fitmodels routine. weights : list (NOT IMPLEMENTED YET) List of weights to define importance of fitting regions. Returns ------- continuum_model Fitted continuum as a model of whatever class ``model`` provides. Notes ----- * Could add functionality to set the bounds in ``model`` if they are not set. * The models in the list of ``model`` are added together and passed as a compound model to the `~astropy.modeling.fitting.Fitter` class instance. """ # Simple median smooth to remove spikes and peaks spectrum_smoothed = median_smooth(spectrum, median_window) return fit_continuum(spectrum_smoothed, model=model, fitter=fitter, exclude_regions=exclude_regions, weights=weights) def fit_continuum(spectrum, model=Chebyshev1D(3), fitter=LevMarLSQFitter(), exclude_regions=None, window=None, weights=None): """ Entry point for fitting using the `~astropy.modeling.fitting` machinery. Parameters ---------- spectrum : Spectrum1D The spectrum object overwhich the equivalent width will be calculated. model: list of `~astropy.modeling.Model` The list of models that contain the initial guess. fitter : `~astropy.fitting._FitterMeta` The astropy fitter to use for fitting the model. Default: `~astropy.modeling.fitting.LevMarLSQFitter` exclude_regions : list of 2-tuples List of regions to exclude in the fitting. Passed through to the fitmodels routine. window : tuple of wavelengths Start and end wavelengths used for fitting. weights : list (NOT IMPLEMENTED YET) List of weights to define importance of fitting regions. Returns ------- models : list of `~astropy.modeling.Model` The list of models that contain the fitted model parameters. """ if weights is not None: raise NotImplementedError("Weights support is not yet implemented.") w = window if type(w) in [list, tuple] and all([_is_valid_sequence(x) for x in w]): w = SpectralRegion(w) # Fit the flux to the model continuum_spectrum = fit_lines(spectrum, model, fitter, exclude_regions, weights, w) return continuum_spectrum # Checks for sequences of of 2-tuples with Quantities def _is_valid_sequence(value): if type(value) in [list, tuple]: return len(value) == 2 and \ isinstance(value[0], u.Quantity) and \ isinstance(value[1], u.Quantity) return False ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/fitting/fitmodels.py0000644000503700020070000007330200000000000023630 0ustar00rosteenSTSCI\science00000000000000import itertools import operator import logging import numpy as np from astropy.modeling import fitting, Model, models from astropy.table import QTable from scipy.signal import convolve import astropy.units as u from ..spectra.spectral_region import SpectralRegion from ..spectra.spectrum1d import Spectrum1D from ..utils import QuantityModel from ..analysis import fwhm, gaussian_sigma_width, centroid, warn_continuum_below_threshold from ..manipulation import extract_region, noise_region_uncertainty from ..manipulation.utils import excise_regions __all__ = ['find_lines_threshold', 'find_lines_derivative', 'fit_lines', 'estimate_line_parameters'] log = logging.getLogger(__name__) # Define the initial estimators. This are the default methods to use to # estimate astropy model parameters. This is based on only a small subset of # the astropy models but it was determined that this is a decent start as most # fitting will probably use one of these. # # Each method list must take a Spectrum1D object and should return a Quantity. _parameter_estimators = { 'Gaussian1D': { 'amplitude': lambda s: max(s.flux), 'mean': lambda s: centroid(s, region=None), 'stddev': lambda s: gaussian_sigma_width(s) }, 'Lorentz1D': { 'amplitude': lambda s: max(s.flux), 'x_0': lambda s: centroid(s, region=None), 'fwhm': lambda s: fwhm(s) }, 'Voigt1D': { 'x_0': lambda s: centroid(s, region=None), 'amplitude_L': lambda s: max(s.flux), 'fwhm_L': lambda s: fwhm(s) / np.sqrt(2), 'fwhm_G': lambda s: fwhm(s) / np.sqrt(2) } } def _set_parameter_estimators(model): """ Helper method used in method below. """ if model.__class__.__name__ in _parameter_estimators: model_pars = _parameter_estimators[model.__class__.__name__] for name in model.param_names: par = getattr(model, name) setattr(par, "estimator", model_pars[name]) return model def estimate_line_parameters(spectrum, model): """ The input ``model`` parameters will be estimated from the input ``spectrum``. The ``model`` can be specified with default parameters, for example ``Gaussian1D()``. Parameters ---------- spectrum : `~specutils.Spectrum1D` The spectrum object from which we will estimate the model parameters. model : `~astropy.modeling.Model` Model for which we want to estimate parameters from the spectrum. Returns ------- model : `~astropy.modeling.Model` Model with parameters estimated. """ model = _set_parameter_estimators(model) # Estimate the parameters based on the estimators already # attached to the model for name in model.param_names: par = getattr(model, name) try: estimator = getattr(par, "estimator") setattr(model, name, estimator(spectrum)) except AttributeError: raise Exception('No method to estimate parameter {}'.format(name)) return model def _consecutive(data, stepsize=1): return np.split(data, np.where(np.diff(data) != stepsize)[0]+1) @warn_continuum_below_threshold(threshold=0.01) def find_lines_threshold(spectrum, noise_factor=1): """ Find the emission and absorption lines in a spectrum. The method here is based on deviations larger than the spectrum's uncertainty by the ``noise_factor``. This method only works with continuum-subtracted spectra and the uncertainty must be defined on the spectrum. To add the uncertainty, one could use `~specutils.manipulation.noise_region_uncertainty` to add the uncertainty. Parameters ---------- spectrum : `~specutils.Spectrum1D` The spectrum object in which the lines will be found. noise_factor : float ``noise_factor`` multiplied by the spectrum's``uncertainty``, used for thresholding. Returns ------- qtable: `~astropy.table.QTable` Table of emission and absorption lines. Line center (``line_center``), line type (``line_type``) and index of line center (``line_center_index``) are stored for each line. """ # Threshold based on noise estimate and factor. uncertainty = spectrum.uncertainty uncert_val = 0 if uncertainty is None else uncertainty.array inds = np.where(np.abs(spectrum.flux) > (noise_factor * uncert_val) * spectrum.flux.unit)[0] pos_inds = inds[spectrum.flux.value[inds] > 0] line_inds_grouped = _consecutive(pos_inds, stepsize=1) if len(line_inds_grouped[0]) > 0: emission_inds = [inds[np.argmax(spectrum.flux.value[inds])] for inds in line_inds_grouped] else: emission_inds = [] # # Find the absorption lines # neg_inds = inds[spectrum.flux.value[inds] < 0] line_inds_grouped = _consecutive(neg_inds, stepsize=1) if len(line_inds_grouped[0]) > 0: absorption_inds = [inds[np.argmin(spectrum.flux.value[inds])] for inds in line_inds_grouped] else: absorption_inds = [] return _generate_line_list_table(spectrum, emission_inds, absorption_inds) @warn_continuum_below_threshold(threshold=0.01) def find_lines_derivative(spectrum, flux_threshold=None): """ Find the emission and absorption lines in a spectrum. The method here is based on finding the zero crossings in the derivative of the spectrum. Parameters ---------- spectrum : Spectrum1D The spectrum object over which the equivalent width will be calculated. flux_threshold : float, `~astropy.units.Quantity` or None The threshold a pixel must be above to be considered part of a line. If a float, will assume the same units as ``spectrum.flux``. This threshold is above and beyond the derivative searching step. Default is None so no thresholding. The threshold is positive for emission lines and negative for absorption lines. Returns ------- qtable: `~astropy.table.QTable` Table of emission and absorption lines. Line center (``line_center``), line type (``line_type``) and index of line center (``line_center_index``) are stored for each line. """ # Take the derivative to find the zero crossings which correspond to # the peaks (positive or negative) kernel = [1, 0, -1] dY = convolve(spectrum.flux, kernel, 'valid') # Use sign flipping to determine direction of change S = np.sign(dY) ddS = convolve(S, kernel, 'valid') # Add units if needed. if flux_threshold is not None and isinstance(flux_threshold, (int, float)): flux_threshold = float(flux_threshold) * spectrum.flux.unit # # Emmision lines # # Find all the indices that appear to be part of a +ve peak candidates = np.where(dY > 0)[0] + (len(kernel) - 1) line_inds = sorted(set(candidates).intersection(np.where(ddS == -2)[0] + 1)) if flux_threshold is not None: line_inds = np.array(line_inds)[spectrum.flux[line_inds] > flux_threshold] # Now group them and find the max highest point. line_inds_grouped = _consecutive(line_inds, stepsize=1) if len(line_inds_grouped[0]) > 0: emission_inds = [inds[np.argmax(spectrum.flux[inds])] for inds in line_inds_grouped] else: emission_inds = [] # # Absorption lines # # Find all the indices that appear to be part of a -ve peak candidates = np.where(dY < 0)[0] + (len(kernel) - 1) line_inds = sorted(set(candidates).intersection(np.where(ddS == 2)[0] + 1)) if flux_threshold is not None: line_inds = np.array(line_inds)[spectrum.flux[line_inds] < -flux_threshold] # Now group them and find the max highest point. line_inds_grouped = _consecutive(line_inds, stepsize=1) if len(line_inds_grouped[0]) > 0: absorption_inds = [inds[np.argmin(spectrum.flux[inds])] for inds in line_inds_grouped] else: absorption_inds = [] return _generate_line_list_table(spectrum, emission_inds, absorption_inds) def _generate_line_list_table(spectrum, emission_inds, absorption_inds): qtable = QTable() qtable['line_center'] = list( itertools.chain( *[spectrum.spectral_axis.value[emission_inds], spectrum.spectral_axis.value[absorption_inds]] )) * spectrum.spectral_axis.unit qtable['line_type'] = ['emission'] * len(emission_inds) + \ ['absorption'] * len(absorption_inds) qtable['line_center_index'] = list( itertools.chain( *[emission_inds, absorption_inds])) return qtable def fit_lines(spectrum, model, fitter=fitting.LevMarLSQFitter(), exclude_regions=None, weights=None, window=None, **kwargs): """ Fit the input models to the spectrum. The parameter values of the input models will be used as the initial conditions for the fit. Parameters ---------- spectrum : Spectrum1D The spectrum object over which the equivalent width will be calculated. model: `~astropy.modeling.Model` or list of `~astropy.modeling.Model` The model or list of models that contain the initial guess. fitter : `~astropy.modeling.fitting.Fitter`, optional Fitter instance to be used when fitting model to spectrum. exclude_regions : list of `~specutils.SpectralRegion` List of regions to exclude in the fitting. weights : array-like or 'unc', optional If 'unc', the unceratinties from the spectrum object are used to to calculate the weights. If array-like, represents the weights to use in the fitting. Note that if a mask is present on the spectrum, it will be applied to the ``weights`` as it would be to the spectrum itself. window : `~specutils.SpectralRegion` or list of `~specutils.SpectralRegion` Regions of the spectrum to use in the fitting. If None, then the whole spectrum will be used in the fitting. Additional keyword arguments are passed directly into the call to the ``fitter``. Returns ------- models : Compound model of `~astropy.modeling.Model` A compound model of models with fitted parameters. Notes ----- * Could add functionality to set the bounds in ``model`` if they are not set. * The models in the list of ``model`` are added together and passed as a compound model to the `~astropy.modeling.fitting.Fitter` class instance. """ # # If we are to exclude certain regions, then remove them. # if exclude_regions is not None: spectrum = excise_regions(spectrum, exclude_regions) # # Make the model a list if not already # single_model_in = not isinstance(model, list) if single_model_in: model = [model] # # If a single model is passed in then just do that. # fitted_models = [] for modeli, model_guess in enumerate(model): # # Determine the window if it is not None. There # are several options here: # window = 4 * u.Angstrom -> Quantity # window = (4*u.Angstrom, 6*u.Angstrom) -> tuple # window = (4, 6)*u.Angstrom -> Quantity # # # Determine the window if there is one # if window is not None and isinstance(window, list): model_window = window[modeli] elif window is not None: model_window = window else: model_window = None # # Check to see if the model has units. If it does not # have units then we are going to ignore them. # ignore_units = getattr(model_guess, model_guess.param_names[0]).unit is None fit_model = _fit_lines(spectrum, model_guess, fitter, exclude_regions, weights, model_window, ignore_units, **kwargs) if model_guess.name is not None: fit_model.name = model_guess.name fitted_models.append(fit_model) if single_model_in: fitted_models = fitted_models[0] return fitted_models def _fit_lines(spectrum, model, fitter=fitting.LevMarLSQFitter(), exclude_regions=None, weights=None, window=None, ignore_units=False, **kwargs): """ Fit the input model (initial conditions) to the spectrum. Output will be the same model with the parameters set based on the fitting. spectrum, model -> model """ # # If we are to exclude certain regions, then remove them. # if exclude_regions is not None: spectrum = excise_regions(spectrum, exclude_regions) if isinstance(weights, str): if weights == 'unc': uncerts = spectrum.uncertainty # Astropy fitters expect weights in 1/sigma if uncerts is not None: weights = uncerts.array ** -1 else: log.warning("Uncertainty values are not defined, but are " "trying to be used in model fitting.") else: raise ValueError("Unrecognized value `%s` in keyword argument.", weights) elif weights is not None: # Assume that the weights argument is list-like weights = np.array(weights) mask = spectrum.mask dispersion = spectrum.spectral_axis flux = spectrum.flux flux_unit = spectrum.flux.unit # # Determine the window if it is not None. There # are several options here: # window = 4 * u.Angstrom -> Quantity # window = (4*u.Angstrom, 6*u.Angstrom) -> tuple # window = (4, 6)*u.Angstrom -> Quantity # # # Determine the window if there is one # # In this case the window defines the area around the center of each model window_indices = None if window is not None and isinstance(window, (float, int)): center = model.mean window_indices = np.nonzero((dispersion >= center-window) & (dispersion < center+window)) # In this case the window is the start and end points of where we # should fit elif window is not None and isinstance(window, tuple): window_indices = np.nonzero((dispersion >= window[0]) & (dispersion <= window[1])) # in this case the window is spectral regions that determine where # to fit. elif window is not None and isinstance(window, SpectralRegion): idx1, idx2 = window.bounds if idx1 == idx2: raise IndexError("Tried to fit a region containing no pixels.") # HACK WARNING! This uses the extract machinery to create a set of # indices by making an "index spectrum" # note that any unit will do but Jy is at least flux-y # TODO: really the spectral region machinery should have the power # to create a mask, and we'd just use that... idxarr = np.arange(spectrum.flux.size).reshape(spectrum.flux.shape) index_spectrum = Spectrum1D(spectral_axis=dispersion, flux=u.Quantity(idxarr, u.Jy, dtype=int)) extracted_regions = extract_region(index_spectrum, window) if isinstance(extracted_regions, list): if len(extracted_regions) == 0: raise ValueError('The whole spectrum is windowed out!') window_indices = np.concatenate([s.flux.value.astype(int) for s in extracted_regions]) else: if len(extracted_regions.flux) == 0: raise ValueError('The whole spectrum is windowed out!') window_indices = extracted_regions.flux.value.astype(int) if window_indices is not None: dispersion = dispersion[window_indices] flux = flux[window_indices] if mask is not None: mask = mask[window_indices] if weights is not None: weights = weights[window_indices] if flux is None or len(flux) == 0: raise Exception("Spectrum flux is empty or None.") input_spectrum = spectrum spectrum = Spectrum1D( flux=flux.value * flux_unit, spectral_axis=dispersion, wcs=input_spectrum.wcs, velocity_convention=input_spectrum.velocity_convention, rest_value=input_spectrum.rest_value) if not model._supports_unit_fitting: # Not all astropy models support units. For those that don't # we will strip the units and then re-add them before returning # the model. model, dispersion, flux = _strip_units_from_model(model, spectrum, convert=not ignore_units) # # Do the fitting of spectrum to the model. # if mask is not None: nmask = ~mask dispersion = dispersion[nmask] flux = flux[nmask] if weights is not None: weights = weights[nmask] fit_model = fitter(model, dispersion, flux, weights=weights, **kwargs) if not model._supports_unit_fitting: fit_model = QuantityModel(fit_model, spectrum.spectral_axis.unit, spectrum.flux.unit) return fit_model def _convert(quantity, dispersion_unit, dispersion, flux_unit): """ Convert the quantity to the spectrum's units, and then we will use the *value* of it in the new unitless-model. """ with u.set_enabled_equivalencies(u.spectral()): if quantity.unit.is_equivalent(dispersion_unit): quantity = quantity.to(dispersion_unit) with u.set_enabled_equivalencies(u.spectral_density(dispersion)): if quantity.unit.is_equivalent(flux_unit): quantity = quantity.to(flux_unit) return quantity def _convert_and_dequantify(poss_quantity, dispersion_unit, dispersion, flux_unit, convert=True): """ This method will convert the ``poss_quantity`` value to the proper dispersion or flux units and then strip the units. If the ``poss_quantity`` is None, or a number, we just return that. Notes ----- This method can be removed along with most of the others here when astropy.fitting will fit models that contain units. """ if poss_quantity is None or isinstance(poss_quantity, (float, int)): return poss_quantity if convert and hasattr(poss_quantity, 'quantity') and poss_quantity.quantity is not None: q = poss_quantity.quantity quantity = _convert(q, dispersion_unit, dispersion, flux_unit) v = quantity.value elif convert and isinstance(poss_quantity, u.Quantity): quantity = _convert(poss_quantity, dispersion_unit, dispersion, flux_unit) v = quantity.value else: v = poss_quantity.value return v def _strip_units_from_model(model_in, spectrum, convert=True): """ This method strips the units from the model, so the result can be passed to the fitting routine. This is necessary as CoumpoundModel with units does not work in the fitters. Notes ----- When CompoundModel with units works in the fitters this method can be removed. This assumes there are two types of models, those that are based on `~astropy.modeling.models.PolynomialModel` and therefore require the ``degree`` parameter when instantiating the class, and "everything else" that does not require an "extra" parameter for class instantiation. If convert is False, then we will *not* do the conversion of units to the units of the Spectrum1D object. Otherwise we will convert. """ # # Get the dispersion and flux information from the spectrum # dispersion = spectrum.spectral_axis dispersion_unit = spectrum.spectral_axis.unit flux = spectrum.flux flux_unit = spectrum.flux.unit # # Determine if a compound model # compound_model = model_in.n_submodels > 1 if not compound_model: # For this we are going to just make it a list so that we # can use the looping structure below. model_in = [model_in] else: # If it is a compound model then we are going to create the RPN # representation of it which is a list that contains either astropy # models or string representations of operators (e.g., '+' or '*'). model_in = model_in.traverse_postorder(include_operator=True) # # Run through each model in the list or compound model # model_out_stack = [] for sub_model in model_in: # # If it is an operator put onto the stack and move on... # if not isinstance(sub_model, Model): model_out_stack.append(sub_model) continue # # Make a new instance of the class. # if isinstance(sub_model, models.PolynomialModel): new_sub_model = sub_model.__class__(sub_model.degree, name=sub_model.name) else: new_sub_model = sub_model.__class__(name=sub_model.name) # Now for each parameter in the model determine if a dispersion or # flux type of unit, then convert to spectrum units and then # get the value. for pn in new_sub_model.param_names: # This could be a Quantity or Parameter v = _convert_and_dequantify(getattr(sub_model, pn), dispersion_unit, dispersion, flux_unit, convert=convert) # # Add this information for the parameter name into the # new sub model. # setattr(new_sub_model, pn, v) # # Copy over all the constraints (e.g., tied, fixed...) # for constraint in ('tied', 'fixed'): for k, v in getattr(sub_model, constraint).items(): getattr(new_sub_model, constraint)[k] = v # # Convert teh bounds parameter # new_bounds = [] for a in sub_model.bounds[pn]: v = _convert_and_dequantify(a, dispersion_unit, dispersion, flux_unit, convert=convert) new_bounds.append(v) new_sub_model.bounds[pn] = tuple(new_bounds) # The new model now has unitless information in it but has been # converted to spectral unit scale. model_out_stack.append(new_sub_model) # If a compound model we need to re-create it, otherwise # it is a single model and we just get the first one (as # there is only one). if compound_model: model_out = _combine_postfix(model_out_stack) else: model_out = model_out_stack[0] return model_out, dispersion.value, flux.value def _add_units_to_model(model_in, model_orig, spectrum): """ This method adds the units to the model based on the units of the model passed in. This is necessary as CoumpoundModel with units does not work in the fitters. Notes ----- When CompoundModel with units works in the fitters this method can be removed. This assumes there are two types of models, those that are based on `~astropy.modeling.models.PolynomialModel` and therefore require the ``degree`` parameter when instantiating the class, and "everything else" that does not require an "extra" parameter for class instantiation. """ dispersion = spectrum.spectral_axis # # If not a compound model, then make a single element # list so we can use the for loop below. # compound_model = model_in.n_submodels > 1 if not compound_model: model_in_list = [model_in] model_orig_list = [model_orig] else: compound_model_in = model_in model_in_list = model_in.traverse_postorder(include_operator=True) model_orig_list = model_orig.traverse_postorder(include_operator=True) model_out_stack = [] model_index = 0 # # For each model in the list we will convert the values back to # the original (sub-)model units. # for ii, m_in in enumerate(model_in_list): # # If an operator (ie not Model) then we'll just add # to the stack and evaluate at the end. # if not isinstance(m_in, Model): model_out_stack.append(m_in) continue # # Get the corresponding *original* sub-model that # will match the current sub-model. From this we will # grab the units to apply. # m_orig = model_orig_list[ii] # # Make the new sub-model. # if isinstance(m_in, models.PolynomialModel): new_sub_model = m_in.__class__(m_in.degree, name=m_in.name) else: new_sub_model = m_in.__class__(name=m_in.name) # # Convert the model values from the spectrum units back to the # original model units. # for pi, pn in enumerate(new_sub_model.param_names): # # Get the parameter from the original model and unit-less model. # m_orig_param = getattr(m_orig, pn) m_in_param = getattr(m_in, pn) if hasattr(m_orig_param, 'quantity') and m_orig_param.quantity is not None: m_orig_param_quantity = m_orig_param.quantity # # If a spectral dispersion type of unit... # if m_orig_param_quantity.unit.is_equivalent(spectrum.spectral_axis.unit, equivalencies=u.equivalencies.spectral()): # If it is a compound model, then we need to get the value # from the actual compound model as the tree is not # updated in the fitting if compound_model: current_value = getattr(compound_model_in, '{}_{}'.format(pn, model_index)).value *\ spectrum.spectral_axis.unit else: current_value = m_in_param.value * spectrum.spectral_axis.unit v = current_value.to(m_orig_param_quantity.unit, equivalencies=u.equivalencies.spectral()) # # If a spectral density type of unit... # elif m_orig_param_quantity.unit.is_equivalent(spectrum.flux.unit, equivalencies=u.equivalencies.spectral_density(dispersion)): # If it is a compound model, then we need to get the value # from the actual compound model as the tree is not # updated in the fitting if compound_model: current_value = getattr(compound_model_in, '{}_{}'.format(pn, model_index)).value *\ spectrum.flux.unit else: current_value = m_in_param.value * spectrum.flux.unit v = current_value.to(m_orig_param_quantity.unit, equivalencies=u.equivalencies.spectral_density(dispersion)) else: raise ValueError( "The parameter '{}' with unit '{}' is not convertible " "to either the current flux unit '{}' or spectral " "axis unit '{}'.".format( m_orig_param.name, m_orig_param.unit, spectrum.flux.unit, spectrum.spectral_axis.unit)) else: v = getattr(m_in, pn).value # # Set the parameter value into the new sub-model. # setattr(new_sub_model, pn, v) # # Copy over all the constraints (e.g., tied, fixed, bounds...) # for constraint in ('tied', 'bounds', 'fixed'): for k, v in getattr(m_orig, constraint).items(): getattr(new_sub_model, constraint)[k] = v # # Add the new unit-filled model onto the stack. # model_out_stack.append(new_sub_model) model_index += 1 # # Create the output model which is either the evaulation # of the RPN representation of the model (if a compound model) # or just the first element if a non-compound model. # if compound_model: model_out = _combine_postfix(model_out_stack) else: model_out = model_out_stack[0] # If the first parameter is not a Quantity, then at this point we will # assume none of them are. (It would be inconsistent for fitting to have # a model that has some parameters as Quantities and some values). if getattr(model_orig, model_orig.param_names[0]).unit is None: model_out = QuantityModel(model_out, spectrum.spectral_axis.unit, spectrum.flux.unit) return model_out def _combine_postfix(equation): """ Given a Python list in post order (RPN) of an equation, convert/apply the operations to evaluate. The list order is the same as what is output from ``model._tree.traverse_postorder()``. Structure modified from https://codereview.stackexchange.com/questions/79795/reverse-polish-notation-calculator-in-python """ ops = {'+': operator.add, '-': operator.sub, '*': operator.mul, '/': operator.truediv, '^': operator.pow, '**': operator.pow} stack = [] result = 0 for i in equation: if isinstance(i, Model): stack.insert(0, i) else: if len(stack) < 2: print('Error: insufficient values in expression') break else: n1 = stack.pop(1) n2 = stack.pop(0) result = ops[i](n1, n2) stack.insert(0, result) return result ././@PaxHeader0000000000000000000000000000003200000000000011450 xustar000000000000000026 mtime=1643306919.72762 specutils-1.6.0/specutils/io/0000755000503700020070000000000000000000000020226 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/io/__init__.py0000644000503700020070000000004100000000000022332 0ustar00rosteenSTSCI\science00000000000000from .registers import * # noqa ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/_list_of_loaders.py0000644000503700020070000000203400000000000024106 0ustar00rosteenSTSCI\science00000000000000import io from specutils import Spectrum1D import specutils.io.default_loaders """ The purpose of this file is to receive a list of loaders from specutils.spectrum1d.read.list_formats(), format that list into something that can be used by `automodapi`, and then set it as the __doc__. """ def _list_of_loaders(): # Receive list of loaders list_of_loaders = io.StringIO() Spectrum1D.read.list_formats(list_of_loaders) # Use the second line (which uses "-" to split the # first row from the rest) to create the "=" signs # which are used to create the table in .rst files split_list = list_of_loaders.getvalue().split("\n") line_of_equals = split_list[1].replace("-", "=") split_list[1] = line_of_equals # Combine elements to create formatted table # which can be displayed using `automodapi` formatted_table = line_of_equals + "\n" for line in split_list: formatted_table += line + "\n" formatted_table += line_of_equals + "\n" return formatted_table __doc__ = _list_of_loaders() ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7282171 specutils-1.6.0/specutils/io/asdf/0000755000503700020070000000000000000000000021143 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/__init__.py0000644000503700020070000000131300000000000023252 0ustar00rosteenSTSCI\science00000000000000""" The **asdf** submodule contains code that is used to serialize specutils types so that they can be represented and stored using the Advanced Scientific Data Format (ASDF). If both **asdf** and **specutils** are installed, no further configuration is required in order to process ASDF files that contain **specutils** types. The **asdf** package has been designed to automatically detect the presence of the tags defined by **specutils**. Documentation on the ASDF Standard can be found `here `__. Documentation on the ASDF Python module can be found `here `__. Additional details for specutils developers can be found in :ref:`asdf_dev`. """ ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/extension.py0000644000503700020070000000245100000000000023533 0ustar00rosteenSTSCI\science00000000000000""" Defines extension that is used by ASDF for recognizing specutils types """ import os import urllib from asdf.util import filepath_to_url from asdf.extension import AsdfExtension from astropy.io.misc.asdf.extension import ASTROPY_SCHEMA_URI_BASE from .tags.spectra import * from .types import _specutils_types SCHEMA_PATH = os.path.abspath( os.path.join(os.path.dirname(__file__), 'schemas')) SPECUTILS_URL_MAPPING = [ (urllib.parse.urljoin(ASTROPY_SCHEMA_URI_BASE, 'specutils/'), filepath_to_url( os.path.join(SCHEMA_PATH, 'astropy.org', 'specutils')) + '/{url_suffix}.yaml')] class SpecutilsExtension(AsdfExtension): """ Defines specutils types and schema locations to be used by ASDF """ @property def types(self): """ Collection of tag types that are used by ASDF for serialization """ return _specutils_types @property def tag_mapping(self): """ Defines mapping of specutils tag URIs to URLs """ return [('tag:astropy.org:specutils', ASTROPY_SCHEMA_URI_BASE + 'specutils{tag_suffix}')] @property def url_mapping(self): """ Defines mapping of specutils schema URLs into real locations on disk """ return SPECUTILS_URL_MAPPING ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7017767 specutils-1.6.0/specutils/io/asdf/schemas/0000755000503700020070000000000000000000000022566 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7018623 specutils-1.6.0/specutils/io/asdf/schemas/astropy.org/0000755000503700020070000000000000000000000025055 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7019432 specutils-1.6.0/specutils/io/asdf/schemas/astropy.org/specutils/0000755000503700020070000000000000000000000027070 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7288456 specutils-1.6.0/specutils/io/asdf/schemas/astropy.org/specutils/spectra/0000755000503700020070000000000000000000000030531 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/schemas/astropy.org/specutils/spectra/spectral_coord-1.0.0.yaml0000644000503700020070000000163100000000000035053 0ustar00rosteenSTSCI\science00000000000000%YAML 1.1 --- $schema: "http://stsci.edu/schemas/yaml-schema/draft-01" id: "http://astropy.org/schemas/astropy/coordinates/spectralcoord-1.0.0" tag: "tag:astropy.org:specutils/spectra/spectral_coord-1.0.0" title: > Represents a SpectralCoord object from astropy type: object properties: value: description: | A vector of one or more values anyOf: - type: number - $ref: "http://stsci.edu/schemas/asdf/core/ndarray-1.0.0" unit: description: | The unit corresponding to the values $ref: "http://stsci.edu/schemas/asdf/unit/unit-1.0.0" observer: description: | The observer frame for this coordinate $ref: "http://astropy.org/schemas/astropy/coordinates/frames/baseframe-1.0.0" target: description: | The target frame for this coordinate $ref: "http://astropy.org/schemas/astropy/coordinates/frames/baseframe-1.0.0" required: [value, unit] ... ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/schemas/astropy.org/specutils/spectra/spectrum1d-1.0.0.yaml0000644000503700020070000000240200000000000034134 0ustar00rosteenSTSCI\science00000000000000%YAML 1.1 --- $schema: "http://stsci.edu/schemas/yaml-schema/draft-01" id: "http://astropy.org/schemas/specutils/spectra/spectrum1d-1.0.0" tag: "tag:astropy.org:specutils/spectra/spectrum1d-1.0.0" title: > Represents a one-dimensional spectrum description: | This schema represents a Spectrum1D object from specutils. type: object properties: flux: description: | Quantity that represents the flux component of the spectrum $ref: "http://stsci.edu/schemas/asdf/unit/quantity-1.1.0" spectral_axis: description: | SpectralCoord that represents the spectral axis of the spectrum anyOf: - $ref: "spectral_coord-1.0.0" - $ref: "http://stsci.edu/schemas/asdf/unit/quantity-1.1.0" - $ref: "http://astropy.org/schemas/astropy/coordinates/spectralcoord-1.0.0" uncertainty: description: | Uncertainty information about the spectrum type: object properties: uncertainty_type: description: | String describing the type of uncertainty data type: string enum: ["std", "var", "ivar", "unknown"] data: description: | Array representing the uncertainty data $ref: "http://stsci.edu/schemas/asdf/core/ndarray-1.0.0" required: [flux, spectral_axis] ... ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/schemas/astropy.org/specutils/spectra/spectrum_list-1.0.0.yaml0000644000503700020070000000060200000000000034742 0ustar00rosteenSTSCI\science00000000000000%YAML 1.1 --- $schema: "http://stsci.edu/schemas/yaml-schema/draft-01" id: "http://astropy.org/schemas/specutils/spectra/spectrum_list-1.0.0" tag: "tag:astropy.org:specutils/spectra/spectrum_list-1.0.0" title: > Represents a list of one-dimensional spectra description: This schema represents a SpectrumList object from specutils type: array items: $ref: "spectrum1d-1.0.0" ... ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7292013 specutils-1.6.0/specutils/io/asdf/tags/0000755000503700020070000000000000000000000022101 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/tags/__init__.py0000644000503700020070000000000000000000000024200 0ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/tags/spectra.py0000644000503700020070000000735200000000000024123 0ustar00rosteenSTSCI\science00000000000000""" Contains classes that serialize spectral data types into ASDF representations. """ from astropy import __version__ as astropy_version from numpy.testing import assert_allclose from astropy.units import allclose import astropy.nddata from asdf.tags.core import NDArrayType from asdf.yamlutil import (custom_tree_to_tagged_tree, tagged_tree_to_custom_tree) from astropy.io.misc.asdf.tags.unit.unit import UnitType from ....spectra import Spectrum1D, SpectrumList from ..types import SpecutilsType __all__ = ['Spectrum1DType', 'SpectrumListType'] UNCERTAINTY_TYPE_MAPPING = { 'std': astropy.nddata.StdDevUncertainty, 'var': astropy.nddata.VarianceUncertainty, 'ivar': astropy.nddata.InverseVariance, 'unknown': astropy.nddata.UnknownUncertainty, } class Spectrum1DType(SpecutilsType): """ ASDF tag implementation used to serialize/deserialize Spectrum1D objects """ name = 'spectra/spectrum1d' types = [Spectrum1D] version = '1.0.0' @classmethod def to_tree(cls, obj, ctx): """ Converts Spectrum1D object into tree used for YAML representation """ node = {} node['flux'] = custom_tree_to_tagged_tree(obj.flux, ctx) node['spectral_axis'] = custom_tree_to_tagged_tree(obj.spectral_axis, ctx) if obj.uncertainty is not None: node['uncertainty'] = {} node['uncertainty'][ 'uncertainty_type'] = obj.uncertainty.uncertainty_type data = custom_tree_to_tagged_tree(obj.uncertainty.array, ctx) node['uncertainty']['data'] = data return node @classmethod def from_tree(cls, tree, ctx): """ Converts tree representation back into Spectrum1D object """ flux = tagged_tree_to_custom_tree(tree['flux'], ctx) spectral_axis = tagged_tree_to_custom_tree(tree['spectral_axis'], ctx) uncertainty = tree.get('uncertainty', None) if uncertainty is not None: klass = UNCERTAINTY_TYPE_MAPPING[uncertainty['uncertainty_type']] data = tagged_tree_to_custom_tree(uncertainty['data'], ctx) uncertainty = klass(data) return Spectrum1D(flux=flux, spectral_axis=spectral_axis, uncertainty=uncertainty) @classmethod def assert_equal(cls, old, new): """ Equality method for use in ASDF unit tests """ assert allclose(old.flux, new.flux) assert allclose(old.spectral_axis, new.spectral_axis) if old.uncertainty is None: assert new.uncertainty is None else: assert old.uncertainty.uncertainty_type == new.uncertainty.uncertainty_type assert_allclose(old.uncertainty.array, new.uncertainty.array) class SpectrumListType(SpecutilsType): """ ASDF tag implementation used to serialize/deserialize SpectrumList objects """ name = 'spectra/spectrum_list' types = [SpectrumList] version = '1.0.0' @classmethod def to_tree(cls, obj, ctx): """ Converts SpectrumList object into tree used for YAML representation """ return [custom_tree_to_tagged_tree(spectrum, ctx) for spectrum in obj] @classmethod def from_tree(cls, tree, ctx): """ Converts tree representation back into SpectrumList object """ spectra = [tagged_tree_to_custom_tree(node, ctx) for node in tree] return SpectrumList(spectra) @classmethod def assert_equal(cls, old, new): """ Equality test used in ASDF unit tests """ assert len(old) == len(new) for x, y in zip(old, new): Spectrum1DType.assert_equal(x, y) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7295551 specutils-1.6.0/specutils/io/asdf/tags/tests/0000755000503700020070000000000000000000000023243 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/tags/tests/__init__.py0000644000503700020070000000000000000000000025342 0ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/tags/tests/test_spectra.py0000644000503700020070000000351600000000000026322 0ustar00rosteenSTSCI\science00000000000000import pytest # Make sure these tests do not run if ASDF is not installed pytest.importorskip('asdf') import numpy as np import astropy.units as u from astropy.coordinates import FK5 from astropy.nddata import StdDevUncertainty from asdf.tests.helpers import assert_roundtrip_tree import asdf from specutils import Spectrum1D, SpectrumList, SpectralAxis def create_spectrum1d(xmin, xmax, uncertainty=None): flux = np.ones(xmax - xmin) * u.Jy wavelength = np.arange(xmin, xmax) * 0.1 * u.nm uncertainty = StdDevUncertainty(np.ones(xmax - xmin) * u.Jy) if uncertainty is not None else None return Spectrum1D(spectral_axis=wavelength, flux=flux, uncertainty=uncertainty) def test_asdf_spectrum1d(tmpdir): spectrum = create_spectrum1d(5100, 5300) tree = dict(spectrum=spectrum) assert_roundtrip_tree(tree, tmpdir) def test_asdf_spectrum1d_uncertainty(tmpdir): spectrum = create_spectrum1d(5100, 5300, uncertainty=True) tree = dict(spectrum=spectrum) assert_roundtrip_tree(tree, tmpdir) @pytest.mark.xfail def test_asdf_spectralaxis(tmpdir): wavelengths = np.arange(5100, 5300) * 0.1 * u.nm spectral_axis = SpectralAxis(wavelengths, bin_specification="edges") tree = dict(spectral_axis=spectral_axis) assert_roundtrip_tree(tree, tmpdir) def test_asdf_spectrumlist(tmpdir): spectra = SpectrumList([ create_spectrum1d(5100, 5300), create_spectrum1d(5000, 5500), create_spectrum1d(0, 100), create_spectrum1d(1, 5) ]) tree = dict(spectra=spectra) assert_roundtrip_tree(tree, tmpdir) @pytest.mark.filterwarnings("error::UserWarning") def test_asdf_url_mapper(): """Make sure specutils asdf extension url_mapping doesn't interfere with astropy schemas""" frame = FK5() af = asdf.AsdfFile() af.tree = {'frame': frame} ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/asdf/types.py0000644000503700020070000000136000000000000022661 0ustar00rosteenSTSCI\science00000000000000from asdf.types import CustomType, ExtensionTypeMeta _specutils_types = set() class SpecutilsTypeMeta(ExtensionTypeMeta): """ Keeps track of `SpecutilsType` subclasses that are created so that they can be stored automatically by specutils extensions for ASDF. """ def __new__(mcls, name, bases, attrs): cls = super().__new__(mcls, name, bases, attrs) # Classes using this metaclass are automatically added to the list of # specutils extensions _specutils_types.add(cls) return cls class SpecutilsType(CustomType, metaclass=SpecutilsTypeMeta): """ Parent class of all specutils tag implementations used by ASDF """ organization = 'astropy.org' standard = 'specutils' ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7359605 specutils-1.6.0/specutils/io/default_loaders/0000755000503700020070000000000000000000000023363 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/io/default_loaders/__init__.py0000644000503700020070000000027500000000000025500 0ustar00rosteenSTSCI\science00000000000000import os import glob from os.path import dirname, basename, isfile modules = glob.glob(os.path.join(dirname(__file__), "*.py")) __all__ = [basename(f)[:-3] for f in modules if isfile(f)] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/aaomega_2df.py0000644000503700020070000001702700000000000026071 0ustar00rosteenSTSCI\science00000000000000from copy import deepcopy import numpy as np import astropy.io.fits as fits from astropy.nddata import VarianceUncertainty from astropy.table import Table import astropy.units as u from specutils import Spectrum1D, SpectrumList from specutils.io.registers import data_loader from .dc_common import ( FITS_FILE_EXTS, compute_wcs_from_keys_and_values, add_labels, ) from ..parsing_utils import read_fileobj_or_hdulist AAOMEGA_LOADER = "Data Central AAOmega" AAOMEGA_2DF_FLUX_UNIT = u.Unit("count") AAOMEGA_2DF_WCS_SETTINGS = { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", } AAOMEGA_SCIENCE_INDEX = 0 AAOMEGA_FIBRE_INDEX = 2 def identify_aaomega(origin, *args, **kwargs): """ Identify if the current file is a 2dF-AAOmega file reduced by 2dfdr """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: hdu_names = {hdu.name.strip() for hdu in hdulist} if "FIBRES" not in hdu_names: return False if "REDUCTION_ARGS" not in hdu_names: return False if hdulist[0].header.get("INSTRUME", "").strip() == "AAOMEGA-2dF": return True return False @data_loader( label=AAOMEGA_LOADER, extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_aaomega, priority=10, ) def load_aaomega_file(filename, *args, **kwargs): with read_fileobj_or_hdulist(filename, *args, **kwargs) as fits_file: fits_header = fits_file[AAOMEGA_SCIENCE_INDEX].header # fits_file is the hdulist var_idx = None rwss_idx = None for idx, extn in enumerate(fits_file): if extn.name == "VARIANCE": var_idx = idx if extn.name == "RWSS": rwss_idx = idx # science data fits_data = fits_file[AAOMEGA_SCIENCE_INDEX].data # read in Fibre table data.... ftable = Table(fits_file[AAOMEGA_FIBRE_INDEX].data) # A SpectrumList to hold all the Spectrum1D objects sl = SpectrumList() # the row var contains the pixel data from the science frame for i, row in enumerate(fits_data): # Definitely need deepcopy here, otherwise it does *NOT* work! fib_header = deepcopy(fits_header) # Adjusting some values from primary header so individual fibre # spectra have meaningful headers fib_header["FLDNAME"] = ( fits_header["OBJECT"], "Name of 2dF .fld file" ) fib_header["FLDRA"] = ( fits_header["MEANRA"], "Right Ascension of 2dF field", ) fib_header["FLDDEC"] = ( fits_header["MEANDEC"], "Declination of 2dF field" ) # Now for the fibre specific information from the Fibre Table # (extension 2) # Caution: RA and DEC are stored in RADIANS in the FIBRE TABLE! fib_header["RA"] = ( ftable["RA"][i] * 180.0 / np.pi, "Right Ascension of fibre from configure .fld file", ) fib_header["DEC"] = ( ftable["DEC"][i] * 180.0 / np.pi, "Declination of fibre from configure .fld file", ) fib_header["OBJECT"] = ( ftable["NAME"][i], "Name of target observed by fibre", ) fib_header["OBJCOM"] = ( ftable["COMMENT"][i], "Comment from configure .fld file for target", ) fib_header["OBJMAG"] = ( ftable["MAGNITUDE"][i], "Magnitude of target observed by fibre", ) fib_header["OBJTYPE"] = ( ftable["TYPE"][i], "Type of target observed by fibre", ) fib_header["OBJPIV"] = ( ftable["PIVOT"][i], "Pivot number used to observe target", ) fib_header["OBJPID"] = ( ftable["PID"][i], "Program ID from configure .fld file", ) fib_header["OBJX"] = ( ftable["X"][i], "X coord of target observed by fibre (microns)", ) fib_header["OBJY"] = ( ftable["Y"][i], "Y coord of target observed by fibre (microns)", ) fib_header["OBJXERR"] = ( ftable["XERR"][i], "X coord error of target observed by fibre (microns)", ) fib_header["OBJYERR"] = ( ftable["YERR"][i], "Y coord error of target observed by fibre (microns)", ) fib_header["OBJTHETA"] = ( ftable["THETA"][i], "Angle of fibre used to observe target", ) fib_header["OBJRETR"] = ( ftable["RETRACTOR"][i], "Retractor number used to observe target", ) # WLEN added around 2005 according to AAOmega obs manual... # so not always available if "WLEN" in ftable.colnames: fib_header["OBJWLEN"] = ( ftable["WLEN"][i], "Retractor of target observed by fibre", ) # ftable['TYPE'][i]: # P == program (science) # S == sky # U == unallocated or unused # F == fiducial (guide) fibre # N == broken, dead or no fibre meta = {"header": fib_header} if ftable["TYPE"][i] == "P": meta["purpose"] = "reduced" elif ftable["TYPE"][i] == "S": meta["purpose"] = "sky" else: # Don't include other fibres that are not science or sky continue wcs = compute_wcs_from_keys_and_values( fib_header, **AAOMEGA_2DF_WCS_SETTINGS ) flux = row * AAOMEGA_2DF_FLUX_UNIT meta["fibre_index"] = i # Our science spectrum spectrum = Spectrum1D(wcs=wcs, flux=flux, meta=meta) # If the VARIANCE spectrum exists, add it as an additional spectrum # in the meta dict with key 'variance' if var_idx is not None: var_data = fits_file[var_idx].data var_flux = var_data[i] * AAOMEGA_2DF_FLUX_UNIT ** 2 spectrum.uncertainty = VarianceUncertainty(var_flux) # If the RWSS spectrum exists, add it as an additional spectrum in # the meta dict with key 'science_sky' # This is an optional extension produced by 2dfdr on request: all # spectra without the average/median sky subtraction # Useful in case users want to do their own sky subtraction. if rwss_idx is not None: rwss_data = fits_file[rwss_idx].data rwss_flux = rwss_data[i] * AAOMEGA_2DF_FLUX_UNIT rwss_meta = { "header": fib_header, "purpose": "science_sky" } spectrum.meta["science_sky"] = Spectrum1D( wcs=wcs, flux=rwss_flux, meta=rwss_meta ) # Add our spectrum to the list. # The additional spectra are accessed using # spectrum.meta['variance'] and spectrum.meta['science_sky'] sl.append(spectrum) add_labels(sl) return sl ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/apogee.py0000644000503700020070000001547000000000000025204 0ustar00rosteenSTSCI\science00000000000000""" Loader for APOGEE spectrum files: apVisit_, apStar_, aspcapStar_ files. .. _apVisit: https://data.sdss.org/datamodel/files/APOGEE_REDUX/APRED_VERS/TELESCOPE/PLATE_ID/MJD5/apVisit.html .. _apStar: https://data.sdss.org/datamodel/files/APOGEE_REDUX/APRED_VERS/APSTAR_VERS/TELESCOPE/LOCATION_ID/apStar.html .. _aspcapStar: https://data.sdss.org/datamodel/files/APOGEE_REDUX/APRED_VERS/APSTAR_VERS/ASPCAP_VERS/RESULTS_VERS/LOCATION_ID/aspcapStar.html """ import os from astropy.io import fits from astropy.table import Table from astropy.wcs import WCS from astropy.units import Unit, def_unit from astropy.nddata import StdDevUncertainty import numpy as np from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist __all__ = ['apVisit_identify', 'apStar_identify', 'aspcapStar_identify', 'apVisit_loader', 'apStar_loader', 'aspcapStar_loader'] def apVisit_identify(origin, *args, **kwargs): """ Check whether given file is FITS. This is used for Astropy I/O Registry. """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: # Test if fits has extension of type BinTable and check for # apVisit-specific keys return (hdulist[0].header.get('SURVEY') == 'apogee' and len(hdulist) > 4 and hdulist[1].header.get('BUNIT', 'none').startswith('Flux') and hdulist[2].header.get('BUNIT', 'none').startswith('Flux') and hdulist[4].header.get('BUNIT', 'none').startswith('Wavelen')) def apStar_identify(origin, *args, **kwargs): """ Check whether given file is FITS. This is used for Astropy I/O Registry. """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: # Test if fits has extension of type BinTable and check for # apogee-specific keys + keys for individual apVisits return (hdulist[0].header.get('SURVEY') == 'apogee' and hdulist[0].header.get('SFILE1', 'none').startswith('apVisit')) def aspcapStar_identify(origin, *args, **kwargs): """ Check whether given file is FITS. This is used for Astropy I/O Registry. """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: # Test if fits has extension of type BinTable and check for # aspcapStar-specific keys return (hdulist[0].header.get('TARG1') is not None and len(hdulist) > 4 and hdulist[1].header.get('NAXIS1', 0) > 8000 and hdulist[2].header.get('NAXIS1', 0) > 8000 and hdulist[-1].header.get('TTYPE45') == 'ASPCAPFLAG') @data_loader( label="APOGEE apVisit", identifier=apVisit_identify, extensions=['fits'], priority=10, ) def apVisit_loader(file_obj, **kwargs): """ Loader for APOGEE apVisit files. Parameters ---------- file_obj: str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header meta = {'header': header} # spectrum is stored in three rows (for three chips) data = np.concatenate([hdulist[1].data[0, :], hdulist[1].data[1, :], hdulist[1].data[2, :]]) unit = Unit('1e-17 erg / (Angstrom cm2 s)') stdev = np.concatenate([hdulist[2].data[0, :], hdulist[2].data[1, :], hdulist[2].data[2, :]]) uncertainty = StdDevUncertainty(stdev * unit) # Dispersion is not a simple function in these files. There's a # look-up table instead. dispersion = np.concatenate([hdulist[4].data[0, :], hdulist[4].data[1, :], hdulist[4].data[2, :]]) dispersion_unit = Unit('Angstrom') return Spectrum1D(data=data * unit, uncertainty=uncertainty, spectral_axis=dispersion * dispersion_unit, meta=meta) @data_loader( label="APOGEE apStar", identifier=apStar_identify, extensions=['fits'], priority=10, ) def apStar_loader(file_obj, **kwargs): """ Loader for APOGEE apStar files. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name or object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header meta = {'header': header} wcs = WCS(hdulist[1].header) data = hdulist[1].data[0, :] # spectrum in the first row of the first extension unit = Unit('1e-17 erg / (Angstrom cm2 s)') uncertainty = StdDevUncertainty(hdulist[2].data[0, :]) # dispersion from the WCS but convert out of logspace # dispersion = 10**wcs.all_pix2world(np.arange(data.shape[0]), 0)[0] dispersion = 10**wcs.all_pix2world(np.vstack((np.arange(data.shape[0]), np.zeros((data.shape[0],)))).T, 0)[:, 0] dispersion_unit = Unit('Angstrom') return Spectrum1D(data=data * unit, uncertainty=uncertainty, spectral_axis=dispersion * dispersion_unit, meta=meta) @data_loader( label="APOGEE aspcapStar", identifier=aspcapStar_identify, extensions=['fits'], priority=10, ) def aspcapStar_loader(file_obj, **kwargs): """ Loader for APOGEE aspcapStar files. Parameters ---------- file_obj: str or file-like FITS file name or object (provided from name by Astropy I/O Registry). Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header meta = {'header': header} wcs = WCS(hdulist[1].header) data = hdulist[1].data # spectrum in the first extension unit = def_unit('arbitrary units') uncertainty = StdDevUncertainty(hdulist[2].data) # dispersion from the WCS but convert out of logspace dispersion = 10**wcs.all_pix2world(np.arange(data.shape[0]), 0)[0] dispersion_unit = Unit('Angstrom') return Spectrum1D(data=data * unit, uncertainty=uncertainty, spectral_axis=dispersion * dispersion_unit, meta=meta, wcs=wcs) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/ascii.py0000644000503700020070000000632700000000000025035 0ustar00rosteenSTSCI\science00000000000000import os import astropy.units as u from astropy.nddata import StdDevUncertainty from astropy.table import Table from ... import Spectrum1D from ..registers import data_loader from ..parsing_utils import (generic_spectrum_from_table, spectrum_from_column_mapping) __all__ = ['ascii_identify', 'ascii_loader', 'ipac_identify', 'ipac_loader'] def ascii_identify(origin, *args, **kwargs): """Check if it's an ASCII file.""" name = os.path.basename(args[0]) if name.lower().split('.')[-1] in ['txt', 'ascii']: return True return False @data_loader( label="ASCII", identifier=ascii_identify, extensions=['txt', 'ascii'], priority=-10 ) def ascii_loader(file_name, column_mapping=None, **kwargs): """ Load spectrum from ASCII file. Parameters ---------- file_name: str The path to the ASCII file. column_mapping : dict A dictionary describing the relation between the ASCII file columns and the arguments of the `Spectrum1D` class, along with unit information. The dictionary keys should be the ASCII file column names while the values should be a two-tuple where the first element is the associated `Spectrum1D` keyword argument, and the second element is the unit for the ASCII file column:: column_mapping = {'FLUX': ('flux', 'Jy')} Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ tab = Table.read(file_name, format='ascii') # If no column mapping is given, attempt to parse the ascii files using # unit information if column_mapping is None: return generic_spectrum_from_table(tab, **kwargs) return spectrum_from_column_mapping(tab, column_mapping) def ipac_identify(*args, **kwargs): """Check if it's an IPAC-style ASCII file.""" name = os.path.basename(args[0]) if name.lower().split('.')[-1] in ['txt', 'dat']: return True return False @data_loader(label="IPAC", identifier=ipac_identify, extensions=['txt', 'dat']) def ipac_loader(file_name, column_mapping=None, **kwargs): """ Load spectrum from IPAC-style ASCII file Parameters ---------- file_name: str The path to the IPAC-style ASCII file. column_mapping : dict A dictionary describing the relation between the IPAC-style ASCII file columns and the arguments of the `Spectrum1D` class, along with unit information. The dictionary keys should be the IPAC-style ASCII file column names while the values should be a two-tuple where the first element is the associated `Spectrum1D` keyword argument, and the second element is the unit for the IPAC-style ASCII file column:: column_mapping = {'FLUX': ('flux', 'Jy')} Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ tab = Table.read(file_name, format='ascii.ipac') # If no column mapping is given, attempt to parse the ascii files using # unit information if column_mapping is None: return generic_spectrum_from_table(tab, **kwargs) return spectrum_from_column_mapping(tab, column_mapping) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/dc_common.py0000644000503700020070000004224300000000000025700 0ustar00rosteenSTSCI\science00000000000000from collections.abc import Callable from copy import deepcopy from enum import Enum from astropy.nddata import ( VarianceUncertainty, StdDevUncertainty, InverseVariance, ) import astropy.units as u from astropy.wcs import WCS from astropy.wcs.utils import pixel_to_pixel from ..parsing_utils import read_fileobj_or_hdulist from ..registers import data_loader from ... import Spectrum1D, SpectrumList HEADER_PUPOSE_KEYWORDS = ["EXTNAME", "HDUNAME"] HEADER_INDEX_PUPOSE_KEYWORDS = ["ROW", "ARRAY"] FITS_FILE_EXTS = ["fit", "fits", "fts"] SINGLE_SPLIT_LABEL = "Data Central Single-Split" MULTILINE_SINGLE_LABEL = "Data Central Multiline-Single" UNKNOWN_LABEL = "Unable to find a sensible label for spectrum" # These are order in a best guess of priority, ideally the loader would know # which label to use. HEADER_LABEL_KEYWORDS = [ "OBJECT", "OBJNAME", "OBS_ID", "EXTNAME", "HDUNAME", "TITLE", "ORIGIN", "ROOTNAME", "FILENAME", "AUTHOR", "OBSERVER", "CREATOR", "INSTRUME", "PROGRAM", ] def guess_label_from_header(header): """ Guess the label from `header`, which is assumed to be some mapping with FITS-like keys. """ for header_key in HEADER_LABEL_KEYWORDS: label = header.get(header_key) if label is not None: return str(label) raise ValueError(UNKNOWN_LABEL) class Purpose(Enum): SKIP = "skip" SCIENCE = "science" ERROR_STDEV = "error_stdev" ERROR_VARIANCE = "error_variance" ERROR_INVERSEVARIANCE = "error_inversevariance" SKY = "sky" COMBINED_SCIENCE = "combined_science" COMBINED_ERROR_STDEV = "combined_error_stdev" COMBINED_ERROR_VARIANCE = "combined_error_variance" COMBINED_ERROR_INVERSEVARIANCE = "combined_error_inversevariance" UNREDUCED_SCIENCE = "unreduced_science" UNREDUCED_ERROR_STDEV = "unreduced_error_stdev" UNREDUCED_ERROR_VARIANCE = "unreduced_error_variance" UNREDUCED_ERROR_INVERSEVARIANCE = "unreduced_error_inversevariance" NORMALISED_SCIENCE = "normalised_science" NORMALISED_ERROR_STDEV = "normalised_error_stdev" NORMALISED_ERROR_VARIANCE = "normalised_error_variance" NORMALISED_ERROR_INVERSEVARIANCE = "normalised_error_inversevariance" CREATE_SPECTRA = { Purpose.SCIENCE, Purpose.SKY, Purpose.COMBINED_SCIENCE, Purpose.UNREDUCED_SCIENCE, Purpose.NORMALISED_SCIENCE, } ERROR_PURPOSES = { Purpose.ERROR_STDEV, Purpose.ERROR_VARIANCE, Purpose.ERROR_INVERSEVARIANCE, Purpose.COMBINED_ERROR_STDEV, Purpose.COMBINED_ERROR_VARIANCE, Purpose.COMBINED_ERROR_INVERSEVARIANCE, Purpose.UNREDUCED_ERROR_STDEV, Purpose.UNREDUCED_ERROR_VARIANCE, Purpose.UNREDUCED_ERROR_INVERSEVARIANCE, Purpose.NORMALISED_ERROR_STDEV, Purpose.NORMALISED_ERROR_VARIANCE, Purpose.NORMALISED_ERROR_INVERSEVARIANCE, } PURPOSE_SPECTRA_MAP = { Purpose.SCIENCE: "reduced", Purpose.ERROR_STDEV: "reduced", Purpose.ERROR_VARIANCE: "reduced", Purpose.ERROR_INVERSEVARIANCE: "reduced", Purpose.SKY: "sky", Purpose.COMBINED_SCIENCE: "combined", Purpose.COMBINED_ERROR_STDEV: "combined", Purpose.COMBINED_ERROR_VARIANCE: "combined", Purpose.COMBINED_ERROR_INVERSEVARIANCE: "combined", Purpose.UNREDUCED_SCIENCE: "unreduced", Purpose.UNREDUCED_ERROR_STDEV: "unreduced", Purpose.UNREDUCED_ERROR_VARIANCE: "unreduced", Purpose.UNREDUCED_ERROR_INVERSEVARIANCE: "unreduced", Purpose.NORMALISED_SCIENCE: "normalised", Purpose.NORMALISED_ERROR_STDEV: "normalised", Purpose.NORMALISED_ERROR_VARIANCE: "normalised", Purpose.NORMALISED_ERROR_INVERSEVARIANCE: "normalised", } UNCERTAINTY_MAP = { Purpose.ERROR_STDEV: StdDevUncertainty, Purpose.ERROR_VARIANCE: VarianceUncertainty, Purpose.ERROR_INVERSEVARIANCE: InverseVariance, Purpose.COMBINED_ERROR_STDEV: StdDevUncertainty, Purpose.COMBINED_ERROR_VARIANCE: VarianceUncertainty, Purpose.COMBINED_ERROR_INVERSEVARIANCE: InverseVariance, Purpose.UNREDUCED_ERROR_STDEV: StdDevUncertainty, Purpose.UNREDUCED_ERROR_VARIANCE: VarianceUncertainty, Purpose.UNREDUCED_ERROR_INVERSEVARIANCE: InverseVariance, Purpose.NORMALISED_ERROR_STDEV: StdDevUncertainty, Purpose.NORMALISED_ERROR_VARIANCE: VarianceUncertainty, Purpose.NORMALISED_ERROR_INVERSEVARIANCE: InverseVariance, } GUESS_TO_PURPOSE = { "badpix": Purpose.SKIP, "": Purpose.SKIP, "sky": Purpose.SKY, "stdev": Purpose.ERROR_STDEV, "sigma": Purpose.ERROR_STDEV, "variance": Purpose.ERROR_VARIANCE, "spectrum": Purpose.SCIENCE, } def add_labels(spec_list, use_purpose=False): not_labeled = 0 label_set = set() for spec in spec_list: meta = spec.meta purpose = meta.get("purpose") if use_purpose: tail = " (" + str(purpose) + ")" else: tail = "" try: meta["label"] = guess_label_from_header(meta["header"]) + tail except ValueError: not_labeled += 1 else: label_set.add(meta["label"]) if len(label_set) + not_labeled < len(spec_list): # This implies there are duplicates for i, spec in enumerate(spec_list, start=1): label = spec.meta.get("label") if label is not None: spec.meta["label"] = label + " #" + str(i) def compute_wcs_from_keys_and_values( header=None, *, wavelength_unit_keyword=None, wavelength_unit=None, pixel_reference_point_keyword=None, pixel_reference_point=None, pixel_reference_point_value_keyword=None, pixel_reference_point_value=None, pixel_width_keyword=None, pixel_width=None, ): if wavelength_unit is None: if wavelength_unit_keyword is None: raise ValueError( "Either wavelength_unit or wavelength_unit_keyword must be " "provided" ) wavelength_unit = u.Unit(header[wavelength_unit_keyword]) if pixel_reference_point is None: if pixel_reference_point_keyword is None: raise ValueError( "Either pixel_reference_point or " "pixel_reference_point_keyword must be provided" ) pixel_reference_point = header[pixel_reference_point_keyword] if pixel_reference_point_value is None: if pixel_reference_point_value_keyword is None: raise ValueError( "Either pixel_reference_point_value or " "pixel_reference_point_value_keyword must be provided" ) pixel_reference_point_value = header[ pixel_reference_point_value_keyword ] if pixel_width is None: if pixel_width_keyword is None: raise ValueError( "Either pixel_width or pixel_width_keyword must be provided" ) pixel_width = header[pixel_width_keyword] w = WCS(naxis=1) w.wcs.crpix[0] = pixel_reference_point w.wcs.crval[0] = pixel_reference_point_value w.wcs.cdelt[0] = pixel_width w.wcs.cunit[0] = wavelength_unit return w def get_flux_units_from_keys_and_values( header, *, flux_unit_keyword="BUNIT", flux_unit=None, flux_scale_keyword="BSCALE", flux_scale=None, ): if flux_unit is None and flux_unit_keyword is None: raise ValueError( "Either flux_unit or flux_unit_keyword must be provided" ) flux_unit_from_header = header.get(flux_unit_keyword) if flux_unit is None and flux_unit_from_header is None: raise ValueError( "No units found for flux, check flux_unit and flux_unit_keyword" ) flux_unit = u.Unit(flux_unit_from_header or flux_unit) flux_scale_from_header = header.get(flux_scale_keyword) if flux_scale is None and flux_scale_from_header is None: flux_scale = 1 else: flux_scale = flux_scale_from_header or flux_scale return flux_scale * flux_unit def add_single_spectra_to_map( spectra_map, *, header, data, spec_info=None, wcs_info=None, units_info=None, purpose_prefix=None, all_standard_units, all_keywords, valid_wcs, index=None, drop_wcs_axes=None, ): spec_wcs_info = {} spec_units_info = {} if wcs_info is not None: spec_wcs_info.update(wcs_info) if units_info is not None: spec_units_info.update(units_info) if spec_info is not None: spec_wcs_info.update(spec_info.get("wcs", {})) spec_units_info.update(spec_info.get("units", {})) purpose = spec_info.get("purpose") else: purpose = None purpose = get_purpose( header, purpose=purpose, purpose_prefix=purpose_prefix, all_keywords=all_keywords, index=index, ) if purpose == Purpose.SKIP: return None if valid_wcs or not spec_wcs_info: wcs = WCS(header) if drop_wcs_axes is not None: if isinstance(drop_wcs_axes, Callable): wcs = drop_wcs_axes(wcs) else: wcs = wcs.dropaxis(drop_wcs_axes) else: wcs = compute_wcs_from_keys_and_values(header, **spec_wcs_info) if all_standard_units: spec_units_info = {} flux_unit = get_flux_units_from_keys_and_values(header, **spec_units_info) flux = data * flux_unit meta = {"header": header, "purpose": PURPOSE_SPECTRA_MAP[purpose]} if purpose in CREATE_SPECTRA: spectrum = Spectrum1D(wcs=wcs, flux=flux, meta=meta) spectra_map[PURPOSE_SPECTRA_MAP[purpose]].append(spectrum) elif purpose in ERROR_PURPOSES: try: spectrum = spectra_map[PURPOSE_SPECTRA_MAP[purpose]][-1] except IndexError: raise ValueError(f"No spectra to associate with {purpose}") aligned_flux = pixel_to_pixel(wcs, spectrum.wcs, flux) spectrum.uncertainty = UNCERTAINTY_MAP[purpose](aligned_flux) spectrum.meta["uncertainty_header"] = header # We never actually want to return something, this just flags it to pylint # that we know we're breaking out of the function when skip is selected return None def get_purpose( header, *, purpose=None, purpose_prefix=None, all_keywords, index=None ): def guess_purpose(header): for keyword in HEADER_PUPOSE_KEYWORDS: guess = header.get(keyword) if guess is not None: return GUESS_TO_PURPOSE[guess.strip().lower()] return None def guess_index_purpose(header, index): for keyword in HEADER_INDEX_PUPOSE_KEYWORDS: guess = header.get(keyword + str(index)) if guess is not None: return GUESS_TO_PURPOSE[guess.strip().lower()] return None if all_keywords: if index is None: guessed_purpose = guess_purpose(header) if guessed_purpose is not None: return guessed_purpose if "XTENSION" not in header: # we have a primary HDU, assume science return Purpose.SCIENCE raise ValueError( "Cannot identify purpose, cannot use all_keywords" ) guessed_purpose = guess_index_purpose(header, index) if guessed_purpose is not None: return guessed_purpose raise ValueError("Cannot identify purpose, cannot use all_keywords") if purpose is not None: return Purpose(purpose) if purpose_prefix is not None: if index is None: return Purpose(header.get(purpose_prefix)) return Purpose(header.get(purpose_prefix + str(index))) raise ValueError( "Either all_keywords must be True, or one of purpose or " "purpose_prefix must not be None." ) def no_auto_identify(*args, **kwargs): return False @data_loader( label=SINGLE_SPLIT_LABEL, extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=no_auto_identify, ) def load_single_split_file( filename, *, hdus, wcs, units, all_standard_units, all_keywords, valid_wcs, label=True, drop_wcs_axes=None, ): spectra_map = { "sky": [], "combined": [], "unreduced": [], "normalised": [], "reduced": [], } with read_fileobj_or_hdulist(filename) as fits_file: hdus = deepcopy(hdus) if hdus is not None: # extract hdu information and validate it cycle = hdus.pop("cycle", None) cycle_start = hdus.pop("cycle_start", None) purpose_prefix = hdus.pop("purpose_prefix", None) if len(hdus) != 0 and cycle is None: if len(hdus) < len(fits_file): raise ValueError("Not all HDUs have been specified") if len(hdus) > len(fits_file): raise ValueError("Too many HDUs have been specified") if cycle is not None and cycle_start is None: if len(hdus) == 0: raise ValueError( "If HDUs are not specified, cycle_start must be used" ) cycle_start = len(hdus) if cycle is not None: cycle_purpose_prefix = cycle.pop("purpose_prefix", None) cycle_length = len(cycle) # validate cycle if (len(fits_file) - cycle_start) % cycle_length != 0: raise ValueError( "Full cycle cannot be read from fits file" ) else: cycle = None cycle_start = 0 purpose_prefix = None cycle_purpose_prefix = None cycle_length = 0 for i, fits_hdu in enumerate(fits_file): if cycle is not None and i >= cycle_start: hdu_info = cycle.get(str((i - cycle_start) % cycle_length)) hdu_purpose_prefix = cycle_purpose_prefix elif hdus is not None: hdu_info = hdus.get(str(i)) hdu_purpose_prefix = purpose_prefix else: hdu_info = None hdu_purpose_prefix = None add_single_spectra_to_map( spectra_map, data=fits_hdu.data, header=fits_hdu.header, spec_info=hdu_info, wcs_info=wcs, units_info=units, purpose_prefix=hdu_purpose_prefix, all_standard_units=all_standard_units, all_keywords=all_keywords, valid_wcs=valid_wcs, drop_wcs_axes=drop_wcs_axes, ) if label: add_labels(spectra_map["combined"]) add_labels(spectra_map["reduced"]) add_labels(spectra_map["normalised"]) add_labels(spectra_map["unreduced"], use_purpose=True) add_labels(spectra_map["sky"], use_purpose=True) return SpectrumList( spectra_map["combined"] + spectra_map["normalised"] + spectra_map["reduced"] + spectra_map["unreduced"] + spectra_map["sky"] ) @data_loader( label=MULTILINE_SINGLE_LABEL, extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=no_auto_identify, ) def load_multiline_single_file( filename, *, hdu, wcs, units, all_standard_units, all_keywords, valid_wcs, label=True, drop_wcs_axes=1, ): spectra_map = { "sky": [], "combined": [], "unreduced": [], "normalised": [], "reduced": [], } with read_fileobj_or_hdulist(filename) as fits_file: fits_header = fits_file[0].header fits_data = fits_file[0].data hdu = deepcopy(hdu) if hdu is not None: # extract hdu information and validate it if hdu.pop("require_transpose", False): fits_data = fits_data.T purpose_prefix = hdu.pop("purpose_prefix", None) num_rows = fits_data.shape[0] if len(hdu) != 0: if len(hdu) < num_rows: raise ValueError("Not all rows have been specified") if len(hdu) > num_rows: raise ValueError("Too many rows have been specified") else: purpose_prefix = None for i, row in enumerate(fits_data, start=1): if hdu is not None: row_info = hdu.get(str(i)) else: row_info = None add_single_spectra_to_map( spectra_map, header=fits_header, data=row, index=i, spec_info=row_info, wcs_info=wcs, units_info=units, purpose_prefix=purpose_prefix, all_standard_units=all_standard_units, all_keywords=all_keywords, valid_wcs=valid_wcs, drop_wcs_axes=drop_wcs_axes, ) if label: add_labels(spectra_map["combined"]) add_labels(spectra_map["reduced"]) add_labels(spectra_map["normalised"]) add_labels(spectra_map["unreduced"], use_purpose=True) add_labels(spectra_map["sky"], use_purpose=True) return SpectrumList( spectra_map["combined"] + spectra_map["normalised"] + spectra_map["reduced"] + spectra_map["unreduced"] + spectra_map["sky"] ) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/galah.py0000644000503700020070000000524600000000000025020 0ustar00rosteenSTSCI\science00000000000000from specutils import SpectrumList from specutils.io.registers import data_loader from .dc_common import FITS_FILE_EXTS, SINGLE_SPLIT_LABEL from ..parsing_utils import read_fileobj_or_hdulist GALAH_5EXT_CONFIG = { "hdus": { "0": { "purpose": "science", "units": {"flux_unit": "count"}, }, "1": { "purpose": "error_stdev", "units": {"flux_unit": "count"}, }, "2": { "purpose": "unreduced_science", "units": {"flux_unit": "count"}, }, "3": { "purpose": "unreduced_error_stdev", "units": {"flux_unit": "count"}, }, "4": { "purpose": "normalised_science", "units": {"flux_unit": ""}, }, }, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", }, "units": None, "all_standard_units": False, "all_keywords": False, "valid_wcs": False, } GALAH_4EXT_CONFIG = { "hdus": { "0": {"purpose": "science"}, "1": {"purpose": "error_stdev"}, "2": {"purpose": "unreduced_science"}, "3": {"purpose": "unreduced_error_stdev"}, }, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": False, "valid_wcs": False, } def identify_galah(origin, *args, **kwargs): """ Identify if the current file is a GALAH file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: if "GALAH" in hdulist[0].header.get("ORIGIN"): return True return False @data_loader( label="GALAH", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_galah, priority=10, ) def galah_loader(filename): with read_fileobj_or_hdulist(filename) as hdulist: if len(hdulist) == 5: spectra = SpectrumList.read( hdulist, format=SINGLE_SPLIT_LABEL, **GALAH_5EXT_CONFIG ) spectra[0].meta["galah_hdu_format"] = 5 elif len(hdulist) == 4: spectra = SpectrumList.read( hdulist, format=SINGLE_SPLIT_LABEL, **GALAH_4EXT_CONFIG ) spectra[0].meta["galah_hdu_format"] = 4 else: raise RuntimeError( "Unknown GALAH format, has {} extensions".format(len(hdulist)) ) return spectra ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/gama.py0000644000503700020070000001575300000000000024655 0ustar00rosteenSTSCI\science00000000000000from specutils import SpectrumList from specutils.io.registers import data_loader from .dc_common import ( FITS_FILE_EXTS, SINGLE_SPLIT_LABEL, MULTILINE_SINGLE_LABEL, ) from ..parsing_utils import read_fileobj_or_hdulist GAMA_2QZ_CONFIG = { "hdus": None, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CD1_1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": True, "valid_wcs": False, } GAMA_2SLAQ_QSO_CONFIG = { "hdus": None, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": True, "valid_wcs": False, } GAMA_CONFIG = { "hdu": { "1": {"purpose": "science", "units": { "flux_unit": "10^-17 erg/s/cm^2/A" }, }, "2": {"purpose": "error_stdev"}, "3": {"purpose": "unreduced_science"}, "4": {"purpose": "unreduced_error_stdev"}, "5": {"purpose": "sky"}, }, "wcs": None, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": False, "valid_wcs": True, } GAMA_LT_CONFIG = { "hdus": {"0": {"purpose": "science"}, }, "wcs": { "pixel_reference_point_keyword": "CRPIX", "pixel_reference_point_value_keyword": "CRVAL", "pixel_width_keyword": "CDELT", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": False, "valid_wcs": False, } MGC_CONFIG = { "hdu": None, "units": {"flux_unit": "count"}, "wcs": None, "all_standard_units": False, "all_keywords": True, "valid_wcs": True, } WIGGLEZ_CONFIG = { "hdus": { "0": {"purpose": "science"}, "1": {"purpose": "error_variance"}, "2": {"purpose": "skip"}, }, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": False, "valid_wcs": False, } GAMA_2DFGRS_CONFIG = { "hdu": None, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": True, "valid_wcs": False, } def identify_2qz(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: header = hdulist[0].header if "2QZ" in header.get("SURVEY", "") and "GAMANAME" in header: return True return False @data_loader( label="GAMA-2QZ", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_2qz, priority=10, ) def twoqz_loader(filename): spectra = SpectrumList.read( filename, format=SINGLE_SPLIT_LABEL, **GAMA_2QZ_CONFIG ) return spectra def identify_2slaq_qso(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: header = hdulist[0].header if "2SLAQ-QSO" in header.get("SURVEY", "") and "GAMANAME" in header: return True return False @data_loader( label="GAMA-2SLAQ-QSO", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_2slaq_qso, priority=10, ) def twoslaq_qso_loader(filename): spectra = SpectrumList.read( filename, format=SINGLE_SPLIT_LABEL, **GAMA_2SLAQ_QSO_CONFIG ) return spectra def identify_gama(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: header = hdulist[0].header if "GAMA" in header.get("ORIGIN", "").strip() and "GAMANAME" in header: return True return False @data_loader( label="GAMA", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_gama, priority=10, ) def gama_loader(filename): spectra = SpectrumList.read( filename, format=MULTILINE_SINGLE_LABEL, **GAMA_CONFIG ) return spectra def identify_gama_lt(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: header = hdulist[0].header if "Liverpool JMU" in header.get("ORIGIN", "") and "GAMANAME" in header: return True return False @data_loader( label="GAMA-LT", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_gama_lt, priority=10, ) def gama_lt_loader(filename): spectra = SpectrumList.read( filename, format=SINGLE_SPLIT_LABEL, **GAMA_LT_CONFIG ) return spectra def identify_mgc(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: header = hdulist[0].header if "MGC" in header.get("SURVEY", "") and "GAMANAME" in header: return True return False @data_loader( label="GAMA-MGC", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_mgc, priority=10, ) def mgc_loader(filename): spectra = SpectrumList.read( filename, format=MULTILINE_SINGLE_LABEL, **MGC_CONFIG ) return spectra def identify_wigglez(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: header = hdulist[0].header if "WiggleZ" in header.get("SURVEY", "") and "GAMANAME" in header: return True return False @data_loader( label="GAMA-WiggleZ", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_wigglez, priority=10, ) def wigglez_loader(filename): spectra = SpectrumList.read( filename, format=SINGLE_SPLIT_LABEL, **WIGGLEZ_CONFIG ) return spectra def identify_2dfgrs(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: header = hdulist[0].header if "2dFGRS" in header.get("SURVEY", "") and "GAMANAME" in header: return True return False @data_loader( label="GAMA-2dFGRS", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_2dfgrs, priority=10, ) def gama_2dfgrs_loader(filename): spectra = SpectrumList.read( filename, format=MULTILINE_SINGLE_LABEL, **GAMA_2DFGRS_CONFIG ) return spectra ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/generic_cube.py0000644000503700020070000000546400000000000026360 0ustar00rosteenSTSCI\science00000000000000# ~/.specutils/my_custom_loader.py # # Load a FITS cube , extract the spectrum at the reference pixel, but this # can be optionally overriden. # # 21-apr-2016 Peter Teuben hackday at "SPECTROSCOPY TOOLS IN PYTHON WORKSHOP" STSCI import os import logging import numpy as np from astropy.io import fits from astropy.units import Unit from astropy.wcs import WCS from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist log = logging.getLogger(__name__) # Define an optional identifier. If made specific enough, this circumvents the # need to add `format="my-format"` in the `Spectrum1D.read` call. def identify_generic_fits(origin, *args, **kwargs): with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header['NAXIS'] == 3) # not yet ready because it's not generic enough and does not use column_mapping # @data_loader("Cube", identifier=identify_generic_fits, extensions=['fits']) def generic_fits(file_obj, **kwargs): name = os.path.basename(file_name.rstrip(os.sep)).rsplit('.', 1)[0] with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header data3 = hdulist[0].data wcs = WCS(header) shape = data3.shape # take the reference pixel if the pos= was not supplied by the reader if 'pos' in kwargs: ix = kwargs['pos'][0] iy = kwargs['pos'][1] else: ix = int(wcs.wcs.crpix[0]) iy = int(wcs.wcs.crpix[1]) # grab a spectrum from the cube if len(shape) == 3: data = data3[:,iy,ix] elif len(shape) == 4: data = data3[:,:,iy,ix].squeeze() # make sure this is a 1D array # if len(data.shape) != 1: # raise Exception,"not a true cube" else: log.error("Unexpected shape %s.", shape) # store some meta data meta = {'header': header} meta['xpos'] = ix meta['ypos'] = iy # attach units (get it from header['BUNIT'] - what about 'JY/BEAM ' # NOTE: astropy doesn't support beam, but see comments in radio_beam data = data * Unit("Jy") # now figure out the frequency axis.... sp_axis = 3 naxis3 = header['NAXIS%d' % sp_axis] cunit3 = wcs.wcs.cunit[sp_axis-1] crval3 = wcs.wcs.crval[sp_axis-1] cdelt3 = wcs.wcs.cdelt[sp_axis-1] crpix3 = wcs.wcs.crpix[sp_axis-1] freqs = np.arange(naxis3) + 1 freqs = (freqs - crpix3) * cdelt3 + crval3 freqs = freqs * cunit3 # should wcs be transformed to a 1D case ? return Spectrum1D(flux=data, wcs=wcs, meta=meta, spectral_axis=freqs) # return Spectrum1D(flux=data, wcs=wcs, meta=meta) # this does not work yet ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/generic_ecsv_reader.py0000644000503700020070000000345500000000000027722 0ustar00rosteenSTSCI\science00000000000000import os from astropy.table import Table from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import (generic_spectrum_from_table, spectrum_from_column_mapping) def identify_ecsv(origin, *args, **kwargs): """Check if it's an ECSV file.""" return (isinstance(args[0], str) and os.path.splitext(args[0].lower())[1] == '.ecsv') @data_loader("ECSV", identifier=identify_ecsv, dtype=Spectrum1D, priority=-10) def generic_ecsv(file_name, column_mapping=None, **kwargs): """ Read a spectrum from an ECSV file, using generic_spectrum_from_table_loader() to try to figure out which column is which. The ECSV columns must have units, as `generic_spectrum_from_table_loader` depends on this to determine the meaning of the columns. For manual control over the column to spectrum mapping, use the ASCII loader. Parameters ---------- file_name: str The path to the ECSV file. column_mapping : dict A dictionary describing the relation between the ECSV file columns and the arguments of the `Spectrum1D` class, along with unit information. The dictionary keys should be the ECSV file column names while the values should be a two-tuple where the first element is the associated `Spectrum1D` keyword argument, and the second element is the unit for the ECSV file column:: column_mapping = {'FLUX': ('flux', 'Jy')} Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ table = Table.read(file_name, format='ascii.ecsv') if column_mapping is None: return generic_spectrum_from_table(table, **kwargs) return spectrum_from_column_mapping(table, column_mapping) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/hst_cos.py0000644000503700020070000000357600000000000025412 0ustar00rosteenSTSCI\science00000000000000import os from astropy.io import fits from astropy.units import Unit from astropy.nddata import StdDevUncertainty from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist __all__ = ['cos_identify', 'cos_spectrum_loader'] def cos_identify(origin, *args, **kwargs): """Check whether given file contains HST/COS spectral data.""" with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header['TELESCOP'] == 'HST' and hdulist[0].header['INSTRUME'] == 'COS') @data_loader( label="HST/COS", identifier=cos_identify, extensions=['FITS', 'FIT', 'fits', 'fit'], priority=10, ) def cos_spectrum_loader(file_obj, **kwargs): """ Load COS spectral data from the MAST archive into a spectrum object. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header name = header.get('FILENAME') meta = {'header': header} unit = Unit("erg/cm**2 Angstrom s") disp_unit = Unit('Angstrom') data = hdulist[1].data['FLUX'].flatten() * unit dispersion = hdulist[1].data['wavelength'].flatten() * disp_unit uncertainty = StdDevUncertainty(hdulist[1].data["ERROR"].flatten() * unit) sort_idx = dispersion.argsort() dispersion = dispersion[sort_idx] data = data[sort_idx] uncertainty = uncertainty[sort_idx] return Spectrum1D(flux=data, spectral_axis=dispersion, uncertainty=uncertainty, meta=meta) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/hst_stis.py0000644000503700020070000000363300000000000025602 0ustar00rosteenSTSCI\science00000000000000import os from astropy.io import fits from astropy.units import Unit from astropy.nddata import StdDevUncertainty from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist __all__ = ['stis_identify', 'stis_spectrum_loader'] def stis_identify(origin, *args, **kwargs): """Check whether given file contains HST/STIS spectral data.""" with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header['TELESCOP'] == 'HST' and hdulist[0].header['INSTRUME'] == 'STIS') return False @data_loader( label="HST/STIS", identifier=stis_identify, extensions=['FITS', 'FIT', 'fits', 'fit'], priority=10, ) def stis_spectrum_loader(file_obj, **kwargs): """ Load STIS spectral data from the MAST archive into a spectrum object. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header name = header.get('FILENAME') meta = {'header': header} unit = Unit("erg/cm**2 Angstrom s") disp_unit = Unit('Angstrom') data = hdulist[1].data['FLUX'].flatten() * unit dispersion = hdulist[1].data['wavelength'].flatten() * disp_unit uncertainty = StdDevUncertainty(hdulist[1].data["ERROR"].flatten() * unit) sort_idx = dispersion.argsort() dispersion = dispersion[sort_idx] data = data[sort_idx] uncertainty = uncertainty[sort_idx] return Spectrum1D(flux=data, spectral_axis=dispersion, uncertainty=uncertainty, meta=meta) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/jwst_reader.py0000644000503700020070000005225200000000000026254 0ustar00rosteenSTSCI\science00000000000000import os import glob import logging from astropy.nddata.nduncertainty import VarianceUncertainty import astropy.units as u from astropy.units import Quantity from astropy.table import Table from astropy.io import fits from astropy.nddata import StdDevUncertainty, InverseVariance import logging import numpy as np import asdf from gwcs.wcstools import grid_from_bounding_box from ...spectra import Spectrum1D, SpectrumList from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist __all__ = ["jwst_x1d_single_loader", "jwst_x1d_multi_loader", "jwst_x1d_miri_mrs_loader"] log = logging.getLogger(__name__) def identify_jwst_miri_mrs(origin, *args, **kwargs): """ Check whether the given set of files is a JWST MIRI MRS spectral data product. """ input = args[2] # if string, it can be either a directory or a glob pattern (this last # one not implemented yet due to astropy choking when passed an invalid # file path string). if isinstance(input, str): if os.path.isdir(input): return True if len(glob.glob(input)) > 0: return True # or it can be either a list of file names, or a list of file objects elif isinstance(input, (list, tuple)) and len(input) > 0: return True return False def _identify_spec1d_fits(origin, extname, *args, **kwargs): """ Generic spec 1d identifier function """ is_jwst = _identify_jwst_fits(*args) with read_fileobj_or_hdulist(*args, memmap=False, **kwargs) as hdulist: return (is_jwst and extname in hdulist and (extname, 2) not in hdulist) def _identify_spec1d_multi_fits(origin, extname, *args, **kwargs): """ Check whether the given file is a JWST c1d/x1d spectral data product with many slits. """ is_jwst = _identify_jwst_fits(*args) with read_fileobj_or_hdulist(*args, memmap=False, **kwargs) as hdulist: return is_jwst and (extname, 2) in hdulist def identify_jwst_c1d_fits(origin, *args, **kwargs): """ Check whether the given file is a JWST c1d spectral data product. """ return _identify_spec1d_fits(origin, 'COMBINE1D', *args, **kwargs) def identify_jwst_c1d_multi_fits(origin, *args, **kwargs): """ Check whether the given file is a JWST c1d spectral data product with many slits. """ return _identify_spec1d_multi_fits(origin, 'COMBINE1D', *args, **kwargs) def identify_jwst_x1d_fits(origin, *args, **kwargs): """ Check whether the given file is a JWST x1d spectral data product. """ return _identify_spec1d_fits(origin, 'EXTRACT1D', *args, **kwargs) def identify_jwst_x1d_multi_fits(origin, *args, **kwargs): """ Check whether the given file is a JWST x1d spectral data product with many slits. """ return _identify_spec1d_multi_fits(origin, 'EXTRACT1D', *args, **kwargs) def identify_jwst_s2d_fits(origin, *args, **kwargs): """ Check whether the given file is a JWST s2d spectral data product. """ is_jwst = _identify_jwst_fits(*args) with read_fileobj_or_hdulist(*args, memmap=False, **kwargs) as hdulist: return (is_jwst and "SCI" in hdulist and ("SCI", 2) not in hdulist and "EXTRACT1D" not in hdulist and len(hdulist["SCI"].data.shape) == 2) def identify_jwst_s2d_multi_fits(origin, *args, **kwargs): """ Check whether the given file is a JWST s2d spectral data product with many slits. """ is_jwst = _identify_jwst_fits(*args) with read_fileobj_or_hdulist(*args, memmap=False, **kwargs) as hdulist: return (is_jwst and ("SCI", 2) in hdulist and "EXTRACT1D" not in hdulist and len(hdulist["SCI"].data.shape) == 2) def identify_jwst_s3d_fits(origin, *args, **kwargs): """ Check whether the given file is a JWST s3d spectral data product. """ is_jwst = _identify_jwst_fits(*args) with read_fileobj_or_hdulist(*args, memmap=False, **kwargs) as hdulist: return (is_jwst and "SCI" in hdulist and "EXTRACT1D" not in hdulist and len(hdulist["SCI"].data.shape) == 3) def _identify_jwst_fits(*args): """ Check whether the given file is a JWST data product. """ try: with read_fileobj_or_hdulist(*args, memmap=False) as hdulist: return "ASDF" in hdulist and hdulist[0].header.get("TELESCOP") == "JWST" # This probably means we didn't have a FITS file except Exception: return False @data_loader( "JWST c1d", identifier=identify_jwst_c1d_fits, dtype=Spectrum1D, extensions=['fits'], priority=10, ) def jwst_c1d_single_loader(file_obj, **kwargs): """ Loader for JWST c1d 1-D spectral data in FITS format Parameters ---------- filename : str The path to the FITS file Returns ------- Spectrum1D The spectrum contained in the file. """ spectrum_list = _jwst_spec1d_loader(file_obj, extname='COMBINE1D', **kwargs) if len(spectrum_list) == 1: return spectrum_list[0] else: raise RuntimeError(f"Input data has {len(spectrum_list)} spectra. " "Use SpectrumList.read() instead.") @data_loader( "JWST c1d multi", identifier=identify_jwst_c1d_multi_fits, dtype=SpectrumList, extensions=['fits'], priority=10, ) def jwst_c1d_multi_loader(file_obj, **kwargs): """ Loader for JWST x1d 1-D spectral data in FITS format Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- SpectrumList A list of the spectra that are contained in the file. """ return _jwst_spec1d_loader(file_obj, extname='COMBINE1D', **kwargs) @data_loader( "JWST x1d", identifier=identify_jwst_x1d_fits, dtype=Spectrum1D, extensions=['fits'], priority=10 ) def jwst_x1d_single_loader(file_obj, **kwargs): """ Loader for JWST x1d 1-D spectral data in FITS format Parameters ---------- filename : str The path to the FITS file Returns ------- Spectrum1D The spectrum contained in the file. """ spectrum_list = _jwst_spec1d_loader(file_obj, extname='EXTRACT1D', **kwargs) if len(spectrum_list) == 1: return spectrum_list[0] else: raise RuntimeError(f"Input data has {len(spectrum_list)} spectra. " "Use SpectrumList.read() instead.") @data_loader( "JWST x1d multi", identifier=identify_jwst_x1d_multi_fits, dtype=SpectrumList, extensions=['fits'], priority=10, ) def jwst_x1d_multi_loader(file_obj, **kwargs): """ Loader for JWST x1d 1-D spectral data in FITS format Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- SpectrumList A list of the spectra that are contained in the file. """ return _jwst_spec1d_loader(file_obj, extname='EXTRACT1D', **kwargs) @data_loader( "JWST x1d MIRI MRS", identifier=identify_jwst_miri_mrs, dtype=SpectrumList, extensions=['*'], priority=10, ) def jwst_x1d_miri_mrs_loader(input, missing="raise", **kwargs): """ Loader for JWST x1d MIRI MRS spectral data in FITS format. A single data set consists of a bunch of _x1d files corresponding to a variety of wavelength bands. This reader reads them one by one and packs the result into a SpectrumList instance. Parameters ---------- input : list of str or file-like List of FITS file names, or objects (provided from name by Astropy I/O Registry). Alternatively, a directory path on which glob.glob runs with pattern an implicit pattern "_x1d.fits", or a directory path with a glob pattern already set. missing : {'warn', 'silent'} Allows the user to continue loading if one file is missing by setting the value to "warn" or "silent". In the first case a warning will be issued to the user, in the latter the file will silently be skipped. Any other value will result in a FileNotFoundError if any files in the list are missing. Returns ------- SpectrumList A list of the spectra that are contained in all the files. """ # If input is a list, go read each file. If directory, glob-expand # list of file names. if not isinstance(input, (list, tuple)): if os.path.isdir(input): file_list = glob.glob(os.path.join(input, "*_x1d.fits"), recursive=True) else: file_list = glob.glob(input, recursive=True) else: file_list = input spectra = [] for file_obj in file_list: try: sp = _jwst_spec1d_loader(file_obj, **kwargs) except FileNotFoundError as e: if missing.lower() == "warn": log.warning(f'Failed to load {file_obj}: {repr(e)}') continue elif missing.lower() == "silent": continue else: raise FileNotFoundError(f"Failed to load {file_obj}: {repr(e)}. " "To suppress this error, set argument missing='warn'") # note that the method above returns a single Spectrum1D instance # packaged in a SpectrumList wrapper. We remove the wrapper so as # to avoid ending up with a depth-2 SpectrumList. spectra.append(sp[0]) return SpectrumList(spectra) def _jwst_spec1d_loader(file_obj, extname='EXTRACT1D', **kwargs): """Implementation of loader for JWST x1d 1-D spectral data in FITS format Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). extname: str The name of the science extension. Either "COMBINE1D" or "EXTRACT1D". By default "EXTRACT1D". Returns ------- SpectrumList A list of the spectra that are contained in the file. """ if extname not in ['COMBINE1D', 'EXTRACT1D']: raise ValueError('Incorrect extname given for 1d spectral data.') spectra = [] with read_fileobj_or_hdulist(file_obj, memmap=False, **kwargs) as hdulist: primary_header = hdulist["PRIMARY"].header for hdu in hdulist: # Read only the BinaryTableHDUs named COMBINE1D/EXTRACT1D and SCI if hdu.name != extname: continue data = Table.read(hdu) wavelength = Quantity(data["WAVELENGTH"]) # Determine if FLUX or SURF_BRIGHT column should be returned # based on whether it is point or extended source. # # SRCTYPE used to be in primary header, but was moved around. As # per most recent pipeline definition, it should be in the # EXTRACT1D extension. # # SRCTYPE should either be POINT or EXTENDED. In some cases, it is UNKNOWN # or missing. If that's the case, default to using POINT as the SRCTYPE. # Error out only when SRCTYPE is a bad value. srctype = None if "srctype" in hdu.header: srctype = hdu.header.get("srctype", None) # checking if SRCTPYE is missing or UNKNOWN if not srctype or srctype == 'UNKNOWN': log.warning('SRCTYPE is missing or UNKNOWN in JWST x1d loader. ' 'Defaulting to srctype="POINT".') srctype = 'POINT' if srctype == "POINT": flux = Quantity(data["FLUX"]) if 'ERROR' in data.colnames: uncertainty = StdDevUncertainty(data["ERROR"]) elif 'FLUX_ERROR' in data.colnames: uncertainty = StdDevUncertainty(data["FLUX_ERROR"]) else: uncertainty = None elif srctype == "EXTENDED": flux = Quantity(data["SURF_BRIGHT"]) uncertainty = StdDevUncertainty(hdu.data["SB_ERROR"]) else: raise RuntimeError(f"Keyword SRCTYPE is {srctype}. It should " "be 'POINT' or 'EXTENDED'. Can't decide between `flux` and " "`surf_bright` columns.") # Merge primary and slit headers and dump into meta slit_header = hdu.header header = primary_header.copy() header.extend(slit_header, strip=True, update=True) meta = {'header': header} spec = Spectrum1D(flux=flux, spectral_axis=wavelength, uncertainty=uncertainty, meta=meta) spectra.append(spec) return SpectrumList(spectra) @data_loader( "JWST s2d", identifier=identify_jwst_s2d_fits, dtype=Spectrum1D, extensions=['fits'], priority=10, ) def jwst_s2d_single_loader(filename, **kwargs): """ Loader for JWST s2d 2D rectified spectral data in FITS format. Parameters ---------- filename : str The path to the FITS file Returns ------- Spectrum1D The spectrum contained in the file. """ spectrum_list = _jwst_s2d_loader(filename, **kwargs) if len(spectrum_list) == 1: return spectrum_list[0] elif len(spectrum_list) > 1: raise RuntimeError(f"Input data has {len(spectrum_list)} spectra. " "Use SpectrumList.read() instead.") else: raise RuntimeError(f"Input data has {len(spectrum_list)} spectra.") @data_loader( "JWST s2d multi", identifier=identify_jwst_s2d_multi_fits, dtype=SpectrumList, extensions=['fits'], priority=10, ) def jwst_s2d_multi_loader(filename, **kwargs): """ Loader for JWST s2d 2D rectified spectral data in FITS format. Parameters ---------- filename : str The path to the FITS file Returns ------- SpectrumList The spectra contained in the file. """ return _jwst_s2d_loader(filename, **kwargs) def _jwst_s2d_loader(filename, **kwargs): """ Loader for JWST s2d 2D rectified spectral data in FITS format. Parameters ---------- filename : str The path to the FITS file Returns ------- SpectrumList The spectra contained in the file. """ spectra = [] slits = None # Get a list of GWCS objects from the slits with asdf.open(filename) as af: # Slits can be listed under "slits", "products" or "exposures" if "products" in af.tree: slits = "products" elif "exposures" in af.tree: slits = "exposures" elif "slits" in af.tree: slits = "slits" else: # No slits. Case for s3d cubes wcslist = [af.tree["meta"]["wcs"]] # Create list of the GWCS objects, one for each slit if slits is not None: wcslist = [slit["meta"]["wcs"] for slit in af.tree[slits]] with fits.open(filename, memmap=False) as hdulist: primary_header = hdulist["PRIMARY"].header hdulist_sci = [hdu for hdu in hdulist if hdu.name == "SCI"] for hdu, wcs in zip(hdulist_sci, wcslist): # Get flux try: flux_unit = u.Unit(hdu.header["BUNIT"]) except (ValueError, KeyError): flux_unit = None # The dispersion axis is 1 or 2. 1=x, 2=y. dispaxis = hdu.header.get("DISPAXIS") if dispaxis is None: dispaxis = hdulist["PRIMARY"].header.get("DISPAXIS") # Get the wavelength array from the GWCS object which returns a # tuple of (RA, Dec, lambda) grid = grid_from_bounding_box(wcs.bounding_box) _, _, lam = wcs(*grid) _, _, lam_unit = wcs.output_frame.unit # Make sure the dispersion axis is the same for every spatial axis # for s2d data if dispaxis == 1: flux_array = hdu.data wavelength_array = lam[0] # Make sure all rows are the same if not (lam == wavelength_array).all(): raise RuntimeError("This 2D or 3D spectrum is not rectified " "and cannot be loaded into a Spectrum1D object.") elif dispaxis == 2: flux_array = hdu.data.T wavelength_array = lam[:, 0] # Make sure all columns are the same if not (lam.T == lam[None, :, 0]).all(): raise RuntimeError("This 2D or 3D spectrum is not rectified " "and cannot be loaded into a Spectrum1D object.") else: raise RuntimeError("This 2D spectrum has an unknown dispaxis " "and cannot be loaded into a Spectrum1D object.") flux = Quantity(flux_array, unit=flux_unit) wavelength = Quantity(wavelength_array, unit=lam_unit) # Merge primary and slit headers and dump into meta slit_header = hdu.header header = primary_header.copy() header.extend(slit_header, strip=True, update=True) meta = {'header': header} spec = Spectrum1D(flux=flux, spectral_axis=wavelength, meta=meta) spectra.append(spec) return SpectrumList(spectra) @data_loader( "JWST s3d", identifier=identify_jwst_s3d_fits, dtype=Spectrum1D, extensions=['fits'], priority=10, ) def jwst_s3d_single_loader(filename, **kwargs): """ Loader for JWST s3d 3D rectified spectral data in FITS format. Parameters ---------- filename : str The path to the FITS file Returns ------- Spectrum1D The spectrum contained in the file. """ spectrum_list = _jwst_s3d_loader(filename, **kwargs) if len(spectrum_list) == 1: return spectrum_list[0] elif len(spectrum_list) > 1: raise RuntimeError(f"Input data has {len(spectrum_list)} spectra. " "Use SpectrumList.read() instead.") else: raise RuntimeError(f"Input data has {len(spectrum_list)} spectra.") def _jwst_s3d_loader(filename, **kwargs): """ Loader for JWST s3d 3D rectified spectral data in FITS format. Parameters ---------- filename : str The path to the FITS file Returns ------- SpectrumList The spectra contained in the file. """ spectra = [] # Get a list of GWCS objects from the slits with asdf.open(filename) as af: wcslist = [af.tree["meta"]["wcs"]] with fits.open(filename, memmap=False) as hdulist: primary_header = hdulist["PRIMARY"].header hdulist_sci = [hdu for hdu in hdulist if hdu.name == "SCI"] for hdu, wcs in zip(hdulist_sci, wcslist): # Get flux try: flux_unit = u.Unit(hdu.header["BUNIT"]) except (ValueError, KeyError): flux_unit = None # The spectral axis is first. We need it last flux_array = hdu.data.T flux = Quantity(flux_array, unit=flux_unit) # Get the wavelength array from the GWCS object which returns a # tuple of (RA, Dec, lambda). # Since the spatial and spectral axes are orthogonal in s3d data, # it is much faster to compute a slice down the spectral axis. grid = grid_from_bounding_box(wcs.bounding_box)[:, :, 0, 0] _, _, wavelength_array = wcs(*grid) _, _, wavelength_unit = wcs.output_frame.unit wavelength = Quantity(wavelength_array, unit=wavelength_unit) # Merge primary and slit headers and dump into meta slit_header = hdu.header header = primary_header.copy() header.extend(slit_header, strip=True, update=True) meta = {'header': header} # get uncertainty information ext_name = primary_header.get("ERREXT", "ERR") err_type = hdulist[ext_name].header.get("ERRTYPE", 'ERR') err_unit = hdulist[ext_name].header.get("BUNIT", None) err_array = hdulist[ext_name].data.T # ERRTYPE can be one of "ERR", "IERR", "VAR", "IVAR" # but mostly ERR for JWST cubes # see https://jwst-pipeline.readthedocs.io/en/latest/jwst/data_products/science_products.html#s3d if err_type == "ERR": err = StdDevUncertainty(err_array, unit=err_unit) elif err_type == 'VAR': err = VarianceUncertainty(err_array, unit=err_unit) elif err_type == 'IVAR': err = InverseVariance(err_array, unit=err_unit) elif err_type == 'IERR': log.warning("Inverse Error is not yet a supported Astropy.nddata " "uncertainty. Setting err to None.") err = None # get mask information mask_name = primary_header.get("MASKEXT", "DQ") mask = hdulist[mask_name].data.T spec = Spectrum1D(flux=flux, spectral_axis=wavelength, meta=meta, uncertainty=err, mask=mask) spectra.append(spec) return SpectrumList(spectra) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/manga.py0000644000503700020070000000740200000000000025023 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u from astropy.io import fits from astropy.nddata import InverseVariance from astropy.wcs import WCS from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist __all__ = ["identify_manga_cube", "identify_manga_rss", "manga_cube_loader", "manga_rss_loader"] def identify_manga_cube(origin, *args, **kwargs): """ Check whether the given file is a MaNGA CUBE. """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header["TELESCOP"] == "SDSS 2.5-M" and "FLUX" in hdulist and hdulist[1].header['INSTRUME'] == 'MaNGA' and hdulist[1].header["NAXIS"] == 3) def identify_manga_rss(origin, *args, **kwargs): """ Check whether the given file is a MaNGA RSS. """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header["TELESCOP"] == "SDSS 2.5-M" and "FLUX" in hdulist and hdulist[1].header['INSTRUME'] == 'MaNGA' and hdulist[1].header["NAXIS"] == 2) @data_loader( "MaNGA cube", identifier=identify_manga_cube, dtype=Spectrum1D, extensions=['fits'], priority=10, ) def manga_cube_loader(file_obj, **kwargs): """ Loader for MaNGA 3D rectified spectral data in FITS format. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- Spectrum1D The spectrum contained in the file. """ spaxel = u.Unit('spaxel', represents=u.pixel, doc='0.5" spatial pixel', parse_strict='silent') with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: spectrum = _load_manga_spectra(hdulist, per_unit=spaxel) return spectrum @data_loader( "MaNGA rss", identifier=identify_manga_rss, dtype=Spectrum1D, extensions=['fits'], priority=10, ) def manga_rss_loader(file_obj, **kwargs): """ Loader for MaNGA 2D row-stacked spectral data in FITS format. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- Spectrum1D The spectrum contained in the file. """ fiber = u.Unit('fiber', represents=u.pixel, doc='spectroscopic fiber', parse_strict='silent') with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: spectrum = _load_manga_spectra(hdulist, per_unit=fiber) return spectrum def _load_manga_spectra(hdulist, per_unit=None): """ Return a MaNGA Spectrum1D object Returns a Spectrum1D object for a MaNGA data files. Use the `per_unit` kwarg to indicate the "spaxel" or "fiber" unit for cubes and rss files, respectively. Note that the spectral axis will automatically be moved to be last during Spectrum1D initialization. Parameters ---------- hdulist : fits.HDUList A MaNGA read astropy fits HDUList per_unit : astropy.units.Unit An astropy unit to divide the default flux unit by Returns ------- Spectrum1D The spectrum contained in the file. """ unit = u.Unit('1e-17 erg / (Angstrom cm2 s)') if per_unit: unit = unit / per_unit hdr = hdulist['PRIMARY'].header wcs = WCS(hdulist['FLUX'].header) flux = hdulist['FLUX'].data * unit ivar = InverseVariance(hdulist["IVAR"].data) # SDSS masks are arrays of bit values storing multiple boolean conditions. mask = hdulist['MASK'].data != 0 return Spectrum1D(flux=flux, meta={'header': hdr}, wcs=wcs, uncertainty=ivar, mask=mask) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/muscles_sed.py0000644000503700020070000000403400000000000026244 0ustar00rosteenSTSCI\science00000000000000import os from astropy.io import fits from astropy.nddata import StdDevUncertainty from astropy.table import Table from astropy.units import Unit, Quantity from astropy.wcs import WCS from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist def identify_muscles_sed(origin, *args, **kwargs): # check if file can be opened with this reader # args[0] = filename # fits.open(args[0]) = hdulist with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: # Test if fits has extension of type BinTable and check against # known keys of already defined specific formats return (len(hdulist) > 1 and isinstance(hdulist[1], fits.BinTableHDU) and hdulist[0].header.get('TELESCOP') == 'MULTI' and hdulist[0].header.get('HLSPACRN') == 'MUSCLES' and hdulist[0].header.get('PROPOSID') == 13650) @data_loader( label="MUSCLES SED", identifier=identify_muscles_sed, dtype=Spectrum1D, extensions=['fits'], priority=10, ) def muscles_sed(file_obj, **kwargs): """ Load spectrum from a MUSCLES Treasury Survey panchromatic SED FITS file. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ # name is not used; what was it for? # name = os.path.basename(file_name.rstrip(os.sep)).rsplit('.', 1)[0] with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header tab = Table.read(hdulist[1]) meta = {'header': header} uncertainty = StdDevUncertainty(tab["ERROR"]) data = Quantity(tab["FLUX"]) wavelength = Quantity(tab["WAVELENGTH"]) return Spectrum1D(flux=data, spectral_axis=wavelength, uncertainty=uncertainty, meta=meta) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/ozdes.py0000644000503700020070000000224700000000000025066 0ustar00rosteenSTSCI\science00000000000000from specutils import SpectrumList from specutils.io.registers import data_loader from .dc_common import FITS_FILE_EXTS, SINGLE_SPLIT_LABEL from ..parsing_utils import read_fileobj_or_hdulist OZDES_CONFIG = { "hdus": { "0": {"purpose": "combined_science"}, "1": {"purpose": "combined_error_variance"}, "2": {"purpose": "skip"}, "cycle": { "0": {"purpose": "science"}, "1": {"purpose": "error_variance"}, "2": {"purpose": "skip"}, }, }, "units": None, "wcs": None, "all_standard_units": True, "all_keywords": False, "valid_wcs": True, } def identify_ozdes(origin, *args, **kwargs): """ Identify if the current file is a OzDES file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: if "ozdes" in hdulist[0].header.get("REFERENC"): return True return False @data_loader( label="OzDES", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_ozdes, priority=10 ) def ozdes_loader(filename): spectra = SpectrumList.read( filename, format=SINGLE_SPLIT_LABEL, **OZDES_CONFIG ) return spectra ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/sdss.py0000644000503700020070000002007000000000000024710 0ustar00rosteenSTSCI\science00000000000000""" Loader for SDSS individual spectrum files: spec_ files. .. _spec: https://data.sdss.org/datamodel/files/BOSS_SPECTRO_REDUX/RUN2D/spectra/PLATE4/spec.html """ import os import re import _io from astropy.io import fits from astropy.table import Table from astropy.wcs import WCS from astropy.units import Unit, def_unit from astropy.nddata import StdDevUncertainty, InverseVariance import numpy as np from ...spectra import Spectrum1D from ..registers import data_loader, custom_writer from ..parsing_utils import read_fileobj_or_hdulist __all__ = ['spec_identify', 'spSpec_identify', 'spec_loader', 'spSpec_loader'] _spSpec_pattern = re.compile(r'spSpec-\d{5}-\d{4}-\d{3}\.fit') _spec_pattern = re.compile(r'spec-\d{4,5}-\d{5}-\d{4}\.fits') def _sdss_wcs_to_log_wcs(old_wcs): """ The WCS in the SDSS files does not appear to follow the WCS standard - it claims to be linear, but is logarithmic in base-10. The wavelength is given by: λ = 10^(w0 + w1 * i) with i being the pixel index starting from 0. The FITS standard uses a natural log with a sightly different formulation, see WCS Paper 3 (which discusses spectral WCS). This function does the conversion from the SDSS WCS to FITS WCS. """ w0 = old_wcs.wcs.crval[0] w1 = old_wcs.wcs.cd[0,0] crval = 10 ** w0 cdelt = crval * w1 * np.log(10) cunit = old_wcs.wcs.cunit[0] or Unit('Angstrom') ctype = "WAVE-LOG" w = WCS(naxis=1) w.wcs.crval[0] = crval w.wcs.cdelt[0] = cdelt w.wcs.ctype[0] = ctype w.wcs.cunit[0] = cunit w.wcs.set() return w def spec_identify(origin, *args, **kwargs): """ Check whether given input is FITS and has SDSS-III/IV spec type BINTABLE in first extension. This is used for Astropy I/O Registry. """ # Test if fits has extension of type BinTable and check for spec-specific keys with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header.get('TELESCOP') == 'SDSS 2.5-M' and hdulist[0].header.get('FIBERID', 0) > 0 and len(hdulist) > 1 and (isinstance(hdulist[1], fits.BinTableHDU) and hdulist[1].header.get('TTYPE3').lower() == 'ivar')) def spSpec_identify(origin, *args, **kwargs): """ Check whether given input is FITS with SDSS-I/II spSpec tyamepe data. This is used for Astropy I/O Registry. """ # Test telescope keyword and check if primary HDU contains data # consistent with spSpec format with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header.get('TELESCOP') == 'SDSS 2.5-M' and hdulist[0].header.get('FIBERID', 0) > 0 and isinstance(hdulist[0].data, np.ndarray) and hdulist[0].data.shape[0] == 5) def spPlate_identify(origin, *args, **kwargs): """ Check whether given input is FITS with SDSS spPlate fibre spectral data. This is used for Astropy I/O Registry. """ # Test telescope keyword and check if primary HDU contains data # consistent with spSpec format with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[0].header.get('TELESCOP') == 'SDSS 2.5-M' and hdulist[0].header.get('FIBERID', 0) <= 0 and isinstance(hdulist[0].data, np.ndarray) and hdulist[0].data.shape[0] > 5) @data_loader( label="SDSS-III/IV spec", identifier=spec_identify, extensions=['fits'], priority=10, ) def spec_loader(file_obj, **kwargs): """ Loader for SDSS-III/IV optical spectrum "spec" files. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The spectrum that is represented by the 'loglam' (wavelength) and 'flux' data columns in the BINTABLE extension of the FITS `file_obj`. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header name = header.get('NAME') meta = {'header': header} bunit = header.get('BUNIT', '1e-17 erg / (Angstrom cm2 s)') if 'Ang' in bunit and 'strom' not in bunit: bunit = bunit.replace('Ang', 'Angstrom') flux_unit = Unit(bunit) # spectrum is in HDU 1 flux = hdulist[1].data['flux'] * flux_unit uncertainty = InverseVariance(hdulist[1].data['ivar'] / flux_unit**2) dispersion = 10**hdulist[1].data['loglam'] dispersion_unit = Unit('Angstrom') mask = hdulist[1].data['and_mask'] != 0 return Spectrum1D(flux=flux, spectral_axis=dispersion * dispersion_unit, uncertainty=uncertainty, meta=meta, mask=mask) @data_loader( label="SDSS-I/II spSpec", identifier=spSpec_identify, extensions=['fit', 'fits'], priority=10, ) def spSpec_loader(file_obj, **kwargs): """ Loader for SDSS-I/II spSpec files. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The spectrum that is represented by the wavelength solution from the header WCS and data array of the primary HDU. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header # name = header.get('NAME') meta = {'header': header} wcs = WCS(header).dropaxis(1) bunit = header.get('BUNIT', '1e-17 erg / (Angstrom cm2 s)') # fix mutilated flux unit bunit = bunit.replace('/cm/s/Ang', '/ (Angstrom cm2 s)') if 'Ang' in bunit and 'strom' not in bunit: bunit = bunit.replace('Ang', 'Angstrom') flux_unit = Unit(bunit) flux = hdulist[0].data[0, :] * flux_unit uncertainty = StdDevUncertainty(hdulist[0].data[2, :] * flux_unit) # Fix the WCS if it is claimed to be linear if header.get('DC-Flag', 1) == 1: fixed_wcs = _sdss_wcs_to_log_wcs(wcs) else: fixed_wcs = wcs mask = hdulist[0].data[3, :] != 0 return Spectrum1D(flux=flux, wcs=fixed_wcs, uncertainty=uncertainty, meta=meta, mask=mask) @data_loader(label="SDSS spPlate", identifier=spPlate_identify, extensions=['fits']) def spPlate_loader(file_obj, limit=None, **kwargs): """ Loader for SDSS spPlate files, reading flux spectra from all fibres into single array. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). limit : :class:`int`, optional If set, only return the first `limit` spectra in `flux` array. Returns ------- Spectrum1D The spectra represented by the wavelength solution from the header WCS and the data array of the primary HDU (typically 640 along dimension 1). """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header meta = {'header': header} wcs = WCS(header).dropaxis(1) if limit is None: limit = header['NAXIS2'] bunit = header.get('BUNIT', '1e-17 erg / (Angstrom cm2 s)') if 'Ang' in bunit and 'strom' not in bunit: bunit = bunit.replace('Ang', 'Angstrom') flux_unit = Unit(bunit) flux = hdulist[0].data[0:limit, :] * flux_unit uncertainty = InverseVariance(hdulist[1].data[0:limit, :] / flux_unit**2) # Fix the WCS if it is claimed to be linear if header.get('DC-Flag', 1) == 1: fixed_wcs = _sdss_wcs_to_log_wcs(wcs) else: fixed_wcs = wcs mask = hdulist[2].data[0:limit, :] != 0 meta['plugmap'] = Table.read(hdulist[5])[0:limit] return Spectrum1D(flux=flux, wcs=fixed_wcs, uncertainty=uncertainty, meta=meta, mask=mask) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/sixdfgs_reader.py0000644000503700020070000001532700000000000026736 0ustar00rosteenSTSCI\science00000000000000from astropy.nddata import VarianceUncertainty from astropy.table import Table from astropy.units import Quantity, Unit from astropy.wcs import WCS from ...spectra import Spectrum1D, SpectrumList from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist SIXDFGS_PRIMARY_HDU_KEYWORDS = ["OBSRA", "OBSDEC", "Z", "Z_HELIO", "QUALITY"] COUNTS_PER_SECOND = Unit("counts/s", parse_strict="silent") ANGSTROMS = Unit("Angstroms", parse_strict="silent") def identify_6dfgs_tabular_fits(origin, *args, **kwargs): """ Identify if the current file is a 6dFGS file (stored as a table) """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: if len(hdulist) != 2: return False primary_hdu = hdulist[0] if primary_hdu.header["NAXIS"] != 0: return False for keyword in SIXDFGS_PRIMARY_HDU_KEYWORDS: if keyword not in primary_hdu.header: return False return True def identify_6dfgs_split_fits(origin, *args, **kwargs): """ Identify if the current file is a 6dFGS file (stored in the split variant). """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: if len(hdulist) != 1: return False primary_hdu = hdulist[0] if primary_hdu.header["NAXIS2"] not in (3, 4): return False for keyword in SIXDFGS_PRIMARY_HDU_KEYWORDS: if keyword not in primary_hdu.header: return False return True def identify_6dfgs_combined_fits(origin, *args, **kwargs): """ Identify if the current file is a 6dFGS file (stored in the combined variant). """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: if len(hdulist) < 8: return False first_spectrum_hdu = hdulist[5] if first_spectrum_hdu.header["NAXIS2"] not in (3, 4): return False for keyword in SIXDFGS_PRIMARY_HDU_KEYWORDS: if keyword not in first_spectrum_hdu.header: return False return True @data_loader( "6dFGS-tabular", identifier=identify_6dfgs_tabular_fits, dtype=Spectrum1D, extensions=["fit", "fits"], priority=10, ) def sixdfgs_tabular_fits_loader(file_obj, **kwargs): """ Load the tabular variant of a 6dF Galaxy Survey (6dFGS) file. 6dFGS used the Six-degree Field instrument on the UK Schmidt Telescope (UKST) at the Siding Spring Observatory (SSO) near Coonabarabran, Australia. Further details can be found at http://www.6dfgs.net/, or https://docs.datacentral.org.au/6dfgs/. Catalogues and spectra were produced, with the spectra being provided as both fits tables and as fits images. This loads the tabular variant of the spectra. Note that this does not include uncertainties - uncertainties are only provided in the image variants. Parameters ---------- file_obj: str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The 6dF spectrum that is represented by the data in this table. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header table = Table.read(hdulist) flux = Quantity(table["FLUX"]) wavelength = Quantity(table["WAVE"]) if flux.unit == COUNTS_PER_SECOND: flux._unit = Unit("count/s") meta = {"header": header} return Spectrum1D(flux=flux, spectral_axis=wavelength, meta=meta) @data_loader( "6dFGS-split", identifier=identify_6dfgs_split_fits, dtype=Spectrum1D, extensions=["fit", "fits"], priority=10, ) def sixdfgs_split_fits_loader(file_obj, **kwargs): """ Load the split variant of a 6dF Galaxy Survey (6dFGS) file. 6dFGS used the Six-degree Field instrument on the UK Schmidt Telescope (UKST) at the Siding Spring Observatory (SSO) near Coonabarabran, Australia. Further details can be found at http://www.6dfgs.net/, or https://docs.datacentral.org.au/6dfgs/. Catalogues and spectra were produced, with the spectra being provided as both fits tables and as fits images. This loads the split variants of the spectra, which have been produced by either the GAMA team or Data Central. Parameters ---------- file_obj: str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: Spectrum1D The 6dF spectrum that is represented by the data in this file. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: spec = _load_single_6dfgs_hdu(hdulist[0]) return spec @data_loader( "6dFGS-combined", identifier=identify_6dfgs_combined_fits, dtype=SpectrumList, extensions=["fit", "fits"], priority=10, ) def sixdfgs_combined_fits_loader(file_obj, **kwargs): """ Load the combined variant of a 6dF Galaxy Survey (6dFGS) file. 6dFGS used the Six-degree Field instrument on the UK Schmidt Telescope (UKST) at the Siding Spring Observatory (SSO) near Coonabarabran, Australia. Further details can be found at http://www.6dfgs.net/, or https://docs.datacentral.org.au/6dfgs/. Catalogues and spectra were produced, with the spectra being provided as both fits tables and as fits images. This loads the combined variant of the spectra. Parameters ---------- file_obj: str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). Returns ------- data: SpectrumList The 6dF spectra that are represented by the data in this file. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: specs = SpectrumList([_load_single_6dfgs_hdu(hdu) for hdu in hdulist[5:]]) return specs def _load_single_6dfgs_hdu(hdu): """ Helper function to handle loading spectra from a single 6dFGS HDU """ header = hdu.header w = WCS(naxis=1) w.wcs.crpix[0] = header["CRPIX1"] w.wcs.crval[0] = header["CRVAL1"] w.wcs.cdelt[0] = header["CDELT1"] w.wcs.cunit[0] = header["CUNIT1"] if w.wcs.cunit[0] == ANGSTROMS: w.wcs.cunit[0] = Unit("Angstrom") meta = {"header": header} flux = hdu.data[0] * Unit("count") / w.wcs.cunit[0] uncertainty = VarianceUncertainty(hdu.data[1]) sky_flux = hdu.data[2] * Unit("count") / w.wcs.cunit[0] sky_meta = {"header": header} sky_spec = Spectrum1D(flux=sky_flux, wcs=w, meta=sky_meta) meta["sky"] = sky_spec return Spectrum1D(flux=flux, wcs=w, meta=meta, uncertainty=uncertainty) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/subaru_pfs_spec.py0000644000503700020070000000545600000000000027132 0ustar00rosteenSTSCI\science00000000000000""" Loader for PFS spectrum files. https://github.com/Subaru-PFS/datamodel/blob/master/datamodel.txt """ import os import re from astropy.io import fits from astropy.units import Unit from astropy.nddata import StdDevUncertainty import numpy as np from ...spectra import Spectrum1D from ..registers import data_loader from ..parsing_utils import _fits_identify_by_name, read_fileobj_or_hdulist __all__ = ['spec_identify', 'spec_loader'] # This RE matches the file name pattern defined in Subaru-PFS' datamodel.txt : # "pfsObject-%05d-%s-%3d-%08x-%02d-0x%08x.fits" % (tract, patch, catId, objId, # nVisit % 100, pfsVisitHash) _spec_pattern = re.compile(r'pfsObject-(?P\d{5})-(?P.{3})-' r'(?P\d{3})-(?P\d{8})-' r'(?P\d{2})-(?P0x\w{8})' r'\.fits') def identify_pfs_spec(origin, *args, **kwargs): """ Check whether given file is FITS and name matches `_spec_pattern`. """ return _fits_identify_by_name(origin, *args, pattern=_spec_pattern) @data_loader( label="Subaru-pfsObject", identifier=identify_pfs_spec, extensions=['fits'], priority=10, ) def pfs_spec_loader(file_obj, **kwargs): """ Loader for PFS combined spectrum files. Parameters ---------- file_obj : str or file-like FITS file name or object (provided from name by Astropy I/O Registry). Returns ------- data : Spectrum1D The spectrum that is represented by the data in this table. """ # This will fail for file-like objects without 'name' property like `bz2.BZ2File`, # workarund needed (or better yet, a scheme to parse the `meta` items from the header). if isinstance(file_obj, str): file_name = file_obj else: file_name = file_obj.name m = _spec_pattern.match(os.path.basename(file_name)) with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header meta = {'header': header, 'tract': m['tract'], 'patch': m['patch'], 'catId': m['catId'], 'objId': m['objId'], 'nVisit': m['nVisit'], 'pfsVisitHash': m['pfsVisitHash']} # spectrum is in HDU 2 data = hdulist[2].data['flux'] unit = Unit('nJy') error = hdulist[2].data['fluxVariance'] uncertainty = StdDevUncertainty(np.sqrt(error)) wave = hdulist[2].data['lambda'] wave_unit = Unit('nm') mask = hdulist[2].data['mask'] != 0 return Spectrum1D(flux=data * unit, spectral_axis=wave * wave_unit, uncertainty=uncertainty, meta=meta, mask=mask) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/tabular_fits.py0000644000503700020070000001512100000000000026414 0ustar00rosteenSTSCI\science00000000000000import os import _io import numpy as np from astropy.io import fits from astropy.table import Table import astropy.units as u from astropy.wcs import WCS from ...spectra import Spectrum1D from ..registers import data_loader, custom_writer from ..parsing_utils import (generic_spectrum_from_table, spectrum_from_column_mapping, read_fileobj_or_hdulist) __all__ = ['tabular_fits_loader', 'tabular_fits_writer'] def identify_tabular_fits(origin, *args, **kwargs): # args[0] = filename hdu = kwargs.get('hdu', 1) # Check if filename conforms to naming convention and writer has not been # asked to write to primary (IMAGE-only) HDU if origin == 'write': return (hdu > 0 and args[0].endswith(('.fits', '.fit')) and not args[0].endswith(('wcs.fits', 'wcs1d.fits', 'wcs.fit'))) # Test if fits has extension of type BinTable and check against # known keys of already defined specific formats with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (len(hdulist) > 1 and isinstance(hdulist[hdu], fits.BinTableHDU) and not (hdulist[0].header.get('TELESCOP') == 'MULTI' and hdulist[0].header.get('HLSPACRN') == 'MUSCLES' and hdulist[0].header.get('PROPOSID') == 13650) and not (hdulist[0].header.get('TELESCOP') == 'SDSS 2.5-M' and hdulist[0].header.get('FIBERID') > 0) and not (hdulist[0].header.get('TELESCOP') == 'HST' and hdulist[0].header.get('INSTRUME') in ('COS', 'STIS')) and not hdulist[0].header.get('TELESCOP') == 'JWST') @data_loader("tabular-fits", identifier=identify_tabular_fits, dtype=Spectrum1D, extensions=['fits', 'fit'], priority=6) def tabular_fits_loader(file_obj, column_mapping=None, hdu=1, **kwargs): """ Load spectrum from a FITS file. Parameters ---------- file_obj: str, file-like, or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). hdu: int The HDU of the fits file (default: 1st extension) to read from column_mapping : dict A dictionary describing the relation between the FITS file columns and the arguments of the `Spectrum1D` class, along with unit information. The dictionary keys should be the FITS file column names while the values should be a two-tuple where the first element is the associated `Spectrum1D` keyword argument, and the second element is the unit for the ASCII file column:: column_mapping = {'FLUX': ('flux', 'Jy')} Returns ------- data: Spectrum1D The spectrum that is represented by the data in this table. """ # Parse the wcs information. The wcs will be passed to the column finding # routines to search for spectral axis information in the file. with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: wcs = WCS(hdulist[hdu].header) tab = Table.read(file_obj, format='fits', hdu=hdu) # Minimal checks for wcs consistency with table data - # assume 1D spectral axis (having shape (0, NAXIS1), # or alternatively compare against shape of 1st column. if not (wcs.naxis == 1 and wcs.array_shape[-1] == len(tab) or wcs.array_shape == tab[tab.colnames[0]].shape): wcs = None # If no column mapping is given, attempt to parse the file using # unit information if column_mapping is None: return generic_spectrum_from_table(tab, wcs=wcs, **kwargs) return spectrum_from_column_mapping(tab, column_mapping, wcs=wcs) @custom_writer("tabular-fits") def tabular_fits_writer(spectrum, file_name, hdu=1, update_header=False, **kwargs): """ Write spectrum to BINTABLE extension of a FITS file. Parameters ---------- spectrum: Spectrum1D file_name: str The path to the FITS file hdu: int Header Data Unit in FITS file to write to (currently only extension HDU 1) update_header: bool Update FITS header with all compatible entries in `spectrum.meta` wunit : str or `~astropy.units.Unit` Unit for the spectral axis (wavelength or frequency-like) funit : str or `~astropy.units.Unit` Unit for the flux (and associated uncertainty) wtype : str or `~numpy.dtype` Floating point type for storing spectral axis array ftype : str or `~numpy.dtype` Floating point type for storing flux array """ if hdu < 1: raise ValueError(f'FITS does not support BINTABLE extension in HDU {hdu}.') header = spectrum.meta.get('header', fits.header.Header()).copy() if update_header: hdr_types = (str, int, float, complex, bool, np.floating, np.integer, np.complexfloating, np.bool_) header.update([keyword for keyword in spectrum.meta.items() if isinstance(keyword[1], hdr_types)]) # Strip header of FITS reserved keywords for keyword in ['NAXIS', 'NAXIS1', 'NAXIS2']: header.remove(keyword, ignore_missing=True) # Add dispersion array and unit wtype = kwargs.pop('wtype', spectrum.spectral_axis.dtype) wunit = u.Unit(kwargs.pop('wunit', spectrum.spectral_axis.unit)) disp = spectrum.spectral_axis.to(wunit, equivalencies=u.spectral()) # Mapping of spectral_axis types to header TTYPE1 (no "torque/work" types!) dispname = str(wunit.physical_type) if dispname == "length": dispname = "wavelength" elif "energy" in dispname: dispname = "energy" # Add flux array and unit ftype = kwargs.pop('ftype', spectrum.flux.dtype) funit = u.Unit(kwargs.pop('funit', spectrum.flux.unit)) flux = spectrum.flux.to(funit, equivalencies=u.spectral_density(disp)) columns = [disp.astype(wtype), flux.astype(ftype)] colnames = [dispname, "flux"] # Include uncertainty - units to be inferred from spectrum.flux if spectrum.uncertainty is not None: unc = spectrum.uncertainty.quantity.to(funit, equivalencies=u.spectral_density(disp)) columns.append(unc.astype(ftype)) colnames.append("uncertainty") # For > 1D data transpose from row-major format for c in range(1, len(columns)): if columns[c].ndim > 1: columns[c] = columns[c].T tab = Table(columns, names=colnames, meta=header) # Todo: support writing to other HDUs than the default (1st) # and an 'update' mode so different HDUs can be written to separately tab.write(file_name, format="fits", **kwargs) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7369304 specutils-1.6.0/specutils/io/default_loaders/tests/0000755000503700020070000000000000000000000024525 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/io/default_loaders/tests/__init__.py0000644000503700020070000000000000000000000026624 0ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/tests/test_apogee.py0000644000503700020070000000254600000000000027405 0ustar00rosteenSTSCI\science00000000000000# Third-party from astropy.utils.data import download_file from astropy.config import set_temp_cache import pytest # Package from specutils.io.default_loaders.apogee import (apStar_loader, apVisit_loader, aspcapStar_loader) @pytest.mark.remote_data def test_apStar_loader(tmpdir): apstar_url = ("https://data.sdss.org/sas/dr16/apogee/spectro/redux/r12/" "stars/apo25m/N7789/apStar-r12-2M00005414+5522241.fits") with set_temp_cache(path=str(tmpdir)): filename = download_file(apstar_url, cache=True) spectrum = apStar_loader(filename) @pytest.mark.remote_data def test_apVisit_loader(tmpdir): apvisit_url = ("https://data.sdss.org/sas/dr16/apogee/spectro/redux/r12/" "visit/apo25m/N7789/5094/55874/" "apVisit-r12-5094-55874-123.fits") with set_temp_cache(path=str(tmpdir)): filename = download_file(apvisit_url, cache=True) spectrum = apVisit_loader(filename) @pytest.mark.remote_data def test_aspcapStar_loader(tmpdir): aspcap_url = ("https://data.sdss.org/sas/dr16/apogee/spectro/aspcap/r12/" "l33/apo25m/N7789/aspcapStar-r12-2M00005414+5522241.fits") with set_temp_cache(path=str(tmpdir)): filename = download_file(aspcap_url, cache=True) spectrum = aspcapStar_loader(filename) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/tests/test_jwst_reader.py0000644000503700020070000003446200000000000030460 0ustar00rosteenSTSCI\science00000000000000import numpy as np from astropy.io import fits from astropy.table import Table import astropy.units as u from astropy.io.registry import IORegistryError from astropy.utils.exceptions import AstropyUserWarning from astropy.modeling import models from astropy import coordinates as coord import gwcs.coordinate_frames as cf from gwcs.wcs import WCS import pytest from asdf import fits_embed from specutils import Spectrum1D, SpectrumList # The c1d/x1d reader tests -------------------------- def create_spectrum_hdu(data_len, srctype=None, ver=1, name='EXTRACT1D'): """Mock a JWST x1d BinTableHDU""" data = np.random.random((data_len, 5)) table = Table(data=data, names=['WAVELENGTH', 'FLUX', 'ERROR', 'SURF_BRIGHT', 'SB_ERROR']) hdu = fits.BinTableHDU(table, name=name) hdu.header['TUNIT1'] = 'um' hdu.header['TUNIT2'] = 'Jy' hdu.header['TUNIT3'] = 'Jy' hdu.header['TUNIT4'] = 'MJy/sr' hdu.header['TUNIT5'] = 'MJy/sr' hdu.header['SRCTYPE'] = srctype hdu.ver = ver return hdu @pytest.fixture(scope="function") def x1d_single(): """Mock a JWST x1d HDUList with a single spectrum""" hdulist = fits.HDUList() hdulist.append(fits.PrimaryHDU()) hdulist["PRIMARY"].header["TELESCOP"] = ("JWST", "comment") # Add a BinTableHDU that contains spectral data hdulist.append(create_spectrum_hdu(100, 'POINT', ver=1)) # Mock the ASDF extension hdulist.append(fits.BinTableHDU(name='ASDF')) return hdulist @pytest.fixture(scope="function") def spec_single(request): """Mock a JWST c1d/x1d HDUList with a single spectrum""" name = request.param hdulist = fits.HDUList() hdulist.append(fits.PrimaryHDU()) hdulist["PRIMARY"].header["TELESCOP"] = ("JWST", "comment") # Add a BinTableHDU that contains spectral data hdulist.append(create_spectrum_hdu(100, 'POINT', ver=1, name=name)) # Mock the ASDF extension hdulist.append(fits.BinTableHDU(name='ASDF')) return hdulist @pytest.fixture(scope="function") def spec_multi(request): """Mock a JWST c1d/x1d multispec HDUList with 3 spectra""" name = request.param hdulist = fits.HDUList() hdulist.append(fits.PrimaryHDU()) hdulist["PRIMARY"].header["TELESCOP"] = "JWST" # Add a few BinTableHDUs that contain spectral data hdulist.append(create_spectrum_hdu(100, 'POINT', ver=1, name=name)) hdulist.append(create_spectrum_hdu(120, 'EXTENDED', ver=2, name=name)) hdulist.append(create_spectrum_hdu(110, 'POINT', ver=3, name=name)) # Mock the ASDF extension hdulist.append(fits.BinTableHDU(name='ASDF')) return hdulist @pytest.mark.parametrize('spec_multi, format', [('EXTRACT1D', 'JWST x1d multi'), ('COMBINE1D', 'JWST c1d multi')], indirect=['spec_multi']) def test_jwst_1d_multi_reader(tmpdir, spec_multi, format): """Test SpectrumList.read for JWST c1d/x1d multi data""" tmpfile = str(tmpdir.join('jwst.fits')) spec_multi.writeto(tmpfile) data = SpectrumList.read(tmpfile, format=format) assert type(data) is SpectrumList assert len(data) == 3 for item in data: assert isinstance(item, Spectrum1D) assert data[0].shape == (100,) assert data[1].shape == (120,) assert data[2].shape == (110,) @pytest.mark.parametrize('spec_single, format', [('EXTRACT1D', 'JWST x1d'), ('COMBINE1D', 'JWST c1d')], indirect=['spec_single']) def test_jwst_1d_single_reader(tmpdir, spec_single, format): """Test Spectrum1D.read for JWST x1d data""" tmpfile = str(tmpdir.join('jwst.fits')) spec_single.writeto(tmpfile) data = Spectrum1D.read(tmpfile, format=format) assert type(data) is Spectrum1D assert data.shape == (100,) @pytest.mark.parametrize("srctype", [None, "UNKNOWN"]) def test_jwst_srctpye_defaults(tmpdir, x1d_single, srctype): """ Test """ tmpfile = str(tmpdir.join('jwst.fits')) # Add a spectrum with missing or UNKNOWN SRCTYPE (mutate the fixture) x1d_single['EXTRACT1D'].header['SRCTYPE'] == srctype x1d_single.writeto(tmpfile) data = Spectrum1D.read(tmpfile, format='JWST x1d') assert type(data) is Spectrum1D assert data.shape == (100,) assert x1d_single['EXTRACT1D'].header['SRCTYPE'] == "POINT" @pytest.mark.parametrize('spec_single', ['EXTRACT1D', 'COMBINE1D'], indirect=['spec_single']) def test_jwst_1d_single_reader_no_format(tmpdir, spec_single): """Test Spectrum1D.read for JWST c1d/x1d data without format arg""" tmpfile = str(tmpdir.join('jwst.fits')) spec_single.writeto(tmpfile) data = Spectrum1D.read(tmpfile) assert type(data) is Spectrum1D assert data.shape == (100,) assert data.unit == u.Jy assert data.spectral_axis.unit == u.um @pytest.mark.parametrize('spec_multi', ['EXTRACT1D', 'COMBINE1D'], indirect=['spec_multi']) def test_jwst_1d_multi_reader_no_format(tmpdir, spec_multi): """Test Spectrum1D.read for JWST c1d/x1d data without format arg""" tmpfile = str(tmpdir.join('jwst.fits')) spec_multi.writeto(tmpfile) data = SpectrumList.read(tmpfile) assert type(data) is SpectrumList assert len(data) == 3 for item in data: assert isinstance(item, Spectrum1D) @pytest.mark.parametrize('spec_multi', ['EXTRACT1D', 'COMBINE1D'], indirect=['spec_multi']) def test_jwst_1d_multi_reader_check_units(tmpdir, spec_multi): """Test units for Spectrum1D.read for JWST c1d/x1d data""" tmpfile = str(tmpdir.join('jwst.fits')) spec_multi.writeto(tmpfile) data = SpectrumList.read(tmpfile) assert data[0].unit == u.Jy assert data[1].unit == u.MJy / u.sr assert data[2].unit == u.Jy @pytest.mark.parametrize('spec_single', ['EXTRACT1D', 'COMBINE1D'], indirect=['spec_single']) def test_jwst_1d_reader_meta(tmpdir, spec_single): """Test that the Primary and COMBINE1D/EXTRACT1D extension headers are merged in meta""" tmpfile = str(tmpdir.join('jwst.fits')) spec_single.writeto(tmpfile) data = Spectrum1D.read(tmpfile) assert ('TELESCOP', 'JWST') in data.meta['header'].items() assert ('SRCTYPE', 'POINT') in data.meta['header'].items() @pytest.mark.parametrize('spec_multi', ['EXTRACT1D', 'COMBINE1D'], indirect=['spec_multi']) def test_jwst_1d_single_reader_fail_on_multi(tmpdir, spec_multi): """Make sure Spectrum1D.read on JWST c1d/x1d with many spectra errors out""" tmpfile = str(tmpdir.join('jwst.fits')) spec_multi.writeto(tmpfile) with pytest.raises(IORegistryError): Spectrum1D.read(tmpfile) @pytest.mark.parametrize("srctype", ["BADVAL"]) def test_jwst_reader_fail(tmpdir, x1d_single, srctype): """Check that the reader fails when SRCTYPE is a BADVAL""" tmpfile = str(tmpdir.join('jwst.fits')) hdulist = x1d_single # Add a spectrum with bad SRCTYPE (mutate the fixture) hdulist.append(create_spectrum_hdu(100, srctype, ver=2)) hdulist.writeto(tmpfile) with pytest.raises(RuntimeError, match="^Keyword"): SpectrumList.read(tmpfile, format='JWST x1d multi') @pytest.mark.xfail(reason="JWST loader no longer attempts to auto-find flux column.") def test_jwst_reader_warning_stddev(tmpdir, x1d_single): """Check that the reader raises warning when stddev is zeros""" tmpfile = str(tmpdir.join('jwst.fits')) hdulist = x1d_single # Put zeros in ERROR column hdulist["EXTRACT1D"].data["ERROR"] = 0 hdulist.writeto(tmpfile) with pytest.warns(Warning) as record: Spectrum1D.read(tmpfile) for r in record: if r.message is AstropyUserWarning: assert "Standard Deviation has values of 0" in r.message # The s2d/s3d reader tests ------------------------------- @pytest.fixture def generate_wcs_transform(): def _generate_wcs_transform(dispaxis): """Create mock gwcs.WCS object for resampled s2d data""" detector = cf.Frame2D(name='detector', axes_order=(0, 1), unit=(u.pix, u.pix)) icrs = cf.CelestialFrame(name='icrs', reference_frame=coord.ICRS(), axes_order=(0, 1), unit=(u.deg, u.deg), axes_names=('RA', 'DEC')) spec = cf.SpectralFrame(name='spec', axes_order=(2,), unit=(u.micron,), axes_names=('lambda',)) world = cf.CompositeFrame(name="world", frames=[icrs, spec]) if dispaxis == 1: mapping = models.Mapping((0, 1, 0)) if dispaxis == 2: mapping = models.Mapping((0, 1, 1)) transform = mapping | (models.Const1D(42) & models.Const1D(42) & (models.Shift(30) | models.Scale(0.1))) pipeline = [(detector, transform), (world, None)] wcs = WCS(pipeline) return wcs return _generate_wcs_transform @pytest.fixture def s2d_single(generate_wcs_transform): pytest.importorskip("jwst") from jwst.datamodels import MultiSlitModel, SlitModel from jwst.assign_wcs.util import wcs_bbox_from_shape shape = (10, 100) dispaxis = 1 model = MultiSlitModel() sm = SlitModel(shape) sm.data model.slits.append(sm) for slit in model.slits: slit.meta.wcs = generate_wcs_transform(dispaxis) slit.meta.wcs.bounding_box = wcs_bbox_from_shape(shape) slit.meta.wcsinfo.dispersion_direction = dispaxis model.meta.telescope = "JWST" return model @pytest.fixture(params=[(5, 100), (100, 8)]) def s2d_multi(generate_wcs_transform, request): pytest.importorskip("jwst") from jwst.datamodels import SlitModel, MultiSlitModel from jwst.assign_wcs.util import wcs_bbox_from_shape shape = request.param if shape[0] < shape[1]: dispaxis = 1 else: dispaxis = 2 model = MultiSlitModel() sm = SlitModel(shape) sm.data model.slits.append(sm) model.slits.append(sm) for slit in model.slits: slit.meta.wcs = generate_wcs_transform(dispaxis) slit.meta.wcs.bounding_box = wcs_bbox_from_shape(shape) slit.meta.wcsinfo.dispersion_direction = dispaxis slit.meta.bunit_data = "Jy" slit.meta.bunit_err = "Jy" return model @pytest.mark.xfail(reason="Needs investigation! See #717") def test_jwst_s2d_reader(tmpdir, s2d_single): path = str(tmpdir.join("test.fits")) model = s2d_single model.save(path) spec = Spectrum1D.read(path) assert hasattr(spec, "spectral_axis") assert spec.unit == u.dimensionless_unscaled def test_jwst_s2d_multi_reader(tmpdir, s2d_multi): path = str(tmpdir.join("test.fits")) model = s2d_multi model.save(path) speclist = SpectrumList.read(path, format="JWST s2d multi") assert len(speclist) == 2 assert hasattr(speclist[0], "spectral_axis") assert speclist[1].unit == u.Jy # The s3d reader tests ------------------------------- def generate_s3d_wcs(): """ create a fake gwcs for a cube """ # create input /output frames detector = cf.CoordinateFrame(name='detector', axes_order=(0,1,2), axes_names=['x', 'y', 'z'], axes_type=['spatial', 'spatial', 'spatial'], naxes=3, unit=['pix', 'pix', 'pix']) sky = cf.CelestialFrame(reference_frame=coord.ICRS(), name='sky', axes_names=("RA", "DEC")) spec = cf.SpectralFrame(name='spectral', unit=['um'], axes_names=['wavelength'], axes_order=(2,)) world = cf.CompositeFrame(name="world", frames=[sky, spec]) # create fake transform to at least get a bounding box # for the s3d jwst loader # shape 30,10,10 (spec, y, x) crpix1, crpix2, crpix3 = 5, 5, 15 # (x, y, spec) crval1, crval2, crval3 = 1, 1, 1 cdelt1, cdelt2, cdelt3 = 0.01, 0.01, 0.05 shift = models.Shift(-crpix2) & models.Shift(-crpix1) scale = models.Multiply(cdelt2) & models.Multiply(cdelt1) proj = models.Pix2Sky_TAN() skyrot = models.RotateNative2Celestial(crval2, 90 + crval1, 180) celestial = shift | scale | proj | skyrot wave_model = models.Shift(-crpix3) | models.Multiply(cdelt3) | models.Shift(crval3) transform = models.Mapping((2, 0, 1)) | celestial & wave_model | models.Mapping((1, 2, 0)) # bounding box based on shape (30,10,10) in test transform.bounding_box = ((0, 29), (0, 9), (0, 9)) # create final wcs pipeline = [(detector, transform), (world, None)] return WCS(pipeline) @pytest.fixture() def tmp_asdf(tmpdir): # Create some data sequence = np.arange(100) squares = sequence**2 random = np.random.random(100) # Store the data in an arbitrarily nested dictionary tree = { 'foo': 42, 'name': 'Monty', 'sequence': sequence, 'powers': { 'squares' : squares }, 'random': random, 'meta': { 'wcs' : generate_s3d_wcs() } } yield tree tree = {} def create_image_hdu(name='SCI', data=None, shape=None, hdrs=[], ndim=3): """ Mock an Image HDU """ if data is None: if not shape: shape = [4, 2, 3] if ndim == 3 else [2, 2] if ndim == 2 else [2] data = np.zeros(shape) hdu = fits.ImageHDU(name=name, data=data, header=fits.Header(hdrs)) hdu.ver = 1 return hdu @pytest.fixture(scope='function') def cube(tmpdir, tmp_asdf): """ Mock a JWST s3d cube """ hdulist = fits.HDUList() hdulist.append(fits.PrimaryHDU()) hdulist["PRIMARY"].header["TELESCOP"] = ("JWST", "comment") hdulist["PRIMARY"].header["FLUXEXT"] = ("ERR", "comment") hdulist["PRIMARY"].header["ERREXT"] = ("ERR", "comment") hdulist["PRIMARY"].header["MASKEXT"] = ("DQ", "comment") # Add ImageHDU for cubes shape = [30, 10, 10] hdulist.append(create_image_hdu(name='SCI', shape=shape, hdrs=[("BUNIT", 'MJy')])) hdulist.append(create_image_hdu(name='ERR', shape=shape, hdrs=[("BUNIT", 'MJy'), ('ERRTYPE', 'ERR')])) hdulist.append(create_image_hdu(name='DQ', shape=shape)) # Mock the ASDF extension hdulist.append(fits.BinTableHDU(name='ASDF')) ff = fits_embed.AsdfInFits(hdulist, tmp_asdf) tmpfile = str(tmpdir.join('jwst_embedded_asdf.fits')) ff.write_to(tmpfile) return hdulist def test_jwst_s3d_single(tmpdir, cube): """Test Spectrum1D.read for JWST x1d data""" tmpfile = str(tmpdir.join('jwst_s3d.fits')) cube.writeto(tmpfile) data = Spectrum1D.read(tmpfile, format='JWST s3d') assert type(data) is Spectrum1D assert data.shape == (10, 10, 30) assert data.uncertainty is not None assert data.mask is not None assert data.uncertainty.unit == 'MJy' ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/twodfgrs_reader.py0000644000503700020070000000472000000000000027121 0ustar00rosteenSTSCI\science00000000000000from astropy.nddata import VarianceUncertainty from astropy.units import Unit from astropy.wcs import WCS from ...spectra import Spectrum1D, SpectrumList from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist def identify_2dfgrs(origin, *args, **kwargs): """ Identify if the current file is a 2dFGRS file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return hdulist[0].header.get("IMAGE", "").strip() in ("SKYCHART", "SPECTRUM") def load_spectrum_from_extension(hdu, primary_header): """ 2dFGRS files contain multiple spectra, this is a helper function to load the spectrum for a single HDU. """ header = hdu.header # Due to the missing information, the WCS needs to be read in manually wcs = WCS(naxis=1) wcs.wcs.cdelt[0] = header["CDELT1"] wcs.wcs.crval[0] = header["CRVAL1"] wcs.wcs.crpix[0] = header["CRPIX1"] wcs.wcs.cunit[0] = Unit("Angstrom") meta = { "header": header, "primary_header": primary_header, } spectrum = hdu.data[0] * Unit("count/s") variance = VarianceUncertainty(hdu.data[1]) sky = hdu.data[2] * Unit("count/s") meta["sky"] = Spectrum1D(flux=sky, wcs=wcs) return Spectrum1D(flux=spectrum, wcs=wcs, meta=meta, uncertainty=variance) @data_loader( "2dFGRS", identifier=identify_2dfgrs, dtype=SpectrumList, extensions=["fit", "fits"], priority=10, ) def twodfgrs_fits_loader(file_obj, **kwargs): """ Load a file from the 2dF Galaxy Redshift Survey. The 2dF Galaxy Redshift Survey (2dFGRS) was a major spectroscopic survey taking full advantage of the unique capabilities of the 2dF facility built by the Anglo-Australian Observatory. The 2dFGRS is integrated with the 2dF QSO survey. Further details can be seen at http://www.2dfgrs.net/ or at https://docs.datacentral.org.au/. Parameters ---------- file_obj: str or HDUList The path to the FITS file, or the loaded file as an HDUList Returns ------- data: SpectrumList The 2dFGRS spectra represented by the data in this file. """ spectra = [] with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: primary_header = hdulist[0].header for hdu in hdulist: if hdu.header.get("IMAGE", "").strip() == "SKYCHART": continue spectra.append(load_spectrum_from_extension(hdu, primary_header)) return SpectrumList(spectra) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/twoslaq_lrg.py0000644000503700020070000000733300000000000026301 0ustar00rosteenSTSCI\science00000000000000from astropy.units import Unit from astropy.wcs import WCS from ...spectra import Spectrum1D, SpectrumList from ..registers import data_loader from ..parsing_utils import read_fileobj_or_hdulist def identify_2slaq_lrg(origin, *args, **kwargs): """ Identify if the current file is a 2SLAQ-LRG file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: if hdulist[0].header["MSTITLE"].startswith("2dF-SDSS LRG/QSO survey"): # apparently the QSO part doesn't have MSTITLE, so we should be safe # with just the above condition, but just in case, we know they have # a different structure (LRG has one ext, QSO has more; LRG is 3d, # QSO is 1d if len(hdulist) == 1 and hdulist[0].data.ndim == 3: return True @data_loader( "2SLAQ-LRG", identifier=identify_2slaq_lrg, dtype=SpectrumList, extensions=["fit", "fits"], priority=10, ) def twoslaq_lrg_fits_loader(file_obj, **kwargs): """ Load a file from the LRG subset of the 2dF-SDSS LRG/QSO survey (2SLAQ-LRG) file. 2SLAQ was one of a number of surveys that used the 2dF instrument on the Anglo-Australian Telescope (AAT) at Siding Spring Observatory (SSO) near Coonabarabran, Australia, and followed up the 2QZ survey. Further details can be seen at http://www.physics.usyd.edu.au/2slaq/ or at https://docs.datacentral.org.au/. The LRG and QSO data appear to be in different formats, this only loads the LRG version. As there is a science and sky spectrum, the `SpectrumList` loader provides both, whereas the `Spectrum1D` loader only provides the science. Parameters ---------- file_name: str The path to the FITS file Returns ------- data: SpectrumList The 2SLAQ-LRG spectrum that is represented by the data in this file. """ with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header spectrum = hdulist[0].data[0, 0] * Unit("count/s") sky = hdulist[0].data[1, 0] * Unit("count/s") # Due to the odd structure of the file, the WCS needs to be read in # manually wcs = WCS(naxis=1) wcs.wcs.cdelt[0] = header["CD1_1"] wcs.wcs.crval[0] = header["CRVAL1"] wcs.wcs.crpix[0] = header["CRPIX1"] wcs.wcs.cunit[0] = Unit("Angstrom") meta = {"header": header} return SpectrumList([ Spectrum1D(flux=spectrum, wcs=wcs, meta=meta), Spectrum1D(flux=sky, wcs=wcs, meta=meta), ]) # Commented out until discussion about whether to provide science-only or not # @data_loader("2SLAQ-LRG", identifier=identify_2slaq_lrg,dtype=Spectrum1D, # extensions=["fit", "fits"]) # def twoslaq_lrg_fits_loader_only_science(filename, **kwargs): # """ # Load a file from the LRG subset of the 2dF-SDSS LRG/QSO survey (2SLAQ-LRG) # file. # # 2SLAQ was one of a number of surveys that used the 2dF instrument on the # Anglo-Australian Telescope (AAT) at Siding Spring Observatory (SSO) near # Coonabarabran, Australia, and followed up the 2QZ survey. Further details # can be seen at http://www.physics.usyd.edu.au/2slaq/ or at # https://docs.datacentral.org.au/. # # The LRG and QSO data appear to be in different formats, this only loads the # LRG version. As there is a science and sky spectrum, the `SpectrumList` # loader provides both, whereas the `Spectrum1D` loader only provides the # science. # # Parameters # ---------- # file_name: str # The path to the FITS file # Returns # ------- # data: Spectrum1D # The 2SLAQ-LRG spectrum that is represented by the data in this file. # """ # return SpectrumList.read(filename, format="2SLAQ-LRG")[0] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/wcs_fits.py0000644000503700020070000006535100000000000025570 0ustar00rosteenSTSCI\science00000000000000import warnings import _io import logging from astropy import units as u from astropy.io import fits from astropy.wcs import WCS, _wcs from astropy.modeling import models from astropy.utils.exceptions import AstropyUserWarning import numpy as np import shlex from ...spectra import Spectrum1D, SpectrumCollection from ..registers import data_loader, custom_writer from ..parsing_utils import read_fileobj_or_hdulist __all__ = ['wcs1d_fits_loader', 'non_linear_wcs1d_fits', 'non_linear_multispec_fits'] log = logging.getLogger(__name__) def identify_wcs1d_fits(origin, *args, **kwargs): """ Check whether given input is FITS and has WCS definition of WCSDIM=1 in specified (default primary) HDU. This is used for Astropy I/O Registry. On writing check if filename conforms to naming convention for this format. """ whdu = kwargs.get('hdu', 1) # Default FITS format is BINTABLE in 1st extension HDU, unless IMAGE is # indicated via naming pattern or (explicitly) selecting primary HDU. if origin == 'write': return ((args[0].endswith(('wcs.fits', 'wcs1d.fits', 'wcs.fit')) or (args[0].endswith(('.fits', '.fit')) and whdu == 0)) and not hasattr(args[2], 'uncertainty')) hdu = kwargs.get('hdu', 0) # Check if number of axes is one and dimension of WCS is one with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return (hdulist[hdu].header.get('WCSDIM', 1) == 1 and (hdulist[hdu].header['NAXIS'] == 1 or hdulist[hdu].header.get('WCSAXES', 0) == 1 )and not hdulist[hdu].header.get('MSTITLE', 'n').startswith('2dF-SDSS LRG') and not # Check in CTYPE1 key for linear solution (single spectral axis) hdulist[hdu].header.get('CTYPE1', 'w').upper().startswith('MULTISPE')) @data_loader("wcs1d-fits", identifier=identify_wcs1d_fits, dtype=Spectrum1D, extensions=['fits', 'fit'], priority=5) def wcs1d_fits_loader(file_obj, spectral_axis_unit=None, flux_unit=None, hdu=0, **kwargs): """ Loader for single spectrum-per-HDU spectra in FITS files, with the spectral axis stored in the header as FITS-WCS. The flux unit of the spectrum is determined by the 'BUNIT' keyword of the HDU (if present), while the spectral axis unit is set by the WCS's 'CUNIT'. Parameters ---------- file_obj : str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). spectral_axis_unit : :class:`~astropy.units.Unit` or str, optional Units of the spectral axis. If not given (or None), the unit will be inferred from the CUNIT in the WCS. Note that if this is provided it will *override* any units the CUNIT specifies. The WCS CUNIT will be obtained by default from the header CUNIT1 card; if missing, the loader will try to extract it from the WAT1_001 card. flux_unit : :class:`~astropy.units.Unit` or str, optional Units of the flux for this spectrum. If not given (or None), the unit will be inferred from the BUNIT keyword in the header. Note that this unit will attempt to convert from BUNIT if BUNIT is present. hdu : int The index of the HDU to load into this spectrum. Returns ------- :class:`~specutils.Spectrum1D` Notes ----- Loader contributed by Kelle Cruz. """ log.info("Spectrum file looks like wcs1d-fits") with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[hdu].header wcs = WCS(header) if 'BUNIT' in header: data = u.Quantity(hdulist[hdu].data, unit=header['BUNIT']) if flux_unit is not None: data = data.to(flux_unit) else: data = u.Quantity(hdulist[hdu].data, unit=flux_unit) if spectral_axis_unit is not None: wcs.wcs.cunit[0] = str(spectral_axis_unit) elif wcs.wcs.cunit[0] == '' and 'WAT1_001' in header: # Try to extract from IRAF-style card or use Angstrom as default. wat_dict = dict((rec.split('=') for rec in header['WAT1_001'].split())) unit = wat_dict.get('units', 'Angstrom') if hasattr(u, unit): wcs.wcs.cunit[0] = unit else: # try with unit name stripped of excess plural 's'... wcs.wcs.cunit[0] = unit.rstrip('s') log.info(f"Extracted spectral axis unit '{unit}' from 'WAT1_001'") elif wcs.wcs.cunit[0] == '': wcs.wcs.cunit[0] = 'Angstrom' # Compatibility attribute for lookup_table (gwcs) WCS wcs.unit = tuple(wcs.wcs.cunit) meta = {'header': header} if wcs.naxis > 4: raise ValueError('FITS file input to wcs1d_fits_loader is > 4D') elif wcs.naxis > 1: for i in range(wcs.naxis - 1, 0, -1): try: wcs = wcs.dropaxis(i) except(_wcs.NonseparableSubimageCoordinateSystemError) as e: raise ValueError(f'WCS cannot be reduced to 1D: {e} {wcs}') return Spectrum1D(flux=data, wcs=wcs, meta=meta) @custom_writer("wcs1d-fits") def wcs1d_fits_writer(spectrum, file_name, hdu=0, update_header=False, **kwargs): """ Write spectrum with spectral axis defined by its WCS to (primary) IMAGE_HDU of a FITS file. Parameters ---------- spectrum : :class:`~specutils.Spectrum1D` file_name : str The path to the FITS file hdu : int Header Data Unit in FITS file to write to (base 0; default primary HDU) update_header : bool Update FITS header with all compatible entries in `spectrum.meta` unit : str or :class:`~astropy.units.Unit` Unit for the flux (and associated uncertainty) dtype : str or :class:`~numpy.dtype` Floating point type for storing flux array """ # Create HDU list from WCS try: wcs = spectrum.wcs hdulist = wcs.to_fits() header = hdulist[0].header except AttributeError as err: raise ValueError(f'Only Spectrum1D objects with valid WCS can be written as wcs1d: {err}') # Verify spectral axis constructed from WCS wl = spectrum.spectral_axis dwl = (wcs.all_pix2world(np.arange(len(wl)), 0) - wl.value) / wl.value if np.abs(dwl).max() > 1.e-10: m = np.abs(dwl).argmax() raise ValueError('Relative difference between WCS spectral axis and' f'spectral_axis at {m:}: dwl[m]') if update_header: hdr_types = (str, int, float, complex, bool, np.floating, np.integer, np.complexfloating, np.bool_) header.update([keyword for keyword in spectrum.meta.items() if (isinstance(keyword[1], hdr_types) and keyword[0] not in ('NAXIS', 'NAXIS1', 'NAXIS2'))]) # Cannot include uncertainty in IMAGE_HDU - maybe provide option to # separately write this to BINARY_TBL extension later. if spectrum.uncertainty is not None: warnings.warn("Saving uncertainties in wcs1d format is not yet supported!", AstropyUserWarning) # Add flux array and unit ftype = kwargs.pop('dtype', spectrum.flux.dtype) funit = u.Unit(kwargs.pop('unit', spectrum.flux.unit)) flux = spectrum.flux.to(funit, equivalencies=u.spectral_density(wl)) hdulist[0].data = flux.value.astype(ftype) if hasattr(funit, 'long_names') and len(funit.long_names) > 0: comment = f'[{funit.long_names[0]}] {funit.physical_type}' else: comment = f'[{funit.to_string()}] {funit.physical_type}' header.insert('CRPIX1', card=('BUNIT', f'{funit}', comment)) # If hdu > 0 selected, prepend empty HDUs # Todo: implement `update` mode to write to existing files while len(hdulist) < hdu + 1: hdulist.insert(0, fits.ImageHDU()) hdulist.writeto(file_name, **kwargs) def identify_iraf_wcs(origin, *args, **kwargs): """ IRAF WCS identifier, checking whether input is FITS and has WCS definition of WCSDIM=2 in specified (default primary) HDU. The difference to wcs1d is that this format supports 2D WCS with non-linear wavelength solutions. This is used for Astropy I/O Registry. """ hdu = kwargs.get('hdu', 0) # Check if dimension of WCS is greater one. with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: return ('WAT1_001' in hdulist[hdu].header and 'WAT2_001' in hdulist[hdu].header and not hdulist[hdu].header.get('MSTITLE', 'n').startswith('2dF-SDSS LRG') and not (hdulist[hdu].header.get('TELESCOP', 't') == 'SDSS 2.5-M' and hdulist[hdu].header.get('FIBERID', 0) > 0) and (hdulist[hdu].header.get('WCSDIM', 1) > 1 or hdulist[hdu].header.get('CTYPE1', '').upper().startswith('MULTISPE'))) # Check if dimension of WCS is greater one. is_wcs = ('WAT1_001' in hdulist[0].header and 'WAT2_001' in hdulist[0].header and not (hdulist[0].header.get('TELESCOP', 'LEVIATHAN') == 'SDSS 2.5-M' and hdulist[0].header.get('FIBERID', 0) > 0) and (hdulist[0].header.get('WCSDIM', 1) == 2 or hdulist[0].header.get('CTYPE1', '').upper().startswith('MULTISPE'))) if not isinstance(args[2], (fits.hdu.hdulist.HDUList, _io.BufferedReader)): hdulist.close() return is_wcs @data_loader('iraf', identifier=identify_iraf_wcs, dtype=Spectrum1D, extensions=['fits']) def non_linear_wcs1d_fits(file_obj, **kwargs): """Load Spectrum1D with WCS spectral axis from FITS files written by IRAF Parameters ---------- file_obj : str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). spectral_axis_unit : :class:`~astropy.units.Unit` or str, optional Spectral axis unit, default is None in which case will search for it in the header under the keyword 'WAT1_001'. Note that if provided this will *override* any units from the header. flux_unit : :class:`~astropy.units.Unit` or str, optional Flux units, default is None. If not specified will attempt to read it using the keyword 'BUNIT' and if this keyword does not exist it will assume 'ADU'. Note that if provided this will *override* any units from the header. Returns ------- :class:`~specutils.Spectrum1D` """ spectral_axis, flux, meta = _read_non_linear_iraf_fits(file_obj, **kwargs) if spectral_axis.ndim > 1: log.info(f'Read spectral axis of shape {spectral_axis.shape} - ' 'consider loading into SpectrumCollection.') spectral_axis = spectral_axis.flatten() # Check for ascending or descending values, as flattening might have joined overlapping orders. ds = (spectral_axis[1:] - spectral_axis[:-1]) / (spectral_axis[-1] - spectral_axis[0]) if ds.min() < 0: log.warning('Non-monotonous spectral axis found, consider loading ' 'into SpectrumCollection.') if flux.shape[-1] != spectral_axis.shape[-1]: if np.prod(flux.shape) == spectral_axis.shape[-1]: flux = flux.flatten() else: raise ValueError('Spectral axis and flux dimensions do not match: ' f'{spectral_axis.shape} != {flux.shape}!') return Spectrum1D(flux=flux, spectral_axis=spectral_axis, meta=meta) @data_loader('iraf', identifier=identify_iraf_wcs, dtype=SpectrumCollection, extensions=['fits']) def non_linear_multispec_fits(file_obj, **kwargs): """Load SpectrumCollection with WCS spectral axes from FITS files written by IRAF Loader for files containing 2D spectra, i.e. flux rows of equal length but different spectral axes, such as the individual orders of an Echelle spectrum. Parameters ---------- file_obj : str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). spectral_axis_unit : :class:`~astropy.units.Unit` or str, optional Spectral axis unit, default is None in which case will search for it in the header under the keyword 'WAT1_001'. Note that if provided this will *override* any units from the header. flux_unit : :class:`~astropy.units.Unit` or str, optional Flux units, default is None. If not specified will attempt to read it using the keyword 'BUNIT' and if this keyword does not exist it will assume 'ADU'. Note that if provided this will *override* any units from the header. Returns ------- :class:`~specutils.SpectrumCollection` """ spectral_axis, flux, meta = _read_non_linear_iraf_fits(file_obj, **kwargs) if spectral_axis.ndim == 1: log.info(f'Read 1D spectral axis of length {spectral_axis.shape[0]} - ' 'consider loading into Spectrum1D.') spectral_axis = spectral_axis.reshape((1, -1)) if flux.ndim == 1: flux = flux.reshape((1, -1)) if spectral_axis.shape != flux.shape: raise ValueError('Spectral axis and flux dimensions do not match: ' f'{spectral_axis.shape} != {flux.shape}!') return SpectrumCollection(flux=flux, spectral_axis=spectral_axis, meta=meta) def _read_non_linear_iraf_fits(file_obj, spectral_axis_unit=None, flux_unit=None, **kwargs): """Read spectrum data with WCS spectral axis from FITS files written by IRAF IRAF does not strictly follow the fits standard especially for non-linear wavelength solutions. Parameters ---------- file_obj : str, file-like or HDUList FITS file name, object (provided from name by Astropy I/O Registry), or HDUList (as resulting from astropy.io.fits.open()). spectral_axis_unit : :class:`~astropy.Unit` or str, optional Spectral axis unit, default is None in which case will search for it in the header under the keyword 'WAT1_001', and if none found there, will assume 'Angstrom'. Note that if provided this will *override* any units from the header. flux_unit : :class:`~astropy.Unit` or str, optional Flux units, default is None. If not specified will attempt to read it using the keyword 'BUNIT' and if this keyword does not exist will assume 'ADU'. Note that if provided this will *override* any units from the header. Returns ------- Tuple of data to pass to SpectrumCollection() or Spectrum1D(): spectral_axis : :class:`~astropy.units.Quantity` The spectral axis or axes as constructed from WCS(hdulist[0].header). flux : :class:`~astropy.units.Quantity` The flux data from hdulist[0].data. meta : dict Dictionary of {'header': hdulist[0].header}. """ log.info('Loading 1D non-linear fits solution') with read_fileobj_or_hdulist(file_obj, **kwargs) as hdulist: header = hdulist[0].header for wcsdim in range(1, header['WCSDIM'] + 1): ctypen = header['CTYPE{:d}'.format(wcsdim)] if ctypen == 'LINEAR': log.info("linear Solution: Try using `format='wcs1d-fits'` instead") wcs = WCS(header) spectral_axis = _read_linear_iraf_wcs(wcs=wcs, dc_flag=header['DC-FLAG']) elif ctypen == 'MULTISPE': log.info("Multi spectral or non-linear solution") spectral_axis = _read_non_linear_iraf_wcs(header=header, wcsdim=wcsdim) else: raise NotImplementedError if flux_unit is not None: data = hdulist[0].data * u.Unit(flux_unit) elif 'BUNIT' in header: data = u.Quantity(hdulist[0].data, unit=header['BUNIT']) else: log.info("Flux unit was not provided, nor found in the header. Assuming ADU.") data = u.Quantity(hdulist[0].data, unit='adu') if spectral_axis_unit is None: # Try to extract from IRAF-style card or use Angstrom as default. wat_dict = dict((rec.split('=') for rec in header['WAT1_001'].split())) unit = wat_dict.get('units', 'Angstrom') if hasattr(u, unit): spectral_axis_unit = unit else: # try with unit name stripped of excess plural 's'... spectral_axis_unit = unit.rstrip('s') log.info(f"Extracted spectral axis unit '{spectral_axis_unit}' from 'WAT1_001'") spectral_axis *= u.Unit(spectral_axis_unit) return spectral_axis, data, dict(header=header) def _read_linear_iraf_wcs(wcs, dc_flag): """Linear solution reader This method read the appropriate keywords. Calls the method _set_math_model which decides what is the appropriate mathematical model to be used and creates and then evaluates the model for an array. Parameters ---------- wcs : :class:`~astropy.wcs.WCS` Contains wcs information extracted from the header dc_flag : int Extracted from the header under the keyword DC-FLAG which defines what kind of solution is described. For linear solutions it is 0 or 1. Returns ------- spectral_axis : :class:`~numpy.ndarray` Mathematical model of wavelength solution evaluated for each pixel position """ wcs_dict = {'crval': wcs.wcs.crval[0], 'crpix': wcs.wcs.crpix[0], 'cdelt': wcs.wcs.cd[0], 'dtype': dc_flag, 'pnum': wcs._naxis[0]} math_model = _set_math_model(wcs_dict=wcs_dict) spectral_axis = math_model(range(wcs_dict['pnum'])) return spectral_axis def _read_non_linear_iraf_wcs(header, wcsdim): """Read non-linear wavelength solutions written by IRAF Extracts the appropriate information and organize it in a dictionary for calling the method _set_math_model which decides what is the appropriate mathematical model to be used according the the type of wavelength solution it is dealing with. Parameters ---------- header : :class:`~astropy.io.fits.header.Header` Full header of file being loaded wcsdim : int Number of the wcs dimension to be read. Returns ------- spectral_axis : :class:`~numpy.ndarray` Mathematical model of wavelength solution evaluated for each pixel position """ wat_wcs_dict = {} wcs_parser = {'aperture': int, 'beam': int, 'dtype': int, 'dstart': float, 'avdelt': float, 'pnum': lambda x: int(float(x)), 'z': float, 'alow': lambda x: int(float(x)), 'ahigh': lambda x: int(float(x)), 'weight': float, 'zeropoint': float, 'ftype': int, 'order': lambda x: int(float(x)), 'pmin': lambda x: int(float(x)), 'pmax': lambda x: int(float(x))} ctypen = header['CTYPE{:d}'.format(wcsdim)] log.info('Attempting to read CTYPE{:d}: {:s}'.format(wcsdim, ctypen)) if ctypen == 'MULTISPE': # This is extracting all header cards for f'WAT{wcsdim}_*' into a list wat_head = header['WAT{:d}*'.format(wcsdim)] if len(wat_head) == 1: log.debug('Get units') wat_array = wat_head[0].split(' ') for pair in wat_array: split_pair = pair.split('=') wat_wcs_dict[split_pair[0]] = split_pair[1] elif len(wat_head) > 1: wat_string = '' for key in wat_head: wat_string += f'{header[key]:68s}' # Keep header from stripping trailing blanks! wat_array = shlex.split(wat_string.replace('=', ' ')) if len(wat_array) % 2 == 0: for i in range(0, len(wat_array), 2): # if wat_array[i] not in wcs_dict.keys(): wat_wcs_dict[wat_array[i]] = wat_array[i + 1] # print(wat_array[i], wat_array[i + 1]) for key in wat_wcs_dict.keys(): log.debug("{:d} -{:s}- {:s}".format(wcsdim, key, wat_wcs_dict[key])) specn = [k for k in wat_wcs_dict.keys() if k.startswith('spec')] spectral_axis = np.empty((len(specn), header['NAXIS1'])) for n, sp in enumerate(specn): spec = wat_wcs_dict[sp].split() wcs_dict = dict((k, wcs_parser[k](spec[i])) for i, k in enumerate(wcs_parser.keys())) wcs_dict['fpar'] = [float(i) for i in spec[15:]] log.debug(f'Retrieving model for {sp}: {wcs_dict["dtype"]} {wcs_dict["ftype"]}') math_model = _set_math_model(wcs_dict=wcs_dict) spectral_axis[n] = math_model(range(1, wcs_dict['pnum'] + 1)) / (1. + wcs_dict['z']) log.info(f'Constructed spectral axis of shape {spectral_axis.shape}') return spectral_axis def _set_math_model(wcs_dict): """Defines a mathematical model of the wavelength solution Uses 2 keywords to decide which model is to be built and calls the appropriate function. dtype: -1: None, no wavelength solution available 0: Linear wavelength solution 1: Log-Linear wavelength solution (not implemented) 2: Non-Linear solutions ftype: 1: Chebyshev 2: Legendre 3: Linear Spline (not implemented) 4: Cubic Spline (not implemented) 5: Pixel Coordinates (not implemented) Not implemented models could be implemented on user-request. Parameters ---------- wcs_dict : dict Contains all the necessary wcs information needed for building any of models supported. Returns ------- The mathematical model which describes the transformation from pixel to wavelength. An instance of `~astropy.modeling.Model`. """ if wcs_dict['dtype'] == -1: log.debug('No wavelength solution found (DTYPE={dtype:d})'.format(**wcs_dict)) return _none() elif wcs_dict['dtype'] == 0: log.debug('Setting model for DTYPE={dtype:d}'.format(**wcs_dict)) return _linear_solution(wcs_dict=wcs_dict) elif wcs_dict['dtype'] == 1: log.debug('Setting model for DTYPE={dtype:d}'.format(**wcs_dict)) return _log_linear(wcs_dict=wcs_dict) elif wcs_dict['dtype'] == 2: log.debug('Setting model for DTYPE={dtype:d} FTYPE={ftype:d}'.format(**wcs_dict)) if wcs_dict['ftype'] == 1: return _chebyshev(wcs_dict=wcs_dict) elif wcs_dict['ftype'] == 2: return _non_linear_legendre(wcs_dict=wcs_dict) elif wcs_dict['ftype'] == 3: return _non_linear_cspline(wcs_dict=wcs_dict) elif wcs_dict['ftype'] == 4: return _non_linear_lspline(wcs_dict=wcs_dict) elif wcs_dict['ftype'] == 5: # pixel coordinates raise NotImplementedError elif wcs_dict['ftype'] == 6: # sampled coordinate array raise NotImplementedError else: raise SyntaxError('ftype {:d} is not defined in the ' 'standard'.format(wcs_dict['ftype'])) else: raise SyntaxError('dtype {:d} is not defined in the ' 'standard'.format(wcs_dict['dtype'])) def _none(): """Required to handle No-wavelength solution No wavelength solution is considered in the FITS standard (dtype = -1) This will return the identity function. It does not use `~astropy.modeling.models.Identity` because is not simpler to instantiate. Instead it uses `~astropy.modeling.models.Linear1D` Rretuns ------- A mathematical model instance of `~astropy.modeling.models.Linear1D` with slope 1 and intercept 0. """ model = models.Linear1D(slope=1, intercept=0) return model def _linear_solution(wcs_dict): """Constructs a Linear1D model based on the WCS information obtained from the header. """ intercept = wcs_dict['crval'] - (wcs_dict['crpix'] - 1) * wcs_dict['cdelt'] model = models.Linear1D(slope=wcs_dict['cdelt'], intercept=intercept) return model def _log_linear(wcs_dict): """Returns a log linear model of the wavelength solution. Not implemented Raises ------ NotImplementedError """ raise NotImplementedError def _chebyshev(wcs_dict): """Returns a chebyshev model of the wavelength solution. Constructs a Chebyshev1D mathematical model Parameters ---------- wcs_dict : dict Dictionary containing all the wcs information decoded from the header and necessary for constructing the Chebyshev1D model. Returns ------- `~astropy.modeling.Model` """ model = models.Chebyshev1D(degree=wcs_dict['order'] - 1, domain=[wcs_dict['pmin'], wcs_dict['pmax']], ) new_params = [wcs_dict['fpar'][i] for i in range(wcs_dict['order'])] model.parameters = new_params return model def _non_linear_legendre(wcs_dict): """Returns a legendre model Constructs a Legendre1D mathematical model Parameters ---------- wcs_dict : dict Dictionary containing all the wcs information decoded from the header and necessary for constructing the Legendre1D model. Returns ------- :class:`~astropy.modeling.Model` """ model = models.Legendre1D(degree=wcs_dict['order'] - 1, domain=[wcs_dict['pmin'], wcs_dict['pmax']], ) new_params = [wcs_dict['fpar'][i] for i in range(wcs_dict['order'])] model.parameters = new_params return model def _non_linear_lspline(wcs_dict): """Returns a linear spline model of the wavelength solution Not implemented This function should extract certain parameters from the `wcs_dict` parameter and construct a mathematical model that makes the conversion from pixel to wavelength. All the necessary information is already contained in the dictionary so the only work to be done is to make the instantiation of the appropriate subclass of `~astropy.modeling.Model`. Parameters ---------- wcs_dict : dict Contains all the WCS information decoded from an IRAF fits header. Raises ------ NotImplementedError """ raise NotImplementedError('Linear spline is not implemented') def _non_linear_cspline(wcs_dict): """Returns a cubic spline model of the wavelength solution. This function should extract certain parameters from the `wcs_dict` parameter and construct a mathematical model that makes the conversion from pixel to wavelength. All the necessary information is already contained in the dictionary so the only work to be done is to make the instantiation of the appropriate subclass of `~astropy.modeling.Model`. Not implemented Parameters ---------- wcs_dict : dict Contains all the WCS information decoded from an IRAF fits header. Raises ------ NotImplementedError """ raise NotImplementedError('Cubic spline is not implemented') ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/default_loaders/wigglez.py0000644000503700020070000000166200000000000025412 0ustar00rosteenSTSCI\science00000000000000import astropy.io.fits as fits from specutils import SpectrumList from specutils.io.registers import data_loader from .dc_common import FITS_FILE_EXTS, SINGLE_SPLIT_LABEL from ..parsing_utils import read_fileobj_or_hdulist WIGGLEZ_CONFIG = { "hdus": None, "wcs": None, "units": None, "all_standard_units": True, "all_keywords": True, "valid_wcs": True, } def identify_wigglez(origin, *args, **kwargs): """ Identify if the current file is a WiggleZ file """ with read_fileobj_or_hdulist(*args, **kwargs) as hdulist: if '2018MNRAS.474.4151D' in hdulist[0].header.get("REFCODE"): return True return False @data_loader( label="WiggleZ", extensions=FITS_FILE_EXTS, dtype=SpectrumList, identifier=identify_wigglez, priority=10, ) def wigglez_loader(fname): spectra = SpectrumList.read( fname, format=SINGLE_SPLIT_LABEL, **WIGGLEZ_CONFIG ) return spectra ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/parsing_utils.py0000644000503700020070000003125200000000000023466 0ustar00rosteenSTSCI\science00000000000000import numpy as np import os import re import urllib import io import contextlib import logging from astropy.io import fits from astropy.table import Table from astropy.nddata import StdDevUncertainty from astropy.utils.exceptions import AstropyUserWarning import astropy.units as u import warnings from specutils.spectra import Spectrum1D, SpectrumCollection log = logging.getLogger(__name__) @contextlib.contextmanager def read_fileobj_or_hdulist(*args, **kwargs): """ Context manager for reading a filename or file object Returns ------- hdulist : :class:`~astropy.io.fits.HDUList` Provides a generator-iterator representing the open file object handle. """ # Access the fileobj or filename arg # Do this so identify functions are useable outside of Spectrum1d.read context try: fileobj = args[2] except IndexError: fileobj = args[0] if isinstance(fileobj, fits.hdu.hdulist.HDUList): if fits.util.fileobj_closed(fileobj): hdulist = fits.open(fileobj.name, **kwargs) else: hdulist = fileobj elif isinstance(fileobj, io.BufferedReader): hdulist = fits.open(fileobj) else: hdulist = fits.open(fileobj, **kwargs) try: yield hdulist # Cleanup even after identifier function has thrown an exception: rewind generic file handles. finally: if not isinstance(fileobj, fits.hdu.hdulist.HDUList): try: fileobj.seek(0) except (AttributeError, io.UnsupportedOperation): hdulist.close() def spectrum_from_column_mapping(table, column_mapping, wcs=None): """ Given a table and a mapping of the table column names to attributes on the Spectrum1D object, parse the information into a Spectrum1D. Parameters ---------- table : :class:`~astropy.table.Table` The table object (e.g. returned from ``Table.read('data_file')``). column_mapping : dict A dictionary describing the relation between the table columns and the arguments of the `Spectrum1D` class, along with unit information. The dictionary keys should be the table column names while the values should be a two-tuple where the first element is the associated `Spectrum1D` keyword argument, and the second element is the unit for the file column (or ``None`` to take unit from the table):: column_mapping = {'FLUX': ('flux', 'Jy'), 'WAVE': ('spectral_axis'spectral_axisu', 'um')} wcs : :class:`~astropy.wcs.WCS` or :class:`gwcs.WCS` WCS object passed to the Spectrum1D initializer. Returns ------- :class:`~specutils.Spectrum1D` The spectrum with 'spectral_axis', 'flux' and optionally 'uncertainty' as identified by `column_mapping`. """ spec_kwargs = {} # Associate columns of the file with the appropriate spectrum1d arguments for col_name, (kwarg_name, cm_unit) in column_mapping.items(): # If the table object couldn't parse any unit information, # fallback to the column mapper defined unit tab_unit = table[col_name].unit if tab_unit and cm_unit is not None: # If the table unit is defined, retrieve the quantity array for # the column kwarg_val = u.Quantity(table[col_name], tab_unit) # Attempt to convert the table unit to the user-defined unit. log.debug("Attempting auto-convert of table unit '%s' to " "user-provided unit '%s'.", tab_unit, cm_unit) if not isinstance(cm_unit, u.Unit): cm_unit = u.Unit(cm_unit) if cm_unit.physical_type in ('length', 'frequency', 'energy'): # Spectral axis column information kwarg_val = kwarg_val.to(cm_unit, equivalencies=u.spectral()) elif 'spectral flux' in str(cm_unit.physical_type): # Flux/error column information kwarg_val = kwarg_val.to(cm_unit, equivalencies=u.spectral_density(1 * u.AA)) elif tab_unit: # The user has provided no unit in the column mapping, so we # use the unit as defined in the table object. kwarg_val = u.Quantity(table[col_name], tab_unit) elif cm_unit is not None: # In this case, the user has defined a unit in the column mapping # but no unit has been defined in the table object. kwarg_val = u.Quantity(table[col_name], cm_unit) else: # Neither the column mapping nor the table contain unit information. # This may be desired e.g. for the mask or bit flag arrays. kwarg_val = table[col_name] # Transpose > 1D data to row-major format if kwarg_val.ndim > 1: kwarg_val = kwarg_val.T spec_kwargs.setdefault(kwarg_name, kwarg_val) # Ensure that the uncertainties are a subclass of NDUncertainty if spec_kwargs.get('uncertainty') is not None: spec_kwargs['uncertainty'] = StdDevUncertainty( spec_kwargs.get('uncertainty')) return Spectrum1D(**spec_kwargs, wcs=wcs, meta={'header': table.meta}) def generic_spectrum_from_table(table, wcs=None, **kwargs): """ Load spectrum from an Astropy table into a Spectrum1D object. Uses the following logic to figure out which column is which: * Spectral axis (dispersion) is the first column with units compatible with u.spectral() or with length units such as 'pix'. * Flux is taken from the first column with units compatible with u.spectral_density(), or with other likely culprits such as 'adu' or 'cts/s'. * Uncertainty comes from the next column with the same units as flux. Parameters ---------- file_name: str The path to the ECSV file wcs : :class:`~astropy.wcs.WCS` A FITS WCS object. If this is present, the machinery will fall back to using the wcs to find the dispersion information. Returns ------- :class:`~specutils.Spectrum1D` The spectrum that is represented by the data from the columns as automatically identified above. Raises ------ Warns if uncertainty has zeros or negative numbers. Raises IOError if it can't figure out the columns. """ # Local function to find the wavelength or frequency column def _find_spectral_axis_column(table, columns_to_search): """ Figure out which column in a table holds the spectral axis (dispersion). Take the first column that has units compatible with u.spectral() equivalencies. If none meet that criterion, look for other likely length units such as 'pix'. """ additional_valid_units = [u.Unit('pix')] found_column = None # First, search for a column with units compatible with Angstroms for c in columns_to_search: try: table[c].to("AA", equivalencies=u.spectral()) found_column = c break except Exception: continue # If no success there, check for other possible length units if found_column is None: for c in columns_to_search: if table[c].unit in additional_valid_units: found_column = c break return found_column # Local function to find the flux column def _find_spectral_column(table, columns_to_search, spectral_axis): """ Figure out which column in a table holds the fluxes or uncertainties. Take the first column that has units compatible with u.spectral_density() equivalencies. If none meet that criterion, look for other likely length units such as 'adu' or 'cts/s'. """ additional_valid_units = [u.Unit('adu'), u.Unit('ct/s')] found_column = None # First, search for a column with units compatible with Janskies for c in columns_to_search: try: # Check for multi-D flux columns if table[c].ndim == 1: spec_ax = spectral_axis else: # Assume leading dimension corresponds to spectral_axis spec_shape = np.ones(table[c].ndim, dtype=np.int) spec_shape[0] = -1 spec_ax = spectral_axis.reshape(spec_shape) table[c].to("Jy", equivalencies=u.spectral_density(spec_ax)) found_column = c break except Exception: continue # If no success there, check for other possible flux units if found_column is None: for c in columns_to_search: if table[c].unit in additional_valid_units: found_column = c break return found_column # Make a copy of the column names so we can remove them as they are found colnames = table.colnames.copy() # Use the first column that has spectral unit as the dispersion axis spectral_axis_column = _find_spectral_axis_column(table, colnames) if spectral_axis_column is None and wcs is None: raise IOError("Could not identify column containing the wavelength, frequency or energy") elif wcs is not None: spectral_axis = None else: spectral_axis = table[spectral_axis_column].to(table[spectral_axis_column].unit) colnames.remove(spectral_axis_column) # Use the first column that has a spectral_density equivalence as the flux flux_column = _find_spectral_column(table, colnames, spectral_axis) if flux_column is None: raise IOError("Could not identify column containing the flux") flux = table[flux_column].to(table[flux_column].unit) colnames.remove(flux_column) # For > 1D data transpose to row-major format if flux.ndim > 1: flux = flux.T # Use the next column with the same units as flux as the uncertainty # Interpret it as a standard deviation and check if it has zeros or negative values err_column = None for c in colnames: if table[c].unit == table[flux_column].unit: err_column = c break if err_column is not None: if table[err_column].ndim > 1: err = table[err_column].T elif flux.ndim > 1: # Repeat uncertainties over all flux columns err = np.tile(table[err_column], flux.shape[0], 1) else: err = table[err_column] err = StdDevUncertainty(err.to(err.unit)) if np.min(table[err_column]) <= 0.: warnings.warn("Standard Deviation has values of 0 or less", AstropyUserWarning) else: err = None # Create the Spectrum1D object and return it if wcs is not None or spectral_axis_column is not None and flux_column is not None: # For > 1D spectral axis transpose to row-major format and return SpectrumCollection spectrum = Spectrum1D(flux=flux, spectral_axis=spectral_axis, uncertainty=err, meta={'header': table.meta}, wcs=wcs) return spectrum def _fits_identify_by_name(origin, fileinp, *args, pattern=r'(?i).*\.fit[s]?$', **kwargs): """ Check whether input file is FITS and matches a given name pattern. Utility function to construct an `identifier` for Astropy I/O Registry. Parameters ---------- fileinp : str or file-like object FITS file name or object (provided from name by Astropy I/O Registry). pattern : regex str or re.Pattern File name pattern to be matched. Note: loaders should define a pattern sufficiently specific for their spectrum file types to avoid ambiguous/multiple matches. """ fileobj = None filepath = None if pattern is None: pattern = r'' _spec_pattern = re.compile(pattern) if isinstance(fileinp, str): filepath = fileinp try: fileobj = open(filepath, mode='rb') except FileNotFoundError: # Check if path points to valid url try: fileinp = urllib.request.urlopen(filepath) except ValueError: return False elif fits.util.isfile(fileinp): fileobj = fileinp filepath = fileobj.name # Check for `urlopen` object - can only probe content if seekable if hasattr(fileinp, 'url') and hasattr(fileinp, 'seekable'): filepath = urllib.parse.unquote(fileinp.url) if fileinp.seekable(): fileobj = fileinp check = (_spec_pattern.match(os.path.basename(filepath)) is not None and fits.connect.is_fits(origin, filepath, fileobj, *args)) if fileobj is not None: fileobj.close() return check ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/io/registers.py0000644000503700020070000002145400000000000022615 0ustar00rosteenSTSCI\science00000000000000""" A module containing the mechanics of the specutils io registry. """ import inspect import os import pathlib import sys from functools import wraps import logging from astropy.io import registry as io_registry from ..spectra import Spectrum1D, SpectrumList, SpectrumCollection __all__ = ['data_loader', 'custom_writer', 'get_loaders_by_extension', 'identify_spectrum_format'] log = logging.getLogger(__name__) def _astropy_has_priorities(): """ Check if astropy has support for loader priorities """ sig = inspect.signature(io_registry.register_reader) if sig.parameters.get("priority") is not None: return True return False def data_loader(label, identifier=None, dtype=Spectrum1D, extensions=None, priority=0, force=False): """ Wraps a function that can be added to an `~astropy.io.registry` for custom file reading. Parameters ---------- label : str The label given to the function inside the registry. identifier : func The identified function used to verify that a file is to use a particular file. dtype : class A class reference for which the data loader should be store. extensions : list A list of file extensions this loader supports loading from. In the case that no identifier function is defined, but a list of file extensions is, a simple identifier function will be created to check for consistency with the extensions. priority : int Set the priority of the loader. Currently influences the sorting of the returned loaders for a dtype. force : bool, optional Whether to override any existing function if already present. Default is ``False``. Passed down to astropy registry. """ def identifier_wrapper(ident): def wrapper(*args, **kwargs): '''In case the identifier function raises an exception, log that and continue''' try: return ident(*args, **kwargs) except Exception as e: log.debug("Tried to read this as {} file, but could not.".format(label)) log.debug(e, exc_info=True) return False return wrapper def decorator(func): if _astropy_has_priorities(): io_registry.register_reader( label, dtype, func, priority=priority, force=force, ) else: io_registry.register_reader( label, dtype, func, force=force, ) if identifier is None: # If the identifier is not defined, but the extensions are, create # a simple identifier based off file extension. if extensions is not None: log.info("'{}' data loader provided for {} without " "explicit identifier. Creating identifier using " "list of compatible extensions".format( label, dtype.__name__)) id_func = lambda *args, **kwargs: any([args[1].endswith(x) for x in extensions]) # Otherwise, create a dummy identifier else: log.warning("'{}' data loader provided for {} without " "explicit identifier or list of compatible " "extensions".format(label, dtype.__name__)) id_func = lambda *args, **kwargs: True else: id_func = identifier_wrapper(identifier) io_registry.register_identifier( label, dtype, id_func, force=force, ) # Include the file extensions as attributes on the function object func.extensions = extensions log.debug("Successfully loaded reader \"{}\".".format(label)) # Automatically register a SpectrumList reader for any data_loader that # reads Spectrum1D objects. TODO: it's possible that this # functionality should be opt-in rather than automatic. if dtype is Spectrum1D: def load_spectrum_list(*args, **kwargs): return SpectrumList([ func(*args, **kwargs) ]) # Add these attributes to the SpectrumList reader as well load_spectrum_list.extensions = extensions load_spectrum_list.priority = priority if _astropy_has_priorities(): io_registry.register_reader( label, SpectrumList, load_spectrum_list, priority=priority, force=force, ) else: io_registry.register_reader( label, SpectrumList, load_spectrum_list, force=force, ) io_registry.register_identifier( label, SpectrumList, id_func, force=force, ) log.debug("Created SpectrumList reader for \"{}\".".format(label)) @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper return decorator def custom_writer(label, dtype=Spectrum1D, priority=0, force=False): def decorator(func): if _astropy_has_priorities(): io_registry.register_writer( label, dtype, func, priority=priority, force=force, ) else: io_registry.register_writer(label, dtype, func, force=force) @wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) return wrapper return decorator def get_loaders_by_extension(extension): """ Retrieve a list of loader labels associated with a given extension. Parameters ---------- extension : str The extension for which associated loaders will be matched against. Returns ------- loaders : list A list of loader names that are associated with the extension. """ def _registered_readers(): # With the implementation of priorities support in the astropy registry # loaders, astropy version 4.2 and up return a tuple of ``func`` and # ``priority``, while versions < 4.2 return just the ``func`` object. # This function ignores priorities when calling extension loaders. return [((fmt, cls), func[0]) if isinstance(func, tuple) else ((fmt, cls), func) for (fmt, cls), func in io_registry._readers.items()] return [fmt for (fmt, cls), func in _registered_readers() if issubclass(cls, Spectrum1D) and func.extensions is not None and extension in func.extensions] def _load_user_io(): # Get the path relative to the user's home directory path = os.path.expanduser("~/.specutils") # Import all python files from the directory if it exists if os.path.exists(path): for file in os.listdir(path): if not file.endswith("py"): continue try: import importlib.util as util spec = util.spec_from_file_location(file[:-3], os.path.join(path, file)) mod = util.module_from_spec(spec) spec.loader.exec_module(mod) except ImportError: from importlib import import_module sys.path.insert(0, path) try: import_module(file[:-3]) except ModuleNotFoundError: # noqa pass def identify_spectrum_format(filename, dtype=Spectrum1D): """ Attempt to identify a spectrum file format Given a filename, attempts to identify a valid file format from the list of registered specutils loaders. Essentially a wrapper for `~astropy.io.registry.identify_format` setting **origin** to ``read`` and **data_class_required** to `~specutils.Spectrum1D`. Parameters ---------- filename : str A path to a file to be identified dtype: object class type of Spectrum1D, SpectrumList, or SpectrumCollection. Default is Spectrum1D. Returns ------- valid_format : list, str A list of valid file formats. If only one valid format found, returns just that element. """ # check for valid string input if not isinstance(filename, (str, pathlib.Path)) or not os.path.isfile(filename): raise ValueError(f'{filename} is not a valid string path to a file') # check for proper class type assert dtype in \ [Spectrum1D, SpectrumList, SpectrumCollection], \ 'dtype class must be either Spectrum1D, SpectrumList, or SpectrumCollection' # identify the file format valid_format = io_registry.identify_format( 'read', dtype, filename, None, {}, {}) if valid_format and len(valid_format) == 1: return valid_format[0] return valid_format ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7386026 specutils-1.6.0/specutils/manipulation/0000755000503700020070000000000000000000000022317 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/manipulation/__init__.py0000644000503700020070000000032600000000000024431 0ustar00rosteenSTSCI\science00000000000000from .smoothing import * # noqa from .estimate_uncertainty import * # noqa from .extract_spectral_region import * # noqa from .utils import * # noqa from .manipulation import * # noqa from .resample import * ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/manipulation/estimate_uncertainty.py0000644000503700020070000000504100000000000027131 0ustar00rosteenSTSCI\science00000000000000import numpy as np from astropy import units as u from .. import Spectrum1D, SpectralRegion from astropy.nddata.nduncertainty import StdDevUncertainty, VarianceUncertainty, InverseVariance from .extract_spectral_region import extract_region __all__ = ['noise_region_uncertainty'] def noise_region_uncertainty(spectrum, spectral_region, noise_func=np.std): """ Generates a new spectrum with an uncertainty from the noise in a particular region of the spectrum. Parameters ---------- spectrum : `~specutils.Spectrum1D` The spectrum to which we want to set the uncertainty. spectral_region : `~specutils.SpectralRegion` The region to use to calculate the standard deviation. noise_func : callable A function which takes the (1D) flux in the ``spectral_region`` and yields a *single* value for the noise to use in the result spectrum. Returns ------- spectrum_uncertainty : `~specutils.Spectrum1D` The ``spectrum``, but with a constant uncertainty set by the result of the noise region calculation """ # Extract the sub spectrum based on the region sub_spectra = extract_region(spectrum, spectral_region) # TODO: make this work right for multi-dimensional spectrum1D's? if not isinstance(sub_spectra, list): sub_spectra = [sub_spectra] sub_flux = u.Quantity(np.concatenate([s.flux.value for s in sub_spectra]), spectrum.flux.unit) # Compute the standard deviation of the flux. noise = noise_func(sub_flux) # Uncertainty type will be selected based on the unit coming from the # noise function compared to the original spectral flux units. if noise.unit == spectrum.flux.unit: uncertainty = StdDevUncertainty(noise*np.ones(spectrum.flux.shape)) elif noise.unit == spectrum.flux.unit**2: uncertainty = VarianceUncertainty(noise*np.ones(spectrum.flux.shape)) elif noise.unit == 1/(spectrum.flux.unit**2): uncertainty = InverseVariance(noise*np.ones(spectrum.flux.shape)) else: raise ValueError('Can not determine correct NDData Uncertainty based on units {} relative to the flux units {}'.format(noise.unit, spectrum.flux.unit)) # Return new specturm with uncertainty set. return Spectrum1D(flux=spectrum.flux, spectral_axis=spectrum.spectral_axis, uncertainty=uncertainty, wcs=spectrum.wcs, velocity_convention=spectrum.velocity_convention, rest_value=spectrum.rest_value) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/manipulation/extract_spectral_region.py0000644000503700020070000002510500000000000027606 0ustar00rosteenSTSCI\science00000000000000import sys from math import floor, ceil # faster than int(np.floor/ceil(float)) import numpy as np from astropy import units as u from ..spectra import Spectrum1D, SpectralRegion __all__ = ['extract_region', 'extract_bounding_spectral_region', 'spectral_slab'] def _edge_value_to_pixel(edge_value, spectrum, order, side): spectral_axis = spectrum.spectral_axis if order == "ascending": if edge_value > spectral_axis[-1]: return len(spectral_axis) if edge_value < spectral_axis[0]: return 0 elif order == "descending": if edge_value < spectral_axis[-1]: return len(spectral_axis) if edge_value > spectral_axis[0]: return 0 try: if hasattr(spectrum.wcs, "spectral"): index = spectrum.wcs.spectral.world_to_pixel(edge_value) else: index = spectrum.wcs.world_to_pixel(edge_value) if side == "left": index = int(np.ceil(index)) elif side == "right": index = int(np.floor(index)) + 1 return index except Exception as e: raise ValueError(f"Bound {edge_value}, could not be converted to pixel index" f" using spectrum's WCS. Exception: {e}") def _subregion_to_edge_pixels(subregion, spectrum): """ Calculate and return the left and right indices defined by the lower and upper bounds and based on the input `~specutils.spectra.spectrum1d.Spectrum1D`. The left and right indices will be returned. Parameters ---------- spectrum: `~specutils.spectra.spectrum1d.Spectrum1D` The spectrum object from which the region will be extracted. Returns ------- left_index, right_index: int, int Left and right indices defined by the lower and upper bounds. """ spectral_axis = spectrum.spectral_axis if spectral_axis[-1] > spectral_axis[0]: order = "ascending" left_func = min right_func = max else: order = "descending" left_func = max right_func = min # Left/lower side of sub region if subregion[0].unit.is_equivalent(u.pix) and not spectral_axis.unit.is_equivalent(u.pix): left_index = floor(subregion[0].value) else: # Convert lower value to spectrum spectral_axis units left_reg_in_spec_unit = left_func(subregion).to(spectral_axis.unit, u.spectral()) left_index = _edge_value_to_pixel(left_reg_in_spec_unit, spectrum, order, "left") # Right/upper side of sub region if subregion[1].unit.is_equivalent(u.pix) and not spectral_axis.unit.is_equivalent(u.pix): right_index = ceil(subregion[1].value) else: # Convert upper value to spectrum spectral_axis units right_reg_in_spec_unit = right_func(subregion).to(spectral_axis.unit, u.spectral()) right_index = _edge_value_to_pixel(right_reg_in_spec_unit, spectrum, order, "right") return left_index, right_index def extract_region(spectrum, region, return_single_spectrum=False): """ Extract a region from the input `~specutils.Spectrum1D` defined by the lower and upper bounds defined by the ``region`` instance. The extracted region will be returned as a new `~specutils.Spectrum1D`. Parameters ---------- spectrum: `~specutils.Spectrum1D` The spectrum object from which the region will be extracted. region: `~specutils.SpectralRegion` The spectral region to extract from the original spectrum. return_single_spectrum: `bool` If ``region`` has multiple sections, whether to return a single spectrum instead of multiple `~specutils.Spectrum1D` objects. The returned spectrum will be a unique, concatenated, spectrum of all sub-regions. Returns ------- spectrum: `~specutils.Spectrum1D` or list of `~specutils.Spectrum1D` Excised spectrum, or list of spectra if the input region contained multiple subregions and ``return_single_spectrum`` is `False`. Notes ----- The region extracted is a discrete subset of the input spectrum. No interpolation is done on the left and right side of the spectrum. The region is assumed to be a closed interval (as opposed to Python which is open on the upper end). For example: Given: A ``spectrum`` with spectral_axis of ``[0.1, 0.2, 0.3, 0.4, 0.5, 0.6]*u.um``. A ``region`` defined as ``SpectralRegion(0.2*u.um, 0.5*u.um)`` And we calculate ``sub_spectrum = extract_region(spectrum, region)``, then the ``sub_spectrum`` spectral axis will be ``[0.2, 0.3, 0.4, 0.5] * u.um``. If the ``region`` does not overlap with the ``spectrum`` then an empty Spectrum1D object will be returned. """ extracted_spectrum = [] for subregion in region._subregions: left_index, right_index = _subregion_to_edge_pixels(subregion, spectrum) # If both indices are out of bounds then return an empty spectrum if left_index == right_index: empty_spectrum = Spectrum1D(spectral_axis=[]*spectrum.spectral_axis.unit, flux=[]*spectrum.flux.unit) extracted_spectrum.append(empty_spectrum) else: extracted_spectrum.append(spectrum[..., left_index:right_index]) # If there is only one subregion in the region then we will # just return a spectrum. if len(region) == 1: extracted_spectrum = extracted_spectrum[0] # Otherwise, if requested to return a single spectrum, we need to combine # the spectrum1d objects in extracted_spectrum and return a single object. elif return_single_spectrum: concat_keys = ['flux', 'uncertainty', 'mask'] # spectral_axis handled manually copy_keys = ['velocity_convention', 'rest_value', 'meta'] # NOTE: WCS is intentionally dropped, which will then fallback on lookup def _get_joined_value(sps, key, unique_inds=None): if key == 'uncertainty': # uncertainty cannot be appended directly as its an object, # not an array so instead we'll take a copy of the first entry # and overwrite the internal array with an appended array uncert = sps[0].uncertainty if uncert is None: return None uncert._array = np.concatenate([sp.uncertainty._array for sp in sps]) return uncert[unique_inds] if unique_inds is not None else uncert elif key in concat_keys or key == 'spectral_axis': if getattr(sps[0], key) is None: return None concat_arr = np.concatenate([getattr(sp, key) for sp in sps]) return concat_arr[unique_inds] if unique_inds is not None else concat_arr else: # all were extracted from the same input spectrum, so we don't # need to make sure the properties match return getattr(sps[0], key) # we'll need to account for removing overlapped regions in the spectral axis, # so we'll concatenate that first and track the unique indices spectral_axis = _get_joined_value(extracted_spectrum, 'spectral_axis') spectral_axis_unique, unique_inds = np.unique(spectral_axis, return_index=True) return Spectrum1D(spectral_axis=spectral_axis_unique, **{key: _get_joined_value(extracted_spectrum, key, unique_inds) for key in concat_keys+copy_keys}) return extracted_spectrum def spectral_slab(spectrum, lower, upper): """ Extract a slab from the input `~specutils.Spectrum1D` defined by the lower and upper bounds defined by the ``region`` instance. The extracted region will be returned as a new `~specutils.Spectrum1D`. Parameters ---------- spectrum: `~specutils.Spectrum1D` The spectrum object from which the region will be extracted. lower, upper: `~astropy.units.Quantity` The lower and upper bounds of the region to extract from the original spectrum. Returns ------- spectrum: `~specutils.Spectrum1D` or list of `~specutils.Spectrum1D` Excised spectrum, or list of spectra if the input region contained multiple subregions. Notes ----- This is for now just a proxy for function `extract_region`, to ease the transition from spectral-cube. """ region = SpectralRegion(lower, upper) return extract_region(spectrum, region) def extract_bounding_spectral_region(spectrum, region): """ Extract the entire bounding region that encompasses all sub-regions contained in a multi-sub-region instance of `~specutils.SpectralRegion`. In case only one sub-region exists, this method is equivalent to `extract_region`. Parameters ---------- spectrum: `~specutils.Spectrum1D` The spectrum object from which the region will be extracted. region: `~specutils.SpectralRegion` The spectral region to extract from the original spectrum, comprised of one or more sub-regions. Returns ------- spectrum: `~specutils.Spectrum1D` Excised spectrum from the bounding region defined by the set of sub-regions in the input ``region`` instance. """ # If there is only one subregion in the region then we will # just return a spectrum. if len(region) == 1: return extract_region(spectrum, region) min_left = sys.maxsize max_right = -sys.maxsize - 1 # Look for indices that bound the entire set of sub-regions. index_list = [_subregion_to_edge_pixels(sr, spectrum) for sr in region._subregions] for left_index, right_index in index_list: if left_index is not None: min_left = min(left_index, min_left) if right_index is not None: max_right = max(right_index, max_right) # If both indices are out of bounds then return an empty spectrum if min_left is None and max_right is None: empty_spectrum = Spectrum1D(spectral_axis=[]*spectrum.spectral_axis.unit, flux=[]*spectrum.flux.unit) return empty_spectrum else: # If only one index is out of bounds then set it to # the lower or upper extent if min_left is None: min_left = 0 if max_right is None: max_right = len(spectrum.spectral_axis) if min_left > max_right: min_left, max_right = max_right, min_left return spectrum[..., min_left:max_right] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/manipulation/manipulation.py0000644000503700020070000000474000000000000025376 0ustar00rosteenSTSCI\science00000000000000""" A module for analysis tools dealing with uncertainties or error analysis in spectra. """ import copy import numpy as np import operator __all__ = ['snr_threshold'] def snr_threshold(spectrum, value, op=operator.gt): """ Calculate the mean S/N of the spectrum based on the flux and uncertainty in the spectrum. This will be calculated over the regions, if they are specified. Parameters ---------- spectrum : `~specutils.Spectrum1D`, `~specutils.SpectrumCollection` or `~astropy.nddata.NDData` The spectrum object overwhich the S/N threshold will be calculated. value: ``float`` Threshold value to be applied to flux / uncertainty. op: One of operator.gt, operator.ge, operator.lt, operator.le or the str equivalent '>', '>=', '<', '<=' The mathematical operator to apply for thresholding. Returns ------- spectrum: `~specutils.Spectrum1D` Output object with ``spectrum.mask`` set based on threshold. Notes ----- The input object will need to have the uncertainty defined in order for the SNR to be calculated. """ # Setup the mapping operator_mapping = { '>': operator.gt, '<': operator.lt, '>=': operator.ge, '<=': operator.le } if not hasattr(spectrum, 'uncertainty') or spectrum.uncertainty is None: raise Exception("S/N thresholding requires the uncertainty be defined.") if (op not in [operator.gt, operator.ge, operator.lt, operator.le] and op not in operator_mapping.keys()): raise ValueError('Threshold operator must be a string or operator that represents ' + 'greater-than, less-than, greater-than-or-equal or ' + 'less-than-or-equal') # If the operator passed in is a string, then map to the # operator method. if isinstance(op, str): op = operator_mapping[op] # Spectrum1D if hasattr(spectrum, 'flux'): data = spectrum.flux # NDData elif hasattr(spectrum, 'data'): data = spectrum.data * (spectrum.unit if spectrum.unit is not None else 1) else: raise ValueError('Could not find data attribute.') # NDData convention: Masks should follow the numpy convention that valid # data points are marked by False and invalid ones with True. mask = ~op(data / (spectrum.uncertainty.quantity), value) spectrum_out = copy.copy(spectrum) spectrum_out._mask = mask return spectrum_out ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/manipulation/model_replace.py0000644000503700020070000001730400000000000025471 0ustar00rosteenSTSCI\science00000000000000import numpy as np from scipy.interpolate import CubicSpline from astropy.units import Quantity from astropy.modeling import Fittable1DModel from ..spectra import Spectrum1D from ..utils import QuantityModel from . import extract_region def model_replace(spectrum, replace_region, model=10, extrapolation_treatment='data_fill', interpolate_uncertainty=True): """ Generates a new spectrum with a region replaced by a smooth spline. Parameters ---------- spectrum : `~specutils.Spectrum1D` The spectrum to be modified. replace_region : `~specutils.SpectralRegion` Spectral region that specifies the region to be replaced. If None, parameter `model` below should define spline knots explicitly in the form of an `~astropy.units.Quantity` list or array model : An `~astropy.modeling` model object, which is assumed to have already been fit to the spectrum; Or a list or array of spectral axis values to be used as spline knots. They all should share the same units, which can be different from the units of the input spectrum spectral axis, but most be of compatible physical type; Or an integer value that will be used to build a list of equally-spaced knots, based on the `replace_region` instance. extrapolation_treatment : str What to do with data off the edges of the region encompassed by the spline knots. Default is ``'data_fill'`` to have points filled with the input flux values. ``'zero_fill'`` sets them to zero. interpolate_uncertainty : bool If True, the uncertainty, if present in the input spectrum, is also interpolated over the replaced segment. Returns ------- spectrum : `~specutils.Spectrum1D` The spectrum with the region replaced by spline values, and data values or zeros outside the spline region. The spectral axis will have the same units as the spline knots. Examples -------- >>> import numpy as np >>> import astropy.units as u >>> from specutils.spectra.spectrum1d import Spectrum1D >>> from specutils.manipulation.model_replace import model_replace >>> wave_val = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) >>> flux_val = np.array([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]) >>> input_spectrum = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy) >>> spline_knots = [3.5, 4.7, 6.8, 7.1] * u.AA >>> result = model_replace(input_spectrum, None, spline_knots) # doctest: +IGNORE_OUTPUT """ if extrapolation_treatment not in ('data_fill', 'zero_fill'): raise ValueError('invalid extrapolation_treatment value: ' + str(extrapolation_treatment)) # If input model is an array with spline knots and region # is not present, use knots directly to fit spline. if isinstance(model, Quantity) and replace_region is None: new_spectral_axis = spectrum.spectral_axis.to(model.unit) out_flux_val, out_uncert_val = _compute_spline_values(spectrum, model, new_spectral_axis, interpolate_uncertainty) # If input model is an int, use it and the spectral region to build a # list of equally spaced spline knots. elif isinstance(model, int) and replace_region is not None: nknots = max(model, 3) dw = (replace_region.upper - replace_region.lower) / (nknots - 1) spline_knots = [] for k in range(nknots): spline_knots.append(replace_region.lower + k * dw) spline_knots = spline_knots * replace_region.lower.unit new_spectral_axis = spectrum.spectral_axis.to(spline_knots.unit) out_flux_val, out_uncert_val = _compute_spline_values(spectrum, spline_knots, new_spectral_axis, interpolate_uncertainty) # If input model is a fitted model, use it and the spectral region to place the # model values over the relevant stretch of the spectrum's spectral axis. elif (isinstance(model, Fittable1DModel) or (isinstance(model, QuantityModel))) \ and replace_region is not None: new_spectral_axis = spectrum.spectral_axis subspectrum = extract_region(spectrum, replace_region) model_values = model(subspectrum.spectral_axis) out_flux_val = np.full(spectrum.spectral_axis.shape, np.nan) indices_up = np.where(spectrum.spectral_axis >= replace_region.lower) indices_dn = np.where(spectrum.spectral_axis <= replace_region.upper) i = int(indices_up[0][0]) j = int(indices_dn[0][-1]) + 1 out_flux_val[i:j] = model_values # models do not propagate uncertainties. out_uncert_val = None else: raise NotImplementedError("This combination of input parameters is not yet implemented.") # Careful with units handling from here on: astropylts handles the # np.where filter differently than the other supported environments. # Initialize with zero fill. out = np.where(np.isnan(out_flux_val), 0., out_flux_val) * spectrum.flux.unit # Fill extrapolated regions with original flux if extrapolation_treatment == 'data_fill': data = np.where(np.isnan(out_flux_val), spectrum.flux.value, 0.) out += (data * spectrum.flux.unit) # Do the same in case we want to interpolate the uncertainty. # Otherwise, do not propagate uncertainty into output. new_unc = None if out_uncert_val is not None: out_uncert = np.where(np.isnan(out_uncert_val), 0., out_uncert_val) * \ spectrum.uncertainty.unit data = np.where(np.isnan(out_uncert_val), spectrum.uncertainty.quantity.value, 0.) out_uncert += (data * spectrum.uncertainty.unit) new_unc = spectrum.uncertainty.__class__(array=out_uncert, unit=spectrum.uncertainty.unit) return Spectrum1D(spectral_axis=new_spectral_axis, flux=out, uncertainty=new_unc) def _compute_spline_values(spectrum, spline_knots, new_spectral_axis, interpolate_uncertainty): # Compute output flux values interpolated over the spline knots. out_flux_val = _interpolate_spline(spectrum.flux.value, new_spectral_axis, spline_knots) # Do the same in case we want to interpolate the uncertainty. # Otherwise, do not propagate uncertainty into output. out_uncert_val = None if spectrum.uncertainty is not None and interpolate_uncertainty: out_uncert_val = _interpolate_spline(spectrum.uncertainty.quantity, new_spectral_axis, spline_knots) return out_flux_val, out_uncert_val def _interpolate_spline(input_values, spectral_axis, spline_knots): # Create spline to interpolate on input data. # Knots are the spectral axis values themselves. spline_1 = CubicSpline(spectral_axis.value, input_values, extrapolate=False) # Now use that spline interpolator to compute interpolated # values from the input array at each spline knot. values_at_spline_knots = spline_1(spline_knots.value) # Finally, compute another spline interpolator over only the values # at spline knots, and use it to interpolate the output value at each # point on the spectral axis. spline_2 = CubicSpline(spline_knots.value, values_at_spline_knots, extrapolate=False) return spline_2(spectral_axis.value) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/manipulation/resample.py0000644000503700020070000003517300000000000024512 0ustar00rosteenSTSCI\science00000000000000from abc import ABC, abstractmethod from warnings import warn import numpy as np from astropy.nddata import StdDevUncertainty, VarianceUncertainty, \ InverseVariance from astropy.units import Quantity from scipy.interpolate import CubicSpline from ..spectra import Spectrum1D, SpectralAxis __all__ = ['ResamplerBase', 'FluxConservingResampler', 'LinearInterpolatedResampler', 'SplineInterpolatedResampler'] class ResamplerBase(ABC): """ Base class for resample classes. The algorithms and needs for difference resamples will vary quite a bit, so this class is relatively sparse. Parameters ---------- extrapolation_treatment : str What to do when resampling off the edge of the spectrum. Can be ``'nan_fill'`` to have points beyond the edges by set to NaN, or ``'zero_fill'`` to be set to zero. """ def __init__(self, extrapolation_treatment='nan_fill'): if extrapolation_treatment not in ('nan_fill', 'zero_fill'): raise ValueError('invalid extrapolation_treatment value: ' + str(extrapolation_treatment)) self.extrapolation_treatment = extrapolation_treatment def __call__(self, orig_spectrum, fin_spec_axis): """ Return the resulting `~specutils.Spectrum1D` of the resampling. """ return self.resample1d(orig_spectrum, fin_spec_axis) @abstractmethod def resample1d(self, orig_spectrum, fin_spec_axis): """ Workhorse method that will return the resampled Spectrum1D object. """ return NotImplemented class FluxConservingResampler(ResamplerBase): """ This resampling algorithm conserves overall integrated flux (as opposed to flux density). Algorithm based on the equations documented in the following paper: https://ui.adsabs.harvard.edu/abs/2017arXiv170505165C/abstract Parameters ---------- extrapolation_treatment : str What to do when resampling off the edge of the spectrum. Can be ``'nan_fill'`` to have points beyond the edges by set to NaN, or ``'zero_fill'`` to be set to zero. Examples -------- To resample an input spectrum to a user specified spectral grid using a flux conserving algorithm: >>> import numpy as np >>> import astropy.units as u >>> from specutils import Spectrum1D >>> from specutils.manipulation import FluxConservingResampler >>> input_spectra = Spectrum1D( ... flux=np.array([1, 3, 7, 6, 20]) * u.mJy, ... spectral_axis=np.array([2, 4, 12, 16, 20]) * u.nm) >>> resample_grid = [1, 5, 9, 13, 14, 17, 21, 22, 23] *u.nm >>> fluxc_resample = FluxConservingResampler() >>> output_spectrum1D = fluxc_resample(input_spectra, resample_grid) # doctest: +IGNORE_OUTPUT """ def _resample_matrix(self, orig_spec_axis, fin_spec_axis): """ Create a re-sampling matrix to be used in re-sampling spectra in a way that conserves flux. This code was heavily influenced by Nick Earl's resample rough draft: nmearl@0ff6ef1. Parameters ---------- orig_spec_axis : SpectralAxis The original spectral axis array. fin_spec_axis : SpectralAxis The desired spectral axis array. Returns ------- resample_mat : ndarray An [[N_{fin_spec_axis}, M_{orig_spec_axis}]] matrix. """ # Lower bin and upper bin edges orig_edges = orig_spec_axis.bin_edges fin_edges = fin_spec_axis.bin_edges # I could get rid of these alias variables, # but it does add readability orig_low = orig_edges[:-1] fin_low = fin_edges[:-1] orig_upp = orig_edges[1:] fin_upp = fin_edges[1:] # Here's the real work in figuring out the bin overlaps # i.e., contribution of each original bin to the resampled bin l_inf = np.where(orig_low > fin_low[:, np.newaxis], orig_low, fin_low[:, np.newaxis]) l_sup = np.where(orig_upp < fin_upp[:, np.newaxis], orig_upp, fin_upp[:, np.newaxis]) resamp_mat = (l_sup - l_inf).clip(0) resamp_mat = resamp_mat * (orig_upp - orig_low) # set bins that don't overlap 100% with original bins # to zero by checking edges, and applying generated mask left_clip = np.where(fin_edges[:-1] - orig_edges[0] < 0, 0, 1) right_clip = np.where(orig_edges[-1] - fin_edges[1:] < 0, 0, 1) keep_overlapping_matrix = left_clip * right_clip resamp_mat *= keep_overlapping_matrix[:, np.newaxis] return resamp_mat.value def resample1d(self, orig_spectrum, fin_spec_axis): """ Create a re-sampling matrix to be used in re-sampling spectra in a way that conserves flux. If an uncertainty is present in the input spectra it will be propagated through to the final resampled output spectra as an InverseVariance uncertainty. Parameters ---------- orig_spectrum : `~specutils.Spectrum1D` The original 1D spectrum. fin_spec_axis : Quantity The desired spectral axis array. Returns ------- resample_spectrum : `~specutils.Spectrum1D` An output spectrum containing the resampled `~specutils.Spectrum1D` """ # Check if units on original spectrum and new wavelength (if defined) # match if isinstance(fin_spec_axis, Quantity): if orig_spectrum.spectral_axis.unit != fin_spec_axis.unit: raise ValueError("Original spectrum spectral axis grid and new" "spectral axis grid must have the same units.") if not isinstance(fin_spec_axis, SpectralAxis): fin_spec_axis = SpectralAxis(fin_spec_axis) # todo: Would be good to return uncertainty in type it was provided? # todo: add in weighting options # Get provided uncertainty into variance if orig_spectrum.uncertainty is not None: if isinstance(orig_spectrum.uncertainty, StdDevUncertainty): pixel_uncer = np.square(orig_spectrum.uncertainty.array) elif isinstance(orig_spectrum.uncertainty, VarianceUncertainty): pixel_uncer = orig_spectrum.uncertainty.array elif isinstance(orig_spectrum.uncertainty, InverseVariance): pixel_uncer = np.reciprocal(orig_spectrum.uncertainty.array) else: pixel_uncer = None orig_axis_in_fin = orig_spectrum.spectral_axis.to(fin_spec_axis.unit) resample_grid = self._resample_matrix(orig_axis_in_fin, fin_spec_axis) # Now for some broadcasting magic to handle multi dimensional flux inputs # Essentially this part is inserting length one dimensions as fillers # For example, if we have a (5,6,10) input flux, and an output grid # of 3, flux will be broadcast to (5,6,1,10) and resample_grid will # Be broadcast to (1,1,3,10). The sum then reduces down the 10, the # original dispersion grid, leaving 3, the new dispersion grid, as # the last index. new_flux_shape = list(orig_spectrum.flux.shape) new_flux_shape.insert(-1, 1) in_flux = orig_spectrum.flux.reshape(new_flux_shape) ones = [1] * len(orig_spectrum.flux.shape[:-1]) new_shape_resample_grid = ones + list(resample_grid.shape) resample_grid = resample_grid.reshape(new_shape_resample_grid) # Calculate final flux out_flux = np.sum(in_flux * resample_grid, axis=-1) / np.sum( resample_grid, axis=-1) # Calculate output uncertainty if pixel_uncer is not None: pixel_uncer = pixel_uncer.reshape(new_flux_shape) out_variance = np.sum(pixel_uncer * resample_grid**2, axis=-1) / np.sum( resample_grid**2, axis=-1) out_uncertainty = InverseVariance(np.reciprocal(out_variance)) else: out_uncertainty = None # nan-filling happens by default - replace with zeros if requested: if self.extrapolation_treatment == 'zero_fill': origedges = orig_spectrum.spectral_axis.bin_edges off_edges = (fin_spec_axis < origedges[0]) | (origedges[-1] < fin_spec_axis) out_flux[off_edges] = 0 if out_uncertainty is not None: out_uncertainty.array[off_edges] = 0 # todo: for now, use the units from the pre-resampled # spectra, although if a unit is defined for fin_spec_axis and it doesn't # match the input spectrum it won't work right, will have to think # more about how to handle that... could convert before and after # calculation, which is probably easiest. Matrix math algorithm is # geometry based, so won't work to just let quantity math handle it. resampled_spectrum = Spectrum1D(flux=out_flux, spectral_axis=np.array(fin_spec_axis) * orig_spectrum.spectral_axis.unit, uncertainty=out_uncertainty) return resampled_spectrum class LinearInterpolatedResampler(ResamplerBase): """ Resample a spectrum onto a new ``spectral_axis`` using linear interpolation. Parameters ---------- extrapolation_treatment : str What to do when resampling off the edge of the spectrum. Can be ``'nan_fill'`` to have points beyond the edges by set to NaN, or ``'zero_fill'`` to be set to zero. Examples -------- To resample an input spectrum to a user specified dispersion grid using linear interpolation: >>> import numpy as np >>> import astropy.units as u >>> from specutils import Spectrum1D >>> from specutils.manipulation import LinearInterpolatedResampler >>> input_spectra = Spectrum1D( ... flux=np.array([1, 3, 7, 6, 20]) * u.mJy, ... spectral_axis=np.array([2, 4, 12, 16, 20]) * u.nm) >>> resample_grid = [1, 5, 9, 13, 14, 17, 21, 22, 23] * u.nm >>> fluxc_resample = LinearInterpolatedResampler() >>> output_spectrum1D = fluxc_resample(input_spectra, resample_grid) # doctest: +IGNORE_OUTPUT """ def __init__(self, extrapolation_treatment='nan_fill'): super().__init__(extrapolation_treatment) def resample1d(self, orig_spectrum, fin_spec_axis): """ Call interpolation, repackage new spectra Parameters ---------- orig_spectrum : `~specutils.Spectrum1D` The original 1D spectrum. fin_spec_axis : ndarray The desired spectral axis array. Returns ------- resample_spectrum : `~specutils.Spectrum1D` An output spectrum containing the resampled `~specutils.Spectrum1D` """ fill_val = np.nan # bin_edges=nan_fill case if self.extrapolation_treatment == 'zero_fill': fill_val = 0 orig_axis_in_fin = orig_spectrum.spectral_axis.to(fin_spec_axis.unit) out_flux_arr = np.interp(fin_spec_axis.value, orig_axis_in_fin.value, orig_spectrum.flux.value, left=fill_val, right=fill_val) out_flux = Quantity(out_flux_arr, unit=orig_spectrum.flux.unit) new_unc = None if orig_spectrum.uncertainty is not None: out_unc_arr = np.interp(fin_spec_axis.value, orig_axis_in_fin.value, orig_spectrum.uncertainty.array, left=fill_val, right=fill_val) new_unc = orig_spectrum.uncertainty.__class__(array=out_unc_arr, unit=orig_spectrum.unit) return Spectrum1D(spectral_axis=fin_spec_axis, flux=out_flux, uncertainty=new_unc) class SplineInterpolatedResampler(ResamplerBase): """ This resample algorithim uses a cubic spline interpolator. Any uncertainty is also interpolated using an identical spline. Parameters ---------- extrapolation_treatment : str What to do when resampling off the edge of the spectrum. Can be ``'nan_fill'`` to have points beyond the edges by set to NaN, or ``'zero_fill'`` to be set to zero. Examples -------- To resample an input spectrum to a user specified spectral axis grid using a cubic spline interpolator: >>> import numpy as np >>> import astropy.units as u >>> from specutils import Spectrum1D >>> from specutils.manipulation import SplineInterpolatedResampler >>> input_spectra = Spectrum1D( ... flux=np.array([1, 3, 7, 6, 20]) * u.mJy, ... spectral_axis=np.array([2, 4, 12, 16, 20]) * u.nm) >>> resample_grid = [1, 5, 9, 13, 14, 17, 21, 22, 23] * u.nm >>> fluxc_resample = SplineInterpolatedResampler() >>> output_spectrum1D = fluxc_resample(input_spectra, resample_grid) # doctest: +IGNORE_OUTPUT """ def __init__(self, bin_edges='nan_fill'): super().__init__(bin_edges) def resample1d(self, orig_spectrum, fin_spec_axis): """ Call interpolation, repackage new spectra Parameters ---------- orig_spectrum : `~specutils.Spectrum1D` The original 1D spectrum. fin_spec_axis : Quantity The desired spectral axis array. Returns ------- resample_spectrum : `~specutils.Spectrum1D` An output spectrum containing the resampled `~specutils.Spectrum1D` """ orig_axis_in_new = orig_spectrum.spectral_axis.to(fin_spec_axis.unit) flux_spline = CubicSpline(orig_axis_in_new.value, orig_spectrum.flux.value, extrapolate=self.extrapolation_treatment != 'nan_fill') out_flux_val = flux_spline(fin_spec_axis.value) new_unc = None if orig_spectrum.uncertainty is not None: unc_spline = CubicSpline(orig_axis_in_new.value, orig_spectrum.uncertainty.array, extrapolate=self.extrapolation_treatment != 'nan_fill') out_unc_val = unc_spline(fin_spec_axis.value) new_unc = orig_spectrum.uncertainty.__class__(array=out_unc_val, unit=orig_spectrum.unit) if self.extrapolation_treatment == 'zero_fill': origedges = orig_spectrum.spectral_axis.bin_edges off_edges = (fin_spec_axis < origedges[0]) | (origedges[-1] < fin_spec_axis) out_flux_val[off_edges] = 0 if new_unc is not None: new_unc.array[off_edges] = 0 return Spectrum1D(spectral_axis=fin_spec_axis, flux=out_flux_val*orig_spectrum.flux.unit, uncertainty=new_unc) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/manipulation/smoothing.py0000644000503700020070000002145200000000000024704 0ustar00rosteenSTSCI\science00000000000000import copy import warnings import astropy.units as u import numpy as np from astropy import convolution from astropy.nddata import StdDevUncertainty, VarianceUncertainty, InverseVariance from astropy.utils.exceptions import AstropyUserWarning from scipy.signal import medfilt from ..spectra import Spectrum1D __all__ = ['convolution_smooth', 'box_smooth', 'gaussian_smooth', 'trapezoid_smooth', 'median_smooth'] def convolution_smooth(spectrum, kernel): """ Apply a convolution based smoothing to the spectrum. The kernel must be one of the 1D kernels defined in `astropy.convolution`, and will be applied along the spectral axis of the flux. This method can be used alone but also is used by other specific methods below. If the spectrum uncertainty exists and is ``StdDevUncertainty``, ``VarianceUncertainty`` or ``InverseVariance`` then the errors will be propagated through the convolution using a standard propagation of errors. The covariance is not considered, currently. Parameters ---------- spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to which the smoothing will be applied. kernel : `astropy.convolution.Kernel1D` subclass or array. The convolution based smoothing kernel - anything that `astropy.convolution.convolve` accepts. Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` which is copy of the one passed in with the updated flux. Raises ------ ValueError In the case that ``spectrum`` and ``kernel`` are not the correct types. """ # Parameter checks if not isinstance(spectrum, Spectrum1D): raise ValueError('The spectrum parameter must be a Spectrum1D object') # Get the flux of the input spectrum flux = spectrum.flux # Expand kernel with empty leading dimensions if flux is multidimensional # and kernel is 1D. if isinstance(kernel, np.ndarray): kernel_ndim = kernel.ndim else: kernel_ndim = kernel.array.ndim if flux.ndim > 1 and kernel_ndim == 1: expand_axes = tuple(np.arange(flux.ndim-1)) kernel = np.expand_dims(kernel, expand_axes) # Smooth based on the input kernel smoothed_flux = convolution.convolve(flux, kernel) # Propagate the uncertainty if it exists... uncertainty = copy.deepcopy(spectrum.uncertainty) if uncertainty is not None: if isinstance(uncertainty, StdDevUncertainty): # Convert values = uncertainty.array ivar_values = 1 / values**2 # Propagate prop_ivar_values = convolution.convolve(ivar_values, kernel) # Put back in uncertainty.array = 1 / np.sqrt(prop_ivar_values) elif isinstance(uncertainty, VarianceUncertainty): # Convert values = uncertainty.array ivar_values = 1 / values # Propagate prop_ivar_values = convolution.convolve(ivar_values, kernel) # Put back in uncertainty.array = 1 / prop_ivar_values elif isinstance(uncertainty, InverseVariance): # Convert ivar_values = uncertainty.array # Propagate prop_ivar_values = convolution.convolve(ivar_values, kernel) # Put back in uncertainty.array = prop_ivar_values else: uncertainty = None warnings.warn( "Uncertainty is {} but convolutional error propagation is " "not defined for that type. Uncertainty will be dropped in " "the convolved spectrum.".format(type(uncertainty)), AstropyUserWarning) # Return a new object with the smoothed flux. return spectrum._copy(flux=u.Quantity(smoothed_flux, spectrum.unit), spectral_axis=u.Quantity(spectrum.spectral_axis, spectrum.spectral_axis.unit), uncertainty=uncertainty) def box_smooth(spectrum, width): """ Smooth a `~specutils.Spectrum1D` instance along the spectral axis based on a `astropy.convolution.Box1DKernel` kernel. Parameters ---------- spectrum : `~specutils.Spectrum1D` The spectrum object to which the smoothing will be applied. width : number The width of the kernel, in pixels, as defined in `astropy.convolution.Box1DKernel` Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` which a copy of the one passed in with the updated flux. Raises ------ ValueError In the case that ``width`` is not the correct type or value. """ # Parameter checks if not isinstance(width, (int, float)) or width <= 0: raise ValueError("The width parameter, {}, must be a number greater " "than 0".format(width)) # Create the gaussian kernel box1d_kernel = convolution.Box1DKernel(width) # Call and return the convolution smoothing. return convolution_smooth(spectrum, box1d_kernel) def gaussian_smooth(spectrum, stddev): """ Smooth a `~specutils.Spectrum1D` instance along the spectral axis based on a `astropy.convolution.Gaussian1DKernel`. Parameters ---------- spectrum : `~specutils.Spectrum1D` The spectrum object to which the smoothing will be applied. stddev : number The stddev of the kernel, in pixels, as defined in `astropy.convolution.Gaussian1DKernel` Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` which is copy of the one passed in with the updated flux. Raises ------ ValueError In the case that ``stddev`` is not the correct type or value. """ # Parameter checks if not isinstance(stddev, (int, float)) or stddev <= 0: raise ValueError("The stddev parameter, {}, must be a number greater " "than 0".format(stddev)) # Create the gaussian kernel gaussian_kernel = convolution.Gaussian1DKernel(stddev) # Call and return the convolution smoothing. return convolution_smooth(spectrum, gaussian_kernel) def trapezoid_smooth(spectrum, width): """ Smooth a `~specutils.Spectrum1D` instance along the spectral axis based on a `astropy.convolution.Trapezoid1DKernel` kernel. Parameters ---------- spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to which the smoothing will be applied. width : number The width of the kernel, in pixels, as defined in `astropy.convolution.Trapezoid1DKernel` Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` which is copy of the one passed in with the updated flux. Raises ------ ValueError In the case that ``width`` is not the correct type or value. """ # Parameter checks if not isinstance(width, (int, float)) or width <= 0: raise ValueError("The stddev parameter, {}, must be a number greater " "than 0".format(width)) # Create the gaussian kernel trapezoid_kernel = convolution.Trapezoid1DKernel(width) # Call and return the convolution smoothing. return convolution_smooth(spectrum, trapezoid_kernel) def median_smooth(spectrum, width): """ Smoothing based on a median filter. The median filter smoothing is implemented using the `scipy.signal.medfilt` function. Parameters ---------- spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to which the smoothing will be applied. width : number The width of the median filter in pixels. Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` which is copy of the one passed in with the updated flux. Raises ------ ValueError In the case that ``spectrum`` or ``width`` are not the correct type or value. """ # Parameter checks if not isinstance(spectrum, Spectrum1D): raise ValueError('The spectrum parameter must be a Spectrum1D object') if not isinstance(width, (int, float)) or width <= 0: raise ValueError("The stddev parameter, {}, must be a number greater " "than 0".format(width)) # Get the flux of the input spectrum flux = spectrum.flux # Smooth based on the input kernel smoothed_flux = medfilt(flux, width) # Return a new object with the smoothed flux. return spectrum._copy(flux=u.Quantity(smoothed_flux, spectrum.unit), spectral_axis=u.Quantity(spectrum.spectral_axis, spectrum.spectral_axis.unit)) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/manipulation/utils.py0000644000503700020070000002445700000000000024045 0ustar00rosteenSTSCI\science00000000000000import numpy as np import warnings from astropy.utils.exceptions import AstropyUserWarning from astropy.coordinates import SpectralCoord from ..spectra import Spectrum1D, SpectralRegion __all__ = ['excise_regions', 'linear_exciser', 'spectrum_from_model'] def true_exciser(spectrum, region): """ Basic spectral excise method where the array elements in the spectral region defined by the parameter ``region`` (a `~specutils.SpectralRegion`) will be deleted from all applicable elements of the Spectrum1D object: flux, spectral_axis, mask, and uncertainty. If multiple subregions are defined in ``region``, all the subregions will be excised. Other methods could be defined by the user to do other types of excision. Parameters ---------- spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to which the excision will be applied. region : `~specutils.SpectralRegion` The region of the spectrum to remove. Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` with the region excised. Raises ------ ValueError In the case that ``spectrum`` and ``region`` are not the correct types. """ spectral_axis = spectrum.spectral_axis excise_indices = None for subregion in region: # Find the indices of the spectral_axis array corresponding to the subregion region_mask = (spectral_axis >= region.lower) & (spectral_axis < region.upper) temp_indices = np.nonzero(region_mask)[0] if excise_indices is None: excise_indices = temp_indices else: excise_indices = np.hstack(excise_indices, temp_indices) new_flux = np.delete(spectrum.flux, excise_indices) new_spectral_axis = np.delete(spectrum.spectral_axis, excise_indices) if spectrum.mask is not None: new_mask = np.delete(spectrum.mask, excise_indices) else: new_mask = None if spectrum.uncertainty is not None: new_uncertainty = spectrum.uncertainty.__class__( np.delete(spectrum.uncertainty.array, excise_indices), unit=spectrum.uncertainty.unit) else: new_uncertainty = None # Return a new object with the regions excised. return Spectrum1D(flux=new_flux, spectral_axis=new_spectral_axis, uncertainty=new_uncertainty, mask=new_mask, wcs=spectrum.wcs, velocity_convention=spectrum.velocity_convention, rest_value=spectrum.rest_value if not isinstance(new_spectral_axis, SpectralCoord) else None, radial_velocity=spectrum.radial_velocity if not isinstance(new_spectral_axis, SpectralCoord) else None) def linear_exciser(spectrum, region): """ Basic spectral excise method where the spectral region defined by the parameter ``region`` (a `~specutils.SpectralRegion`) will result in the flux between those regions set to a linear ramp of the two points immediately before and after the start and end of the region. Other methods could be defined by the user to do other types of excision. Parameters ---------- spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to which the excision will be applied. region : `~specutils.SpectralRegion` The region of the spectrum to replace. Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` with the region excised. Raises ------ ValueError In the case that ``spectrum`` and ``region`` are not the correct types. """ spectral_axis = spectrum.spectral_axis.copy() flux = spectrum.flux.copy() modified_flux = flux if spectrum.uncertainty is not None: new_uncertainty = spectrum.uncertainty.copy() else: new_uncertainty = None # Need to add a check that the subregions don't overlap, since that could # cause undesired results. For now warn if there is more than one subregion if len(region) > 1: # Raise a warning if the SpectralRegion has more than one subregion, since # the handling for this is perhaps unexpected warnings.warn("A SpectralRegion with multiple subregions was provided as " "input. This may lead to undesired behavior with linear_exciser if " "the subregions overlap.", AstropyUserWarning) for subregion in region: # Find the indices of the spectral_axis array corresponding to the subregion region_mask = (spectral_axis >= subregion.lower) & (spectral_axis < subregion.upper) inclusive_indices = np.nonzero(region_mask)[0] # Now set the flux values for these indices to be a # linear range s, e = max(inclusive_indices[0]-1, 0), min(inclusive_indices[-1]+1, spectral_axis.size-1) modified_flux[s:e+1] = np.linspace(flux[s], flux[e], modified_flux[s:e+1].size) # Add the uncertainty of the two linear interpolation endpoints in # quadrature and apply to the excised region. if new_uncertainty is not None: new_uncertainty[s:e] = np.sqrt(spectrum.uncertainty[s]**2 + spectrum.uncertainty[e]**2) # Return a new object with the regions excised. return Spectrum1D(flux=modified_flux, spectral_axis=spectral_axis, uncertainty=new_uncertainty, wcs=spectrum.wcs, mask=spectrum.mask, velocity_convention=spectrum.velocity_convention, rest_value=spectrum.rest_value if not isinstance(spectral_axis, SpectralCoord) else None, radial_velocity=spectrum.radial_velocity if not isinstance(spectral_axis, SpectralCoord) else None) def excise_regions(spectrum, regions, exciser=true_exciser): """ Method to remove or replace the flux in the defined regions of the spectrum depending on the function provided in the ``exciser`` argument. Parameters ---------- spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to which the excision will be applied. regions : list of `~specutils.SpectralRegion` Each element of the list is a `~specutils.SpectralRegion`. The flux between the lower and upper spectral axis value of each region will be "cut out" and optionally replaced with interpolated values using the ``exciser`` method. Note that non-overlapping regions should be provided as separate `~specutils.SpectralRegion` objects in this list, not as sub-regions in a single object in the list. exciser : function Method that takes the spectrum and region and does the excising. Other methods could be defined and used by this routine. default: true_exciser Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` which has the regions excised. Raises ------ ValueError In the case that ``spectrum`` and ``regions`` are not the correct types. """ # Parameter checks if not isinstance(spectrum, Spectrum1D): raise ValueError('The spectrum parameter must be Spectrum1D object.') for region in regions: spectrum = excise_region(spectrum, region, exciser) return spectrum def excise_region(spectrum, region, exciser=true_exciser): """ Method to remove or replace the flux in the defined regions of the spectrum depending on the function provided in the ``exciser`` argument. Parameters ---------- spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to which the smoothing will be applied. region : `~specutils.SpectralRegion` A `~specutils.SpectralRegion` object defining the region to excise. If excising multiple regions is desired, they should be input as a list of separate `~specutils.SpectralRegion` objects to ``excise_regions``, not as subregions defined in a single `~specutils.SpectralRegion`. exciser: method Method that takes the spectrum and region and does the excising. Other methods could be defined and used by this routine. default: true_exciser Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` with the region excised. Raises ------ ValueError In the case that ``spectrum`` and ``region`` are not the correct types. """ # Parameter checks if not isinstance(spectrum, Spectrum1D): raise ValueError('The spectrum parameter must be a Spectrum1D object.') if not isinstance(region, SpectralRegion): raise ValueError('The region parameter must be a SpectralRegion object.') # # Call the exciser method # return exciser(spectrum, region) def spectrum_from_model(model_input, spectrum): """ This method will create a `~specutils.Spectrum1D` object with the flux defined by calling the input ``model``. All other parameters for the output `~specutils.Spectrum1D` object will be the same as the input `~specutils.Spectrum1D` object. Parameters ---------- model : `~astropy.modeling.Model` The input model or compound model from which flux is calculated. spectrum : `~specutils.Spectrum1D` The `~specutils.Spectrum1D` object to use as the model template. Returns ------- spectrum : `~specutils.Spectrum1D` Output `~specutils.Spectrum1D` which is copy of the one passed in with the updated flux. The uncertainty will not be copied as it is not necessarily the same. """ # If the input model has units then we will call it normally. if getattr(model_input, model_input.param_names[0]).unit is not None: flux = model_input(spectrum.spectral_axis) # If the input model does not have units, then assume it is in # the same units as the input spectrum. else: flux = model_input(spectrum.spectral_axis.value)*spectrum.flux.unit return Spectrum1D(flux=flux, spectral_axis=spectrum.spectral_axis, wcs=spectrum.wcs, velocity_convention=spectrum.velocity_convention, rest_value=spectrum.rest_value) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7402527 specutils-1.6.0/specutils/spectra/0000755000503700020070000000000000000000000021260 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/__init__.py0000644000503700020070000000051700000000000023374 0ustar00rosteenSTSCI\science00000000000000""" The core specutils data objects package. This contains the `~astropy.nddata.NDData`-inherited classes used for storing the spectrum data. """ from .spectrum1d import * # noqa from .spectral_region import * # noqa from .spectrum_collection import * # noqa from .spectrum_list import * # noqa from .spectral_axis import * # noqa ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/spectral_axis.py0000644000503700020070000000615000000000000024475 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u from astropy.utils.decorators import lazyproperty from astropy.coordinates import SpectralCoord import numpy as np __all__ = ['SpectralAxis'] # We don't want to run doctests in the docstrings we inherit from Quantity __doctest_skip__ = ['SpectralAxis.*'] class SpectralAxis(SpectralCoord): """ Coordinate object representing spectral values corresponding to a specific spectrum. Overloads SpectralCoord with additional information (currently only bin edges). Parameters ---------- bin_specification: str, optional Must be "edges" or "centers". Determines whether specified axis values are interpreted as bin edges or bin centers. Defaults to "centers". """ _equivalent_unit = SpectralCoord._equivalent_unit + (u.pixel,) def __new__(cls, value, *args, bin_specification="centers", **kwargs): # Convert to bin centers if bin edges were given, since SpectralCoord # only accepts centers if bin_specification == "edges": bin_edges = value value = SpectralAxis._centers_from_edges(value) obj = super().__new__(cls, value, *args, **kwargs) if bin_specification == "edges": obj._bin_edges = bin_edges return obj @staticmethod def _edges_from_centers(centers, unit): """ Calculates interior bin edges based on the average of each pair of centers, with the two outer edges based on extrapolated centers added to the beginning and end of the spectral axis. """ a = np.insert(centers, 0, 2*centers[0] - centers[1]) b = np.append(centers, 2*centers[-1] - centers[-2]) edges = (a + b) / 2 return edges*unit @staticmethod def _centers_from_edges(edges): """ Calculates the bin centers as the average of each pair of edges """ return (edges[1:] + edges[:-1]) / 2 @lazyproperty def bin_edges(self): """ Calculates bin edges if the spectral axis was created with centers specified. """ if hasattr(self, '_bin_edges'): return self._bin_edges else: return self._edges_from_centers(self.value, self.unit) def with_observer_stationary_relative_to(self, frame, velocity=None, preserve_observer_frame=False): if self.unit is u.pixel: raise u.UnitsError("Cannot transform spectral coordinates in pixel units") super().with_observer_stationary_relative_to(frame, velocity=velocity, preserve_observer_frame=preserve_observer_frame) def with_radial_velocity_shift(self, target_shift=None, observer_shift=None): if self.unit is u.pixel: raise u.UnitsError("Cannot transform spectral coordinates in pixel units") return super().with_radial_velocity_shift(target_shift=target_shift, observer_shift=observer_shift) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/spectral_coordinate.py0000644000503700020070000000000000000000000025644 0ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/spectral_region.py0000644000503700020070000002552300000000000025021 0ustar00rosteenSTSCI\science00000000000000import itertools import sys import numpy as np import astropy.units as u class SpectralRegion: """ A `SpectralRegion` is a container class for regions (intervals) along a spectral coordinate. This class can either represent a single contiguous region or a set of regions related to each other in some way (For example, a pair of continuum windows around a line or a doublet of lines). Parameters ---------- *args : variable Either a single parameter or two parameters can be given: * 1 argument ``regioniter``: An iterable of length-2 `~astropy.units.Quantity` objects (or a single n x 2 `~astropy.units.Quantity` object), where the length-2 dimension is ``lower``, ``upper``. * ``lower``, ``upper``: Each should be an `~astropy.units.Quantity` object. Notes ----- The subregions will be ordered based on the lower bound of each subregion. """ def __init__(self, *args): # Create instance variables self._subregions = None # Set the values (using the setters for doing the proper checking) if len(args) == 1: if all([self._is_2_element(x) for x in args[0]]): self._subregions = [tuple(x) for x in args[0]] else: raise ValueError("SpectralRegion 1-argument input must be " "a list of length-two Quantity objects.") elif len(args) == 2: if self._is_2_element(args): self._subregions = [tuple(args)] else: raise ValueError("SpectralRegion 2-argument inputs must be " "Quantity objects.") else: raise TypeError(f'SpectralRegion initializer takes 1 or 2' f'positional arguments but {len(args)} were given') # Check validity of the input sub regions. self._valid() # The sub-regions are to always be ordered based on the lower bound. self._reorder() @classmethod def from_center(cls, center=None, width=None): """ SpectralRegion class method that enables the definition of a `SpectralRegion` from the center and width rather than lower and upper bounds. Parameters ---------- center : Scalar `~astropy.units.Quantity` with pixel or any valid ``spectral_axis`` unit The center of the spectral region. width : Scalar `~astropy.units.Quantity` with pixel or any valid ``spectral_axis`` unit The full width of the spectral region (upper bound - lower bound). """ if width.value <= 0: raise ValueError("SpectralRegion width must be positive.") if center.unit.physical_type not in ('length', 'unknown'): return cls(center + width/2, center - width/2) return cls(center - width/2, center + width/2) @classmethod def from_line_list(cls, table, width=1): """ Generate a ``SpectralRegion`` instance from the `~astropy.table.QTable` object returned from `~specutils.fitting.find_lines_derivative` or `~specutils.fitting.find_lines_threshold`. Parameters ---------- table : `~astropy.table.QTable` List of found lines. width : float The width of the spectral line region. If not unit information is provided, it's assumed to be the same units as used in the line list table. Returns ------- `~specutils.SpectralRegion` The spectral region based on the line list. """ width = u.Quantity(width, table['line_center'].unit) return cls([(x - width * 0.5, x + width * 0.5) for x in table['line_center']]) def _info(self): """ Pretty print the sub-regions. """ toreturn = "Spectral Region, {} sub-regions:\n".format( len(self._subregions)) # Setup the subregion text. subregion_text = [] for ii, subregion in enumerate(self._subregions): subregion_text.append(' ({}, {})'.format(subregion[0], subregion[1])) # Determine the length of the text boxes. max_len = max(len(srt) for srt in subregion_text) + 1 ncols = 70 // max_len # Add sub region info to the output text. fmt = '{' + ':<{}'.format(max_len) + '}' for ii, srt in enumerate(subregion_text): toreturn += fmt.format(srt) if ii % ncols == (ncols-1): toreturn += '\n' return toreturn def __str__(self): return self._info() def __repr__(self): return self._info() def __add__(self, other): """ Ability to add two SpectralRegion classes together. """ return SpectralRegion(self._subregions + other._subregions) def __iadd__(self, other): """ Ability to add one SpectralRegion to another using +=. """ self._subregions += other._subregions self._reorder() return self def __len__(self): """ Number of spectral regions. """ return len(self._subregions) def __getslice__(self, item): """ Enable slicing of the SpectralRegion list. """ return SpectralRegion(self._subregions[item]) def __getitem__(self, item): """ Enable slicing or extracting the SpectralRegion. """ if isinstance(item, slice): return self.__getslice__(item) else: return SpectralRegion([self._subregions[item]]) def __delitem__(self, item): """ Delete a specific item from the list. """ del self._subregions[item] def _valid(self): bound_unit = self._subregions[0][0].unit for x in self._subregions: if x[0].unit != bound_unit or x[1].unit != bound_unit: raise ValueError("All SpectralRegion bounds must have the same unit.") if x[0] == x[1]: raise ValueError("Upper and lower bound must be different values.") return True @staticmethod def _is_2_element(value): """ Helper function to check a variable to see if it is a 2-tuple of Quantity objects. """ return len(value) == 2 and \ isinstance(value[0], u.Quantity) and \ isinstance(value[1], u.Quantity) def _reorder(self): """ Re-order the list based on lower bounds. """ self._subregions.sort(key=lambda k: k[0]) @property def subregions(self): """ An iterable over ``(lower, upper)`` tuples that are each of the sub-regions. """ return self._subregions @property def bounds(self): """ Compute the lower and upper extent of the SpectralRegion. """ return self.lower, self.upper @property def lower(self): """ The most minimum value of the sub-regions. The sub-regions are ordered based on the lower bound, so the lower bound for this instance is the lower bound of the first sub-region. """ return self._subregions[0][0] @property def upper(self): """ The most maximum value of the sub-regions. The sub-regions are ordered based on the lower bound, but the upper bound might not be the upper bound of the last sub-region so we have to look for it. """ return max(x[1] for x in self._subregions) def invert_from_spectrum(self, spectrum): """ Invert a SpectralRegion based on the extent of the input spectrum. See notes in SpectralRegion.invert() method. """ return self.invert(spectrum.spectral_axis[0], spectrum.spectral_axis[-1]) def _in_range(self, value, lower, upper): return (value >= lower) and (value <= upper) def invert(self, lower_bound, upper_bound): """ Invert this spectral region. That is, given a set of sub-regions this object defines, create a new `SpectralRegion` such that the sub-regions are defined in the new one as regions *not* in this `SpectralRegion`. Parameters ---------- lower_bound : `~astropy.units.Quantity` The lower bound of the region. Can be scalar with pixel or any valid ``spectral_axis`` unit upper_bound : `~astropy.units.Quantity` The upper bound of the region. Can be scalar with pixel or any valid ``spectral_axis`` unit Returns ------- spectral_region : `~specutils.SpectralRegion` Spectral region of the non-selected regions Notes ----- This is applicable if, for example, a `SpectralRegion` has sub-regions defined for peaks in a spectrum and then one wants to create a `SpectralRegion` defined as all the *non*-peaks, then one could use this function. As an example, assume this SpectralRegion is defined as ``sr = SpectralRegion([(0.45*u.um, 0.6*u.um), (0.8*u.um, 0.9*u.um)])``. If we call ``sr_invert = sr.invert(0.3*u.um, 1.0*u.um)`` then ``sr_invert`` will be ``SpectralRegion([(0.3*u.um, 0.45*u.um), (0.6*u.um, 0.8*u.um), (0.9*u.um, 1*u.um)])`` """ # # Create 'rs' region list with left and right extra ranges. # min_num = -sys.maxsize-1 max_num = sys.maxsize rs = self._subregions + [(min_num*u.um, lower_bound), (upper_bound, max_num*u.um)] # # Sort the region list based on lower bound. # sorted_regions = sorted(rs, key=lambda k: k[0]) # # Create new region list that has overlapping regions merged # merged = [] for higher in sorted_regions: if not merged: merged.append(higher) else: lower = merged[-1] # test for intersection between lower and higher: # we know via sorting that lower[0] <= higher[0] if higher[0] <= lower[1]: upper_bound = max(lower[1], higher[1]) merged[-1] = (lower[0], upper_bound) # replace by merged interval else: merged.append(higher) # # Create new list and drop first and last (the maxsize ones). # We go from -inf, upper1, lower2, upper2.... # and remap to lower1, upper1, lower2, ... # newlist = list(itertools.chain.from_iterable(merged)) newlist = newlist[1:-1] # # Now create new Spectrum region # return SpectralRegion([(x, y) for x, y in zip(newlist[0::2], newlist[1::2])]) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/spectrum1d.py0000644000503700020070000007440000000000000023726 0ustar00rosteenSTSCI\science00000000000000from copy import deepcopy import logging import numpy as np from astropy import units as u from astropy.utils.decorators import lazyproperty from astropy.nddata import NDUncertainty, NDIOMixin, NDArithmeticMixin from .spectral_axis import SpectralAxis from .spectrum_mixin import OneDSpectrumMixin from .spectral_region import SpectralRegion from ..utils.wcs_utils import gwcs_from_array from astropy.coordinates import SpectralCoord from ndcube import NDCube __all__ = ['Spectrum1D'] log = logging.getLogger(__name__) class Spectrum1D(OneDSpectrumMixin, NDCube, NDIOMixin, NDArithmeticMixin): """ Spectrum container for 1D spectral data. Note that "1D" in this case refers to the fact that there is only one spectral axis. `Spectrum1D` can contain "vector 1D spectra" by having the ``flux`` have a shape with dimension greater than 1. The requirement is that the last dimension of ``flux`` match the length of the ``spectral_axis``. For multidimensional spectra that are all the same shape but have different spectral axes, use a :class:`~specutils.SpectrumCollection`. For a collection of spectra that have different shapes, use :class:`~specutils.SpectrumList`. For more on this topic, see :ref:`specutils-representation-overview`. Parameters ---------- flux : `~astropy.units.Quantity` or `~astropy.nddata.NDData`-like The flux data for this spectrum. This can be a simple `~astropy.units.Quantity`, or an existing `~Spectrum1D` or `~ndcube.NDCube` object. spectral_axis : `~astropy.units.Quantity` or `~specutils.SpectralAxis` Dispersion information with the same shape as the last (or only) dimension of flux, or one greater than the last dimension of flux if specifying bin edges. wcs : `~astropy.wcs.WCS` or `~gwcs.wcs.WCS` WCS information object that either has a spectral component or is only spectral. velocity_convention : {"doppler_relativistic", "doppler_optical", "doppler_radio"} Convention used for velocity conversions. rest_value : `~astropy.units.Quantity` Any quantity supported by the standard spectral equivalencies (wavelength, energy, frequency, wave number). Describes the rest value of the spectral axis for use with velocity conversions. redshift See `redshift` for more information. radial_velocity See `radial_velocity` for more information. bin_specification : str Either "edges" or "centers" to indicate whether the `spectral_axis` values represent edges of the wavelength bin, or centers of the bin. uncertainty : `~astropy.nddata.NDUncertainty` Contains uncertainty information along with propagation rules for spectrum arithmetic. Can take a unit, but if none is given, will use the unit defined in the flux. mask : `~numpy.ndarray`-like Array where values in the flux to be masked are those that ``astype(bool)`` converts to True. (For example, integer arrays are not masked where they are 0, and masked for any other value.) meta : dict Arbitrary container for any user-specific information to be carried around with the spectrum container object. """ def __init__(self, flux=None, spectral_axis=None, wcs=None, velocity_convention=None, rest_value=None, redshift=None, radial_velocity=None, bin_specification=None, **kwargs): # Check for pre-defined entries in the kwargs dictionary. unknown_kwargs = set(kwargs).difference( {'data', 'unit', 'uncertainty', 'meta', 'mask', 'copy', 'extra_coords'}) if len(unknown_kwargs) > 0: raise ValueError("Initializer contains unknown arguments(s): {}." "".format(', '.join(map(str, unknown_kwargs)))) # If the flux (data) argument is already a Spectrum1D (as it would # be for internal arithmetic operations), avoid setup entirely. if isinstance(flux, Spectrum1D): super().__init__(flux) return # Handle initializing from NDCube objects elif isinstance(flux, NDCube): if flux.unit is None: raise ValueError("Input NDCube missing unit parameter") # Change the flux array from bare ndarray to a Quantity q_flux = flux.data << u.Unit(flux.unit) self.__init__(flux=q_flux, wcs=flux.wcs, mask=flux.mask, uncertainty=flux.uncertainty) return # If the mask kwarg is not passed to the constructor, but the flux array # contains NaNs, add the NaN locations to the mask. if "mask" not in kwargs and flux is not None: nan_mask = np.isnan(flux) if nan_mask.any(): if hasattr(self, "mask"): kwargs["mask"] = np.logical_or(nan_mask, self.mask) else: kwargs["mask"] = nan_mask.copy() del nan_mask # Ensure that the flux argument is an astropy quantity if flux is not None: if not isinstance(flux, u.Quantity): raise ValueError("Flux must be a `Quantity` object.") elif flux.isscalar: flux = u.Quantity([flux]) # Ensure that only one or neither of these parameters is set if redshift is not None and radial_velocity is not None: raise ValueError("Cannot set both radial_velocity and redshift at " "the same time.") # In cases of slicing, new objects will be initialized with `data` # instead of ``flux``. Ensure we grab the `data` argument. if flux is None and 'data' in kwargs: flux = kwargs.pop('data') # Ensure that the unit information codified in the quantity object is # the One True Unit. kwargs.setdefault('unit', flux.unit if isinstance(flux, u.Quantity) else kwargs.get('unit')) # In the case where the arithmetic operation is being performed with # a single float, int, or array object, just go ahead and ignore wcs # requirements if (not isinstance(flux, u.Quantity) or isinstance(flux, float) or isinstance(flux, int)) and np.ndim(flux) == 0: super(Spectrum1D, self).__init__(data=flux, wcs=wcs, **kwargs) return if rest_value is None: if hasattr(wcs, 'rest_frequency') and wcs.rest_frequency != 0: rest_value = wcs.rest_frequency * u.Hz elif hasattr(wcs, 'rest_wavelength') and wcs.rest_wavelength != 0: rest_value = wcs.rest_wavelength * u.AA elif hasattr(wcs, 'wcs') and hasattr(wcs.wcs, 'restfrq') and wcs.wcs.restfrq > 0: rest_value = wcs.wcs.restfrq * u.Hz elif hasattr(wcs, 'wcs') and hasattr(wcs.wcs, 'restwav') and wcs.wcs.restwav > 0: rest_value = wcs.wcs.restwav * u.m else: rest_value = None else: if not isinstance(rest_value, u.Quantity): log.info("No unit information provided with rest value. " "Assuming units of spectral axis ('%s').", spectral_axis.unit) rest_value = u.Quantity(rest_value, spectral_axis.unit) elif not rest_value.unit.is_equivalent(u.AA, equivalencies=u.spectral()): raise u.UnitsError("Rest value must be " "energy/wavelength/frequency equivalent.") # If flux and spectral axis are both specified, check that their lengths # match or are off by one (implying the spectral axis stores bin edges) if flux is not None and spectral_axis is not None: if spectral_axis.shape[0] == flux.shape[-1]: if bin_specification == "edges": raise ValueError("A spectral axis input as bin edges" "must have length one greater than the flux axis") bin_specification = "centers" elif spectral_axis.shape[0] == flux.shape[-1]+1: if bin_specification == "centers": raise ValueError("A spectral axis input as bin centers" "must be the same length as the flux axis") bin_specification = "edges" else: raise ValueError( "Spectral axis length ({}) must be the same size or one " "greater (if specifying bin edges) than that of the last " "flux axis ({})".format(spectral_axis.shape[0], flux.shape[-1])) # If a WCS is provided, check that the spectral axis is last and reorder # the arrays if not if wcs is not None and hasattr(wcs, "naxis"): if wcs.naxis > 1: temp_axes = [] phys_axes = wcs.world_axis_physical_types for i in range(len(phys_axes)): if phys_axes[i] is None: continue if phys_axes[i][0:2] == "em" or phys_axes[i][0:5] == "spect": temp_axes.append(i) if len(temp_axes) != 1: raise ValueError("Input WCS must have exactly one axis with " "spectral units, found {}".format(len(temp_axes))) # Due to FITS conventions, a WCS with spectral axis first corresponds # to a flux array with spectral axis last. if temp_axes[0] != 0: log.warning("Input WCS indicates that the spectral axis is not" " last. Reshaping arrays to put spectral axis last.") wcs = wcs.swapaxes(0, temp_axes[0]) if flux is not None: flux = np.swapaxes(flux, len(flux.shape)-temp_axes[0]-1, -1) if "mask" in kwargs: if kwargs["mask"] is not None: kwargs["mask"] = np.swapaxes(kwargs["mask"], len(kwargs["mask"].shape)-temp_axes[0]-1, -1) if "uncertainty" in kwargs: if kwargs["uncertainty"] is not None: if isinstance(kwargs["uncertainty"], NDUncertainty): # Account for Astropy uncertainty types unc_len = len(kwargs["uncertainty"].array.shape) temp_unc = np.swapaxes(kwargs["uncertainty"].array, unc_len-temp_axes[0]-1, -1) if kwargs["uncertainty"].unit is not None: temp_unc = temp_unc * u.Unit(kwargs["uncertainty"].unit) kwargs["uncertainty"] = type(kwargs["uncertainty"])(temp_unc) else: kwargs["uncertainty"] = np.swapaxes(kwargs["uncertainty"], len(kwargs["uncertainty"].shape) - temp_axes[0]-1, -1) # Attempt to parse the spectral axis. If none is given, try instead to # parse a given wcs. This is put into a GWCS object to # then be used behind-the-scenes for all specutils operations. if spectral_axis is not None: # Ensure that the spectral axis is an astropy Quantity if not isinstance(spectral_axis, u.Quantity): raise ValueError("Spectral axis must be a `Quantity` or " "`SpectralAxis` object.") # If spectral axis is provided as an astropy Quantity, convert it # to a specutils SpectralAxis object. if not isinstance(spectral_axis, SpectralAxis): if spectral_axis.shape[0] == flux.shape[-1] + 1: bin_specification = "edges" else: bin_specification = "centers" self._spectral_axis = SpectralAxis( spectral_axis, redshift=redshift, radial_velocity=radial_velocity, doppler_rest=rest_value, doppler_convention=velocity_convention, bin_specification=bin_specification) # If a SpectralAxis object is provided, we assume it doesn't need # information from other keywords added else: for a in [radial_velocity, redshift]: if a is not None: raise ValueError("Cannot separately set redshift or " "radial_velocity if a SpectralAxis " "object is input to spectral_axis") self._spectral_axis = spectral_axis if wcs is None: wcs = gwcs_from_array(self._spectral_axis) elif wcs is None: # If no spectral axis or wcs information is provided, initialize # with an empty gwcs based on the flux. size = len(flux) if not flux.isscalar else 1 wcs = gwcs_from_array(np.arange(size) * u.Unit("")) super().__init__( data=flux.value if isinstance(flux, u.Quantity) else flux, wcs=wcs, **kwargs ) # If no spectral_axis was provided, create a SpectralCoord based on # the WCS if spectral_axis is None: # If the WCS doesn't have a spectral attribute, we assume it's the # dummy GWCS we created or a solely spectral WCS if hasattr(self.wcs, "spectral"): # Handle generated 1D WCS that aren't set to spectral if not self.wcs.is_spectral and self.wcs.naxis == 1: spec_axis = self.wcs.pixel_to_world(np.arange(self.flux.shape[-1])) else: spec_axis = self.wcs.spectral.pixel_to_world(np.arange(self.flux.shape[-1])) else: spec_axis = self.wcs.pixel_to_world(np.arange(self.flux.shape[-1])) try: if spec_axis.unit.is_equivalent(u.one): spec_axis = spec_axis * u.pixel except AttributeError: raise AttributeError(f"spec_axis does not have unit: " f"{type(spec_axis)} {spec_axis}") self._spectral_axis = SpectralAxis( spec_axis, redshift=redshift, radial_velocity=radial_velocity, doppler_rest=rest_value, doppler_convention=velocity_convention) if hasattr(self, 'uncertainty') and self.uncertainty is not None: if not flux.shape == self.uncertainty.array.shape: raise ValueError( "Flux axis ({}) and uncertainty ({}) shapes must be the " "same.".format(flux.shape, self.uncertainty.array.shape)) def __getitem__(self, item): """ Override the class indexer. We do this here because there are two cases for slicing on a ``Spectrum1D``: 1.) When the flux is one dimensional, indexing represents a single flux value at a particular spectral axis bin, and returns a new ``Spectrum1D`` where all attributes are sliced. 2.) When flux is multi-dimensional (i.e. several fluxes over the same spectral axis), indexing returns a new ``Spectrum1D`` with the sliced flux range and a deep copy of all other attributes. The first case is handled by the parent class, while the second is handled here. """ if self.flux.ndim > 1 or (type(item) == tuple and item[0] == Ellipsis): if type(item) == tuple: if len(item) == len(self.flux.shape) or item[0] == Ellipsis: spec_item = item[-1] if not isinstance(spec_item, slice): if isinstance(item, u.Quantity): raise ValueError("Indexing on single spectral axis " "values is not currently allowed, " "please use a slice.") spec_item = slice(spec_item, spec_item+1, None) item = item[:-1] + (spec_item,) else: # Slicing on less than the full number of axes means we want # to keep the whole spectral axis spec_item = slice(None, None, None) elif isinstance(item, slice) and (isinstance(item.start, u.Quantity) or isinstance(item.stop, u.Quantity)): # We only allow slicing with world coordinates along the spectral # axis for now for attr in ("start", "stop"): if getattr(item, attr) is None: continue if not getattr(item, attr).unit.is_equivalent(u.AA, equivalencies=u.spectral()): raise ValueError("Slicing with world coordinates is only" " enabled for spectral coordinates.") break spec_item = item else: # Slicing with a single integer or slice uses the leading axis, # so we keep the whole spectral axis, which is last spec_item = slice(None, None, None) if (isinstance(spec_item.start, u.Quantity) or isinstance(spec_item.stop, u.Quantity)): temp_spec = self._spectral_slice(spec_item) if spec_item is item: return temp_spec else: # Drop the spectral axis slice and perform only the spatial part return temp_spec[item[:-1]] return self._copy( flux=self.flux[item], spectral_axis=self.spectral_axis[spec_item], uncertainty=self.uncertainty[item] if self.uncertainty is not None else None, mask=self.mask[item] if self.mask is not None else None) if not isinstance(item, slice): if isinstance(item, u.Quantity): raise ValueError("Indexing on a single spectral axis values is not" " currently allowed, please use a slice.") # Handle tuple slice as input by NDCube crop method elif isinstance(item, tuple): if len(item) == 1 and isinstance(item[0], slice): item = item[0] else: raise ValueError(f"Unclear how to slice with tuple {item}") else: item = slice(item, item + 1, None) elif (isinstance(item.start, u.Quantity) or isinstance(item.stop, u.Quantity)): return self._spectral_slice(item) tmp_spec = super().__getitem__(item) # TODO: this is a workaround until we figure out how to deal with non- # strictly ascending spectral axes. Currently, the wcs is created from # a spectral axis array by converting to a length physical type. On # a regular slicing operation, the wcs is handed back to the # initializer and a new spectral axis is created. This would then also # be in length units, which may not be the units used initially. So, # we create a new ``Spectrum1D`` that includes the sliced spectral # axis. This means that a new wcs object will be created with the # appropriate unit translation handling. return tmp_spec._copy( spectral_axis=self.spectral_axis[item]) def _copy(self, **kwargs): """ Perform deep copy operations on each attribute of the ``Spectrum1D`` object. """ alt_kwargs = dict( flux=deepcopy(self.flux), spectral_axis=deepcopy(self.spectral_axis), uncertainty=deepcopy(self.uncertainty), wcs=deepcopy(self.wcs), mask=deepcopy(self.mask), meta=deepcopy(self.meta), unit=deepcopy(self.unit), velocity_convention=deepcopy(self.velocity_convention), rest_value=deepcopy(self.rest_value)) alt_kwargs.update(kwargs) return self.__class__(**alt_kwargs) def _spectral_slice(self, item): """ Perform a region extraction given a slice on the spectral axis. """ from ..manipulation import extract_region if item.start is None: start = self.spectral_axis[0] else: start = item.start if item.stop is None: stop = self.spectral_axis[-1] else: # Force the upper bound to be open, as in normal python array slicing exact_match = np.where(self.spectral_axis == item.stop) if len(exact_match[0]) == 1: stop_index = exact_match[0][0] - 1 stop = self.spectral_axis[stop_index] else: stop = item.stop reg = SpectralRegion(start, stop) return extract_region(self, reg) def collapse(self, method, axis=None): """ Collapse the flux array given a method. Will collapse either to a single value (default), over a specified numerical axis or axes if specified, or over the spectral or non-spectral axes if ``physical_type`` is specified. If the collapse leaves the spectral axis unchanged, a `~specutils.Spectrum1D` will be returned. Otherwise an `~astropy.units.Quantity` array will be returned. Note that these calculations are not currently uncertainty-aware, but do respect masks. Parameters ---------- method : str, function The method by which the flux will be collapsed. String options are 'mean', 'min', 'max', 'sum', and 'median'. Also accepts a function as input, which must take an `astropy.units.Quantity` array as input and accept an 'axis' argument. axis : int, tuple, str, optional The axis or axes over which to collapse the flux array. May also be a string, either 'spectral' to collapse over the spectral axis, or 'spatial' to collapse over all other axes. Returns ------- :class:`~specutils.Spectrum1D` or :class:`~astropy.units.Quantity` """ collapse_funcs = {"mean": np.nanmean, "max": np.nanmax, "min": np.nanmin, "median": np.nanmedian, "sum": np.nansum} if isinstance(axis, str): if axis == 'spectral': axis = -1 elif axis == 'spatial': # generate tuple if needed for multiple spatial axes axis = tuple([x for x in range(len(self.flux.shape) - 1)]) else: raise ValueError("String axis input must be 'spatial' or 'spectral'") # Set masked locations to NaN for the calculation, since the `where` argument # does not seem to work consistently in the numpy functions. flux_to_collapse = self.flux.copy() if self.mask is not None: flux_to_collapse[np.where(self.mask != 0)] = np.nan # Leave open the possibility of the user providing their own method if callable(method): collapsed_flux = method(flux_to_collapse, axis=axis) else: collapsed_flux = collapse_funcs[method](flux_to_collapse, axis=axis) # Return a Spectrum1D if we collapsed over the spectral axis, a Quantity if not if axis in (-1, None, len(self.flux.shape)-1): return collapsed_flux elif isinstance(axis, tuple) and -1 in axis: return collapsed_flux else: return Spectrum1D(collapsed_flux, wcs=self.wcs) def mean(self, **kwargs): return self.collapse("mean", **kwargs) def max(self, **kwargs): return self.collapse("max", **kwargs) def min(self, **kwargs): return self.collapse("min", **kwargs) def median(self, **kwargs): return self.collapse("median", **kwargs) def sum(self, **kwargs): return self.collapse("sum", **kwargs) @NDCube.mask.setter def mask(self, value): # Impose stricter checks than the base NDData mask setter if value is not None: value = np.array(value) if not self.data.shape == value.shape: raise ValueError( "Flux axis ({}) and mask ({}) shapes must be the " "same.".format(self.data.shape, value.shape)) self._mask = value @property def frequency(self): """ The `spectral_axis` as a `~astropy.units.Quantity` in units of GHz """ return self.spectral_axis.to(u.GHz, u.spectral()) @property def wavelength(self): """ The `spectral_axis` as a `~astropy.units.Quantity` in units of Angstroms """ return self.spectral_axis.to(u.AA, u.spectral()) @property def energy(self): """ The energy of the spectral axis as a `~astropy.units.Quantity` in units of eV. """ return self.spectral_axis.to(u.eV, u.spectral()) @property def photon_flux(self): """ The flux density of photons as a `~astropy.units.Quantity`, in units of photons per cm^2 per second per spectral_axis unit """ flux_in_spectral_axis_units = self.flux.to( u.W * u.cm**-2 * self.spectral_axis.unit**-1, u.spectral_density(self.spectral_axis)) photon_flux_density = flux_in_spectral_axis_units / (self.energy / u.photon) return photon_flux_density.to(u.photon * u.cm**-2 * u.s**-1 * self.spectral_axis.unit**-1) @lazyproperty def bin_edges(self): return self.spectral_axis.bin_edges @property def shape(self): return self.flux.shape @property def redshift(self): """ The redshift(s) of the objects represented by this spectrum. May be scalar (if this spectrum's ``flux`` is 1D) or vector. Note that the concept of "redshift of a spectrum" can be ambiguous, so the interpretation is set to some extent by either the user, or operations (like template fitting) that set this attribute when they are run on a spectrum. """ return self.spectral_axis.redshift @redshift.setter def redshift(self, val): new_spec_coord = self.spectral_axis.with_radial_velocity_shift( -self.spectral_axis.radial_velocity).with_radial_velocity_shift(val) self._spectral_axis = new_spec_coord @property def radial_velocity(self): """ The radial velocity(s) of the objects represented by this spectrum. May be scalar (if this spectrum's ``flux`` is 1D) or vector. Note that the concept of "RV of a spectrum" can be ambiguous, so the interpretation is set to some extent by either the user, or operations (like template fitting) that set this attribute when they are run on a spectrum. """ return self.spectral_axis.radial_velocity @radial_velocity.setter def radial_velocity(self, val): if val is not None: if not val.unit.is_equivalent(u.km/u.s): raise u.UnitsError("Radial velocity must be a velocity.") new_spectral_axis = self.spectral_axis.with_radial_velocity_shift( -self.spectral_axis.radial_velocity).with_radial_velocity_shift(val) self._spectral_axis = new_spectral_axis def __add__(self, other): if not isinstance(other, NDCube): other = u.Quantity(other, unit=self.unit) return self.add(other) def __sub__(self, other): if not isinstance(other, NDCube): other = u.Quantity(other, unit=self.unit) return self.subtract(other) def __mul__(self, other): if not isinstance(other, NDCube): other = u.Quantity(other) return self.multiply(other) def __div__(self, other): if not isinstance(other, NDCube): other = u.Quantity(other) return self.divide(other) def __truediv__(self, other): if not isinstance(other, NDCube): other = u.Quantity(other) return self.divide(other) def _format_array_summary(self, label, array): if len(array) == 1: mean = np.mean(array) s = "{:17} [ {:.5} ], mean={:.5}" return s.format(label+':', array[0], array[-1], mean) elif len(array) > 1: mean = np.mean(array) s = "{:17} [ {:.5}, ..., {:.5} ], mean={:.5}" return s.format(label+':', array[0], array[-1], mean) else: return "{:17} [ ], mean= n/a".format(label+':') def __str__(self): result = "Spectrum1D " # Handle case of single value flux if self.flux.ndim == 0: result += "(length=1)\n" return result + "flux: {}".format(self.flux) # Handle case of multiple flux arrays result += "(length={})\n".format(len(self.spectral_axis)) if self.flux.ndim > 1: for i, flux in enumerate(self.flux): label = 'flux{:2}'.format(i) result += self._format_array_summary(label, flux) + '\n' else: result += self._format_array_summary('flux', self.flux) + '\n' # Add information about spectral axis result += self._format_array_summary('spectral axis', self.spectral_axis) # Add information about uncertainties if available if self.uncertainty: result += "\nuncertainty: [ {}, ..., {} ]".format( self.uncertainty[0], self.uncertainty[-1]) return result def __repr__(self): inner_str = "flux={}, spectral_axis={}".format(repr(self.flux), repr(self.spectral_axis)) if self.uncertainty is not None: inner_str += ", uncertainty={}".format(repr(self.uncertainty)) result = "".format(inner_str) return result ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/spectrum_collection.py0000644000503700020070000002551600000000000025720 0ustar00rosteenSTSCI\science00000000000000import logging import astropy.units as u import numpy as np from astropy.nddata import NDUncertainty, StdDevUncertainty from astropy.coordinates import SpectralCoord from .spectrum1d import Spectrum1D from astropy.nddata import NDIOMixin __all__ = ['SpectrumCollection'] log = logging.getLogger(__name__) class SpectrumCollection(NDIOMixin): """ A class to represent a heterogeneous set of spectra that are the same length but have different spectral axes. Spectra that meet this requirement can be stored as multidimensional arrays, and thus can have operations performed on them faster than if they are treated as individual :class:`~specutils.Spectrum1D` objects. For multidimensional spectra that all have the *same* spectral axis, use a :class:`~specutils.Spectrum1D` with dimension greater than 1. For a collection of spectra that have different shapes, use :class:`~specutils.SpectrumList`. For more on this topic, see :ref:`specutils-representation-overview`. The attributes on this class uses the same names and conventions as :class:`~specutils.Spectrum1D`, allowing some operations to work the same. Where this does not work, the user can use standard indexing notation to access individual :class:`~specutils.Spectrum1D` objects. Parameters ---------- flux : :class:`astropy.units.Quantity` The flux data. The trailing dimension should be the spectral dimension. spectral_axis : :class:`astropy.units.Quantity` The spectral axes of the spectra (e.g., wavelength). Must match the dimensionality of ``flux``. wcs : wcs object or None A wcs object (if available) for the collection of spectra. The object must follow standard indexing rules to get a sub-wcs if it is provided. uncertainty : :class:`astropy.nddata.NDUncertainty` or ndarray The uncertainties associated with each spectrum of the collection. In the case that only an n-dimensional quantity or ndaray is provided, the uncertainties are assumed to be standard deviations. Must match the dimensionality of ``flux``. mask : ndarray or None The n-dimensional mask information associated with each spectrum. If present, must match the dimensionality of ``flux``. meta : list The list of dictionaries containing meta data to be associated with each spectrum in the collection. """ def __init__(self, flux, spectral_axis=None, wcs=None, uncertainty=None, mask=None, meta=None): # Check for quantity if not isinstance(flux, u.Quantity): raise u.UnitsError("Flux must be a `Quantity`.") if spectral_axis is not None: if not isinstance(spectral_axis, u.Quantity): raise u.UnitsError("Spectral axis must be a `Quantity`.") spectral_axis = SpectralCoord(spectral_axis) # Ensure that the input values are the same shape if not (flux.shape == spectral_axis.shape): raise ValueError("Shape of all data elements must be the same.") if uncertainty is not None and uncertainty.array.shape != flux.shape: raise ValueError("Uncertainty must be the same shape as flux and " "spectral axis.") if mask is not None and mask.shape != flux.shape: raise ValueError("Mask must be the same shape as flux and " "spectral axis.") # Convert uncertainties to standard deviations if not already defined # to be of some type if uncertainty is not None and not isinstance(uncertainty, NDUncertainty): # If the uncertainties are not provided a unit, raise a warning # and use the flux units if not isinstance(uncertainty, u.Quantity): log.warning("No unit associated with uncertainty, assuming" "flux units of '{}'.".format(flux.unit)) uncertainty = u.Quantity(uncertainty, unit=flux.unit) uncertainty = StdDevUncertainty(uncertainty) self._flux = flux self._spectral_axis = spectral_axis self._wcs = wcs self._uncertainty = uncertainty self._mask = mask self._meta = meta def __getitem__(self, key): flux = self.flux[key] if flux.ndim != 1: raise ValueError("Currently only 1D data structures may be " "returned from slice operations.") spectral_axis = self.spectral_axis[key] uncertainty = None if self.uncertainty is None else self.uncertainty[key] wcs = None if self.wcs is None else self.wcs[key] mask = None if self.mask is None else self.mask[key] if self.meta is None: meta = None else: try: meta = self.meta[key] except KeyError: meta = self.meta return Spectrum1D(flux=flux, spectral_axis=spectral_axis, uncertainty=uncertainty, wcs=wcs, mask=mask, meta=meta) @classmethod def from_spectra(cls, spectra): """ Create a spectrum collection from a set of individual :class:`specutils.Spectrum1D` objects. Parameters ---------- spectra : list, ndarray A list of :class:`~specutils.Spectrum1D` objects to be held in the collection. Currently the spectral_axis parameters (e.g. observer, radial_velocity) must be the same for each spectrum. """ # Enforce that the shape of each item must be the same if not all((x.shape == spectra[0].shape for x in spectra)): raise ValueError("Shape of all elements must be the same.") # Compose multi-dimensional ndarrays for each property flux = u.Quantity([spec.flux for spec in spectra]) # Check that the spectral parameters are the same for each input # spectral_axis and create the multi-dimensional SpectralCoord sa = [x.spectral_axis for x in spectra] if not all(x.radial_velocity == sa[0].radial_velocity for x in sa) or \ not all(x.target == sa[0].target for x in sa) or \ not all(x.observer == sa[0].observer for x in sa) or \ not all(x.doppler_convention == sa[0].doppler_convention for x in sa) or \ not all(x.doppler_rest == sa[0].doppler_rest for x in sa): raise ValueError("All input spectral_axis SpectralCoord " "objects must have the same parameters.") spectral_axis = SpectralCoord(sa, radial_velocity=sa[0].radial_velocity, doppler_rest=sa[0].doppler_rest, doppler_convention=sa[0].doppler_convention, observer=sa[0].observer, target=sa[0].target) # Check that either all spectra have associated uncertainties, or that # none of them do. If only some do, log an error and ignore the # uncertainties. if not all((x.uncertainty is None for x in spectra)) and \ any((x.uncertainty is not None for x in spectra)) and \ all((x.uncertainty.uncertainty_type == spectra[0].uncertainty.uncertainty_type for x in spectra)): quncs = u.Quantity([spec.uncertainty.quantity for spec in spectra]) uncertainty = spectra[0].uncertainty.__class__(quncs) else: uncertainty = None log.warning("Not all spectra have associated uncertainties of " "the same type, skipping uncertainties.") # Check that either all spectra have associated masks, or that # none of them do. If only some do, log an error and ignore the masks. if not all((x.mask is None for x in spectra)) and \ any((x.mask is not None for x in spectra)): mask = np.array([spec.mask for spec in spectra]) else: mask = None log.warning("Not all spectra have associated masks, " "skipping masks.") # Store the wcs and meta as lists wcs = [spec.wcs for spec in spectra] meta = [spec.meta for spec in spectra] return cls(flux=flux, spectral_axis=spectral_axis, uncertainty=uncertainty, wcs=wcs, mask=mask, meta=meta) @property def flux(self): """The flux in the spectrum as a `~astropy.units.Quantity`.""" return self._flux @property def spectral_axis(self): """The spectral axes as a `~astropy.units.Quantity`.""" return self._spectral_axis @property def frequency(self): """ The spectral axis as a frequency `~astropy.units.Quantity` (in GHz). """ return self.spectral_axis.to(u.GHz, u.spectral()) @property def wavelength(self): """ The spectral axis as a wavelength `~astropy.units.Quantity` (in Angstroms). """ return self.spectral_axis.to(u.AA, u.spectral()) @property def energy(self): """ The spectral axis as an energy `~astropy.units.Quantity` (in eV). """ return self.spectral_axis.to(u.eV, u.spectral()) @property def wcs(self): """The WCS's as an object array""" return self._wcs @property def uncertainty(self): """The uncertainty in the spectrum as a `~astropy.units.Quantity`.""" return self._uncertainty @property def mask(self): """The mask array for the spectrum.""" return self._mask @property def meta(self): """A dictionary of metadata for theis spectrum collection, or `None`.""" return self._meta @property def shape(self): """ The shape of the collection. This is *not* the same as the shape of the flux et al., because the trailing (spectral) dimension is not included here. """ return self.flux.shape[:-1] def __len__(self): return self.shape[0] @property def ndim(self): """ The dimensionality of the collection. This is *not* the same as the dimensionality of the flux et al., because the trailing (spectral) dimension is not included here. """ return self.flux.ndim - 1 @property def nspectral(self): """ The length of the spectral dimension. """ return self.flux.shape[-1] def __repr__(self): return """SpectrumCollection(ndim={}, shape={}) Flux units: {} Spectral axis units: {} Uncertainty type: {}""".format( self.ndim, self.shape, self.flux.unit, self.spectral_axis.unit, self.uncertainty.uncertainty_type if self.uncertainty is not None else None) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/spectrum_list.py0000644000503700020070000000113000000000000024522 0ustar00rosteenSTSCI\science00000000000000from astropy.nddata import NDIOMixin __all__ = ['SpectrumList'] class SpectrumList(list, NDIOMixin): """ A list that is used to hold a list of `~specutils.Spectrum1D` objects The primary purpose of this class is to allow loaders to return a list of spectra that have different shapes. For spectra that have the same shape but different spectral axes, see `~specutils.SpectrumCollection`. For a spectrum or spectra that all share the same spectral axis, use `~specutils.Spectrum1D`. For more on this topic, see :ref:`specutils-representation-overview`. """ ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/spectra/spectrum_mixin.py0000644000503700020070000003066600000000000024713 0ustar00rosteenSTSCI\science00000000000000from copy import deepcopy import logging import numpy as np import astropy.units.equivalencies as eq from astropy import units as u from astropy.utils.decorators import lazyproperty, deprecated from astropy.wcs.wcsapi import HighLevelWCSWrapper from specutils.utils.wcs_utils import gwcs_from_array DOPPLER_CONVENTIONS = {} DOPPLER_CONVENTIONS['radio'] = u.doppler_radio DOPPLER_CONVENTIONS['optical'] = u.doppler_optical DOPPLER_CONVENTIONS['relativistic'] = u.doppler_relativistic __all__ = ['OneDSpectrumMixin'] log = logging.getLogger(__name__) class OneDSpectrumMixin(): @property def _spectral_axis_numpy_index(self): return self.data.ndim - 1 - self.wcs.wcs.spec @property def _spectral_axis_len(self): """ How many elements are in the spectral dimension? """ return self.data.shape[self._spectral_axis_numpy_index] @property def _data_with_spectral_axis_last(self): """ Returns a view of the data with the spectral axis last """ if self._spectral_axis_numpy_index == self.data.ndim - 1: return self.data else: return self.data.swapaxes(self._spectral_axis_numpy_index, self.data.ndim - 1) @property def _data_with_spectral_axis_first(self): """ Returns a view of the data with the spectral axis first """ if self._spectral_axis_numpy_index == 0: return self.data else: return self.data.swapaxes(self._spectral_axis_numpy_index, 0) @property def spectral_wcs(self): """ Returns the spectral axes of the WCS """ return self.wcs.axes.spectral @property def spectral_axis(self): """ Returns the SpectralCoord object. """ return self._spectral_axis @property @deprecated('v1.1', alternative="spectral_axis.unit") def spectral_axis_unit(self): """ Returns the units of the spectral axis. """ return u.Unit(self.wcs.world_axis_units[0]) @property def flux(self): """ Converts the stored data and unit information into a quantity. Returns ------- `~astropy.units.Quantity` Spectral data as a quantity. """ return u.Quantity(self.data, unit=self.unit, copy=False) def new_flux_unit(self, unit, equivalencies=None, suppress_conversion=False): """ Converts the flux data to the specified unit. This is an in-place change to the object. Parameters ---------- unit : str or `~astropy.units.Unit` The unit to convert the flux array to. equivalencies : list of equivalencies Custom equivalencies to apply to conversions. Set to spectral_density by default. suppress_conversion : bool Set to true if updating the unit without converting data values. Returns ------- `~specutils.Spectrum1D` A new spectrum with the converted flux array """ new_spec = deepcopy(self) if not suppress_conversion: if equivalencies is None: equivalencies = eq.spectral_density(self.spectral_axis) new_data = self.flux.to( unit, equivalencies=equivalencies) new_spec._data = new_data.value new_spec._unit = new_data.unit else: new_spec._unit = u.Unit(unit) return new_spec @property def velocity_convention(self): """ Returns the velocity convention """ return self.spectral_axis.doppler_convention def with_velocity_convention(self, velocity_convention): return self.__class__(flux=self.flux, wcs=self.wcs, meta=self.meta, velocity_convention=velocity_convention) @property def rest_value(self): return self.spectral_axis.doppler_rest @rest_value.setter def rest_value(self, value): self.spectral_axis.doppler_rest = value @property def velocity(self): """ Converts the spectral axis array to the given velocity space unit given the rest value. These aren't input parameters but required Spectrum attributes Parameters ---------- unit : str or ~`astropy.units.Unit` The unit to convert the dispersion array to. rest : ~`astropy.units.Quantity` Any quantity supported by the standard spectral equivalencies (wavelength, energy, frequency, wave number). type : {"doppler_relativistic", "doppler_optical", "doppler_radio"} The type of doppler spectral equivalency. redshift or radial_velocity If present, this shift is applied to the final output velocity to get into the rest frame of the object. Returns ------- ~`astropy.units.Quantity` The converted dispersion array in the new dispersion space. """ if self.rest_value is None: raise ValueError("Cannot get velocity representation of spectral " "axis without specifying a reference value.") if self.velocity_convention is None: raise ValueError("Cannot get velocity representation of spectral " "axis without specifying a velocity convention.") equiv = getattr(u.equivalencies, 'doppler_{0}'.format( self.velocity_convention))(self.rest_value) new_data = self.spectral_axis.to(u.km/u.s, equivalencies=equiv).quantity # if redshift/rv is present, apply it: if self.spectral_axis.radial_velocity is not None: new_data += self.spectral_axis.radial_velocity return new_data def with_spectral_unit(self, unit, velocity_convention=None, rest_value=None): """ Returns a new spectrum with a different spectral axis unit. Parameters ---------- unit : :class:`~astropy.units.Unit` Any valid spectral unit: velocity, (wave)length, or frequency. Only vacuum units are supported. velocity_convention : 'relativistic', 'radio', or 'optical' The velocity convention to use for the output velocity axis. Required if the output type is velocity. This can be either one of the above strings, or an `astropy.units` equivalency. rest_value : :class:`~astropy.units.Quantity` A rest wavelength or frequency with appropriate units. Required if output type is velocity. The spectrum's WCS should include this already if the *input* type is velocity, but the WCS's rest wavelength/frequency can be overridden with this parameter. .. note: This must be the rest frequency/wavelength *in vacuum*, even if your spectrum has air wavelength units """ new_wcs, new_meta = self._new_spectral_wcs( unit=unit, velocity_convention=velocity_convention or self.velocity_convention, rest_value=rest_value or self.rest_value) spectrum = self.__class__(flux=self.flux, wcs=new_wcs, meta=new_meta) return spectrum def _new_wcs_argument_validation(self, unit, velocity_convention, rest_value): # Allow string specification of units, for example if not isinstance(unit, u.UnitBase): unit = u.Unit(unit) # Velocity conventions: required for frq <-> velo # convert_spectral_axis will handle the case of no velocity # convention specified & one is required if velocity_convention in DOPPLER_CONVENTIONS: velocity_convention = DOPPLER_CONVENTIONS[velocity_convention] elif (velocity_convention is not None and velocity_convention not in DOPPLER_CONVENTIONS.values()): raise ValueError("Velocity convention must be radio, optical, " "or relativistic.") # If rest value is specified, it must be a quantity if (rest_value is not None and (not hasattr(rest_value, 'unit') or not rest_value.unit.is_equivalent(u.m, u.spectral()))): raise ValueError("Rest value must be specified as an astropy " "quantity with spectral equivalence.") return unit def _new_spectral_wcs(self, unit, velocity_convention=None, rest_value=None): """ Returns a new WCS with a different Spectral Axis unit. Parameters ---------- unit : :class:`~astropy.units.Unit` Any valid spectral unit: velocity, (wave)length, or frequency. Only vacuum units are supported. velocity_convention : 'relativistic', 'radio', or 'optical' The velocity convention to use for the output velocity axis. Required if the output type is velocity. This can be either one of the above strings, or an `astropy.units` equivalency. rest_value : :class:`~astropy.units.Quantity` A rest wavelength or frequency with appropriate units. Required if output type is velocity. The cube's WCS should include this already if the *input* type is velocity, but the WCS's rest wavelength/frequency can be overridden with this parameter. .. note: This must be the rest frequency/wavelength *in vacuum*, even if your cube has air wavelength units """ unit = self._new_wcs_argument_validation(unit, velocity_convention, rest_value) if velocity_convention is not None: equiv = getattr(u, 'doppler_{0}'.format(velocity_convention)) rest_value.to(unit, equivalencies=equiv) # Store the original unit information for posterity meta = self._meta.copy() orig_unit = self.wcs.unit[0] if hasattr(self.wcs, 'unit') else self.spectral_axis.unit if 'original_unit' not in self._meta: meta['original_unit'] = orig_unit # Create the new wcs object if isinstance(unit, u.UnitBase) and unit.is_equivalent( orig_unit, equivalencies=u.spectral()): return gwcs_from_array(self.spectral_axis), meta log.error("WCS units incompatible: {} and {}.".format( unit, orig_unit)) class InplaceModificationMixin: # Example methods follow to demonstrate how methods can be written to be # agnostic of the non-spectral dimensions. def substract_background(self, background): """ Proof of concept, this subtracts a background spectrum-wise """ data = self._data_with_spectral_axis_last if callable(background): # create substractable array pass elif (isinstance(background, np.ndarray) and background.shape == data[-1].shape): substractable_continuum = background else: raise ValueError( "background needs to be callable or have the same shape as the spectum") data[-1] -= substractable_continuum def normalize(self): """ Proof of concept, this normalizes each spectral dimension based on a trapezoidal integration. """ # this gets a view - if we want normalize to return a new NDData object # then we should make _data_with_spectral_axis_first return a copy. data = self._data_with_spectral_axis_first dx = np.diff(self.spectral_axis) dy = 0.5 * (data[:-1] + data[1:]) norm = np.sum(dx * dy.transpose(), axis=-1).transpose() data /= norm def spectral_interpolation(self, spectral_value, flux_unit=None): """ Proof of concept, this interpolates along the spectral dimension """ data = self._data_with_spectral_axis_last from scipy.interpolate import interp1d interp = interp1d(self.spectral_axis.value, data) x = spectral_value.to(self.spectral_axis.unit, equivalencies=u.spectral()) y = interp(x) if self.unit is not None: y *= self.unit if flux_unit is None: # Lim: Is this acceptable? return y else: return y.to(flux_unit, equivalencies=u.spectral_density(x)) ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7471392 specutils-1.6.0/specutils/tests/0000755000503700020070000000000000000000000020761 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/tests/__init__.py0000644000503700020070000000017100000000000023071 0ustar00rosteenSTSCI\science00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This packages contains affiliated package tests. """ ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/conftest.py0000644000503700020070000000177100000000000023166 0ustar00rosteenSTSCI\science00000000000000import pytest import os import urllib remote_access = lambda argvals: pytest.mark.parametrize( 'remote_data_path', argvals, indirect=True, scope='function') @pytest.fixture(scope='module') def remote_data_path(request, tmp_path): """ Remotely access the Zenodo deposition archive to retrieve the versioned test data. """ # Make use of configuration option from pytest-remotedata in order to # control access to remote data. if request.config.getoption('remote_data', 'any') != 'any': pytest.skip() file_id, file_name = request.param.values() url = "https://zenodo.org/record/{}/files/{}?download=1".format( file_id, file_name) # Create a temporary directory that is automatically cleaned up when the # context is exited, removing any temporarily stored data. file_path = os.path.join(tmp_path, file_name) with urllib.request.urlopen(url) as r, open(file_path, 'wb') as tmp_file: tmp_file.write(r.read()) yield file_path ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/coveragerc0000644000503700020070000000120300000000000023020 0ustar00rosteenSTSCI\science00000000000000[run] source = specutils omit = specutils/*__init__* specutils/_astropy_init.py specutils/conftest* specutils/cython_version* specutils/*setup* specutils/*tests/* specutils/version* [report] exclude_lines = # Have to re-enable the standard pragma pragma: no cover # Don't complain about packages we have installed except ImportError # Don't complain if tests don't hit assertions raise AssertionError raise NotImplementedError # Don't complain about script hooks def main\(.*\): # Ignore branches that don't pertain to this version of Python pragma: py{ignore_python_version} ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/setup_package.py0000644000503700020070000000000000000000000024134 0ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/spectral_examples.py0000644000503700020070000001341500000000000025052 0ustar00rosteenSTSCI\science00000000000000from copy import copy import numpy as np import astropy.units as u from astropy.modeling import models from ..spectra import Spectrum1D import pytest class SpectraExamples: """ The ``SpectralExamples`` class is a *container class* that has several examples of simple spectra that are to be used in the tests (e.g., arithmetic tests, smoothing tests etc). The purpose of this being a test class instead of using a `Spectrum1D` directly is that it contains both the `Spectrum1D` object and the flux that was used to *create* the Spectrum. That's for tests that ensure the simpler operations just on the flux arrays are carried through to the `Spectrum1D` operations. Each of the spectra are created from a base noise-less spectrum constructed from 4 Gaussians and a ramp. Then three example spectra are created, and then gaussian random noise is added. 1. s1_um_mJy_e1 - 4 Gaussians + ramp with one instantion of noise dispersion: um, flux: mJy 2. s1_um_mJy_e2 - same as 1, but with a different instance of noise dispersion: um, flux: mJy 3. s1_AA_mJy_e3 - same as 1, but with a third instance of noise dispersion: Angstroms, flux: mJy 4. s1_AA_nJy_e3 - same as 1, but with a fourth instance of noise dispersion: Angstroms, flux: nJy 5. s1_um_mJy_e1_masked - same as 1, but with a random set of pixels masked. 6. s1_um_mJy_e1_desc - same as 1, but with the spectral axis in descending rather than ascending order. """ def __init__(self): # # Create the base wavelengths and flux # self.wavelengths_um = np.linspace(0.4, 1.05, 100) g1 = models.Gaussian1D(amplitude=2000, mean=0.56, stddev=0.01) g2 = models.Gaussian1D(amplitude=500, mean=0.62, stddev=0.02) g3 = models.Gaussian1D(amplitude=-400, mean=0.80, stddev=0.02) g4 = models.Gaussian1D(amplitude=-350, mean=0.52, stddev=0.01) ramp = models.Linear1D(slope=300, intercept=0.0) self.base_flux = g1(self.wavelengths_um) + g2(self.wavelengths_um) + \ g3(self.wavelengths_um) + g4(self.wavelengths_um) + \ ramp(self.wavelengths_um) + 1000 # # Initialize the seed so the random numbers are not quite as random # np.random.seed(42) # # Create two spectra with the only difference in the instance of noise # self._flux_e1 = self.base_flux + 400 * np.random.random(self.base_flux.shape) self._s1_um_mJy_e1 = Spectrum1D(spectral_axis=self.wavelengths_um * u.um, flux=self._flux_e1 * u.mJy) self._flux_e2 = self.base_flux + 400 * np.random.random(self.base_flux.shape) self._s1_um_mJy_e2 = Spectrum1D(spectral_axis=self.wavelengths_um * u.um, flux=self._flux_e2 * u.mJy) # # Create one spectrum with the same flux but in angstrom units # self.wavelengths_AA = self.wavelengths_um * 10000 self._s1_AA_mJy_e3 = Spectrum1D(spectral_axis=self.wavelengths_AA * u.AA, flux=self._flux_e1 * u.mJy) # # Create one spectrum with the same flux but in angstrom units and nJy # self._flux_e4 = (self.base_flux + 400 * np.random.random(self.base_flux.shape)) * 1000000 self._s1_AA_nJy_e4 = Spectrum1D(spectral_axis=self.wavelengths_AA * u.AA, flux=self._flux_e4 * u.nJy) # # Create one spectrum like 1 but with a mask # self._s1_um_mJy_e1_masked = copy(self._s1_um_mJy_e1) # SHALLOW copy - the data are shared with the above non-masked case self._s1_um_mJy_e1_masked.mask = (np.random.randn(*self.base_flux.shape) + 1) > 0 # Create a spectrum like 1, but with descending spectral axis self._s1_um_mJy_e1_desc = Spectrum1D(spectral_axis=self.wavelengths_um[::-1] * u.um, flux=self._flux_e1[::-1] * u.mJy) @property def s1_um_mJy_e1(self): return self._s1_um_mJy_e1 @property def s1_um_mJy_e1_flux(self): return self._flux_e1 @property def s1_um_mJy_e2(self): return self._s1_um_mJy_e2 @property def s1_um_mJy_e2_flux(self): return self._flux_e2 @property def s1_AA_mJy_e3(self): return self._s1_AA_mJy_e3 @property def s1_AA_mJy_e3_flux(self): return self._flux_e1 @property def s1_AA_nJy_e4(self): return self._s1_AA_nJy_e4 @property def s1_AA_nJy_e4_flux(self): return self._flux_e4 @property def s1_um_mJy_e1_masked(self): return self._s1_um_mJy_e1_masked @property def s1_um_mJy_e1_desc(self): return self._s1_um_mJy_e1_desc @pytest.fixture def simulated_spectra(): """ The method will be called as a fixture to tests. Parameters ---------- N/A Return ------ ``SpectralExamples`` An instance of the SpectraExamples class. Examples -------- This fixture can be used in a test as: ``` from .spectral_examples import spectral_examples def test_add_spectra(spectral_examples): # Get the numpy array of data flux1 = define_spectra.s1_um_mJy_e1_flux flux2 = define_spectra.s1_um_mJy_e2_flux flux3 = flux1 + flux2 # Calculate using the spectrum1d/nddata code spec3 = define_spectra.s1_um_mJy_e1 + define_spectra.s1_um_mJy_e2 assert np.allclose(spec3.flux.value, flux3) ``` """ return SpectraExamples() ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_analysis.py0000644000503700020070000010513500000000000024222 0ustar00rosteenSTSCI\science00000000000000import pytest import numpy as np import astropy.units as u from astropy.modeling import models from astropy.nddata import StdDevUncertainty from astropy.stats.funcs import gaussian_sigma_to_fwhm from astropy.tests.helper import quantity_allclose from astropy.utils.exceptions import AstropyUserWarning from ..spectra import Spectrum1D, SpectralRegion from ..spectra.spectrum_collection import SpectrumCollection from ..spectra.spectral_axis import SpectralAxis from ..analysis import (line_flux, equivalent_width, snr, centroid, gaussian_sigma_width, gaussian_fwhm, fwhm, moment, snr_derived, fwzi, is_continuum_below_threshold) from ..fitting import find_lines_threshold from ..manipulation import snr_threshold, FluxConservingResampler from ..tests.spectral_examples import simulated_spectra def test_line_flux(): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz g = models.Gaussian1D(amplitude=1*u.Jy, mean=10*u.GHz, stddev=1*u.GHz) noise = np.random.normal(0., 0.01, frequencies.shape) * u.Jy flux = g(frequencies) + noise spectrum = Spectrum1D(spectral_axis=frequencies, flux=flux) result = line_flux(spectrum) assert result.unit.is_equivalent(u.erg / u.cm**2 / u.s) # Account for the fact that Astropy uses a different normalization of the # Gaussian where the integral is not 1 expected = np.sqrt(2*np.pi) * u.GHz * u.Jy assert quantity_allclose(result, expected, atol=0.01*u.GHz*u.Jy) def test_line_flux_uncertainty(): np.random.seed(42) spec = Spectrum1D(spectral_axis=np.arange(10) * u.AA, flux=np.random.sample(10) * u.Jy, uncertainty=StdDevUncertainty(np.random.sample(10) * 0.01)) lf = line_flux(spec) assert quantity_allclose(lf, u.Quantity(5.20136736, u.Jy * u.AA)) assert quantity_allclose(lf.uncertainty, u.Quantity(0.01544415, u.Jy * u.AA)) def test_line_flux_masked(): np.random.seed(42) N = 100 wavelengths = np.linspace(0.4, 1.05, N) * u.um g = models.Gaussian1D(amplitude=2000*u.mJy, mean=0.56*u.um, stddev=0.01*u.um) flux = g(wavelengths) + 1000 * u.mJy noise = 400 * np.random.random(flux.shape)* u.mJy flux += noise spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) spectrum.uncertainty = StdDevUncertainty(noise) spectrum_masked = snr_threshold(spectrum, 10.) # Ensure we have at least 50% of the data being masked. assert len(np.where(spectrum_masked.mask)[0]) > N/2 result = line_flux(spectrum_masked) assert result.unit.is_equivalent(u.Jy * u.um) assert quantity_allclose(result.value, 720.52992, atol=0.001) # With flux conserving resampler result = line_flux(spectrum_masked, mask_interpolation=FluxConservingResampler) assert quantity_allclose(result.value, 720.61116, atol=0.001) def test_line_flux_uncertainty(): np.random.seed(42) spec = Spectrum1D(spectral_axis=np.arange(10) * u.AA, flux=np.random.sample(10) * u.Jy, uncertainty=StdDevUncertainty(np.random.sample(10) * 0.01)) lf = line_flux(spec) assert quantity_allclose(lf, u.Quantity(5.20136736, u.Jy * u.AA)) assert quantity_allclose(lf.uncertainty, u.Quantity(0.01544415, u.Jy * u.AA)) def test_equivalent_width(): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz g = models.Gaussian1D(amplitude=1*u.Jy, mean=10*u.GHz, stddev=1*u.GHz) noise = np.random.normal(0., 0.01, frequencies.shape) * u.Jy flux = g(frequencies) + noise + 1*u.Jy spectrum = Spectrum1D(spectral_axis=frequencies, flux=flux) result = equivalent_width(spectrum) assert result.unit.is_equivalent(spectrum.wcs.unit, equivalencies=u.spectral()) # Since this is an emission line, we expect the equivalent width value to # be negative expected = -(np.sqrt(2*np.pi) * u.GHz) assert quantity_allclose(result, expected, atol=0.0025*u.GHz) def test_equivalent_width_masked (): np.random.seed(42) N = 100 wavelengths = np.linspace(0.4, 1.05, N) * u.um g = models.Gaussian1D(amplitude=2000*u.mJy, mean=0.56*u.um, stddev=0.01*u.um) flux = g(wavelengths) + 1000 * u.mJy noise = 400 * np.random.random(flux.shape)* u.mJy flux += noise spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) spectrum.uncertainty = StdDevUncertainty(noise) spectrum_masked = snr_threshold(spectrum, 10.) # Ensure we have at least 50% of the data being masked. assert len(np.where(spectrum_masked.mask)[0]) > N/2 result = equivalent_width(spectrum_masked) assert result.unit.is_equivalent(spectrum.wcs.unit) assert quantity_allclose(result.value, -719.90618, atol=0.0005) # With flux conserving resampler result = equivalent_width(spectrum_masked, mask_interpolation=FluxConservingResampler) assert quantity_allclose(result.value, -719.89962, atol=0.0005) def test_equivalent_width_regions(): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz g = models.Gaussian1D(amplitude=1*u.Jy, mean=20*u.GHz, stddev=1*u.GHz) noise = np.random.normal(0., 0.001, frequencies.shape) * u.Jy flux = g(frequencies) + noise + 1*u.Jy spec = Spectrum1D(spectral_axis=frequencies, flux=flux) result = equivalent_width(spec, regions=SpectralRegion(60*u.GHz, 10*u.GHz)) expected = -(np.sqrt(2*np.pi) * u.GHz) assert quantity_allclose(result, expected, atol=0.005*u.GHz) @pytest.mark.parametrize('continuum', [1*u.Jy, 2*u.Jy, 5*u.Jy]) def test_equivalent_width_continuum(continuum): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz g = models.Gaussian1D(amplitude=1*u.Jy, mean=10*u.GHz, stddev=1*u.GHz) noise = np.random.normal(0., 0.01, frequencies.shape) * u.Jy flux = g(frequencies) + noise + continuum spectrum = Spectrum1D(spectral_axis=frequencies, flux=flux) result = equivalent_width(spectrum, continuum=continuum) assert result.unit.is_equivalent(spectrum.wcs.unit, equivalencies=u.spectral()) # Since this is an emission line, we expect the equivalent width value to # be negative expected = -(np.sqrt(2*np.pi) * u.GHz) / continuum.value assert quantity_allclose(result, expected, atol=0.005*u.GHz) def test_equivalent_width_absorption(): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz amplitude = 0.5 g = models.Gaussian1D(amplitude=amplitude*u.Jy, mean=10*u.GHz, stddev=1*u.GHz) continuum = 1*u.Jy noise = np.random.normal(0., 0.01, frequencies.shape) * u.Jy flux = continuum - g(frequencies) + noise spectrum = Spectrum1D(spectral_axis=frequencies, flux=flux) result = equivalent_width(spectrum) assert result.unit.is_equivalent(spectrum.wcs.unit, equivalencies=u.spectral()) expected = amplitude*np.sqrt(2*np.pi) * u.GHz assert quantity_allclose(result, expected, atol=0.005*u.GHz) @pytest.mark.parametrize('bin_specification', ["centers", "edges"]) def test_equivalent_width_bin_edges(bin_specification): """ Test spectrum with bin specifications as centers or edges, and at modest sampling. """ np.random.seed(42) wavelengths = np.linspace(500, 2500, 401) * u.AA spectral_axis = SpectralAxis(wavelengths, bin_specification=bin_specification) flunit = u.Unit('W m-2 AA-1') amplitude = 0.5 stddev = 100*u.AA expected = amplitude*np.sqrt(2*np.pi) * stddev g = models.Gaussian1D(amplitude=amplitude*flunit, mean=1000*u.AA, stddev=stddev) continuum = 1 * flunit noise = np.random.normal(0., 0.01, spectral_axis.shape) * flunit flux = continuum - g(spectral_axis) + noise spectrum = Spectrum1D(spectral_axis=spectral_axis, flux=flux) result = equivalent_width(spectrum) assert result.unit.is_equivalent(spectrum.wcs.unit, equivalencies=u.spectral()) assert quantity_allclose(result, expected, atol=0.5*u.AA) # With SpectralRegion; need higher tolerance as this is cutting more of the wings. result = equivalent_width(spectrum, regions=SpectralRegion(750*u.AA, 1500*u.AA)) assert quantity_allclose(result, expected, atol=1*u.AA) def test_snr(simulated_spectra): """ Test the simple version of the spectral SNR. """ np.random.seed(42) # # Set up the data and add the uncertainty and calculate the expected SNR # spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty flux = spectrum.flux spec_snr_expected = np.mean(flux / (uncertainty.array*uncertainty.unit)) # # SNR of the whole spectrum # spec_snr = snr(spectrum) assert isinstance(spec_snr, u.Quantity) assert np.allclose(spec_snr.value, spec_snr_expected.value) def test_snr_masked(simulated_spectra): """ Test the simple version of the spectral SNR, with masked spectrum. """ np.random.seed(42) spectrum = simulated_spectra.s1_um_mJy_e1_masked uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty uncertainty_array = uncertainty.array[~spectrum.mask] flux = spectrum.flux[~spectrum.mask] spec_snr_expected = np.mean(flux / (uncertainty_array * uncertainty.unit)) spec_snr = snr(spectrum) assert isinstance(spec_snr, u.Quantity) assert np.allclose(spec_snr.value, spec_snr_expected.value) def test_snr_no_uncertainty(simulated_spectra): """ Test the simple version of the spectral SNR. """ # # Set up the data and add the uncertainty and calculate the expected SNR # spectrum = simulated_spectra.s1_um_mJy_e1 with pytest.raises(Exception) as e_info: _ = snr(spectrum) def test_snr_multiple_flux(simulated_spectra): """ Test the simple version of the spectral SNR, with multiple flux per single dispersion. """ np.random.seed(42) # # Set up the data and add the uncertainty and calculate the expected SNR # uncertainty = StdDevUncertainty(0.1*np.random.random((5, 10))*u.mJy) spec = Spectrum1D(spectral_axis=np.arange(10) * u.AA, flux=np.random.sample((5, 10)) * u.Jy, uncertainty=uncertainty) snr_spec = snr(spec) assert np.allclose(np.array(snr_spec), [18.20863867, 31.89475309, 14.51598119, 22.24603204, 32.01461421]) uncertainty = StdDevUncertainty(0.1*np.random.random(10)*u.mJy) spec = Spectrum1D(spectral_axis=np.arange(10) * u.AA, flux=np.random.sample(10) * u.Jy, uncertainty=uncertainty) snr_spec = snr(spec) assert np.allclose(np.array(snr_spec), 31.325265361800415) def test_snr_single_region(simulated_spectra): """ Test the simple version of the spectral SNR over a region of the spectrum. """ np.random.seed(42) region = SpectralRegion(0.52*u.um, 0.59*u.um) # # Set up the data # spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty wavelengths = spectrum.spectral_axis flux = spectrum.flux # +1 because we want to include it in the calculation l = np.nonzero(wavelengths>region.lower)[0][0] r = np.nonzero(wavelengths= region.lower)[0][0] r = np.nonzero(wavelengths <= region.upper)[0][-1]+1 spec_snr_expected.append(np.mean(flux[l:r] / (uncertainty.array[l:r]*uncertainty.unit))) # # SNR of the whole spectrum # spec_snr = snr(spectrum, regions) assert np.allclose(spec_snr, spec_snr_expected) def test_snr_derived(): np.random.seed(42) x = np.arange(1, 101) * u.um y = np.random.random(len(x))*u.Jy spectrum = Spectrum1D(spectral_axis=x, flux=y) assert np.allclose(snr_derived(spectrum), 1.604666860424951) sr = SpectralRegion(38*u.um, 48*u.um) assert np.allclose(snr_derived(spectrum, sr), 2.330463630828406) sr2 = SpectralRegion(48*u.um, 57*u.um) assert np.allclose(snr_derived(spectrum, [sr, sr2]), [2.330463630828406, 2.9673559890209305]) def test_snr_derived_masked(): np.random.seed(42) x = np.arange(1, 101) * u.um y = np.random.random(len(x))*u.Jy mask = (np.random.randn(x.shape[0])) > 0 spectrum = Spectrum1D(spectral_axis=x, flux=y, mask=mask) assert np.allclose(snr_derived(spectrum), 2.08175408) sr = SpectralRegion(38*u.um, 48*u.um) assert np.allclose(snr_derived(spectrum, sr), 4.01610033) sr2 = SpectralRegion(48*u.um, 57*u.um) assert np.allclose(snr_derived(spectrum, [sr, sr2]), [4.01610033, 1.94906157]) def test_centroid(simulated_spectra): """ Test the simple version of the spectral centroid. """ np.random.seed(42) # # Set up the data and add the uncertainty and calculate the expected SNR # spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty wavelengths = spectrum.spectral_axis flux = spectrum.flux spec_centroid_expected = np.sum(flux * wavelengths) / np.sum(flux) # # SNR of the whole spectrum # spec_centroid = centroid(spectrum, None) assert isinstance(spec_centroid, u.Quantity) assert np.allclose(spec_centroid.value, spec_centroid_expected.value) def test_centroid_masked(simulated_spectra): """ Test centroid with masked spectrum. """ np.random.seed(42) # Same as in test for unmasked spectrum, but using # masked version of same spectrum. spectrum = simulated_spectra.s1_um_mJy_e1_masked uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty # Use masked flux and dispersion arrays to compute # the expected value for centroid. wavelengths = spectrum.spectral_axis[~spectrum.mask] flux = spectrum.flux[~spectrum.mask] spec_centroid_expected = np.sum(flux * wavelengths) / np.sum(flux) spec_centroid = centroid(spectrum, None) assert isinstance(spec_centroid, u.Quantity) assert np.allclose(spec_centroid.value, spec_centroid_expected.value) def test_inverted_centroid(simulated_spectra): """ Ensures the centroid calculation also works for *inverted* spectra - i.e. continuum-subtracted absorption lines. """ spectrum = simulated_spectra.s1_um_mJy_e1 spec_centroid_expected = (np.sum(spectrum.flux * spectrum.spectral_axis) / np.sum(spectrum.flux)) spectrum_inverted = Spectrum1D(spectral_axis=spectrum.spectral_axis, flux=-spectrum.flux) spec_centroid_inverted = centroid(spectrum_inverted, None) assert np.allclose(spec_centroid_inverted.value, spec_centroid_expected.value) def test_inverted_centroid_masked(simulated_spectra): """ Ensures the centroid calculation also works for *inverted* spectra with masked data - i.e. continuum-subtracted absorption lines. """ spectrum = simulated_spectra.s1_um_mJy_e1_masked spec_centroid_expected = (np.sum(spectrum.flux[~spectrum.mask] * spectrum.spectral_axis[~spectrum.mask]) / np.sum(spectrum.flux[~spectrum.mask])) spectrum_inverted = Spectrum1D(spectral_axis=spectrum.spectral_axis, flux=-spectrum.flux, mask=spectrum.mask) spec_centroid_inverted = centroid(spectrum_inverted, None) assert np.allclose(spec_centroid_inverted.value, spec_centroid_expected.value) def test_centroid_multiple_flux(simulated_spectra): """ Test the simple version of the spectral SNR, with multiple flux per single dispersion. """ # # Set up the data and add the uncertainty and calculate the expected SNR # np.random.seed(42) spec = Spectrum1D(spectral_axis=np.arange(10) * u.um, flux=np.random.sample((5, 10)) * u.Jy) centroid_spec = centroid(spec, None) assert np.allclose(centroid_spec.value, np.array([4.46190995, 4.17223565, 4.37778249, 4.51595259, 4.7429066])) assert centroid_spec.unit == u.um def test_gaussian_sigma_width(): np.random.seed(42) # Create a (centered) gaussian spectrum for testing mean = 5 frequencies = np.linspace(0, mean*2, 100) * u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=mean*u.GHz, stddev=0.8*u.GHz) spectrum = Spectrum1D(spectral_axis=frequencies, flux=g1(frequencies)) result = gaussian_sigma_width(spectrum) assert quantity_allclose(result, g1.stddev, atol=0.01*u.GHz) def test_gaussian_sigma_width_masked(): np.random.seed(42) # Create a (centered) gaussian masked spectrum for testing mean = 5 frequencies = np.linspace(0, mean*2, 100) * u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=mean*u.GHz, stddev=0.8*u.GHz) uncertainty = StdDevUncertainty(0.1*np.random.random(len(frequencies))*u.Jy) mask = (np.random.randn(frequencies.shape[0]) + 1.) > 0 spectrum = Spectrum1D(spectral_axis=frequencies, flux=g1(frequencies), uncertainty=uncertainty, mask=mask) result = gaussian_sigma_width(spectrum) assert quantity_allclose(result, g1.stddev, atol=0.01*u.GHz) def test_gaussian_sigma_width_regions(): np.random.seed(42) frequencies = np.linspace(100, 0, 10000) * u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=10*u.GHz, stddev=0.8*u.GHz) g2 = models.Gaussian1D(amplitude=5*u.Jy, mean=2*u.GHz, stddev=0.3*u.GHz) g3 = models.Gaussian1D(amplitude=5*u.Jy, mean=70*u.GHz, stddev=10*u.GHz) compound = g1 + g2 + g3 spectrum = Spectrum1D(spectral_axis=frequencies, flux=compound(frequencies)) region1 = SpectralRegion(15*u.GHz, 5*u.GHz) result1 = gaussian_sigma_width(spectrum, regions=region1) exp1 = g1.stddev assert quantity_allclose(result1, exp1, atol=0.25*exp1) region2 = SpectralRegion(3*u.GHz, 1*u.GHz) result2 = gaussian_sigma_width(spectrum, regions=region2) exp2 = g2.stddev assert quantity_allclose(result2, exp2, atol=0.25*exp2) region3 = SpectralRegion(100*u.GHz, 40*u.GHz) result3 = gaussian_sigma_width(spectrum, regions=region3) exp3 = g3.stddev assert quantity_allclose(result3, exp3, atol=0.25*exp3) # Test using a list of regions result_list = gaussian_sigma_width(spectrum, regions=[region1, region2, region3]) for model, result in zip((g1, g2, g3), result_list): exp = model.stddev assert quantity_allclose(result, exp, atol=0.25*exp) def test_gaussian_sigma_width_multi_spectrum(): np.random.seed(42) frequencies = np.linspace(100, 0, 10000) * u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=50*u.GHz, stddev=0.8*u.GHz) g2 = models.Gaussian1D(amplitude=5*u.Jy, mean=50*u.GHz, stddev=5*u.GHz) g3 = models.Gaussian1D(amplitude=5*u.Jy, mean=50*u.GHz, stddev=10*u.GHz) flux = np.ndarray((3, len(frequencies))) * u.Jy flux[0] = g1(frequencies) flux[1] = g2(frequencies) flux[2] = g3(frequencies) spectra = Spectrum1D(spectral_axis=frequencies, flux=flux) results = gaussian_sigma_width(spectra) expected = (g1.stddev, g2.stddev, g3.stddev) for result, exp in zip(results, expected): assert quantity_allclose(result, exp, atol=0.25*exp) def test_gaussian_fwhm(): np.random.seed(42) # Create a (centered) gaussian spectrum for testing mean = 5 frequencies = np.linspace(0, mean*2, 100) * u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=mean*u.GHz, stddev=0.8*u.GHz) spectrum = Spectrum1D(spectral_axis=frequencies, flux=g1(frequencies)) result = gaussian_fwhm(spectrum) expected = g1.stddev * gaussian_sigma_to_fwhm assert quantity_allclose(result, expected, atol=0.01*u.GHz) def test_gaussian_fwhm_masked(): np.random.seed(42) # Create a (centered) gaussian masked spectrum for testing mean = 5 frequencies = np.linspace(0, mean*2, 100) * u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=mean*u.GHz, stddev=0.8*u.GHz) uncertainty = StdDevUncertainty(0.1*np.random.random(len(frequencies))*u.Jy) mask = (np.random.randn(frequencies.shape[0]) + 1.) > 0 spectrum = Spectrum1D(spectral_axis=frequencies, flux=g1(frequencies), uncertainty=uncertainty, mask=mask) result = gaussian_fwhm(spectrum) expected = g1.stddev * gaussian_sigma_to_fwhm assert quantity_allclose(result, expected, atol=0.01*u.GHz) @pytest.mark.parametrize('mean', range(3,8)) def test_gaussian_fwhm_uncentered(mean): np.random.seed(42) # Create an uncentered gaussian spectrum for testing frequencies = np.linspace(0, 10, 1000) * u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=mean*u.GHz, stddev=0.8*u.GHz) spectrum = Spectrum1D(spectral_axis=frequencies, flux=g1(frequencies)) result = gaussian_fwhm(spectrum) expected = g1.stddev * gaussian_sigma_to_fwhm assert quantity_allclose(result, expected, atol=0.05*u.GHz) def test_fwhm_masked(): np.random.seed(42) # Create a masked (uncentered) spectrum for testing frequencies = np.linspace(0, 10, 1000) * u.GHz stddev = 0.8*u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=2*u.GHz, stddev=stddev) mask = (np.random.randn(frequencies.shape[0]) + 1.) > 0 spectrum = Spectrum1D(spectral_axis=frequencies, flux=g1(frequencies), mask=mask) result = fwhm(spectrum) expected = stddev * gaussian_sigma_to_fwhm assert quantity_allclose(result, expected, atol=0.01*u.GHz) # Highest point at the first point wavelengths = np.linspace(1, 10, 100) * u.um flux = (1.0 / wavelengths.value) * u.Jy # highest point first. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) # Note that this makes a little more sense than the previous version; # since the maximum value occurs at wavelength=1, and the half-value of # flux (0.5) occurs at exactly wavelength=2, the result should be # exactly 1 (2 - 1). assert result == 1.0 * u.um # Test the interpolation used in FWHM for wavelength values that are not # on the grid wavelengths = np.linspace(1, 10, 31) * u.um flux = (1.0 / wavelengths.value) * u.Jy # highest point first. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) assert quantity_allclose(result, 1.01 * u.um) # Highest point at the last point wavelengths = np.linspace(1, 10, 100) * u.um flux = wavelengths.value*u.Jy # highest point last. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) assert result == 5*u.um # Flat spectrum wavelengths = np.linspace(1, 10, 100) * u.um flux = np.ones(wavelengths.shape)*u.Jy # highest point last. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) assert result == 9*u.um def test_fwhm(): np.random.seed(42) # Create an (uncentered) spectrum for testing frequencies = np.linspace(0, 10, 1000) * u.GHz stddev = 0.8*u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=2*u.GHz, stddev=stddev) spectrum = Spectrum1D(spectral_axis=frequencies, flux=g1(frequencies)) result = fwhm(spectrum) expected = stddev * gaussian_sigma_to_fwhm assert quantity_allclose(result, expected, atol=0.01*u.GHz) # Highest point at the first point wavelengths = np.linspace(1, 10, 100) * u.um flux = (1.0 / wavelengths.value) * u.Jy # highest point first. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) # Note that this makes a little more sense than the previous version; # since the maximum value occurs at wavelength=1, and the half-value of # flux (0.5) occurs at exactly wavelength=2, the result should be # exactly 1 (2 - 1). assert result == 1.0 * u.um # Test the interpolation used in FWHM for wavelength values that are not # on the grid wavelengths = np.linspace(1, 10, 31) * u.um flux = (1.0 / wavelengths.value) * u.Jy # highest point first. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) assert quantity_allclose(result, 1.01 * u.um) # Highest point at the last point wavelengths = np.linspace(1, 10, 100) * u.um flux = wavelengths.value*u.Jy # highest point last. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) assert result == 5*u.um # Flat spectrum wavelengths = np.linspace(1, 10, 100) * u.um flux = np.ones(wavelengths.shape)*u.Jy # highest point last. spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux) result = fwhm(spectrum) assert result == 9*u.um def test_fwhm_multi_spectrum(): np.random.seed(42) frequencies = np.linspace(0, 100, 10000) * u.GHz stddevs = [0.8, 5, 10]*u.GHz g1 = models.Gaussian1D(amplitude=5*u.Jy, mean=5*u.GHz, stddev=stddevs[0]) g2 = models.Gaussian1D(amplitude=5*u.Jy, mean=50*u.GHz, stddev=stddevs[1]) g3 = models.Gaussian1D(amplitude=5*u.Jy, mean=83*u.GHz, stddev=stddevs[2]) flux = np.ndarray((3, len(frequencies))) * u.Jy flux[0] = g1(frequencies) flux[1] = g2(frequencies) flux[2] = g3(frequencies) spectra = Spectrum1D(spectral_axis=frequencies, flux=flux) results = fwhm(spectra) expected = stddevs * gaussian_sigma_to_fwhm assert quantity_allclose(results, expected, atol=0.01*u.GHz) def test_fwzi(): np.random.seed(42) disp = np.linspace(0, 100, 1000) * u.AA g = models.Gaussian1D(mean=np.mean(disp), amplitude=1 * u.Jy, stddev=2 * u.AA) flux = g(disp) flux_with_noise = g(disp) + ((np.random.sample(disp.size) - 0.5) * 0.1) * u.Jy spec = Spectrum1D(spectral_axis=disp, flux=flux) spec_with_noise = Spectrum1D(spectral_axis=disp, flux=flux_with_noise) assert quantity_allclose(fwzi(spec), 226.89732509 * u.AA) assert quantity_allclose(fwzi(spec_with_noise), 106.99998944 * u.AA) def test_fwzi_masked(): np.random.seed(42) disp = np.linspace(0, 100, 100) * u.AA g = models.Gaussian1D(mean=np.mean(disp), amplitude=1 * u.Jy, stddev=10 * u.AA) flux = g(disp) + ((np.random.sample(disp.size) - 0.5) * 0.1) * u.Jy # Add mask. It is built such that about 50% of the data points # on and around the Gaussian peak are masked out (this was checked # with the debugger to examine in-memory data). uncertainty = StdDevUncertainty(0.1*np.random.random(len(disp))*u.Jy) mask = (np.random.randn(disp.shape[0]) - 0.5) > 0 spec = Spectrum1D(spectral_axis=disp, flux=flux, uncertainty=uncertainty, mask=mask) assert quantity_allclose(fwzi(spec), 35.9996284 * u.AA) def test_fwzi_multi_spectrum(): np.random.seed(42) disp = np.linspace(0, 100, 1000) * u.AA amplitudes = [0.1, 1, 10] * u.Jy means = [25, 50, 75] * u.AA stddevs = [1, 5, 10] * u.AA params = list(zip(amplitudes, means, stddevs)) flux = np.zeros(shape=(3, len(disp))) for i in range(3): flux[i] = models.Gaussian1D(*params[i])(disp) spec = Spectrum1D(spectral_axis=disp, flux=flux * u.Jy) expected = [113.51706001 * u.AA, 567.21252727 * u.AA, 499.5024546 * u.AA] assert quantity_allclose(fwzi(spec), expected) def test_is_continuum_below_threshold(): # No mask, no uncertainty wavelengths = [300, 500, 1000] * u.nm data = [0.001, -0.003, 0.003] * u.Jy spectrum = Spectrum1D(spectral_axis=wavelengths, flux=data) assert True == is_continuum_below_threshold(spectrum, threshold=0.1*u.Jy) # # No mask, no uncertainty, threshold is float # wavelengths = [300, 500, 1000] * u.nm # data = [0.0081, 0.0043, 0.0072] * u.Jy # spectrum = Spectrum1D(spectral_axis=wavelengths, flux=data) # assert True == is_continuum_below_threshold(spectrum, threshold=0.1) # No mask, with uncertainty wavelengths = [300, 500, 1000] * u.nm data = [0.03, 0.029, 0.031] * u.Jy uncertainty = StdDevUncertainty([1.01, 1.03, 1.01] * u.Jy) spectrum = Spectrum1D(spectral_axis=wavelengths, flux=data, uncertainty=uncertainty) assert True == is_continuum_below_threshold(spectrum, threshold=0.1*u.Jy) # With mask, with uncertainty wavelengths = [300, 500, 1000] * u.nm data = [0.01, 1.029, 0.013] * u.Jy mask = np.array([False, True, False]) uncertainty = StdDevUncertainty([1.01, 1.13, 1.1] * u.Jy) spectrum = Spectrum1D(spectral_axis=wavelengths, flux=data, uncertainty=uncertainty, mask=mask) assert True == is_continuum_below_threshold(spectrum, threshold=0.1*u.Jy) # With mask, with uncertainty -- should throw an exception wavelengths = [300, 500, 1000] * u.nm data = [10.03, 10.029, 10.033] * u.Jy mask = np.array([False, False, False]) uncertainty = StdDevUncertainty([1.01, 1.13, 1.1] * u.Jy) spectrum = Spectrum1D(spectral_axis=wavelengths, flux=data, uncertainty=uncertainty, mask=mask) print('spectrum has flux {}'.format(spectrum.flux)) with pytest.warns(AstropyUserWarning) as e_info: find_lines_threshold(spectrum, noise_factor=1) assert len(e_info)==1 and 'if you want to suppress this warning' in e_info[0].message.args[0].lower() def test_moment(): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz g = models.Gaussian1D(amplitude=1*u.Jy, mean=10*u.GHz, stddev=1*u.GHz) noise = np.random.normal(0., 0.01, frequencies.shape) * u.Jy flux = g(frequencies) + noise spectrum = Spectrum1D(spectral_axis=frequencies, flux=flux) moment_0 = moment(spectrum, order=0) assert moment_0.unit.is_equivalent(u.Jy ) assert quantity_allclose(moment_0, 252.96*u.Jy, atol=0.01*u.Jy) moment_1 = moment(spectrum, order=1) assert moment_1.unit.is_equivalent(u.GHz) assert quantity_allclose(moment_1, 10.08*u.GHz, atol=0.01*u.GHz) moment_2 = moment(spectrum, order=2) assert moment_2.unit.is_equivalent(u.GHz**2) assert quantity_allclose(moment_2, 13.40*u.GHz**2, atol=0.01*u.GHz**2) moment_3 = moment(spectrum, order=3) assert moment_3.unit.is_equivalent(u.GHz**3) assert quantity_allclose(moment_3, 1233.78*u.GHz**3, atol=0.01*u.GHz**3) def test_moment_cube(): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz g = models.Gaussian1D(amplitude=100*u.Jy, mean=50*u.GHz, stddev=1000*u.GHz) noise = np.random.normal(0., 0.01, frequencies.shape) * u.Jy flux = g(frequencies) + noise # use identical arrays in each spaxel. The purpose here is not to # check accuracy (already tested elsewhere), but dimensionality. flux_multid = np.broadcast_to(flux, [10,10,flux.shape[0]]) * u.Jy spectrum = Spectrum1D(spectral_axis=frequencies, flux=flux_multid) moment_1 = moment(spectrum, order=1) assert moment_1.shape == (10,10) assert moment_1.unit.is_equivalent(u.GHz) assert quantity_allclose(moment_1, 50.50*u.GHz, atol=0.01*u.GHz) # select the last axis of the cube. Should be identical with # the default call above. moment_1_last = moment(spectrum, order=1, axis=2) assert moment_1_last.shape == moment_1.shape assert moment_1_last.unit.is_equivalent(moment_1.unit) assert quantity_allclose(moment_1, moment_1_last, rtol=1.E-5) # spatial 1st order - returns the dispersion moment_1 = moment(spectrum, order=1, axis=1) assert moment_1.shape == (10,10000) assert moment_1.unit.is_equivalent(u.GHz) assert quantity_allclose(moment_1, frequencies, rtol=1.E-5) # higher order moment_2 = moment(spectrum, order=2) assert moment_2.shape == (10,10) assert moment_2.unit.is_equivalent(u.GHz**2) assert quantity_allclose(moment_2, 816.648*u.GHz**2, atol=0.01*u.GHz**2) # spatial higher order (what's the meaning of this?) moment_2 = moment(spectrum, order=2, axis=1) assert moment_2.shape == (10,10000) assert moment_2.unit.is_equivalent(u.GHz**2) # check assorted values. assert quantity_allclose(moment_2[0][0], 2.019e-28*u.GHz**2, rtol=0.01) assert quantity_allclose(moment_2[1][0], 2.019e-28*u.GHz**2, rtol=0.01) assert quantity_allclose(moment_2[0][3], 8.078e-28*u.GHz**2, rtol=0.01) def test_moment_collection(): np.random.seed(42) frequencies = np.linspace(100, 1, 10000) * u.GHz g = models.Gaussian1D(amplitude=1*u.Jy, mean=10*u.GHz, stddev=1*u.GHz) noise = np.random.normal(0., 0.01, frequencies.shape) * u.Jy flux = g(frequencies) + noise s1 = Spectrum1D(spectral_axis=frequencies, flux=flux) frequencies = np.linspace(200, 2, 10000) * u.GHz g = models.Gaussian1D(amplitude=2*u.Jy, mean=20*u.GHz, stddev=2*u.GHz) noise = np.random.normal(0., 0.02, frequencies.shape) * u.Jy flux = g(frequencies) + noise s2 = Spectrum1D(spectral_axis=frequencies, flux=flux) collection = SpectrumCollection.from_spectra([s1, s2]) # Compare moments derived from collection, with moments # derived from individual members. moment_0 = moment(collection, order=0) moment_0_s1 = moment(s1, order=0) moment_0_s2 = moment(s2, order=0) assert quantity_allclose(moment_0[0], moment_0_s1, rtol=1.E-5) assert quantity_allclose(moment_0[1], moment_0_s2, rtol=1.E-5) moment_1 = moment(collection, order=1) moment_1_s1 = moment(s1, order=1) moment_1_s2 = moment(s2, order=1) assert quantity_allclose(moment_1[0], moment_1_s1, rtol=1.E-5) assert quantity_allclose(moment_1[1], moment_1_s2, rtol=1.E-5) moment_2 = moment(collection, order=2) moment_2_s1 = moment(s1, order=2) moment_2_s2 = moment(s2, order=2) assert quantity_allclose(moment_2[0], moment_2_s1, rtol=1.E-5) assert quantity_allclose(moment_2[1], moment_2_s2, rtol=1.E-5) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_arithmetic.py0000644000503700020070000000675200000000000024535 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u import numpy as np import pytest from ..spectra.spectrum1d import Spectrum1D from .spectral_examples import simulated_spectra def test_spectral_axes(): flux1 = (np.random.sample(49) * 100).astype(int) flux2 = (np.random.sample(49) * 100).astype(int) flux3 = flux1 + flux2 spec1 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=flux1 * u.Jy) spec2 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=flux2 * u.Jy) spec3 = spec1 + spec2 assert np.allclose(spec3.flux.value, flux3) def test_add_basic_spectra(simulated_spectra): # Get the numpy array of data flux1 = simulated_spectra.s1_um_mJy_e1_flux flux2 = simulated_spectra.s1_um_mJy_e2_flux flux3 = flux1 + flux2 # Calculate using the spectrum1d/nddata code spec3 = simulated_spectra.s1_um_mJy_e1 + simulated_spectra.s1_um_mJy_e2 assert np.allclose(spec3.flux.value, flux3) def test_add_diff_flux_prefix(simulated_spectra): # Get the numpy array of data # this assumes output will be in mJy units flux1 = simulated_spectra.s1_AA_mJy_e3_flux flux2 = simulated_spectra.s1_AA_nJy_e4_flux flux3 = flux1 + (flux2 / 1000000) # Calculate using the spectrum1d/nddata code spec3 = simulated_spectra.s1_AA_mJy_e3 + simulated_spectra.s1_AA_nJy_e4 assert np.allclose(spec3.flux.value, flux3) def test_subtract_basic_spectra(simulated_spectra): # Get the numpy array of data flux1 = simulated_spectra.s1_um_mJy_e1_flux flux2 = simulated_spectra.s1_um_mJy_e2_flux flux3 = flux2 - flux1 # Calculate using the spectrum1d/nddata code spec3 = simulated_spectra.s1_um_mJy_e2 - simulated_spectra.s1_um_mJy_e1 assert np.allclose(spec3.flux.value, flux3) def test_divide_basic_spectra(simulated_spectra): # Get the numpy array of data flux1 = simulated_spectra.s1_um_mJy_e1_flux flux2 = simulated_spectra.s1_um_mJy_e2_flux flux3 = flux1 / flux2 # Calculate using the spectrum1d/nddata code spec3 = simulated_spectra.s1_um_mJy_e1 / simulated_spectra.s1_um_mJy_e2 assert np.allclose(spec3.flux.value, flux3) def test_multiplication_basic_spectra(simulated_spectra): # Get the numpy array of data flux1 = simulated_spectra.s1_um_mJy_e1_flux flux2 = simulated_spectra.s1_um_mJy_e2_flux flux3 = flux1 * flux2 # Calculate using the spectrum1d/nddata code spec3 = simulated_spectra.s1_um_mJy_e1 * simulated_spectra.s1_um_mJy_e2 assert np.allclose(spec3.flux.value, flux3) def test_add_diff_spectral_axis(simulated_spectra): # Calculate using the spectrum1d/nddata code spec3 = simulated_spectra.s1_um_mJy_e1 + simulated_spectra.s1_AA_mJy_e3 def test_masks(simulated_spectra): masked_spec = simulated_spectra.s1_um_mJy_e1_masked masked_sum = masked_spec + masked_spec assert np.all(masked_sum.mask == simulated_spectra.s1_um_mJy_e1_masked.mask) masked_sum.mask[:50] = True masked_diff = masked_sum - masked_spec assert u.allclose(masked_diff.flux, masked_spec.flux) assert np.all(masked_diff.mask == masked_sum.mask | masked_spec.mask) def test_mask_nans(): flux1 = np.random.random(10) flux2 = np.random.random(10) nan_idx = [1, 3, 5] flux2[nan_idx] = np.nan spec1 = Spectrum1D(spectral_axis=np.arange(10) * u.nm, flux=flux1 * u.Jy) spec2 = Spectrum1D(spectral_axis=np.arange(10) * u.nm, flux=flux2 * u.Jy) spec3 = spec1 + spec2 assert spec3.mask[nan_idx].all() == True ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_continuum.py0000644000503700020070000002351400000000000024420 0ustar00rosteenSTSCI\science00000000000000import numpy as np import astropy.units as u from astropy.modeling.polynomial import Chebyshev1D from ..spectra.spectrum1d import Spectrum1D from ..spectra import SpectralRegion from ..fitting.continuum import fit_generic_continuum, fit_continuum from ..manipulation.smoothing import median_smooth def single_peak_continuum(noise=0.2, constant_continuum=False): np.random.seed(0) x = np.linspace(0., 10., 200) y_single = 3 * np.exp(-0.5 * (x - 6.3)**2 / 0.1**2) y_single += np.random.normal(0., noise, x.shape) if not constant_continuum: y_continuum = 3.2 * np.exp(-0.5 * (x - 5.6)**2 / 4.8**2) else: y_continuum = 3.2 y_single += y_continuum return x, y_single def test_continuum_fit(): """ This test fits the first simulated spectrum from the fixture. The initial guesses are manually set here with bounds that essentially make sense as the functionality of the test is to make sure the fit works and we get a reasonable answer out **given** good initial guesses. """ x_single_continuum, y_single_continuum = single_peak_continuum() s_single_continuum = Spectrum1D(flux=y_single_continuum*u.Jy, spectral_axis=x_single_continuum*u.um) g1_fit = fit_generic_continuum(s_single_continuum) y_continuum_fitted = g1_fit(s_single_continuum.spectral_axis) y_continuum_fitted_expected = np.array([1.71364056, 1.87755574, 2.05310622, 2.23545755, 2.41977527, 2.60122493, 2.77497207, 2.93618225, 3.080021, 3.20165388, 3.29624643, 3.3589642, 3.38497273, 3.36943758, 3.30752428, 3.19439839, 3.02522545, 2.79517101, 2.49940062, 2.13307982]) assert np.allclose(y_continuum_fitted.value[::10], y_continuum_fitted_expected, atol=1e-5) def test_continuum_calculation(): """ This test fits the first simulated spectrum from the fixture. The initial guesses are manually set here with bounds that essentially make sense as the functionality of the test is to make sure the fit works and we get a reasonable answer out **given** good initial guesses. """ x_single_continuum, y_single_continuum = single_peak_continuum() spectrum = Spectrum1D(flux=y_single_continuum*u.Jy, spectral_axis=x_single_continuum*u.um) g1_fit = fit_generic_continuum(spectrum) spectrum_normalized = spectrum / g1_fit(spectrum.spectral_axis) y_continuum_fitted_expected = np.array([1.15139925, 0.98509363, 0.73700614, 1.00911864, 0.913129, 0.93145533, 0.94904202, 1.04162879, 0.90851397, 0.9494352, 1.07812394, 1.06376489, 0.98705237, 0.94569623, 0.83502377, 0.91909416, 0.89662208, 1.01458511, 0.96124191, 0.94847744]) assert np.allclose(spectrum_normalized.flux.value[::10], y_continuum_fitted_expected, atol=1e-5) def test_continuum_full_window(): """ This test fits the first simulated spectrum from the fixture, but with the fit_continuum function instead of fit_generic_continuum. Uses a window to select the entire spectrum and checks that it recovers the original, non-windowed fit. """ x_single_continuum, y_single_continuum = single_peak_continuum() spectrum = Spectrum1D(flux=y_single_continuum*u.Jy, spectral_axis=x_single_continuum*u.um) # Smooth in the same way fit_generic_continuum does. spectrum_smoothed = median_smooth(spectrum, 3) # Check that a full width window recovers the original, non-windowed fit. g1_fit = fit_continuum(spectrum_smoothed, window=(0.*u.um, 10.*u.um)) g1_fit_orig = fit_continuum(spectrum_smoothed) sp_normalized = spectrum / g1_fit(spectrum.spectral_axis) sp_normalized_orig = spectrum / g1_fit_orig(spectrum.spectral_axis) assert np.allclose(sp_normalized.flux.value, sp_normalized_orig.flux.value, atol=1e-5) def test_continuum_spectral_region(): """ As before, but with a SpectralRegion as window. """ x_single_continuum, y_single_continuum = single_peak_continuum() spectrum = Spectrum1D(flux=y_single_continuum*u.Jy, spectral_axis=x_single_continuum*u.um) # Smooth in the same way fit_generic_continuum does. spectrum_smoothed = median_smooth(spectrum, 3) # Check that a full width window recovers the original, non-windowed fit. region = SpectralRegion(0.*u.um, 10.*u.um) g1_fit = fit_continuum(spectrum_smoothed, window=region) spectrum_normalized = spectrum / g1_fit(spectrum.spectral_axis) y_continuum_fitted_expected = np.array([1.15139925, 0.98509363, 0.73700614, 1.00911864, 0.913129, 0.93145533, 0.94904202, 1.04162879, 0.90851397, 0.9494352, 1.07812394, 1.06376489, 0.98705237, 0.94569623, 0.83502377, 0.91909416, 0.89662208, 1.01458511, 0.96124191, 0.94847744]) assert np.allclose(spectrum_normalized.flux.value[::10], y_continuum_fitted_expected, atol=1e-5) def test_continuum_window_no_noise(): """ This test repeats the setup from the previous tests, but uses windows to select specific regions to fit. It uses a synthetic spectrum with no noise component. That way, numerical effects coming from the model fit itself can be more easily spotted. """ x_single_continuum, y_single_continuum = single_peak_continuum(noise=0.) spectrum = Spectrum1D(flux=y_single_continuum*u.Jy, spectral_axis=x_single_continuum*u.um) # Smooth in the same way fit_generic_continuum does. spectrum_smoothed = median_smooth(spectrum, 3) # Window selects the first half of the spectrum. g1_fit = fit_continuum(spectrum_smoothed, window=(0.*u.um, 5.*u.um)) spectrum_normalized = spectrum / g1_fit(spectrum.spectral_axis) y_continuum_fitted_expected = np.ones(shape=(spectrum_normalized.spectral_axis.shape)) # Check fit over the first half. The agreement is not so good as before, # probably due to the narrow component at 6.5 um. assert np.allclose(spectrum_normalized.flux.value[0:100], y_continuum_fitted_expected[0:100], atol=5.5e-4) # Window selects the red end of the spectrum. g1_fit = fit_continuum(spectrum_smoothed, window=(8.*u.um, 10.*u.um)) spectrum_normalized = spectrum / g1_fit(spectrum.spectral_axis) # Check fit over the red end. assert np.allclose(spectrum_normalized.flux.value[160:], y_continuum_fitted_expected[160:], atol=1.e-5) def test_double_continuum_window(): """ Fit to no-noise spectrum comprised of a exponential continuum plus an emission Gaussian """ x_single_continuum, y_single_continuum = single_peak_continuum(noise=0.,constant_continuum=False) spectrum = Spectrum1D(flux=y_single_continuum*u.Jy, spectral_axis=x_single_continuum*u.um) # Smooth in the same way fit_generic_continuum does. spectrum_smoothed = median_smooth(spectrum, 3) # Test spectrum is comprised of an exponential continuum that increases sharply at the # long wavelength end, plus a large-amplitude narrow Gaussian. We define the fitting # window as a SpectralRegion with sub-regions at the blue and red halves of the spectrum, # avoiding the Gaussian in between. The polynomial degree is high to accomodate the large # amplitude range of the expoinential continuum. region = SpectralRegion([(0.*u.um, 5.*u.um), (8.* u.um, 10.* u.um)]) g1_fit = fit_continuum(spectrum_smoothed, model=Chebyshev1D(7), window=region) spectrum_normalized = spectrum / g1_fit(spectrum.spectral_axis) y_continuum_fitted_expected = np.ones(shape=(spectrum_normalized.spectral_axis.shape)) # Check fit over the windowed regions. Note that we fit a noiseless spectrum so we can # actually measure the degree of mismatch between model and data (polynomial X exponential). assert np.allclose(spectrum_normalized.flux.value[0:90], y_continuum_fitted_expected[0:90], atol=1.e-4) assert np.allclose(spectrum_normalized.flux.value[170:], y_continuum_fitted_expected[170:], atol=1.e-4) def test_double_continuum_window_alternate(): """ Fit to no-noise spectrum comprised of a exponential continuum plus an emission Gaussian This is the same test as above, but with a different form for the double window specification. """ x_single_continuum, y_single_continuum = single_peak_continuum(noise=0.,constant_continuum=False) spectrum = Spectrum1D(flux=y_single_continuum*u.Jy, spectral_axis=x_single_continuum*u.um) # Smooth in the same way fit_generic_continuum does. spectrum_smoothed = median_smooth(spectrum, 3) # Test spectrum is comprised of an exponential continuum that increases sharply at the # long wavelength end, plus a large-amplitude narrow Gaussian. We define the fitting # window as a SpectralRegion with sub-regions at the blue and red halves of the spectrum, # avoiding the Gaussian in between. The polynomial degree is high to accomodate the large # amplitude range of the expoinential continuum. region = [(0.*u.um, 5.*u.um), (8.* u.um, 10.* u.um)] g1_fit = fit_continuum(spectrum_smoothed, model=Chebyshev1D(7), window=region) spectrum_normalized = spectrum / g1_fit(spectrum.spectral_axis) y_continuum_fitted_expected = np.ones(shape=(spectrum_normalized.spectral_axis.shape)) # Check fit over the windowed regions. Note that we fit a noiseless spectrum so we can # actually measure the degree of mismatch between model and data (polynomial X exponential). assert np.allclose(spectrum_normalized.flux.value[0:90], y_continuum_fitted_expected[0:90], atol=1.e-4) assert np.allclose(spectrum_normalized.flux.value[170:], y_continuum_fitted_expected[170:], atol=1.e-4) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_correlation.py0000644000503700020070000002164600000000000024724 0ustar00rosteenSTSCI\science00000000000000import os import numpy as np import astropy.units as u from astropy import constants as const from astropy.nddata import StdDevUncertainty from astropy.modeling import models from ..spectra.spectrum1d import Spectrum1D from ..analysis import correlation def test_autocorrelation(): """ Test auto correlation """ size = 42 # Seed np.random so that results are consistent np.random.seed(41) # Create test spectra spec_axis = np.linspace(5000., 5040., num=size) * u.AA f1 = np.random.randn(size) * u.Jy g1 = models.Gaussian1D(amplitude=30 * u.Jy, mean=5020 * u.AA, stddev=2 * u.AA) flux1 = f1 + g1(spec_axis) # Observed spectrum must have a rest wavelength value set in. spec1 = Spectrum1D(spectral_axis=spec_axis, flux=flux1, uncertainty=StdDevUncertainty(np.random.sample(size), unit='Jy'), velocity_convention='optical', rest_value=5020.*u.AA) spec2 = Spectrum1D(spectral_axis=spec_axis, flux=flux1, uncertainty=StdDevUncertainty(np.random.sample(size), unit='Jy')) # Get result from correlation corr, lag = correlation.template_correlate(spec1, spec2) # Check units assert corr.unit == u.dimensionless_unscaled assert lag.unit == u.km / u.s # Check that lags are symmetrical midpoint = int(len(lag) / 2) np.testing.assert_almost_equal(lag[midpoint+1].value, (-(lag[midpoint-1])).value, 2) # Check position of correlation peak. maximum = np.argmax(corr) assert maximum == midpoint def test_correlation(): """ Test correlation when both observed and template spectra have the same wavelength axis """ size = 4000 # Seed np.random so that results are consistent np.random.seed(51) # Create test spectra spec_axis = np.linspace(4001., 8000., num=size) * u.AA # Two narrow Gaussians are offset from each other so # as to generate a correlation peak at a expected lag. f1 = np.random.randn(size)*0.5 * u.Jy f2 = np.random.randn(size)*0.5 * u.Jy expected_lag = 10000. * u.km/u.s mean2 = 5000. * u.AA stdev2 = 30. * u.AA mean1 = (1 + expected_lag / const.c.to('km/s')) * mean2 stdev1 = (1 + expected_lag / const.c.to('km/s')) * stdev2 rest_value = mean2 g1 = models.Gaussian1D(amplitude=30 * u.Jy, mean=mean1, stddev=stdev1) g2 = models.Gaussian1D(amplitude=30 * u.Jy, mean=mean2, stddev=stdev2) flux1 = f1 + g1(spec_axis) flux2 = f2 + g2(spec_axis) # Observed spectrum must have a rest wavelength value set in. spec1 = Spectrum1D(spectral_axis=spec_axis, flux=flux1, uncertainty=StdDevUncertainty(np.random.sample(size), unit='Jy'), velocity_convention='optical', rest_value=rest_value) spec2 = Spectrum1D(spectral_axis=spec_axis, flux=flux2, uncertainty=StdDevUncertainty(np.random.sample(size), unit='Jy')) # Get result from correlation corr, lag = correlation.template_correlate(spec1, spec2) # Check units assert corr.unit == u.dimensionless_unscaled assert lag.unit == u.km / u.s # Check position of correlation peak. maximum = np.argmax(corr) v_fit = _fit_peak(corr, lag, maximum) # checks against 1.5 * 10**(-decimal) np.testing.assert_almost_equal(v_fit.value, expected_lag.value, -1) def _create_arrays(size1, size2): spec_axis_1 = np.linspace(6050., 6100., num=size1) * u.AA spec_axis_2 = np.linspace(6000., 6100., num=size2) * u.AA # Create continuum-subtracted flux arrays with noise. f1 = np.random.randn(size1) * u.Jy f2 = np.random.randn(size2) * u.Jy # Two narrow Gaussians are offset from each other so # as to generate a correlation peak at a expected lag. expected_lag = 1000. * u.km/u.s mean2 = 6050. * u.AA stdev2 = 5. * u.AA mean1 = (1 + expected_lag / const.c.to('km/s')) * mean2 stdev1 = (1 + expected_lag / const.c.to('km/s')) * stdev2 rest_value = mean2 g1 = models.Gaussian1D(amplitude=50 * u.Jy, mean=mean1, stddev=stdev1) g2 = models.Gaussian1D(amplitude=50 * u.Jy, mean=mean2, stddev=stdev2) flux1 = f1 + g1(spec_axis_1) flux2 = f2 + g2(spec_axis_2) return spec_axis_1, spec_axis_2, flux1, flux2, expected_lag, rest_value def _fit_peak(corr, lag, index_peak): # Parabolic fit to maximum n = 3 # points to the left and right of correlation maximum peak_lags = lag[index_peak - n:index_peak + n + 1].value peak_vals = corr[index_peak - n:index_peak + n + 1].value p = np.polyfit(peak_lags, peak_vals, deg=2) roots = np.roots(p) return np.mean(roots) * u.km / u.s # maximum lies at mid point between roots def test_correlation_zero_padding(): """ Test correlation when observed and template spectra have different sizes """ size1 = 51 size2 = 101 # Seed np.random so that results are consistent np.random.seed(41) # Create test spectra spec_axis_1, spec_axis_2, flux1, flux2, expected_lag, rest_value = _create_arrays(size1, size2) # Observed spectrum must have a rest wavelength value set in. # Uncertainty is arbitrary. spec1 = Spectrum1D(spectral_axis=spec_axis_1, flux=flux1, uncertainty=StdDevUncertainty(np.random.sample(size1), unit='Jy'), velocity_convention='optical', rest_value=rest_value) spec2 = Spectrum1D(spectral_axis=spec_axis_2, flux=flux2, uncertainty=StdDevUncertainty(np.random.sample(size2), unit='Jy')) # Get result from correlation corr, lag = correlation.template_correlate(spec1, spec2) # Check units assert corr.unit == u.dimensionless_unscaled assert lag.unit == u.km / u.s # Check that lags are symmetrical midpoint = int(len(lag) / 2) np.testing.assert_almost_equal(lag[midpoint+1].value, (-(lag[midpoint-1])).value, 2) # Check position of correlation peak. maximum = np.argmax(corr) v_fit = _fit_peak(corr, lag, maximum) # checks against 1.5 * 10**(-decimal) np.testing.assert_almost_equal(v_fit.value, expected_lag.value, -1) def test_correlation_random_lines(): """ Test correlation when observed and template spectra have different sizes, and there are non-correlated features in the observed and template spectra. """ size1 = 51 size2 = 101 # Seed np.random so that results are consistent np.random.seed(41) # Create test spectra spec_axis_1, spec_axis_2, flux1, flux2, expected_lag, rest_value = _create_arrays(size1, size2) # Add random lines to both spectra to simulate non-correlated spectral features. # # The more lines we add, and the larger amplitude they have, the larger the error in correlation # peak position will be. In this specific case, increasing nlines by 1, or increasing the # Gaussian amplitudes by a bit, is enough to make the correlation peak move off position. # # So we can say generically (as expected anyway) that the presence of non-correlated features # in observed and template spectra generates an error in the correlation peak position, and # that error will be larger as these non-correlated features become more predominant in the # data. nlines = 20 for i in range(nlines): mean = (spec_axis_1[-1] - spec_axis_1[0]) * np.random.randn(size1) + spec_axis_1[0] g1 = models.Gaussian1D(amplitude=10 * u.Jy, mean=mean, stddev=4 * u.AA) flux1 += g1(spec_axis_1) for i in range(nlines): mean = (spec_axis_2[-1] - spec_axis_2[0]) * np.random.randn(size2) + spec_axis_2[0] g2 = models.Gaussian1D(amplitude=5 * u.Jy, mean=mean, stddev=4 * u.AA) flux2 += g2(spec_axis_2) # Observed spectrum must have a rest wavelength value set in. # Uncertainty is arbitrary. spec1 = Spectrum1D(spectral_axis=spec_axis_1, flux=flux1, uncertainty=StdDevUncertainty(np.random.sample(size1), unit='Jy'), velocity_convention='optical', rest_value=rest_value) spec2 = Spectrum1D(spectral_axis=spec_axis_2, flux=flux2, uncertainty=StdDevUncertainty(np.random.sample(size2), unit='Jy')) # Get result from correlation corr, lag = correlation.template_correlate(spec1, spec2) # Check units assert corr.unit == u.dimensionless_unscaled assert lag.unit == u.km / u.s # Check that lags are symmetrical midpoint = int(len(lag) / 2) np.testing.assert_almost_equal(lag[midpoint+1].value, (-(lag[midpoint-1])).value, 2) # Check position of correlation peak. maximum = np.argmax(corr) v_fit = _fit_peak(corr, lag, maximum) # checks against 1.5 * 10**(-decimal) np.testing.assert_almost_equal(v_fit.value, expected_lag.value, -1) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_dc_common_loaders.py0000644000503700020070000005132300000000000026045 0ustar00rosteenSTSCI\science00000000000000import pytest from astropy.nddata import ( VarianceUncertainty, StdDevUncertainty, InverseVariance, ) import astropy.units as u from .conftest import remote_access from .. import Spectrum1D, SpectrumList from ..io.default_loaders import dc_common as loaders REMOTE_ID = "4059032" GAMA_2QZ_TEST_FILENAME = "J113606.3+001155a.fit" GAMA_2SLAQ_QSO_TEST_FILENAME = "J091726.21+003424.0_a14_040423.fit" GAMA_GAMA_LT_TEST_FILENAME = "LTF_09_1128_0555.fit" GAMA_GAMA_TEST_FILENAME = "G12_Y2_009_044.fit" GAMA_MGC_TEST_FILENAME = "MGC23320.fit" GAMA_WIGGLEZ_TEST_FILENAME = "Spectrum-195254.fit" OZDES_TEST_FILENAME = "OzDES-DR2_04720.fits" WIGGLEZ_TEST_FILENAME = "wig206635.fits" OZDES_CONFIG = { "hdus": { "0": {"purpose": "combined_science"}, "1": {"purpose": "combined_error_variance"}, "2": {"purpose": "skip"}, "cycle": { "0": {"purpose": "science"}, "1": {"purpose": "error_variance"}, "2": {"purpose": "skip"}, }, }, "units": None, "wcs": None, "all_standard_units": True, "all_keywords": False, "valid_wcs": True, } GAMA_2QZ_CONFIG = { "hdus": None, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CD1_1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": True, "valid_wcs": False, } GAMA_2SLAQ_QSO_CONFIG = { "hdus": None, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": True, "valid_wcs": False, } GAMA_LT_CONFIG = { "hdus": {"0": {"purpose": "science"},}, "wcs": { "pixel_reference_point_keyword": "CRPIX", "pixel_reference_point_value_keyword": "CRVAL", "pixel_width_keyword": "CDELT", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": False, "valid_wcs": False, } GAMA_WIGGLEZ_CONFIG = { "hdus": { "0": {"purpose": "science"}, "1": {"purpose": "error_variance"}, "2": {"purpose": "skip"}, }, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CDELT1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": False, "valid_wcs": False, } GAMA_GAMA_CONFIG = { "hdu": { "1": { "purpose": "science", "units": {"flux_unit": "10^-17 erg/s/cm^2/A"}, }, "2": {"purpose": "error_stdev"}, "3": {"purpose": "unreduced_science"}, "4": {"purpose": "unreduced_error_stdev"}, "5": {"purpose": "sky"}, }, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CD1_1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": False, "valid_wcs": False, } GAMA_MGC_CONFIG = { "hdu": None, "wcs": { "pixel_reference_point_keyword": "CRPIX1", "pixel_reference_point_value_keyword": "CRVAL1", "pixel_width_keyword": "CD1_1", "wavelength_unit": "Angstrom", }, "units": {"flux_unit": "count"}, "all_standard_units": False, "all_keywords": True, "valid_wcs": False, } class TestSingleSplit: @remote_access([{'id': "4460981", 'filename': WIGGLEZ_TEST_FILENAME}]) def test_wigglez(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="WiggleZ") assert len(spectra) == 1 assert spectra[0].flux.unit == u.Unit("1e-16 erg / (A cm2 s)") assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @pytest.mark.xfail(reason="Format is ambiguous") @remote_access([{'id': "4460981", 'filename': WIGGLEZ_TEST_FILENAME}]) def test_wigglez_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 1 assert spectra[0].flux.unit == u.Unit("1e-16 erg / (A cm2 s)") assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': OZDES_TEST_FILENAME}]) def test_ozdes(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format=loaders.SINGLE_SPLIT_LABEL, **OZDES_CONFIG ) # The test file has the combined obs, and 4 other sets assert len(spectra) == 5 assert spectra[0].flux.unit == u.count / u.Angstrom assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is None assert spectra[0].meta.get("header") is not None assert spectra[1].flux.unit == u.count / u.Angstrom assert spectra[1].spectral_axis.unit == u.Angstrom assert isinstance(spectra[1].uncertainty, VarianceUncertainty) assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None assert spectra[2].flux.unit == u.count / u.Angstrom assert spectra[2].spectral_axis.unit == u.Angstrom assert isinstance(spectra[2].uncertainty, VarianceUncertainty) assert spectra[2].meta.get("label") is not None assert spectra[2].meta.get("header") is not None assert spectra[3].flux.unit == u.count / u.Angstrom assert spectra[3].spectral_axis.unit == u.Angstrom assert isinstance(spectra[3].uncertainty, VarianceUncertainty) assert spectra[3].meta.get("label") is not None assert spectra[3].meta.get("header") is not None assert spectra[4].flux.unit == u.count / u.Angstrom assert spectra[4].spectral_axis.unit == u.Angstrom assert isinstance(spectra[4].uncertainty, VarianceUncertainty) assert spectra[4].meta.get("label") is not None assert spectra[4].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': OZDES_TEST_FILENAME}]) def test_ozdes_named_loader(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="OzDES") # The test file has the combined obs, and 4 other sets assert len(spectra) == 5 assert spectra[0].flux.unit == u.count / u.Angstrom assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is None assert spectra[0].meta.get("header") is not None assert spectra[1].flux.unit == u.count / u.Angstrom assert spectra[1].spectral_axis.unit == u.Angstrom assert isinstance(spectra[1].uncertainty, VarianceUncertainty) assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None assert spectra[2].flux.unit == u.count / u.Angstrom assert spectra[2].spectral_axis.unit == u.Angstrom assert isinstance(spectra[2].uncertainty, VarianceUncertainty) assert spectra[2].meta.get("label") is not None assert spectra[2].meta.get("header") is not None assert spectra[3].flux.unit == u.count / u.Angstrom assert spectra[3].spectral_axis.unit == u.Angstrom assert isinstance(spectra[3].uncertainty, VarianceUncertainty) assert spectra[3].meta.get("label") is not None assert spectra[3].meta.get("header") is not None assert spectra[4].flux.unit == u.count / u.Angstrom assert spectra[4].spectral_axis.unit == u.Angstrom assert isinstance(spectra[4].uncertainty, VarianceUncertainty) assert spectra[4].meta.get("label") is not None assert spectra[4].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_2QZ_TEST_FILENAME}]) def test_gama_2qz(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format=loaders.SINGLE_SPLIT_LABEL, **GAMA_2QZ_CONFIG ) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_2QZ_TEST_FILENAME}]) def test_gama_2qz_named_loader(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="GAMA-2QZ") assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_2QZ_TEST_FILENAME}]) @pytest.mark.xfail(reason="Format is ambiguous") def test_gama_2qz_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_2SLAQ_QSO_TEST_FILENAME}]) def test_2slaq_qso(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format=loaders.SINGLE_SPLIT_LABEL, **GAMA_2SLAQ_QSO_CONFIG ) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_2SLAQ_QSO_TEST_FILENAME}]) def test_2slaq_qso_named_loader(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="GAMA-2SLAQ-QSO") assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_2SLAQ_QSO_TEST_FILENAME}]) @pytest.mark.xfail(reason="Format is ambiguous") def test_2slaq_qso_normalised(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_GAMA_LT_TEST_FILENAME}]) def test_gama_lt(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format=loaders.SINGLE_SPLIT_LABEL, **GAMA_LT_CONFIG ) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[0].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_GAMA_LT_TEST_FILENAME}]) def test_gama_lt_named_loader(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="GAMA-LT") assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[0].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_GAMA_LT_TEST_FILENAME}]) @pytest.mark.xfail(reason="Format is ambiguous") def test_gama_lt_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[0].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_WIGGLEZ_TEST_FILENAME}]) def test_gama_wigglez(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format=loaders.SINGLE_SPLIT_LABEL, **GAMA_WIGGLEZ_CONFIG ) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_WIGGLEZ_TEST_FILENAME}]) def test_gama_wigglez_named_loader(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="GAMA-WiggleZ") assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_WIGGLEZ_TEST_FILENAME}]) @pytest.mark.xfail(reason="Format is ambiguous") def test_gama_wigglez_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 1 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, VarianceUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None class TestMultilineSingle: @remote_access([{'id': REMOTE_ID, 'filename': GAMA_GAMA_TEST_FILENAME}]) def test_gama_gama(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format=loaders.MULTILINE_SINGLE_LABEL, **GAMA_GAMA_CONFIG ) assert len(spectra) == 3 assert spectra[0].flux.unit == u.Unit("10^-17 erg/s/cm^2/A") assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert spectra[1].spectral_axis.unit == u.Angstrom assert spectra[2].flux.unit == u.count assert spectra[2].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert isinstance(spectra[1].uncertainty, StdDevUncertainty) assert spectra[2].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None assert spectra[2].meta.get("label") is not None assert spectra[2].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_GAMA_TEST_FILENAME}]) def test_gama_gama_named_loader(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="GAMA") assert len(spectra) == 3 assert spectra[0].flux.unit == u.Unit("10^-17 erg/s/cm^2/A") assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert spectra[1].spectral_axis.unit == u.Angstrom assert spectra[2].flux.unit == u.count assert spectra[2].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert isinstance(spectra[1].uncertainty, StdDevUncertainty) assert spectra[2].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None assert spectra[2].meta.get("label") is not None assert spectra[2].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_GAMA_TEST_FILENAME}]) def test_gama_gama_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 3 assert spectra[0].flux.unit == u.Unit("10^-17 erg/s/cm^2/A") assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert spectra[1].spectral_axis.unit == u.Angstrom assert spectra[2].flux.unit == u.count assert spectra[2].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert isinstance(spectra[1].uncertainty, StdDevUncertainty) assert spectra[2].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None assert spectra[2].meta.get("label") is not None assert spectra[2].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_MGC_TEST_FILENAME}]) def test_gama_mgc(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format=loaders.MULTILINE_SINGLE_LABEL, **GAMA_MGC_CONFIG ) assert len(spectra) == 2 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert spectra[1].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert spectra[1].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_MGC_TEST_FILENAME}]) def test_gama_mgc_named_loader(self, remote_data_path): spectra = SpectrumList.read(remote_data_path, format="GAMA-MGC") assert len(spectra) == 2 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert spectra[1].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert spectra[1].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None @remote_access([{'id': REMOTE_ID, 'filename': GAMA_MGC_TEST_FILENAME}]) def test_gama_mgc_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 2 assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert spectra[1].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert spectra[1].uncertainty is None assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_fitting.py0000644000503700020070000006736600000000000024060 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u import numpy as np from astropy.modeling import models from astropy.nddata import StdDevUncertainty from astropy.tests.helper import assert_quantity_allclose from ..analysis import centroid, fwhm from ..fitting import (estimate_line_parameters, find_lines_derivative, find_lines_threshold, fit_lines) from ..manipulation import (extract_region, noise_region_uncertainty, spectrum_from_model) from ..spectra import SpectralRegion, Spectrum1D def single_peak(): np.random.seed(0) x = np.linspace(0., 10., 200) y_single = 3 * np.exp(-0.5 * (x - 6.3)**2 / 0.8**2) y_single += np.random.normal(0., 0.2, x.shape) return x, y_single def single_peak_continuum(): np.random.seed(0) x = np.linspace(0., 10., 200) y_single = 3 * np.exp(-0.5 * (x - 6.3)**2 / 0.3**2) y_single += np.random.normal(0., 0.2, x.shape) y_continuum = 3.2 * np.exp(-0.5 * (x - 0.6)**2 / 2.8**2) y_single += y_continuum return x, y_single def single_peak_extra(): x, y_single = single_peak() extra = 4 * np.exp(-0.5 * (x + 8.3)**2 / 0.1**2) y_single_extra = y_single + extra return x, y_single_extra def double_peak(): np.random.seed(42) g1 = models.Gaussian1D(1, 4.6, 0.2) g2 = models.Gaussian1D(2.5, 5.5, 0.1) x = np.linspace(0, 10, 200) y_double = g1(x) + g2(x) + np.random.normal(0., 0.2, x.shape) return x, y_double def double_peak_absorption_and_emission(): np.random.seed(42) g1 = models.Gaussian1D(1, 4.6, 0.2) g2 = models.Gaussian1D(2.5, 5.5, 0.1) g3 = models.Gaussian1D(-1.7, 8.2, 0.1) x = np.linspace(0, 10, 200) y_double = g1(x) + g2(x) + g3(x) + np.random.normal(0., 0.2, x.shape) return x, y_double def test_find_lines_derivative(): # Create the spectrum to fit x_double, y_double = double_peak_absorption_and_emission() spectrum = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um) # Derivative method lines = find_lines_derivative(spectrum, flux_threshold=0.75) emission_lines = lines[lines['line_type'] == 'emission'] absorption_lines = lines[lines['line_type'] == 'absorption'] assert emission_lines['line_center_index'].tolist() == [90, 109] assert absorption_lines['line_center_index'].tolist() == [163] def test_find_lines_threshold(): # Create the spectrum to fit x_double, y_double = double_peak_absorption_and_emission() spectrum = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um) # Derivative method noise_region = SpectralRegion(0*u.um, 3*u.um) spectrum = noise_region_uncertainty(spectrum, noise_region) lines = find_lines_threshold(spectrum, noise_factor=3) emission_lines = lines[lines['line_type'] == 'emission'] absorption_lines = lines[lines['line_type'] == 'absorption'] assert emission_lines['line_center_index'].tolist() == [91, 96, 109, 179] assert absorption_lines['line_center_index'].tolist() == [163] def test_single_peak_estimate(): """ Single Peak fit. """ # Create the spectrum x_single, y_single = single_peak() s_single = Spectrum1D(flux=y_single*u.Jy, spectral_axis=x_single*u.um) # # Estimate parameter Gaussian1D # we give the true values for the Gaussian because it actually *should* # be pretty close to the true values, because it's a Gaussian... # g_init = estimate_line_parameters(s_single, models.Gaussian1D()) assert np.isclose(g_init.amplitude.value, 3., rtol=.2) assert np.isclose(g_init.mean.value, 6.3, rtol=.1) assert np.isclose(g_init.stddev.value, 0.8, rtol=.3) assert g_init.amplitude.unit == u.Jy assert g_init.mean.unit == u.um assert g_init.stddev.unit == u.um # # Estimate parameter Lorentz1D # unlike the Gaussian1D here we do hand-picked comparison values, because # the "single peak" is a Gaussian and therefore the Lorentzian fit shouldn't # be quite right anyway # g_init = estimate_line_parameters(s_single, models.Lorentz1D()) assert np.isclose(g_init.amplitude.value, 3.354169257846847) assert np.isclose(g_init.x_0.value, 6.218588636687762) assert np.isclose(g_init.fwhm.value, 1.6339001193853715) assert g_init.amplitude.unit == u.Jy assert g_init.x_0.unit == u.um assert g_init.fwhm.unit == u.um # # Estimate parameter Voigt1D # g_init = estimate_line_parameters(s_single, models.Voigt1D()) assert np.isclose(g_init.amplitude_L.value, 3.354169257846847) assert np.isclose(g_init.x_0.value, 6.218588636687762) assert np.isclose(g_init.fwhm_L.value, 1.1553418541989058) assert np.isclose(g_init.fwhm_G.value, 1.1553418541989058) assert g_init.amplitude_L.unit == u.Jy assert g_init.x_0.unit == u.um assert g_init.fwhm_L.unit == u.um assert g_init.fwhm_G.unit == u.um # # Estimate parameter RickerWavelet1D # mh = models.RickerWavelet1D estimators = { 'amplitude': lambda s: max(s.flux), 'x_0': lambda s: centroid(s, region=None), 'sigma': lambda s: fwhm(s) } #mh._constraints['parameter_estimator'] = estimators mh.amplitude.estimator = lambda s: max(s.flux) mh.x_0.estimator = lambda s: centroid(s, region=None) mh.sigma.estimator = lambda s: fwhm(s) g_init = estimate_line_parameters(s_single, mh) assert np.isclose(g_init.amplitude.value, 3.354169257846847) assert np.isclose(g_init.x_0.value, 6.218588636687762) assert np.isclose(g_init.sigma.value, 1.6339001193853715) assert g_init.amplitude.unit == u.Jy assert g_init.x_0.unit == u.um assert g_init.sigma.unit == u.um def test_single_peak_fit(): """ Single peak fit """ # Create the spectrum x_single, y_single = single_peak() s_single = Spectrum1D(flux=y_single*u.Jy, spectral_axis=x_single*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=6.1*u.um, stddev=1.*u.um) g_fit = fit_lines(s_single, g_init) y_single_fit = g_fit(x_single*u.um) # Comparing every 10th value. y_single_fit_expected = np.array([3.69669474e-13, 3.57992454e-11, 2.36719426e-09, 1.06879318e-07, 3.29498310e-06, 6.93605383e-05, 9.96945607e-04, 9.78431032e-03, 6.55675141e-02, 3.00017760e-01, 9.37356842e-01, 1.99969007e+00, 2.91286375e+00, 2.89719280e+00, 1.96758892e+00, 9.12412206e-01, 2.88900005e-01, 6.24602556e-02, 9.22061121e-03, 9.29427266e-04]) * u.Jy assert np.allclose(y_single_fit.value[::10], y_single_fit_expected.value, atol=1e-5) def test_single_peak_fit_with_uncertainties(): """ Single peak fit """ # Create the spectrum line_mod = models.Gaussian1D(amplitude=100, mean=6563*u.angstrom, stddev=20*u.angstrom) + models.Const1D(10) init_mod = models.Gaussian1D(amplitude=85 * u.Jy, mean=6550*u.angstrom, stddev=30*u.angstrom) + models.Const1D(8 * u.Jy) x = np.linspace(6400, 6700, 300) * u.AA def calculate_rms(x, init_mod, implicit_weights): rms = [] for _ in range(100): ymod = line_mod(x) y = np.random.poisson(ymod) unc = np.sqrt(ymod) spec = Spectrum1D(spectral_axis=x, flux=y * u.Jy, uncertainty=StdDevUncertainty(unc * u.Jy)) weights = 'unc' if implicit_weights else unc ** -1 spec_fit = fit_lines(spec, init_mod, weights=weights) rms.append(np.std(spec_fit(x).value - y)) return np.median(rms) assert np.allclose(calculate_rms(x, init_mod, implicit_weights=True), 5.101611033799086) assert np.allclose(calculate_rms(x, init_mod, implicit_weights=False), 5.113697654869089) def test_single_peak_fit_window(): """ Single Peak fit with a window specified """ # Create the sepctrum x_single, y_single = single_peak() s_single = Spectrum1D(flux=y_single*u.Jy, spectral_axis=x_single*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=5.5*u.um, stddev=1.*u.um) g_fit = fit_lines(s_single, g_init, window=2*u.um) y_single_fit = g_fit(x_single*u.um) # Comparing every 10th value. y_single_fit_expected = np.array([3.69669474e-13, 3.57992454e-11, 2.36719426e-09, 1.06879318e-07, 3.29498310e-06, 6.93605383e-05, 9.96945607e-04, 9.78431032e-03, 6.55675141e-02, 3.00017760e-01, 9.37356842e-01, 1.99969007e+00, 2.91286375e+00, 2.89719280e+00, 1.96758892e+00, 9.12412206e-01, 2.88900005e-01, 6.24602556e-02, 9.22061121e-03, 9.29427266e-04]) * u.Jy assert np.allclose(y_single_fit.value[::10], y_single_fit_expected.value, atol=1e-5) def test_single_peak_fit_tuple_window(): """ Single Peak fit with a window specified as a tuple """ # Create the spectrum to fit x_single, y_single = single_peak() s_single = Spectrum1D(flux=y_single*u.Jy, spectral_axis=x_single*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=5.5*u.um, stddev=1.*u.um) g_fit = fit_lines(s_single, g_init, window=(6*u.um, 7*u.um)) y_single_fit = g_fit(x_single*u.um) # Comparing every 10th value. y_single_fit_expected = np.array([2.29674788e-16, 6.65518998e-14, 1.20595958e-11, 1.36656472e-09, 9.68395624e-08, 4.29141576e-06, 1.18925100e-04, 2.06096976e-03, 2.23354585e-02, 1.51371211e-01, 6.41529836e-01, 1.70026100e+00, 2.81799025e+00, 2.92071068e+00, 1.89305291e+00, 7.67294570e-01, 1.94485245e-01, 3.08273612e-02, 3.05570344e-03, 1.89413625e-04])*u.Jy assert np.allclose(y_single_fit.value[::10], y_single_fit_expected.value, atol=1e-5) def test_double_peak_fit(): """ Double Peak fit. """ # Create the spectrum to fit x_double, y_double = double_peak() s_double = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um) # Fit the spectrum g1_init = models.Gaussian1D(amplitude=2.3*u.Jy, mean=5.6*u.um, stddev=0.1*u.um) g2_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.4*u.um, stddev=0.1*u.um) g12_fit = fit_lines(s_double, g1_init+g2_init) y12_double_fit = g12_fit(x_double*u.um) # Comparing every 10th value. y12_double_fit_expected = np.array([2.86790780e-130, 2.12984643e-103, 1.20060032e-079, 5.13707226e-059, 1.66839912e-041, 4.11292970e-027, 7.69608184e-016, 1.09308800e-007, 1.17844042e-002, 9.64333366e-001, 6.04322205e-002, 2.22653307e+000, 5.51964567e-005, 8.13581859e-018, 6.37320251e-038, 8.85834856e-055, 1.05230522e-074, 9.48850399e-098, 6.49412764e-124, 3.37373489e-153]) assert np.allclose(y12_double_fit.value[::10], y12_double_fit_expected, atol=1e-5) def test_double_peak_fit_tuple_window(): """ Doulbe Peak fit with a window specified as a tuple """ # Create the spectrum to fit x_double, y_double = double_peak() s_double = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um, rest_value=0*u.um) # Fit the spectrum. g2_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.7*u.um, stddev=0.2*u.um) g2_fit = fit_lines(s_double, g2_init, window=(4.3*u.um, 5.3*u.um)) y2_double_fit = g2_fit(x_double*u.um) # Comparing every 10th value. y2_double_fit_expected = np.array([2.82386634e-116, 2.84746284e-092, 4.63895634e-071, 1.22104254e-052, 5.19265653e-037, 3.56776869e-024, 3.96051875e-014, 7.10322789e-007, 2.05829545e-002, 9.63624806e-001, 7.28880815e-002, 8.90744929e-006, 1.75872724e-012, 5.61037526e-022, 2.89156942e-034, 2.40781783e-049, 3.23938019e-067, 7.04122962e-088, 2.47276807e-111, 1.40302869e-137]) assert np.allclose(y2_double_fit.value[::10], y2_double_fit_expected, atol=1e-5) def test_double_peak_fit_window(): """ Double Peak fit with a window. """ # Create the specturm to fit x_double, y_double = double_peak() s_double = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um, rest_value=0*u.um) # Fit the spectrum g2_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.7*u.um, stddev=0.2*u.um) g2_fit = fit_lines(s_double, g2_init, window=0.3*u.um) y2_double_fit = g2_fit(x_double*u.um) # Comparing every 10th value. y2_double_fit_expected = np.array([1.66363393e-128, 5.28910721e-102, 1.40949521e-078, 3.14848385e-058, 5.89516506e-041, 9.25224449e-027, 1.21718016e-015, 1.34220626e-007, 1.24062432e-002, 9.61209273e-001, 6.24240938e-002, 3.39815491e-006, 1.55056770e-013, 5.93054936e-024, 1.90132233e-037, 5.10943886e-054, 1.15092572e-073, 2.17309153e-096, 3.43926290e-122, 4.56256813e-151]) assert np.allclose(y2_double_fit.value[::10], y2_double_fit_expected, atol=1e-5) def test_double_peak_fit_separate_window(): """ Double Peak fit with a window. """ # Create the spectrum to fit x_double, y_double = double_peak() s_double = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um, rest_value=0*u.um) # Fit the spectrum gl_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.8*u.um, stddev=0.2*u.um) gr_init = models.Gaussian1D(amplitude=2.*u.Jy, mean=5.3*u.um, stddev=0.2*u.um) gl_fit, gr_fit = fit_lines(s_double, [gl_init, gr_init], window=0.2*u.um) yl_double_fit = gl_fit(x_double*u.um) yr_double_fit = gr_fit(x_double*u.um) # Comparing every 10th value. yl_double_fit_expected = np.array([3.40725147e-18, 5.05500395e-15, 3.59471319e-12, 1.22527176e-09, 2.00182467e-07, 1.56763547e-05, 5.88422893e-04, 1.05866724e-02, 9.12966452e-02, 3.77377148e-01, 7.47690410e-01, 7.10057397e-01, 3.23214276e-01, 7.05201207e-02, 7.37498248e-03, 3.69687164e-04, 8.88245844e-06, 1.02295712e-07, 5.64686114e-10, 1.49410879e-12]) assert np.allclose(yl_double_fit.value[::10], yl_double_fit_expected, atol=1e-5) # Comparing every 10th value. yr_double_fit_expected = np.array([0.00000000e+000, 0.00000000e+000, 0.00000000e+000, 3.04416285e-259, 3.85323221e-198, 2.98888589e-145, 1.42075875e-100, 4.13864520e-064, 7.38793226e-036, 8.08191847e-016, 5.41792361e-004, 2.22575901e+000, 5.60338234e-005, 8.64468603e-018, 8.17287853e-039, 4.73508430e-068, 1.68115300e-105, 3.65774659e-151, 4.87693358e-205, 3.98480359e-267]) assert np.allclose(yr_double_fit.value[::10], yr_double_fit_expected, atol=1e-5) def test_double_peak_fit_separate_window_tuple_window(): """ Double Peak fit with a window. """ x_double, y_double = double_peak() s_double = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um, rest_value=0*u.um) g1_init = models.Gaussian1D(amplitude=2.*u.Jy, mean=5.3*u.um, stddev=0.2*u.um) g2_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.9*u.um, stddev=0.1*u.um) g1_fit, g2_fit = fit_lines(s_double, [g1_init, g2_init], window=[(5.3*u.um, 5.8*u.um), (4.6*u.um, 5.3*u.um)]) y1_double_fit = g1_fit(x_double*u.um) y2_double_fit = g2_fit(x_double*u.um) # Comparing every 10th value. y1_double_fit_expected = np.array([0.00000000e+000, 0.00000000e+000, 5.61595149e-307, 3.38362505e-242, 4.27358433e-185, 1.13149721e-135, 6.28008984e-094, 7.30683649e-060, 1.78214929e-033, 9.11192086e-015, 9.76623021e-004, 2.19429562e+000, 1.03350951e-004, 1.02043415e-016, 2.11206194e-036, 9.16388177e-064, 8.33495900e-099, 1.58920023e-141, 6.35191874e-192, 5.32209240e-250]) assert np.allclose(y1_double_fit.value[::10], y1_double_fit_expected, atol=1e-5) # Comparing every 10th value. y2_double_fit_expected = np.array([2.52990802e-158, 5.15446435e-126, 2.07577138e-097, 1.65231432e-072, 2.59969849e-051, 8.08482210e-034, 4.96975664e-020, 6.03833143e-010, 1.45016006e-003, 6.88386116e-001, 6.45900222e-002, 1.19788723e-006, 4.39120391e-015, 3.18176751e-027, 4.55691000e-043, 1.28999976e-062, 7.21815119e-086, 7.98324559e-113, 1.74521997e-143, 7.54115780e-178]) assert np.allclose(y2_double_fit.value[::10], y2_double_fit_expected, atol=1e-3) def test_double_peak_fit_with_exclusion(): """ Double Peak fit with a window. """ x_double, y_double = double_peak() s_double = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um, rest_value=0*u.um) g1_init = models.Gaussian1D(amplitude=1.*u.Jy, mean=4.9*u.um, stddev=0.2*u.um) g1_fit = fit_lines(s_double, g1_init, exclude_regions=[SpectralRegion(5.2*u.um, 5.8*u.um)]) y1_double_fit = g1_fit(x_double*u.um) # Comparing every 10th value. y1_double_fit_expected = np.array([1.22845792e-129, 6.72991033e-103, 2.89997996e-079, 9.82918347e-059, 2.62045444e-041, 5.49506092e-027, 9.06369193e-016, 1.17591109e-007, 1.19999812e-002, 9.63215290e-001, 6.08139060e-002, 3.02008492e-006, 1.17970152e-013, 3.62461471e-024, 8.75968088e-038, 1.66514266e-054, 2.48972475e-074, 2.92811381e-097, 2.70870241e-123, 1.97093074e-152]) assert np.allclose(y1_double_fit.value[::10], y1_double_fit_expected, atol=1e-5) def tie_center(model): """ Dummy method for testing passing of tied parameter """ mean = 50 * model.stddev return mean def test_fixed_parameters(): """ Test to confirm fixed parameters do not change. """ x = np.linspace(0., 10., 200) y = 3 * np.exp(-0.5 * (x - 6.3)**2 / 0.8**2) y += np.random.normal(0., 0.2, x.shape) spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Test passing fixed and bounds parameters g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=6.1*u.um, stddev=1.*u.um, fixed={'mean': True}, bounds={'amplitude': (2, 5)*u.Jy}, name="Gaussian Test Model") g_fit = fit_lines(spectrum, g_init) assert_quantity_allclose(g_fit.mean, 6.1*u.um) assert g_fit.bounds == g_init.bounds assert g_fit.name == "Gaussian Test Model" # Test passing of tied parameter g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=6.1*u.um, stddev=1.*u.um) g_init.mean.tied = tie_center g_fit = fit_lines(spectrum, g_init) assert g_fit.tied == g_init.tied assert g_fit.name == g_init.name def test_name_preservation_after_fitting(): """ Test to confirm model and submodels names are preserved after fitting. """ x = np.linspace(0., 10., 200) y = 3 * np.exp(-0.5 * (x - 6.3)**2 / 0.8**2) y += np.random.normal(0., 0.2, x.shape) spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) subcomponents = models.Gaussian1D(name="Model I") * models.Gaussian1D(name="Second Model") c_model = subcomponents + models.Gaussian1D(name="Model 3") c_model.name = "Compound Model with 3 components" model_fit = fit_lines(spectrum, c_model) assert model_fit.name == "Compound Model with 3 components" assert model_fit.submodel_names == ("Model I", "Second Model", "Model 3") def test_ignore_units(): """ Ignore the units """ # # Ignore the units based on there not being units on the model # # Create the spectrum x_single, y_single = single_peak() s_single = Spectrum1D(flux=y_single*u.Jy, spectral_axis=x_single*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3, mean=6.1, stddev=1.) g_fit = fit_lines(s_single, g_init) y_single_fit = g_fit(x_single*u.um) # Comparing every 10th value. y_single_fit_expected = np.array([3.69669474e-13, 3.57992454e-11, 2.36719426e-09, 1.06879318e-07, 3.29498310e-06, 6.93605383e-05, 9.96945607e-04, 9.78431032e-03, 6.55675141e-02, 3.00017760e-01, 9.37356842e-01, 1.99969007e+00, 2.91286375e+00, 2.89719280e+00, 1.96758892e+00, 9.12412206e-01, 2.88900005e-01, 6.24602556e-02, 9.22061121e-03, 9.29427266e-04]) assert np.allclose(y_single_fit.value[::10], y_single_fit_expected, atol=1e-5) assert y_single_fit.unit == s_single.flux.unit # # Ignore the units based on not being in the model # # Create the spectrum to fit x_double, y_double = double_peak() s_double = Spectrum1D(flux=y_double*u.Jy, spectral_axis=x_double*u.um) # Fit the spectrum g1_init = models.Gaussian1D(amplitude=2.3, mean=5.6, stddev=0.1) g2_init = models.Gaussian1D(amplitude=1., mean=4.4, stddev=0.1) g12_fit = fit_lines(s_double, g1_init+g2_init) y12_double_fit = g12_fit(x_double*u.um) # Comparing every 10th value. y12_double_fit_expected = np.array([2.86790780e-130, 2.12984643e-103, 1.20060032e-079, 5.13707226e-059, 1.66839912e-041, 4.11292970e-027, 7.69608184e-016, 1.09308800e-007, 1.17844042e-002, 9.64333366e-001, 6.04322205e-002, 2.22653307e+000, 5.51964567e-005, 8.13581859e-018, 6.37320251e-038, 8.85834856e-055, 1.05230522e-074, 9.48850399e-098, 6.49412764e-124, 3.37373489e-153]) assert np.allclose(y12_double_fit.value[::10], y12_double_fit_expected, atol=1e-5) def test_fitter_parameters(): """ Single Peak fit. """ # Create the spectrum x_single, y_single = single_peak() s_single = Spectrum1D(flux=y_single*u.Jy, spectral_axis=x_single*u.um) # Fit the spectrum g_init = models.Gaussian1D(amplitude=3.*u.Jy, mean=6.1*u.um, stddev=1.*u.um) fit_params = {'maxiter': 200} g_fit = fit_lines(s_single, g_init, **fit_params) y_single_fit = g_fit(x_single*u.um) # Comparing every 10th value. y_single_fit_expected = np.array([3.69669474e-13, 3.57992454e-11, 2.36719426e-09, 1.06879318e-07, 3.29498310e-06, 6.93605383e-05, 9.96945607e-04, 9.78431032e-03, 6.55675141e-02, 3.00017760e-01, 9.37356842e-01, 1.99969007e+00, 2.91286375e+00, 2.89719280e+00, 1.96758892e+00, 9.12412206e-01, 2.88900005e-01, 6.24602556e-02, 9.22061121e-03, 9.29427266e-04]) * u.Jy assert np.allclose(y_single_fit.value[::10], y_single_fit_expected.value, atol=1e-5) def test_spectrum_from_model(): """ This test fits the the first simulated spectrum from the fixture. The initial guesses are manually set here with bounds that essentially make sense as the functionality of the test is to make sure the fit works and we get a reasonable answer out **given** good initial guesses. """ np.random.seed(0) x = np.linspace(0., 10., 200) y = 3 * np.exp(-0.5 * (x - 6.3)**2 / 0.1**2) y += np.random.normal(0., 0.2, x.shape) y_continuum = 3.2 * np.exp(-0.5 * (x - 5.6)**2 / 4.8**2) y += y_continuum spectrum = Spectrum1D(flux=y*u.Jy, spectral_axis=x*u.um) # Unitless test chebyshev = models.Chebyshev1D(3, c0=0.1, c1=4, c2=5) spectrum_chebyshev = spectrum_from_model(chebyshev, spectrum) flux_expected = np.array([-4.90000000e+00, -3.64760991e-01, 9.22085553e+00, 2.38568496e+01, 4.35432211e+01, 6.82799702e+01, 9.80670968e+01, 1.32904601e+02, 1.72792483e+02, 2.17730742e+02, 2.67719378e+02, 3.22758392e+02, 3.82847784e+02, 4.47987553e+02, 5.18177700e+02, 5.93418224e+02, 6.73709126e+02, 7.59050405e+02, 8.49442062e+02, 9.44884096e+02]) assert np.allclose(spectrum_chebyshev.flux.value[::10], flux_expected, atol=1e-5) # Unitfull test gaussian = models.Gaussian1D(amplitude=5*u.Jy, mean=4*u.um, stddev=2.3*u.um) spectrum_gaussian = spectrum_from_model(gaussian, spectrum) flux_expected = np.array([1.1020263, 1.57342489, 2.14175093, 2.77946243, 3.4389158, 4.05649712, 4.56194132, 4.89121902, 4.99980906, 4.872576, 4.52723165, 4.01028933, 3.3867847, 2.72689468, 2.09323522, 1.5319218, 1.06886794, 0.71101768, 0.45092638, 0.27264641]) assert np.allclose(spectrum_gaussian.flux.value[::10], flux_expected, atol=1e-5) def test_masking(): """ Test fitting spectra with masks """ wl, flux = double_peak() s = Spectrum1D(flux=flux*u.Jy, spectral_axis=wl*u.um) # first we fit a single gaussian to the double_peak model, using the # known-good second peak (but a bit higher in amplitude). It should lock # in on the *second* peak since it's already close: g_init = models.Gaussian1D(2.5, 5.5, 0.2) g_fit1 = fit_lines(s, g_init) assert u.allclose(g_fit1.mean.value, 5.5, atol=.1) # now create a spectrum where the region around the second peak is masked. # The fit should now go to the *first* peak s_msk = Spectrum1D(flux=flux*u.Jy, spectral_axis=wl*u.um, mask=(5.1 < wl)&(wl < 6.1)) g_fit2 = fit_lines(s_msk, g_init) assert u.allclose(g_fit2.mean.value, 4.6, atol=.1) # double check that it works with weights as well g_fit3 = fit_lines(s_msk, g_init, weights=np.ones_like(s_msk.flux.value)) assert g_fit2.mean == g_fit3.mean def test_window_extras(): """ Test that fitting works with masks and weights when a window is present """ # similar to the masking test, but add a broad window around the whole thing wl, flux = double_peak() g_init = models.Gaussian1D(2.5, 5.5, 0.2) window_region = SpectralRegion(4*u.um, 8*u.um) mask = (5.1 < wl) & (wl < 6.1) s_msk = Spectrum1D(flux=flux*u.Jy, spectral_axis=wl*u.um, mask=mask) g_fit1 = fit_lines(s_msk, g_init, window=window_region) assert u.allclose(g_fit1.mean.value, 4.6, atol=.1) # check that if we weight instead of masking, we get the same result s = Spectrum1D(flux=flux*u.Jy, spectral_axis=wl*u.um) weights = (~mask).astype(float) g_fit2 = fit_lines(s, g_init, weights=weights, window=window_region) assert u.allclose(g_fit2.mean.value, 4.6, atol=.1) # and the same with both together weights = (~mask).astype(float) g_fit3 = fit_lines(s_msk, g_init, weights=weights, window=window_region) assert u.allclose(g_fit3.mean.value, 4.6, atol=.1) def test_fit_subspectrum(): # Create test spectrum spectrum = Spectrum1D( flux=np.random.sample(10) * u.Jy, spectral_axis=(np.arange(10) + 6558) * u.AA, rest_value=6561.16*u.angstrom, velocity_convention='relativistic') # Exract a sub region sub_region = SpectralRegion(6490 * u.AA, 6635 * u.AA) sub_spectrum = extract_region(spectrum, sub_region) # Create model and git to spectrum g_init = models.Gaussian1D(amplitude=1*u.Jy, mean=6561.157*u.angstrom, stddev=4.0721*u.angstrom) g_fit = fit_lines(sub_spectrum, g_init) y_fit = g_fit(sub_spectrum.spectral_axis) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_io.py0000644000503700020070000001556000000000000023010 0ustar00rosteenSTSCI\science00000000000000# Licensed under a 3-clause BSD style license - see LICENSE.rst """ This module tests SpecUtils io routines """ from collections import Counter import astropy.units as u import numpy as np import pytest from astropy.io import registry from astropy.table import Table from astropy.utils.exceptions import AstropyUserWarning from specutils import Spectrum1D, SpectrumList from specutils.io import data_loader from specutils.io.parsing_utils import generic_spectrum_from_table # or something like that from specutils.io.registers import _astropy_has_priorities def test_generic_spectrum_from_table(recwarn): """ Read a simple table with wavelength, flux and uncertainty """ # Create a small data set, first without uncertainties wave = np.arange(1, 1.1, 0.01)*u.AA flux = np.ones(len(wave))*1.e-14*u.Jy table = Table([wave, flux], names=["wave", "flux"]) # Test that the units and values of the Spectrum1D object match those in the table spectrum = generic_spectrum_from_table(table) assert spectrum.spectral_axis.unit == table['wave'].unit assert spectrum.flux.unit == table['flux'].unit assert spectrum.spectral_axis.unit == table['wave'].unit assert np.alltrue(spectrum.spectral_axis == table['wave']) assert np.alltrue(spectrum.flux == table['flux']) # Add uncertainties and retest err = 0.01*flux table = Table([wave, flux, err], names=["wave", "flux", "err"]) spectrum = generic_spectrum_from_table(table) assert spectrum.spectral_axis.unit == table['wave'].unit assert spectrum.flux.unit == table['flux'].unit assert spectrum.uncertainty.unit == table['err'].unit assert spectrum.spectral_axis.unit == table['wave'].unit assert np.alltrue(spectrum.spectral_axis == table['wave']) assert np.alltrue(spectrum.flux == table['flux']) assert np.alltrue(spectrum.uncertainty.array == table['err']) # Test for warning if standard deviation is zero or negative err[0] = 0. table = Table([wave, flux, err], names=["wave", "flux", "err"]) spectrum = generic_spectrum_from_table(table) assert len(recwarn) == 1 w = recwarn.pop(AstropyUserWarning) assert "Standard Deviation has values of 0 or less" in str(w.message) # Test that exceptions are raised if there are no units flux = np.ones(len(wave))*1.e-14 table = Table([wave, flux], names=["wave", "flux"]) with pytest.raises(IOError) as exc: spectrum = generic_spectrum_from_table(table) assert 'Could not identify column containing the flux' in exc wave = np.arange(1, 1.1, 0.01) table = Table([wave, flux, err], names=["wave", "flux", "err"]) with pytest.raises(IOError) as exc: spectrum = generic_spectrum_from_table(table) assert 'Could not identify column containing the wavelength, frequency or energy' in exc def test_speclist_autoidentify(): formats = registry.get_formats(SpectrumList) assert (formats['Auto-identify'] == 'Yes').all() def test_default_identifier(tmpdir): fname = str(tmpdir.join('empty.txt')) with open(fname, 'w') as ff: ff.write('\n') format_name = 'default_identifier_test' @data_loader(format_name) def reader(*args, **kwargs): """Doesn't actually get used.""" return for datatype in [Spectrum1D, SpectrumList]: fmts = registry.identify_format('read', datatype, fname, None, [], {}) assert format_name in fmts # Clean up after ourselves registry.unregister_reader(format_name, datatype) registry.unregister_identifier(format_name, datatype) def test_default_identifier_extension(tmpdir): good_fname = str(tmpdir.join('empty.fits')) bad_fname = str(tmpdir.join('empty.txt')) # Create test data files. for name in [good_fname, bad_fname]: with open(name, 'w') as ff: ff.write('\n') format_name = 'default_identifier_extension_test' @data_loader(format_name, extensions=['fits']) def reader(*args, **kwargs): """Doesn't actually get used.""" return for datatype in [Spectrum1D, SpectrumList]: fmts = registry.identify_format('read', datatype, good_fname, None, [], {}) assert format_name in fmts fmts = registry.identify_format('read', datatype, bad_fname, None, [], {}) assert format_name not in fmts # Clean up after ourselves registry.unregister_reader(format_name, datatype) registry.unregister_identifier(format_name, datatype) def test_custom_identifier(tmpdir): good_fname = str(tmpdir.join('good.txt')) bad_fname = str(tmpdir.join('bad.txt')) # Create test data files. for name in [good_fname, bad_fname]: with open(name, 'w') as ff: ff.write('\n') format_name = 'custom_identifier_test' def identifier(origin, *args, **kwargs): fname = args[0] return 'good' in fname @data_loader(format_name, identifier=identifier) def reader(*args, **kwargs): """Doesn't actually get used.""" return for datatype in [Spectrum1D, SpectrumList]: fmts = registry.identify_format('read', datatype, good_fname, None, [], {}) assert format_name in fmts fmts = registry.identify_format('read', datatype, bad_fname, None, [], {}) assert format_name not in fmts # Clean up after ourselves registry.unregister_reader(format_name, datatype) registry.unregister_identifier(format_name, datatype) @pytest.mark.xfail( not _astropy_has_priorities(), reason="Test requires priorities to be implemented in astropy", raises=registry.IORegistryError, ) def test_loader_uses_priority(tmpdir): counter = Counter() fname = str(tmpdir.join('good.txt')) with open(fname, 'w') as ff: ff.write('\n') def identifier(origin, *args, **kwargs): fname = args[0] return 'good' in fname @data_loader("test_counting_loader1", identifier=identifier, priority=1) def counting_loader1(*args, **kwargs): counter["test1"] += 1 wave = np.arange(1, 1.1, 0.01)*u.AA return Spectrum1D( spectral_axis=wave, flux=np.ones(len(wave))*1.e-14*u.Jy, ) @data_loader("test_counting_loader2", identifier=identifier, priority=2) def counting_loader2(*args, **kwargs): counter["test2"] += 1 wave = np.arange(1, 1.1, 0.01)*u.AA return Spectrum1D( spectral_axis=wave, flux=np.ones(len(wave))*1.e-14*u.Jy, ) Spectrum1D.read(fname) assert counter["test2"] == 1 assert counter["test1"] == 0 for datatype in [Spectrum1D, SpectrumList]: registry.unregister_reader("test_counting_loader1", datatype) registry.unregister_identifier("test_counting_loader1", datatype) registry.unregister_reader("test_counting_loader2", datatype) registry.unregister_identifier("test_counting_loader2", datatype) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_loaders.py0000644000503700020070000016031500000000000024031 0ustar00rosteenSTSCI\science00000000000000import os import sys import shutil import tempfile import urllib import warnings import uuid import pytest import astropy.units as u import numpy as np from astropy.io import fits from astropy.io.fits.verify import VerifyWarning from astropy.table import Table from astropy.units import UnitsWarning from astropy.wcs import FITSFixedWarning, WCS from astropy.io.registry import IORegistryError from astropy.modeling import models from astropy.tests.helper import quantity_allclose from astropy.nddata import StdDevUncertainty from numpy.testing import assert_allclose from .conftest import remote_access from .. import Spectrum1D, SpectrumCollection, SpectrumList from ..io import get_loaders_by_extension from ..io.default_loaders import subaru_pfs_spec from ..io.default_loaders.sdss import _sdss_wcs_to_log_wcs # NOTE: Python can be built without bz2 or lzma. try: import bz2 # noqa except ImportError: HAS_BZ2 = False else: HAS_BZ2 = True try: import lzma # noqa except ImportError: HAS_LZMA = False else: HAS_LZMA = True EBOSS_SPECTRUM_URL = 'https://data.sdss.org/sas/dr16/eboss/spectro/redux/v5_13_0/spectra/lite/4055/spec-4055-55359-0596.fits' def test_get_loaders_by_extension(): loader_labels = get_loaders_by_extension('fits') assert len(loader_labels) > 0 assert isinstance(loader_labels[0], str) @remote_access([{'id': '1481190', 'filename': 'L5g_0355+11_Cruz09.fits'}]) def test_spectrum1d_GMOSfits(remote_data_path): with warnings.catch_warnings(): warnings.simplefilter('ignore', (VerifyWarning, UnitsWarning)) optical_spec_2 = Spectrum1D.read(remote_data_path, format='wcs1d-fits') assert len(optical_spec_2.data) == 3020 @remote_access([{'id': '1481190', 'filename': 'L5g_0355+11_Cruz09.fits'}]) def test_spectrumlist_GMOSfits(remote_data_path, caplog): with warnings.catch_warnings(): warnings.simplefilter('ignore', (VerifyWarning, UnitsWarning)) spectrum_list = SpectrumList.read(remote_data_path, format='wcs1d-fits') assert len(spectrum_list) == 1 spec = spectrum_list[0] assert len(spec.data) == 3020 assert len(caplog.record_tuples) == 0 @remote_access([{'id': '1481190', 'filename': 'L5g_0355+11_Cruz09.fits'}]) def test_specific_spec_axis_unit(remote_data_path): with warnings.catch_warnings(): warnings.simplefilter('ignore', (VerifyWarning, UnitsWarning)) optical_spec = Spectrum1D.read(remote_data_path, spectral_axis_unit="Angstrom", format='wcs1d-fits') assert optical_spec.spectral_axis.unit == "Angstrom" @remote_access([{'id': '2656720', 'filename': '_v1410ori_20181204_261_Forrest%20Sims.fit'}]) def test_ctypye_not_compliant(remote_data_path, caplog): optical_spec = Spectrum1D.read(remote_data_path, spectral_axis_unit="Angstrom", format='wcs1d-fits') assert len(caplog.record_tuples) == 0 def test_generic_ecsv_reader(tmpdir): # Create a small data set wave = np.arange(1, 1.1, 0.01)*u.AA flux = np.ones(len(wave))*1.e-14*u.Jy uncertainty = 0.01*flux table = Table([wave, flux, uncertainty], names=["wave", "flux", "uncertainty"]) tmpfile = str(tmpdir.join('_tst.ecsv')) table.write(tmpfile, format='ascii.ecsv') # Read it in and check against the original spectrum = Spectrum1D.read(tmpfile, format='ECSV') assert spectrum.spectral_axis.unit == table['wave'].unit assert spectrum.flux.unit == table['flux'].unit assert spectrum.uncertainty.unit == table['uncertainty'].unit assert spectrum.spectral_axis.unit == table['wave'].unit assert np.alltrue(spectrum.spectral_axis == table['wave']) assert np.alltrue(spectrum.flux == table['flux']) assert np.alltrue(spectrum.uncertainty.array == table['uncertainty']) @remote_access([{'id': '1481119', 'filename': 'COS_FUV.fits'}, {'id': '1481181', 'filename': 'COS_NUV.fits'}]) def test_hst_cos(remote_data_path): spec = Spectrum1D.read(remote_data_path) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 # HDUList case hdulist = fits.open(remote_data_path) spec = Spectrum1D.read(hdulist, format="HST/COS") assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 @remote_access([{'id': '1481192', 'filename': 'STIS_FUV.fits'}, {'id': '1481185', 'filename': 'STIS_NUV.fits'}, {'id': '1481183', 'filename': 'STIS_CCD.fits'}]) def test_hst_stis(remote_data_path): spec = Spectrum1D.read(remote_data_path) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 # HDUList case hdulist = fits.open(remote_data_path) spec = Spectrum1D.read(hdulist, format="HST/STIS") assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 @pytest.mark.remote_data def test_manga_cube(): url = 'https://dr15.sdss.org/sas/dr15/manga/spectro/redux/v2_4_3/8485/stack/manga-8485-1901-LOGCUBE.fits.gz' spec = Spectrum1D.read(url, format='MaNGA cube') assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.meta['header']['INSTRUME'] == 'MaNGA' assert spec.shape == (34, 34, 4563) @pytest.mark.remote_data def test_manga_rss(): url = 'https://dr15.sdss.org/sas/dr15/manga/spectro/redux/v2_4_3/8485/stack/manga-8485-1901-LOGRSS.fits.gz' spec = Spectrum1D.read(url, format='MaNGA rss') assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.meta['header']['INSTRUME'] == 'MaNGA' assert spec.shape == (171, 4563) @pytest.mark.remote_data def test_sdss_spec(): sp_pattern = 'spec-4055-55359-0596.fits' with urllib.request.urlopen(EBOSS_SPECTRUM_URL) as response: # Read from open file object spec = Spectrum1D.read(response, format="SDSS-III/IV spec") assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 with urllib.request.urlopen(EBOSS_SPECTRUM_URL) as response: # On Windows, NamedTemporaryFile cannot be opened a second time while # already being open, so we avoid using that method. with tempfile.TemporaryDirectory() as tmp_dir: file_path = os.path.join(tmp_dir, sp_pattern) with open(file_path, 'wb') as tmp_file: shutil.copyfileobj(response, tmp_file) # Read from local disk via filename print(tmp_file.name) spec = Spectrum1D.read(tmp_file.name) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 # Read from HDUList object hdulist = fits.open(tmp_file.name) spec = Spectrum1D.read(hdulist) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 # Read from file handle fileio = open(tmp_file.name, mode='rb') spec = Spectrum1D.read(fileio) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 @pytest.mark.remote_data def test_sdss_spspec(): sp_pattern = 'spSpec-51957-0273-016.fit' with urllib.request.urlopen('http://das.sdss.org/spectro/1d_26/0273/1d/spSpec-51957-0273-016.fit') as response: # Read from open file object spec = Spectrum1D.read(response, format="SDSS-I/II spSpec") assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 with urllib.request.urlopen('http://das.sdss.org/spectro/1d_26/0273/1d/spSpec-51957-0273-016.fit') as response: # On Windows, NamedTemporaryFile cannot be opened a second time while # already being open, so we avoid using that method. with tempfile.TemporaryDirectory() as tmp_dir: file_path = os.path.join(tmp_dir, sp_pattern) with open(file_path, 'wb') as tmp_file: shutil.copyfileobj(response, tmp_file) with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) spec = Spectrum1D.read(tmp_file.name) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 # Read from HDUList object hdulist = fits.open(tmp_file.name) spec = Spectrum1D.read(hdulist) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 # Read from file handle fileio = open(tmp_file.name, mode='rb') spec = Spectrum1D.read(fileio) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 @pytest.mark.remote_data def test_sdss_spec_stream(): """Test direct read and recognition of SDSS-III/IV spec from remote URL, i.e. do not rely on filename pattern. """ spec = Spectrum1D.read(EBOSS_SPECTRUM_URL) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.uncertainty.array.min() >= 0.0 @pytest.mark.remote_data def test_sdss_spspec_stream(): """Test direct read and recognition of SDSS-I/II spSpec from remote URL, i.e. do not rely on filename pattern. """ sdss_url = 'http://das.sdss.org/spectro/1d_26/0273/1d/spSpec-51957-0273-016.fit' spec = Spectrum1D.read(sdss_url) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.uncertainty.array.min() >= 0.0 @pytest.mark.skipif('sys.platform.startswith("win")', reason='Uncertain availability of compression utilities') @pytest.mark.remote_data @pytest.mark.parametrize('compress', ['gzip', 'bzip2', 'xz']) def test_sdss_compressed(compress, tmp_path): """Test automatic recognition of supported compression formats. """ ext = {'gzip': '.gz', 'bzip2': '.bz2', 'xz': '.xz'} if compress == 'bzip2' and not HAS_BZ2: pytest.xfail("Python installation has no bzip2 support") if compress == 'xz' and not HAS_LZMA: pytest.xfail("Python installation has no lzma support") # Deliberately not using standard filename pattern to test header info. tmp_filename = tmp_path / 'SDSS-I.fits' with urllib.request.urlopen('http://das.sdss.org/spectro/1d_26/0273/1d/spSpec-51957-0273-016.fit') as response: with open(tmp_filename, 'wb') as tmp_file: shutil.copyfileobj(response, tmp_file) with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) os.system(f'{compress} {tmp_file.name}') spec = Spectrum1D.read(tmp_file.name + ext[compress]) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.uncertainty.array.min() >= 0.0 # Try again without compression suffix: with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) os.system(f'mv {tmp_file.name}{ext[compress]} {tmp_file.name}') spec = Spectrum1D.read(tmp_file.name) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.uncertainty.array.min() >= 0.0 @pytest.mark.remote_data def test_sdss_spplate(): """Test loading of multi-object spectrum from SDSS `spPlate` format FITS file. """ with urllib.request.urlopen('http://das.sdss.org/spectro/1d_26/0273/1d/spSpec-51957-0273-016.fit') as response: # Read reference spectrum from open file object spec = Spectrum1D.read(response, format="SDSS-I/II spSpec") assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 specid = spec.meta['header']['FIBERID'] with urllib.request.urlopen('https://data.sdss.org/sas/dr8/sdss/spectro/redux/26/0273/spPlate-0273-51957.fits') as response: # Read "plate" spectrum with 2D flux array from open file object plate = Spectrum1D.read(response, format="SDSS spPlate") assert isinstance(plate, Spectrum1D) assert plate.flux.ndim == 2 assert plate.flux.shape[0] == 640 assert quantity_allclose(spec.spectral_axis, plate.spectral_axis) assert quantity_allclose(spec.flux, plate.flux[specid-1]) with urllib.request.urlopen('https://data.sdss.org/sas/dr8/sdss/spectro/redux/26/0273/spPlate-0273-51957.fits') as response: with tempfile.NamedTemporaryFile() as tmp_file: shutil.copyfileobj(response, tmp_file) # Read from local disk via file signature with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) plate = Spectrum1D.read(tmp_file.name, limit=32) assert isinstance(plate, Spectrum1D) assert plate.flux.ndim == 2 assert plate.flux.shape[0] == 32 assert quantity_allclose(spec.spectral_axis, plate.spectral_axis) assert quantity_allclose(spec.flux, plate.flux[specid-1]) # Read from HDUList object hdulist = fits.open(tmp_file.name) plate = Spectrum1D.read(hdulist, limit=32) assert plate.flux.shape[0] == 32 assert quantity_allclose(spec.spectral_axis, plate.spectral_axis) assert quantity_allclose(spec.flux, plate.flux[specid-1]) # Read from file handle fileio = open(tmp_file.name, mode='rb') plate = Spectrum1D.read(fileio, limit=32) assert plate.flux.shape[0] == 32 assert quantity_allclose(spec.spectral_axis, plate.spectral_axis) assert quantity_allclose(spec.flux, plate.flux[specid-1]) @pytest.mark.parametrize("name", ['file.fit', 'file.fits', 'file.dat']) def test_no_reader_matches(name): """If no reader matches a file, check that the correct error is raised. This test serves a second purpose: A badly written identifier function might raise an error as supposed to returning False when it cannot identify a file. The fact that this test passes means that at the very least all identifier functions that have been tried for that file ending did not fail with an error. """ with tempfile.TemporaryDirectory() as tmpdirname: filename = os.path.join(tmpdirname, name) with open(filename, 'w') as fp: fp.write('asdfadasdadvzxcv') with pytest.raises(IORegistryError): spec = Spectrum1D.read(filename) @remote_access([{'id': '3359174', 'filename': 'linear_fits_solution.fits'}]) def test_iraf_linear(remote_data_path): spectrum_1d = Spectrum1D.read(remote_data_path, format='iraf') assert isinstance(spectrum_1d, Spectrum1D) assert quantity_allclose(spectrum_1d.wavelength[0], u.Quantity(3514.56625402, unit='Angstrom')) assert quantity_allclose(spectrum_1d.wavelength[100], u.Quantity(3514.56625402, unit='Angstrom') + u.Quantity(0.653432383823 * 100, unit='Angstrom')) @remote_access([{'id': '3359180', 'filename': 'log-linear_fits_solution.fits'}]) def test_iraf_log_linear(remote_data_path): with pytest.raises(NotImplementedError): assert Spectrum1D.read(remote_data_path, format='iraf') @remote_access([{'id': '3359190', 'filename': 'non-linear_fits_solution_cheb.fits'}]) def test_iraf_non_linear_chebyshev(remote_data_path): chebyshev_model = models.Chebyshev1D(degree=2, domain=[1616, 3259]) chebyshev_model.c0.value = 5115.64008186 chebyshev_model.c1.value = 535.515983712 chebyshev_model.c2.value = -0.779265625182 wavelength_axis = chebyshev_model(range(1, 4097)) * u.angstrom spectrum_1d = Spectrum1D.read(remote_data_path, format='iraf') assert isinstance(spectrum_1d, Spectrum1D) assert_allclose(wavelength_axis, spectrum_1d.wavelength) # Read from HDUList hdulist = fits.open(remote_data_path) spectrum_1d = Spectrum1D.read(hdulist, format='iraf') assert isinstance(spectrum_1d, Spectrum1D) assert_allclose(wavelength_axis, spectrum_1d.wavelength) @remote_access([{'id': '3359194', 'filename': 'non-linear_fits_solution_legendre.fits'}]) def test_iraf_non_linear_legendre(remote_data_path): legendre_model = models.Legendre1D(degree=3, domain=[21, 4048]) legendre_model.c0.value = 5468.67555891 legendre_model.c1.value = 835.332144466 legendre_model.c2.value = -6.02202094803 legendre_model.c3.value = -1.13142953897 wavelength_axis = legendre_model(range(1, 4143)) * u.angstrom spectrum_1d = Spectrum1D.read(remote_data_path, format='iraf') assert isinstance(spectrum_1d, Spectrum1D) assert_allclose(wavelength_axis, spectrum_1d.wavelength) @remote_access([{'id': '3359196', 'filename': 'non-linear_fits_solution_linear-spline.fits'}]) def test_iraf_non_linear_linear_spline(remote_data_path): with pytest.raises(NotImplementedError): assert Spectrum1D.read(remote_data_path, format='iraf') @remote_access([{'id': '3359200', 'filename': 'non-linear_fits_solution_cubic-spline.fits'}]) def test_iraf_non_linear_cubic_spline(remote_data_path): with pytest.raises(NotImplementedError): assert Spectrum1D.read(remote_data_path, format='iraf') @pytest.mark.remote_data def test_iraf_multispec_chebyshev(): """Test loading of SpectrumCollection from IRAF MULTISPEC format FITS file - nonlinear 2D WCS with Chebyshev solution (FTYPE=1). """ iraf_url = 'https://github.com/astropy/specutils/raw/legacy-specutils/specutils/io/tests/files' # Read reference ASCII spectrum from remote file. spec10 = Table.read(iraf_url + '/AAO_11.txt', format='ascii.no_header', data_start=175, names=['spectral_axis', 'flux']) # Read full collection of 51 spectra from open FITS file object speccol = SpectrumCollection.read(iraf_url + '/AAO.fits') assert len(speccol) == 51 # Numpy allclose does support quantities, but not with unit 'adu'! assert quantity_allclose(speccol[10].spectral_axis, spec10['spectral_axis'] * u.AA) assert quantity_allclose(speccol[10].flux, spec10['flux'] * u.adu) @pytest.mark.remote_data def test_iraf_multispec_legendre(): """Test loading of SpectrumCollection from IRAF MULTISPEC format FITS file - nonlinear 2D WCS with Legendre solution (FTYPE=2). """ iraf_url = 'https://github.com/astropy/specutils/raw/legacy-specutils/specutils/io/tests/files' # Read reference ASCII spectrum from remote file. spec10 = Table.read(iraf_url + '/TRES.dat', format='ascii.no_header', data_start=127, names=['spectral_axis', 'flux']) # Read full collection of 51 spectra from remote FITS file object speccol = SpectrumCollection.read(iraf_url + '/TRES.fits') assert len(speccol) == 51 # The reference spectrum flux is normalised/flatfielded, just check the wavelength solution assert_allclose(speccol[10].spectral_axis, spec10['spectral_axis'] * u.AA) @pytest.mark.parametrize("spectral_axis", ['wavelength', 'frequency', 'energy', 'wavenumber']) def test_tabular_fits_writer(tmpdir, spectral_axis): wlu = {'wavelength': u.AA, 'frequency': u.GHz, 'energy': u.eV, 'wavenumber': u.cm**-1} # Create a small data set disp = np.arange(1, 1.1, 0.01) * wlu[spectral_axis] flux = np.ones(len(disp)) * 1.e-14 * u.Jy unc = StdDevUncertainty(0.01 * flux) if spectral_axis not in ('wavelength', ): disp = np.flip(disp) spectrum = Spectrum1D(flux=flux, spectral_axis=disp, uncertainty=unc) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile, format='tabular-fits') # Read it in and check against the original spec = Spectrum1D.read(tmpfile) assert spec.flux.unit == spectrum.flux.unit assert spec.spectral_axis.unit == spectrum.spectral_axis.unit assert quantity_allclose(spec.spectral_axis, spectrum.spectral_axis) assert quantity_allclose(spec.flux, spectrum.flux) assert quantity_allclose(spec.uncertainty.quantity, spectrum.uncertainty.quantity) # Test spectrum with different flux unit flux = np.random.normal(0., 1.e-9, disp.shape[0]) * u.W * u.m**-2 * u.AA**-1 unc = StdDevUncertainty(0.1 * np.sqrt(np.abs(flux.value)) * flux.unit) spectrum = Spectrum1D(flux=flux, spectral_axis=disp, uncertainty=unc) # Try to overwrite the file with pytest.raises(OSError, match=r'File .*exists'): spectrum.write(tmpfile, format='tabular-fits') spectrum.write(tmpfile, format='tabular-fits', overwrite=True) # Map to alternative set of units cmap = {spectral_axis: ('spectral_axis', 'micron'), 'flux': ('flux', 'erg / (s cm**2 AA)'), 'uncertainty': ('uncertainty', None)} # Read it back again and check against the original spec = Spectrum1D.read(tmpfile, format='tabular-fits', column_mapping=cmap) assert spec.flux.unit == u.Unit('erg / (s cm**2 AA)') assert spec.spectral_axis.unit == u.um assert quantity_allclose(spec.spectral_axis, spectrum.spectral_axis) assert quantity_allclose(spec.flux, spectrum.flux) assert quantity_allclose(spec.uncertainty.quantity, spectrum.uncertainty.quantity) @pytest.mark.parametrize("ndim", range(1, 4)) @pytest.mark.parametrize("spectral_axis", ['wavelength', 'frequency', 'energy', 'wavenumber']) def test_tabular_fits_multid(tmpdir, ndim, spectral_axis): wlu = {'wavelength': u.AA, 'frequency': u.GHz, 'energy': u.eV, 'wavenumber': u.cm**-1} # Create a small data set with ndim-D flux + uncertainty disp = np.arange(1, 1.1, 0.01) * wlu[spectral_axis] shape = (3, 2, 4)[:ndim+1] + disp.shape flux = np.random.normal(0., 1.e-9, shape) * u.W * u.m**-2 * u.AA**-1 unc = StdDevUncertainty(0.01 * np.random.sample(shape)) if spectral_axis not in ('wavelength', ): disp = np.flip(disp) spectrum = Spectrum1D(flux=flux, spectral_axis=disp, uncertainty=unc) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile, format='tabular-fits') # Read it in and check against the original spec = Spectrum1D.read(tmpfile) assert spec.flux.unit == spectrum.flux.unit assert spec.spectral_axis.unit == spectrum.spectral_axis.unit assert spec.flux.shape == flux.shape assert spec.uncertainty.array.shape == flux.shape assert quantity_allclose(spec.spectral_axis, spectrum.spectral_axis) assert quantity_allclose(spec.flux, spectrum.flux) assert quantity_allclose(spec.uncertainty.quantity, spectrum.uncertainty.quantity) # Test again, using `column_mapping` to convert to different spectral axis and flux units cmap = {spectral_axis: ('spectral_axis', 'THz'), 'flux': ('flux', 'erg / (s cm**2 AA)'), 'uncertainty': ('uncertainty', None)} spec = Spectrum1D.read(tmpfile, format='tabular-fits', column_mapping=cmap) assert spec.flux.unit == u.Unit('erg / (s cm**2 AA)') assert spec.spectral_axis.unit == u.THz assert quantity_allclose(spec.spectral_axis, spectrum.spectral_axis) assert quantity_allclose(spec.flux, spectrum.flux) assert quantity_allclose(spec.uncertainty.quantity, spectrum.uncertainty.quantity) def test_tabular_fits_header(tmpdir): # Create a small data set + header with reserved FITS keywords disp = np.linspace(1, 1.2, 21) * u.AA flux = np.random.normal(0., 1.0e-14, disp.shape[0]) * u.Jy hdr = fits.header.Header({'TELESCOP': 'Leviathan', 'APERTURE': 1.8, 'OBSERVER': 'Parsons', 'NAXIS': 1, 'NAXIS1': 8}) spectrum = Spectrum1D(flux=flux, spectral_axis=disp, meta={'header': hdr}) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile, format='tabular-fits') # Read it in and check against the original hdulist = fits.open(tmpfile) assert hdulist[0].header['NAXIS'] == 0 assert hdulist[1].header['NAXIS'] == 2 assert hdulist[1].header['NAXIS2'] == disp.shape[0] assert hdulist[1].header['OBSERVER'] == 'Parsons' hdulist.close() # Now write with updated header information from spectrum.meta spectrum.meta.update({'OBSERVER': 'Rosse', 'EXPTIME': 32.1, 'NAXIS2': 12}) spectrum.write(tmpfile, format='tabular-fits', overwrite=True, update_header=True) hdulist = fits.open(tmpfile) assert hdulist[1].header['NAXIS2'] == disp.shape[0] assert hdulist[1].header['OBSERVER'] == 'Rosse' assert_allclose(hdulist[1].header['EXPTIME'], 3.21e1) hdulist.close() # Test that unsupported types (dict) are not added to written header spectrum.meta['MYHEADER'] = {'OBSDATE': '1848-02-26', 'TARGET': 'M51'} spectrum.write(tmpfile, format='tabular-fits', overwrite=True, update_header=True) hdulist = fits.open(tmpfile) assert 'MYHEADER' not in hdulist[0].header assert 'MYHEADER' not in hdulist[1].header assert 'OBSDATE' not in hdulist[0].header assert 'OBSDATE' not in hdulist[1].header hdulist.close() def test_tabular_fits_autowrite(tmpdir): """Test writing of Spectrum1D with automatic selection of BINTABLE format.""" disp = np.linspace(1, 1.2, 21) * u.AA flux = np.random.normal(0., 1.0e-14, disp.shape[0]) * u.W / (u.m**2 * u.AA) hdr = fits.header.Header({'TELESCOP': 'Leviathan', 'APERTURE': 1.8, 'OBSERVER': 'Parsons', 'NAXIS': 1, 'NAXIS1': 8}) spectrum = Spectrum1D(flux=flux, spectral_axis=disp, meta={'header': hdr}) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile) # Read it in and check against the original with fits.open(tmpfile) as hdulist: assert hdulist[0].header['NAXIS'] == 0 assert hdulist[1].header['NAXIS'] == 2 assert hdulist[1].header['NAXIS2'] == disp.shape[0] # Trigger exception for illegal HDU (primary HDU only accepts IMAGE_HDU) with pytest.raises(ValueError, match=r'FITS does not support BINTABLE'): spectrum.write(tmpfile, format='tabular-fits', overwrite=True, hdu=0) # Test automatic selection of wcs1d format, which will fail without suitable wcs with pytest.raises(ValueError, match=r'Only Spectrum1D objects with valid WCS'): spectrum.write(tmpfile, overwrite=True, hdu=0) tmpfile = str(tmpdir.join('_wcs.fits')) with pytest.raises(ValueError, match=r'Only Spectrum1D objects with valid WCS'): spectrum.write(tmpfile, overwrite=True) @pytest.mark.skipif('sys.platform.startswith("win")', reason='Uncertain availability of compression utilities') @pytest.mark.parametrize('compress', ['gzip', 'bzip2', 'xz']) def test_tabular_fits_compressed(compress, tmpdir): """Test automatic recognition of supported compression formats for BINTABLE. """ ext = {'gzip': '.gz', 'bzip2': '.bz2', 'xz': '.xz'} if compress == 'bzip2' and not HAS_BZ2: pytest.xfail("Python installation has no bzip2 support") if compress == 'xz' and not HAS_LZMA: pytest.xfail("Python installation has no lzma support") # Create a small data set disp = np.linspace(1, 1.2, 23) * u.AA flux = np.random.normal(0., 1.0e-14, disp.shape[0]) * u.Jy unc = StdDevUncertainty(0.01 * np.abs(flux)) spectrum = Spectrum1D(flux=flux, spectral_axis=disp, uncertainty=unc) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile, format='tabular-fits') # Deliberately not using standard filename pattern to test header info. with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) os.system(f'{compress} {tmpfile}') spec = Spectrum1D.read(tmpfile + ext[compress]) assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.shape[0] == len(disp) assert spec.flux.size == len(disp) assert spec.uncertainty.array.min() >= 0.0 assert quantity_allclose(spec.flux, spectrum.flux) # Try again without compression suffix: with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) os.system(f'mv {tmpfile}{ext[compress]} {tmpfile}') spec = Spectrum1D.read(tmpfile) assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.shape[0] == len(disp) assert spec.flux.size == len(disp) assert spec.uncertainty.array.min() >= 0.0 assert quantity_allclose(spec.flux, spectrum.flux) @pytest.mark.parametrize("spectral_axis", ['wavelength', 'frequency', 'energy', 'wavenumber']) def test_wcs1d_fits_writer(tmpdir, spectral_axis): """Test write/read for Spectrum1D with WCS-constructed spectral_axis.""" wlunits = {'wavelength': 'Angstrom', 'frequency': 'GHz', 'energy': 'eV', 'wavenumber': 'cm**-1'} # Header dictionary for constructing WCS hdr = {'CTYPE1': spectral_axis, 'CUNIT1': wlunits[spectral_axis], 'CRPIX1': 1, 'CRVAL1': 1, 'CDELT1': 0.01} # Create a small data set flux = np.arange(1, 11)**2 * 1.e-14 * u.Jy wlu = u.Unit(hdr['CUNIT1']) wl0 = hdr['CRVAL1'] dwl = hdr['CDELT1'] disp = np.arange(wl0, wl0 + (len(flux) - 0.5) * dwl, dwl) * wlu spectrum = Spectrum1D(flux=flux, wcs=WCS(hdr)) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile, hdu=0) # Read it in and check against the original spec = Spectrum1D.read(tmpfile) assert spec.flux.unit == spectrum.flux.unit assert spec.spectral_axis.unit == spectrum.spectral_axis.unit assert quantity_allclose(spec.spectral_axis, spectrum.spectral_axis) assert quantity_allclose(spec.spectral_axis, disp) assert quantity_allclose(spec.flux, spectrum.flux) # Read from HDUList hdulist = fits.open(tmpfile) spec = Spectrum1D.read(hdulist, format='wcs1d-fits') assert isinstance(spec, Spectrum1D) assert quantity_allclose(spec.spectral_axis, spectrum.spectral_axis) assert quantity_allclose(spec.flux, spectrum.flux) @pytest.mark.parametrize("hdu", range(3)) def test_wcs1d_fits_hdus(tmpdir, hdu): """Test writing of Spectrum1D in WCS1D format to different IMAGE_HDUs.""" # Header dictionary for constructing WCS hdr = {'CTYPE1': 'wavelength', 'CUNIT1': 'um', 'CRPIX1': 1, 'CRVAL1': 1, 'CDELT1': 0.01} # Create a small data set flu = u.W / (u.m**2 * u.nm) flux = np.arange(1, 11)**2 * 1.e-14 * flu spectrum = Spectrum1D(flux=flux, wcs=WCS(hdr)) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile, hdu=hdu, format='wcs1d-fits') # Read it in and check against the original with fits.open(tmpfile) as hdulist: assert hdulist[hdu].is_image assert hdulist[hdu].header['NAXIS'] == 1 assert hdulist[hdu].header['NAXIS1'] == flux.shape[0] assert u.Unit(hdulist[hdu].header['CUNIT1']) == u.Unit(hdr['CUNIT1']) assert quantity_allclose(hdulist[hdu].data * flu, flux) # Test again with automatic format selection by filename pattern tmpfile = str(tmpdir.join('_wcs.fits')) spectrum.write(tmpfile, hdu=hdu) with fits.open(tmpfile) as hdulist: assert hdulist[hdu].is_image assert quantity_allclose(hdulist[hdu].data * flu, flux) @pytest.mark.parametrize("spectral_axis", ['wavelength', 'frequency', 'energy', 'wavenumber']) def test_wcs1d_fits_multid(tmpdir, spectral_axis): """Test spectrum with WCS-1D spectral_axis and higher dimension in flux.""" wlunits = {'wavelength': 'Angstrom', 'frequency': 'GHz', 'energy': 'eV', 'wavenumber': 'cm**-1'} # Header dictionary for constructing WCS hdr = {'CTYPE1': spectral_axis, 'CUNIT1': wlunits[spectral_axis], 'CRPIX1': 1, 'CRVAL1': 1, 'CDELT1': 0.01} # Create a small data set flux = np.arange(1, 11)**2 * 1.e-14 * u.Jy wlu = u.Unit(hdr['CUNIT1']) wl0 = hdr['CRVAL1'] dwl = hdr['CDELT1'] disp = np.arange(wl0, wl0 + len(flux[1:]) * dwl, dwl) * wlu # Construct up to 4D flux array, write and read (no auto-identify) shape = [-1, 1] for i in range(2, 5): flux = flux * np.arange(i, i+5).reshape(*shape) spectrum = Spectrum1D(flux=flux, wcs=WCS(hdr)) tmpfile = str(tmpdir.join(f'_{i}d.fits')) spectrum.write(tmpfile, format='wcs1d-fits') spec = Spectrum1D.read(tmpfile, format='wcs1d-fits') assert spec.flux.ndim == i assert quantity_allclose(spec.spectral_axis, disp) assert quantity_allclose(spec.spectral_axis, spectrum.spectral_axis) assert quantity_allclose(spec.flux, spectrum.flux) shape.append(1) # Test exception for NAXIS > 4 flux = flux * np.arange(i+1, i+6).reshape(*shape) spectrum = Spectrum1D(flux=flux, wcs=WCS(hdr)) tmpfile = str(tmpdir.join(f'_{i+1}d.fits')) spectrum.write(tmpfile, format='wcs1d-fits') with pytest.raises(ValueError, match='input to wcs1d_fits_loader is > 4D'): spec = Spectrum1D.read(tmpfile, format='wcs1d-fits') @pytest.mark.parametrize("spectral_axis", ['wavelength', 'frequency']) def test_wcs1d_fits_non1d(tmpdir, spectral_axis): """Test exception on trying to load FITS with 2D flux and irreducible WCS spectral_axis. """ wlunits = {'wavelength': 'Angstrom', 'frequency': 'GHz', 'energy': 'eV', 'wavenumber': 'cm**-1'} # Header dictionary for constructing WCS hdr = {'CTYPE1': spectral_axis, 'CUNIT1': wlunits[spectral_axis], 'CRPIX1': 1, 'CRVAL1': 1, 'CDELT1': 0.01} # Create a small 2D data set flux = np.arange(1, 11)**2 * np.arange(4).reshape(-1, 1) * 1.e-14 * u.Jy spectrum = Spectrum1D(flux=flux, wcs=WCS(hdr)) tmpfile = str(tmpdir.join(f'_{2}d.fits')) spectrum.write(tmpfile, format='wcs1d-fits') # Reopen file and update header with off-diagonal element hdulist = fits.open(tmpfile, mode='update') hdulist[0].header.update([('PC1_2', 0.2)]) hdulist.close() with pytest.raises(ValueError, match='WCS cannot be reduced to 1D'): Spectrum1D.read(tmpfile, format='wcs1d-fits') @pytest.mark.skipif('sys.platform.startswith("win")', reason='Uncertain availability of compression utilities') @pytest.mark.parametrize('compress', ['gzip', 'bzip2', 'xz']) def test_wcs1d_fits_compressed(compress, tmpdir): """Test automatic recognition of supported compression formats for IMAGE/WCS. """ ext = {'gzip': '.gz', 'bzip2': '.bz2', 'xz': '.xz'} if compress == 'bzip2' and not HAS_BZ2: pytest.xfail("Python installation has no bzip2 support") if compress == 'xz' and not HAS_LZMA: pytest.xfail("Python installation has no lzma support") # Header dictionary for constructing WCS hdr = {'CTYPE1': 'wavelength', 'CUNIT1': 'Angstrom', 'CRPIX1': 1, 'CRVAL1': 1, 'CDELT1': 0.01} # Create a small data set flux = np.arange(1, 43)**2 * 1.e-14 * u.Jy wlu = u.Unit(hdr['CUNIT1']) wl0 = hdr['CRVAL1'] dwl = hdr['CDELT1'] disp = np.arange(wl0, wl0 + (len(flux) - 0.5) * dwl, dwl) * wlu spectrum = Spectrum1D(flux=flux, wcs=WCS(hdr)) tmpfile = str(tmpdir.join('_tst.fits')) spectrum.write(tmpfile, hdu=0) # Deliberately not using standard filename pattern to test header info. with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) os.system(f'{compress} {tmpfile}') spec = Spectrum1D.read(tmpfile + ext[compress]) assert isinstance(spec, Spectrum1D) assert quantity_allclose(spec.spectral_axis, disp) assert quantity_allclose(spec.flux, spectrum.flux) # Try again without compression suffix: with warnings.catch_warnings(): warnings.simplefilter('ignore', FITSFixedWarning) os.system(f'mv {tmpfile}{ext[compress]} {tmpfile}') spec = Spectrum1D.read(tmpfile) assert isinstance(spec, Spectrum1D) assert quantity_allclose(spec.spectral_axis, disp) assert quantity_allclose(spec.flux, spectrum.flux) @pytest.mark.remote_data def test_apstar_loader(): """Test remote read and automatic recognition of apStar spec from URL. """ apstar_url = ("https://data.sdss.org/sas/dr16/apogee/spectro/redux/r12/" "stars/apo25m/N7789/apStar-r12-2M00005414+5522241.fits") spec = Spectrum1D.read(apstar_url) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.flux.unit == 1e-17 * u.erg / (u.s * u.cm**2 * u.AA) assert spec.uncertainty.array.min() >= 0.0 @pytest.mark.remote_data def test_apvisit_loader(): """Test remote read and automatic recognition of apvisit spec from URL. """ apvisit_url = ("https://data.sdss.org/sas/dr16/apogee/spectro/redux/r12/" "visit/apo25m/N7789/5094/55874/" "apVisit-r12-5094-55874-123.fits") spec = Spectrum1D.read(apvisit_url) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.flux.unit == 1e-17 * u.erg / (u.s * u.cm**2 * u.AA) assert spec.uncertainty.array.min() >= 0.0 @pytest.mark.remote_data def test_aspcapstar_loader(): """Test remote read and automatic recognition of aspcapStar spec from URL. """ aspcap_url = ("https://data.sdss.org/sas/dr16/apogee/spectro/aspcap/r12/" "l33/apo25m/N7789/aspcapStar-r12-2M00005414+5522241.fits") spec = Spectrum1D.read(aspcap_url) assert isinstance(spec, Spectrum1D) assert spec.flux.size > 0 assert spec.uncertainty.array.min() >= 0.0 @pytest.mark.remote_data def test_muscles_loader(): """Test remote read and automatic recognition of muscles spec from URL. """ url = ("https://archive.stsci.edu/missions/hlsp/muscles/gj1214/" "hlsp_muscles_multi_multi_gj1214_broadband_v22_const-res-sed.fits") spec = Spectrum1D.read(url) assert isinstance(spec, Spectrum1D) assert len(spec.flux) == len(spec.spectral_axis) > 50000 assert spec.uncertainty.array.min() >= 0.0 assert spec.spectral_axis.unit == u.AA assert spec.flux.unit == u.erg / (u.s * u.cm**2 * u.AA) # Read HDUList hdulist = fits.open(url) spec = Spectrum1D.read(hdulist, format="MUSCLES SED") assert isinstance(spec, Spectrum1D) @pytest.mark.remote_data def test_subaru_pfs_loader(tmpdir): """Test remote read and automatic recognition of Subaru PFS spec from URL. """ pfs = "pfsObject-00000-0,0-000-00000001-01-0x395428ab.fits" url = f"https://github.com/Subaru-PFS/datamodel/raw/master/examples/{pfs}" assert subaru_pfs_spec.identify_pfs_spec(url, url) # PFS loader parses metadata from filename, cannot read directly from url tmpfile = str(tmpdir.join(pfs)) with urllib.request.urlopen(url) as response: shutil.copyfileobj(response, open(tmpfile, mode='wb')) assert subaru_pfs_spec.identify_pfs_spec(pfs, open(tmpfile, mode='rb')) spec = Spectrum1D.read(tmpfile, format='Subaru-pfsObject') assert isinstance(spec, Spectrum1D) spec = Spectrum1D.read(tmpfile) assert isinstance(spec, Spectrum1D) assert len(spec.flux) == len(spec.spectral_axis) > 10000 assert spec.spectral_axis.unit == u.nm assert spec.flux.unit == u.nJy @remote_access([{'id': '3733958', 'filename': '1D-c0022498-344732.fits'}]) def test_spectrum1d_6dfgs_tabular(remote_data_path): spec = Spectrum1D.read(remote_data_path) assert spec.spectral_axis.unit == u.Unit("Angstrom") assert spec.flux.unit == u.Unit("count/s") # Read from HDUList object hdulist = fits.open(remote_data_path) spec = Spectrum1D.read(hdulist, format="6dFGS-tabular") assert isinstance(spec, Spectrum1D) assert spec.flux.unit == u.Unit("count/s") assert spec.flux.size > 0 hdulist.close() @remote_access([{'id': '3733958', 'filename': 'all-c0022498-344732v_spectrum0.fits'}]) def test_spectrum1d_6dfgs_split_v(remote_data_path): spec = Spectrum1D.read(remote_data_path) assert spec.spectral_axis.unit == u.Unit("Angstrom") assert spec.flux.unit == u.Unit("count/Angstrom") # Read from HDUList object hdulist = fits.open(remote_data_path) spec = Spectrum1D.read(hdulist, format="6dFGS-split") assert isinstance(spec, Spectrum1D) assert spec.flux.unit == u.Unit("count/Angstrom") assert spec.flux.size > 0 hdulist.close() @remote_access([{'id': '3733958', 'filename': 'all-c0022498-344732r_spectrum0.fits'}]) def test_spectrum1d_6dfgs_split_r(remote_data_path): spec = Spectrum1D.read(remote_data_path) assert spec.spectral_axis.unit == u.Unit("Angstrom") assert spec.flux.unit == u.Unit("count/Angstrom") # Read from HDUList object hdulist = fits.open(remote_data_path) spec = Spectrum1D.read(hdulist, format="6dFGS-split") assert isinstance(spec, Spectrum1D) assert spec.flux.unit == u.Unit("count/Angstrom") assert spec.flux.size > 0 hdulist.close() @remote_access([{'id': '3733958', 'filename': 'all-c0022498-344732combined_spectrum0.fits'}]) def test_spectrum1d_6dfgs_split_combined(remote_data_path): spec = Spectrum1D.read(remote_data_path) assert spec.spectral_axis.unit == u.Unit("Angstrom") assert spec.flux.unit == u.Unit("count/Angstrom") # Read from HDUList object hdulist = fits.open(remote_data_path) spec = Spectrum1D.read(hdulist, format="6dFGS-split") assert isinstance(spec, Spectrum1D) assert spec.flux.unit == u.Unit("count/Angstrom") assert spec.flux.size > 0 hdulist.close() @remote_access([{'id': '3733958', 'filename': 'all-c0022498-344732.fits'}]) def test_spectrum1d_6dfgs_combined(remote_data_path): specs = SpectrumList.read(remote_data_path) for spec in specs: assert spec.spectral_axis.unit == u.Unit("Angstrom") assert spec.flux.unit == u.Unit("count/Angstrom") assert len(specs) == 3 # Read from HDUList object hdulist = fits.open(remote_data_path) specs = SpectrumList.read(hdulist, format="6dFGS-combined") for spec in specs: assert isinstance(spec, Spectrum1D) assert spec.flux.unit == u.Unit("count/Angstrom") assert spec.flux.size > 0 assert spec.meta["sky"].flux.unit == u.Unit("count/Angstrom") assert spec.meta["sky"].flux.size > 0 assert len(specs) == 3 hdulist.close() # Commented out until science only is discussed # @pytest.mark.remote_data # def test_2slaq_lrg_loader_science_only(): # """Test remote read and automatic recognition of 2SLAQ-LRG data from URL. # """ # url = ("https://datacentral.org.au/services/sov/81480/download/" # "gama.dr2.spectra.2slaq-lrg.spectrum_1d/J143529.78-004306.4_1.fit/") # spec = Spectrum1D.read(url) # # assert spec.spectral_axis.unit == u.AA # assert spec.flux.unit == u.count / u.s # assert spec.uncertainty is None @remote_access([{'id': '3970324', 'filename': 'J143529.78-004306.4_1.fit'}]) def test_2slaq_lrg_loader_science_and_sky(remote_data_path): """Test remote read and automatic recognition of 2SLAQ-LRG data from URL. """ science, sky = SpectrumList.read(remote_data_path) assert science.spectral_axis.unit == u.AA assert science.flux.unit == u.count / u.s assert science.uncertainty is None assert sky.spectral_axis.unit == u.AA assert sky.flux.unit == u.count / u.s assert sky.uncertainty is None @remote_access([ {'id': '3895436', 'filename': '000002.fits'}, {'id': '3895436', 'filename': '000003.fits'}, {'id': '3895436', 'filename': '000004.fits'}, ]) def test_spectrum_list_2dfgrs_single(remote_data_path): specs = SpectrumList.read(remote_data_path) assert len(specs) == 1 for spec in specs: assert spec.spectral_axis.unit == u.Unit("Angstrom") # Read from HDUList object hdulist = fits.open(remote_data_path) specs = SpectrumList.read(hdulist, format="2dFGRS") for spec in specs: assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.unit == u.Unit("Angstrom") assert len(specs) == 1 hdulist.close() @remote_access([{'id': '3895436', 'filename': '000001.fits'}]) def test_spectrum_list_2dfgrs_multiple(remote_data_path): specs = SpectrumList.read(remote_data_path) assert len(specs) == 2 for spec in specs: assert spec.spectral_axis.unit == u.Unit("Angstrom") # Read from HDUList object hdulist = fits.open(remote_data_path) specs = SpectrumList.read(hdulist, format="2dFGRS") for spec in specs: assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.unit == u.Unit("Angstrom") assert len(specs) == 2 hdulist.close() def test_sdss_wcs_handler(): sdss_wcs = WCS(naxis=2) sdss_wcs.wcs.crval[0] = 3.57880000000000E+00 sdss_wcs.wcs.cd = [[1.00000000000000E-04, 0], [0, 1]] sdss_wcs.wcs.cunit[0] = u.Unit('Angstrom') fixed_wcs = _sdss_wcs_to_log_wcs(sdss_wcs) dropped_sdss_wcs = sdss_wcs.dropaxis(1) dropped_sdss_wcs.wcs.cunit[0] = '' # Cannot handle units in powers sdss_wave = 10 ** dropped_sdss_wcs.pixel_to_world(np.arange(10)) * u.Unit('Angstrom') fixed_wave = fixed_wcs.pixel_to_world(np.arange(10)) assert quantity_allclose(sdss_wave, fixed_wave) class TestAAOmega2dF: @remote_access([{'id': '4460981', 'filename': "OBJ0039red.fits"}]) def test_with_rwss(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format="Data Central AAOmega", ) assert len(spectra) == 139 for spec in spectra: assert spec.meta.get("label") is not None assert spec.meta.get("header") is not None assert spec.meta.get("purpose") is not None assert spec.meta.get("fibre_index") is not None @remote_access([{'id': '4460981', 'filename': "OBJ0032red.fits"}]) def test_without_rwss(self, remote_data_path): spectra = SpectrumList.read( remote_data_path, format="Data Central AAOmega", ) assert len(spectra) == 153 for spec in spectra: assert spec.meta.get("label") is not None assert spec.meta.get("header") is not None assert spec.meta.get("purpose") is not None assert spec.meta.get("fibre_index") is not None @remote_access([{'id': '4460981', 'filename': "OBJ0039red.fits"}]) def test_with_rwss_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 139 for spec in spectra: assert spec.meta.get("label") is not None assert spec.meta.get("header") is not None assert spec.meta.get("purpose") is not None assert spec.meta.get("fibre_index") is not None @remote_access([{'id': '4460981', 'filename': "OBJ0032red.fits"}]) def test_without_rwss_guess(self, remote_data_path): spectra = SpectrumList.read(remote_data_path) assert len(spectra) == 153 for spec in spectra: assert spec.meta.get("label") is not None assert spec.meta.get("header") is not None assert spec.meta.get("purpose") is not None assert spec.meta.get("fibre_index") is not None @remote_access([ {'id': "4460981", 'filename':"1812260046012353.fits"}, # 4 exts {'id': "4460981", 'filename':"1311160005010021.fits"} # 5 exts ]) def test_galah(remote_data_path): spectra = SpectrumList.read(remote_data_path, format="GALAH") # Should be main spectra, without sky, and normalised (not in 4 ext) nspec = len(spectra) if spectra[0].meta["galah_hdu_format"] == 4: assert nspec == 2 elif spectra[0].meta["galah_hdu_format"] == 5: assert nspec == 3 else: assert False, "Unknown format" # normalised if nspec == 3: assert spectra[0].flux.unit == u.Unit('') # dimensionless assert spectra[0].spectral_axis.unit == u.Angstrom assert spectra[0].uncertainty is None assert spectra[0].meta.get("label") == "normalised spectra" assert spectra[0].meta.get("header") is not None # drop the normalised spectra, so 4 and 5 should now look the same spectra = spectra[1:] # main spectra assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None # No sky assert spectra[1].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert isinstance(spectra[1].uncertainty, StdDevUncertainty) assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None @pytest.mark.xfail(reason="Format is ambiguous") @remote_access([ {'id': "4460981", 'filename':"1812260046012353.fits"}, # 4 exts {'id': "4460981", 'filename':"1311160005010021.fits"} # 5 exts ]) def test_galah_guess(remote_data_path): spectra = SpectrumList.read(remote_data_path) # Should be main spectra, without sky, and normalised (not in 4 ext) nspec = len(spectra) if spectra[0].meta["galah_hdu_format"] == 4: assert nspec == 2 elif spectra[0].meta["galah_hdu_format"] == 5: assert nspec == 3 else: assert False, "Unknown format" # main spectra assert spectra[0].flux.unit == u.count assert spectra[0].spectral_axis.unit == u.Angstrom assert isinstance(spectra[0].uncertainty, StdDevUncertainty) assert spectra[0].meta.get("label") is not None assert spectra[0].meta.get("header") is not None # normalised if nspec == 3: assert spectra[1].flux.unit == u.Unit('') # dimensionless assert spectra[1].spectral_axis.unit == u.Angstrom assert spectra[1].uncertainty is None assert spectra[1].meta.get("label") == "normalised spectra" assert spectra[1].meta.get("header") is not None # No sky if nspec == 3: assert spectra[2].spectral_axis.unit == u.Angstrom assert spectra[2].flux.unit == u.count assert isinstance(spectra[2].uncertainty, StdDevUncertainty) assert spectra[2].meta.get("label") is not None assert spectra[2].meta.get("header") is not None else: assert spectra[1].spectral_axis.unit == u.Angstrom assert spectra[1].flux.unit == u.count assert isinstance(spectra[1].uncertainty, StdDevUncertainty) assert spectra[1].meta.get("label") is not None assert spectra[1].meta.get("header") is not None # We cannot use remote_access directly in the MIRI MRS tests, because # the test functions are called once for every file in the remote access # list, but we need to feed the entire set to the functions at once. We # store the file names in a list and use a dummy test method to load the # list via the remote_access machinery. # # MIRI MRS 1D data sets are comprised of 12 files. # We add one bad file to test the skip/warn on missing file functionality filename_list = ["bad_file.fits"] @remote_access([ {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch1-long_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch1-medium_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch1-short_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch2-long_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch2-medium_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch2-short_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch3-long_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch3-medium_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch3-short_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch4-long_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch4-medium_x1d.fits'}, {'id': '5082863', 'filename': 'combine_dithers_all_exposures_ch4-short_x1d.fits'}, ]) def test_loaddata_miri_mrs(remote_data_path): filename_list.append(remote_data_path) # loading from a list of file names @pytest.mark.remote_data def test_spectrum_list_names_miri_mrs(caplog): # Format is explicitly set with pytest.raises(FileNotFoundError): specs = SpectrumList.read(filename_list, format="JWST x1d MIRI MRS") # Skip missing file silently specs = SpectrumList.read(filename_list, format="JWST x1d MIRI MRS", missing='silent') assert len(specs) == 12 for spec in specs: assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.unit == u.micron # Warn about missing file specs = SpectrumList.read(filename_list, format="JWST x1d MIRI MRS", missing='warn') assert "Failed to load bad_file.fits: FileNotFoundError" in caplog.text assert len(specs) == 12 for spec in specs: assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.unit == u.Unit("um") # Auto-detect format specs = SpectrumList.read(filename_list[1:]) assert len(specs) == 12 for spec in specs: assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.unit == u.micron # loading from a directory via glob @pytest.mark.remote_data def test_spectrum_list_directory_miri_mrs(tmpdir): # copy files to temp dir. We cannot use the directory generated by # remote_access, because it may have variable structure from run to # run. And also because temp directories created in previous runs # may still be hanging around. This precludes the use of commonpath() tmp_dir = tmpdir.strpath for file_path in filename_list[1:]: shutil.copy(file_path, tmp_dir) specs = SpectrumList.read(tmp_dir) assert len(specs) == 12 for spec in specs: assert isinstance(spec, Spectrum1D) assert spec.spectral_axis.unit == u.micron # x1d and c1d assorted files @remote_access([ {'id': "5394931", 'filename':"jw00623-c1012_t002_miri_p750l_x1d.fits"}, # pipeline 1.2.3 {'id': "5394931", 'filename':"jw00787-o014_s00002_niriss_f150w-gr150c-gr150r_c1d.fits"}, # pipeline 1.2.3 {'id': "5394931", 'filename':"jw00623-o057_t008_miri_ch1-long_x1d.fits"}, # pipeline 1.3.1 {'id': "5394931", 'filename':"jw00626-o064_t007_nirspec_g235h-f170lp_x1d.fits"}, # pipeline 1.3.1 ]) def test_jwst_x1d_c1d(remote_data_path): data = Spectrum1D.read(remote_data_path) assert isinstance(data, Spectrum1D) assert data.shape in [(388,), (5,), (1091,), (3843,)] assert data.unit == u.Jy assert data.spectral_axis.unit == u.um # utility functions to be used with list comprehension in SpectrumList checking def assert_multi_isinstance(a, b): assert isinstance(a, b) def assert_multi_equals(a, b): assert a == b @remote_access([ {'id': "5394931", 'filename':"jw00624-o027_s00001_nircam_f356w-grismr_x1d.fits"}, # pipeline 1.2.3 ]) def test_jwst_nircam_x1d_multi_v1_2_3(remote_data_path): data = SpectrumList.read(remote_data_path) assert isinstance(data, SpectrumList) assert len(data) == 3 [assert_multi_isinstance(d, Spectrum1D) for d in data] [assert_multi_equals(d.shape, r) for d,r in zip(data, [(459,), (336,), (962,)])] [assert_multi_equals(d.unit, u.Jy) for d in data] [assert_multi_equals(d.spectral_axis.unit, u.um) for d in data] @remote_access([ {'id': "5394931", 'filename':"jw00660-o016_s00002_nircam_f444w-grismr_x1d.fits"}, # pipeline 1.3.1 ]) def test_jwst_nircam_x1d_multi_v1_3_1(remote_data_path): data = SpectrumList.read(remote_data_path) assert isinstance(data, SpectrumList) assert len(data) == 4 [assert_multi_isinstance(d, Spectrum1D) for d in data] [assert_multi_equals(d.shape, r) for d,r in zip(data, [(1166,), (786,), (1157,), (795,)])] [assert_multi_equals(d.unit, u.Jy) for d in data] [assert_multi_equals(d.spectral_axis.unit, u.um) for d in data] @remote_access([ {'id': "5394931", 'filename':"jw00776-o003_s00083_nircam_f322w2-grismr_c1d.fits"}, # pipeline 1.2.3 ]) def test_jwst_nircam_c1d_v1_2_3(remote_data_path): data = SpectrumList.read(remote_data_path) assert isinstance(data, SpectrumList) assert len(data) == 2 [assert_multi_isinstance(d, Spectrum1D) for d in data] [assert_multi_equals(d.shape, r) for d,r in zip(data, [(133,), (1139,)])] [assert_multi_equals(d.unit, u.MJy/u.sr) for d in data] [assert_multi_equals(d.spectral_axis.unit, u.um) for d in data] @remote_access([ {'id': "5394931", 'filename':"jw00625-o018_s00001_niriss_f090w-gr150c_c1d.fits"}, # pipeline 1.2.3 ]) def test_jwst_niriss_c1d_v1_2_3(remote_data_path): data = SpectrumList.read(remote_data_path) assert isinstance(data, SpectrumList) assert len(data) == 2 [assert_multi_isinstance(d, Spectrum1D) for d in data] [assert_multi_equals(d.shape, r) for d,r in zip(data, [(56,), (107,)])] [assert_multi_equals(d.unit, u.Jy) for d in data] [assert_multi_equals(d.spectral_axis.unit, u.um) for d in data] ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_manipulation.py0000644000503700020070000001652700000000000025105 0ustar00rosteenSTSCI\science00000000000000import operator import pytest import numpy as np import astropy.units as u from astropy.modeling import models from astropy.nddata import StdDevUncertainty, NDData from astropy.tests.helper import quantity_allclose from ..utils.wcs_utils import gwcs_from_array from ..spectra import Spectrum1D, SpectralRegion, SpectrumCollection from ..manipulation import snr_threshold, excise_regions, linear_exciser def test_true_exciser(): np.random.seed(84) spectral_axis = np.linspace(5000,5100,num=100)*u.AA flux = (np.random.randn(100) + 3) * u.Jy spec = Spectrum1D(flux=flux, spectral_axis=spectral_axis) region = SpectralRegion([(5005,5010), (5060,5065)]*u.AA) excised_spec = excise_regions(spec, region) assert len(excised_spec.spectral_axis) == len(spec.spectral_axis)-10 assert len(excised_spec.flux) == len(spec.flux)-10 assert np.isclose(excised_spec.flux.sum(), 243.2617*u.Jy, atol=0.001*u.Jy) def test_linear_exciser(): np.random.seed(84) spectral_axis = np.linspace(5000,5100,num=100)*u.AA flux = (np.random.rand(100)*100) * u.Jy spec = Spectrum1D(flux=flux, spectral_axis = spectral_axis) region = SpectralRegion([(5020,5030)]*u.AA) excised_spec = excise_regions(spec, region, exciser = linear_exciser) assert len(excised_spec.spectral_axis) == len(spec.spectral_axis) assert len(excised_spec.flux) == len(spec.flux) assert np.isclose(excised_spec.flux[25], 34.9864*u.Jy, atol=0.001*u.Jy) def test_snr_threshold(): np.random.seed(42) # Setup 1D spectrum wavelengths = np.arange(0, 10)*u.um flux = 100*np.abs(np.random.randn(10))*u.Jy uncertainty = StdDevUncertainty(np.abs(np.random.randn(10))*u.Jy) spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux, uncertainty=uncertainty) spectrum_masked = snr_threshold(spectrum, 50) assert all([x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, operator.gt) assert all([x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, '>') assert all([x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, operator.ge) assert all([x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, '>=') assert all([x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, operator.lt) assert all([not x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, '<') assert all([not x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, operator.le) assert all([not x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) spectrum_masked = snr_threshold(spectrum, 50, '<=') assert all([not x==y for x,y in zip(spectrum_masked.mask, [False, True, False, False, True, True, False, False, False, True])]) # Setup 3D spectrum np.random.seed(42) wavelengths = np.arange(0, 10)*u.um flux = 100*np.abs(np.random.randn(3, 4, 10))*u.Jy uncertainty = StdDevUncertainty(np.abs(np.random.randn(3, 4, 10))*u.Jy) spectrum = Spectrum1D(spectral_axis=wavelengths, flux=flux, uncertainty=uncertainty) spectrum_masked = snr_threshold(spectrum, 50) masked_true = np.array([[[ False, True, True, False, True, True, False, False, False, False], [True, False, True, False, False, True, False, False, False, False], [ False, True, True, False, False, True, False, True, False, False], [ False, False, True, False, False, False, True, False, False, True]], [[ False, True, True, True, False, False, False, False, False, False], [True, True, False, False, False, False, False, True, False, True], [ False, True, False, False, False, False, True, False, True, True], [ False, False, True, False, False, False, True, False, False, False]], [[ False, False, False, True, False, False, False, False, False, True], [True, False, False, False, False, False, True, False, True, False], [ False, True, True, True, True, True, False, True, True, True], [ False, True, False, False, True, True, True, False, False, False]]]) assert all([x==y for x,y in zip(spectrum_masked.mask.ravel(), masked_true.ravel())]) # Setup 3D NDData np.random.seed(42) flux = 100*np.abs(np.random.randn(3, 4, 10))*u.Jy uncertainty = StdDevUncertainty(np.abs(np.random.randn(3, 4, 10))*u.Jy) spectrum = NDData(data=flux, uncertainty=uncertainty) spectrum_masked = snr_threshold(spectrum, 50) masked_true = np.array([[[ False, True, True, False, True, True, False, False, False, False], [True, False, True, False, False, True, False, False, False, False], [ False, True, True, False, False, True, False, True, False, False], [ False, False, True, False, False, False, True, False, False, True]], [[ False, True, True, True, False, False, False, False, False, False], [True, True, False, False, False, False, False, True, False, True], [ False, True, False, False, False, False, True, False, True, True], [ False, False, True, False, False, False, True, False, False, False]], [[ False, False, False, True, False, False, False, False, False, True], [True, False, False, False, False, False, True, False, True, False], [ False, True, True, True, True, True, False, True, True, True], [ False, True, False, False, True, True, True, False, False, False]]]) assert all([x==y for x,y in zip(spectrum_masked.mask.ravel(), masked_true.ravel())]) # Test SpectralCollection np.random.seed(42) flux = u.Quantity(np.random.sample((5, 10)), unit='Jy') spectral_axis = u.Quantity(np.arange(50).reshape((5, 10)), unit='AA') wcs = np.array([gwcs_from_array(x) for x in spectral_axis]) uncertainty = StdDevUncertainty(np.random.sample((5, 10)), unit='Jy') mask = np.ones((5, 10)).astype(bool) meta = [{'test': 5, 'info': [1, 2, 3]} for i in range(5)] spec_coll = SpectrumCollection( flux=flux, spectral_axis=spectral_axis, wcs=wcs, uncertainty=uncertainty, mask=mask, meta=meta) spec_coll_masked = snr_threshold(spec_coll, 3) print(spec_coll_masked.mask) ma = np.array([[True, True, True, True, True, True, True, False, False, True], [True, False, True, True, True, True, True, True, False, True], [True, True, False, True, True, True, True, False, True, True], [True, True, True, False, False, True, True, True, True, True], [True, True, True, True, True, True, True, True, False, True]]) assert all([x==y for x,y in zip(spec_coll_masked.mask.ravel(), ma.ravel())]) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_model_replace.py0000644000503700020070000001151300000000000025166 0ustar00rosteenSTSCI\science00000000000000import numpy as np import astropy.units as u from astropy.nddata import StdDevUncertainty from astropy.modeling import models from astropy.tests.helper import assert_quantity_allclose from ..spectra.spectrum1d import Spectrum1D, SpectralRegion from ..manipulation.model_replace import model_replace from ..fitting import fit_lines def test_from_knots(): wave_val = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) flux_val = np.array([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]) input_spectrum = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy) spline_knots = [3.5, 4.7, 6.8, 7.1] * u.AA # with default extrapolation, recovers the input flux result = model_replace(input_spectrum, None, model=spline_knots) assert result.uncertainty is None assert_quantity_allclose(result.flux, flux_val*u.mJy) assert_quantity_allclose(result.spectral_axis, input_spectrum.spectral_axis) # with zero fill extrapolation, fills with zeros. result = model_replace(input_spectrum, None, model=spline_knots, extrapolation_treatment='zero_fill') assert_quantity_allclose(result.flux[0], 0.*u.mJy) assert_quantity_allclose(result.flux[1], 0.*u.mJy) assert_quantity_allclose(result.flux[-1], 0.*u.mJy) assert_quantity_allclose(result.flux[-2], 0.*u.mJy) def test_with_uncert_from_knots(): wave_val = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) flux_val = np.array([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]) uncert_val = flux_val / 10. uncert = StdDevUncertainty(uncert_val * u.mJy) input_spectrum = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy, uncertainty=uncert) spline_knots = [3.5, 4.7, 6.8, 7.1] * u.AA # When replacing directly at spline knots, the spectral region is # redundant and must be omitted. result = model_replace(input_spectrum, None, model=spline_knots) assert isinstance(result.uncertainty, StdDevUncertainty) assert result.flux.unit == result.uncertainty.unit assert_quantity_allclose(result.uncertainty.quantity, uncert_val*u.mJy) # Now try with the non-default no-uncertainty mode: result should # have no uncertainty even when input has. result = model_replace(input_spectrum, None, model=spline_knots, interpolate_uncertainty=False) assert result.uncertainty is None def test_with_uncert_zerofill_from_knots(): wave_val = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) flux_val = np.array([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]) uncert_val = flux_val / 10. uncert = StdDevUncertainty(uncert_val * u.mJy) input_spectrum = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy, uncertainty=uncert) spline_knots = [3.5, 4.7, 6.8, 7.1] * u.AA result = model_replace(input_spectrum, None, model=spline_knots, extrapolation_treatment='zero_fill') assert isinstance(result.uncertainty, StdDevUncertainty) assert result.flux.unit == result.uncertainty.unit assert_quantity_allclose(result.uncertainty.quantity, uncert_val*u.mJy) def test_from_region(): wave_val = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) flux_val = np.array([2, 4, 6, 8, 10, 12, 14, 16, 18, 20]) uncert_val = flux_val / 10. uncert = StdDevUncertainty(uncert_val * u.mJy) input_spectrum = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy, uncertainty=uncert) region = SpectralRegion(3.5*u.AA, 7.1*u.AA) result = model_replace(input_spectrum, region, model=4) assert isinstance(result.uncertainty, StdDevUncertainty) assert result.flux.unit == result.uncertainty.unit assert_quantity_allclose(result.flux, flux_val*u.mJy) assert_quantity_allclose(result.spectral_axis, input_spectrum.spectral_axis) assert_quantity_allclose(result.uncertainty.quantity, uncert_val*u.mJy) def test_from_fitted_model(): wave_val = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]) flux_val = np.array([1, 1.1, 0.9, 4., 10., 5., 2., 1., 1.2, 1.1]) uncert_val = flux_val / 10. uncert = StdDevUncertainty(uncert_val * u.mJy) input_spectrum = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy, uncertainty=uncert) model = models.Gaussian1D(10, 5.6, 1.2) fitted_model = fit_lines(input_spectrum, model) region = SpectralRegion(3.5*u.AA, 7.1*u.AA) result = model_replace(input_spectrum, region, model=fitted_model) assert result.uncertainty is None assert result.flux.unit == input_spectrum.flux.unit expected_flux = np.array([1., 1.1, 0.9, 4.40801804, 9.58271877, 5.61238054, 0.88556096, 1., 1.2, 1.1]) * u.mJy assert_quantity_allclose(result.flux, expected_flux) assert_quantity_allclose(result.spectral_axis, input_spectrum.spectral_axis) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_region_extract.py0000644000503700020070000002662100000000000025416 0ustar00rosteenSTSCI\science00000000000000import numpy as np import pytest import astropy.units as u from astropy.nddata import StdDevUncertainty from astropy.tests.helper import quantity_allclose from ..spectra import Spectrum1D, SpectralRegion from ..manipulation import extract_region, extract_bounding_spectral_region, spectral_slab from ..manipulation.utils import linear_exciser from .spectral_examples import simulated_spectra from astropy.tests.helper import quantity_allclose FLUX_ARRAY = [1605.71612173, 1651.41650744, 2057.65798618, 2066.73502361, 1955.75832537, 1670.52711471, 1491.10034446, 1637.08084112, 1471.28982259, 1299.19484483, 1423.11195734, 1226.74494917, 1572.31888312, 1311.50503403, 1474.05051673, 1335.39944397, 1420.61880528, 1433.18623759, 1290.26966668, 1605.67341284, 1528.52281708, 1592.74392861, 1568.74162534, 1435.29407808, 1536.68040935, 1157.33825995, 1136.12679394, 999.92394692, 1038.61546167, 1011.60297294] def test_region_simple(simulated_spectra): np.random.seed(42) spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty region = SpectralRegion(0.6*u.um, 0.8*u.um) sub_spectrum = extract_region(spectrum, region) sub_spectrum_flux_expected = np.array(FLUX_ARRAY) assert quantity_allclose(sub_spectrum.flux.value, sub_spectrum_flux_expected) def test_slab_simple(simulated_spectra): np.random.seed(42) spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty sub_spectrum = spectral_slab(spectrum, 0.6*u.um, 0.8*u.um) sub_spectrum_flux_expected = np.array(FLUX_ARRAY) assert quantity_allclose(sub_spectrum.flux.value, sub_spectrum_flux_expected) def test_region_ghz(simulated_spectra): spectrum = Spectrum1D(flux=simulated_spectra.s1_um_mJy_e1.flux, spectral_axis=simulated_spectra.s1_um_mJy_e1.frequency) region = SpectralRegion(499654.09666667*u.GHz, 374740.5725*u.GHz) sub_spectrum = extract_region(spectrum, region) sub_spectrum_flux_expected = FLUX_ARRAY * u.mJy assert quantity_allclose(sub_spectrum.flux, sub_spectrum_flux_expected) def test_region_simple_check_ends(simulated_spectra): np.random.seed(42) spectrum = Spectrum1D(spectral_axis=np.linspace(1, 25, 25)*u.um, flux=np.random.random(25)*u.Jy) region = SpectralRegion(8*u.um, 15*u.um) sub_spectrum = extract_region(spectrum, region) assert sub_spectrum.spectral_axis.value[0] == 8 assert sub_spectrum.spectral_axis.value[-1] == 15 region = SpectralRegion(0*u.um, 15*u.um) sub_spectrum = extract_region(spectrum, region) assert sub_spectrum.spectral_axis.value[0] == 1 region = SpectralRegion(8*u.um, 30*u.um) sub_spectrum = extract_region(spectrum, region) assert sub_spectrum.spectral_axis.value[-1] == 25 def test_region_empty(simulated_spectra): np.random.seed(42) empty_spectrum = Spectrum1D(spectral_axis=[]*u.um, flux=[]*u.Jy) # Region past upper range of spectrum spectrum = Spectrum1D(spectral_axis=np.linspace(1, 25, 25)*u.um, flux=np.random.random(25)*u.Jy) region = SpectralRegion(28*u.um, 30*u.um) sub_spectrum = extract_region(spectrum, region) assert np.allclose(sub_spectrum.spectral_axis.value, empty_spectrum.spectral_axis.value) assert sub_spectrum.spectral_axis.unit == empty_spectrum.spectral_axis.unit assert np.allclose(sub_spectrum.flux.value, empty_spectrum.flux.value) assert sub_spectrum.flux.unit == empty_spectrum.flux.unit # Region below lower range of spectrum spectrum = Spectrum1D(spectral_axis=np.linspace(1, 25, 25)*u.um, flux=np.random.random(25)*u.Jy) region = SpectralRegion(0.1*u.um, 0.3*u.um) sub_spectrum = extract_region(spectrum, region) assert np.allclose(sub_spectrum.spectral_axis.value, empty_spectrum.spectral_axis.value) assert sub_spectrum.spectral_axis.unit == empty_spectrum.spectral_axis.unit assert np.allclose(sub_spectrum.flux.value, empty_spectrum.flux.value) assert sub_spectrum.flux.unit == empty_spectrum.flux.unit # Region below lower range of spectrum and upper range in the spectrum. spectrum = Spectrum1D(spectral_axis=np.linspace(1, 25, 25)*u.um, flux=2*np.linspace(1, 25, 25)*u.Jy) region = SpectralRegion(0.1*u.um, 3.3*u.um) sub_spectrum = extract_region(spectrum, region) assert np.allclose(sub_spectrum.spectral_axis.value, [1, 2, 3]) assert sub_spectrum.spectral_axis.unit == empty_spectrum.spectral_axis.unit assert np.allclose(sub_spectrum.flux.value, [2, 4, 6]) assert sub_spectrum.flux.unit == empty_spectrum.flux.unit # Region has lower and upper bound the same with pytest.raises(Exception) as e_info: region = SpectralRegion(3*u.um, 3*u.um) def test_region_descending(simulated_spectra): np.random.seed(42) spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty region = SpectralRegion(0.8*u.um, 0.6*u.um) sub_spectrum = extract_region(spectrum, region) sub_spectrum_flux_expected = np.array(FLUX_ARRAY) assert quantity_allclose(sub_spectrum.flux.value, sub_spectrum_flux_expected) def test_descending_spectral_axis(simulated_spectra): spectrum = simulated_spectra.s1_um_mJy_e1_desc sub_spectrum_flux_expected = np.array(FLUX_ARRAY[::-1]) region = SpectralRegion(0.8*u.um, 0.6*u.um) sub_spectrum = extract_region(spectrum, region) assert quantity_allclose(sub_spectrum.flux.value, sub_spectrum_flux_expected) region = SpectralRegion(0.6*u.um, 0.8*u.um) sub_spectrum = extract_region(spectrum, region) assert quantity_allclose(sub_spectrum.flux.value, sub_spectrum_flux_expected) def test_region_two_sub(simulated_spectra): np.random.seed(42) spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty region = SpectralRegion([(0.6*u.um, 0.8*u.um), (0.86*u.um, 0.89*u.um)]) sub_spectra = extract_region(spectrum, region) # Confirm the end points of the subspectra are correct assert quantity_allclose(sub_spectra[0].spectral_axis[[0, -1]], [0.6035353535353536, 0.793939393939394]*u.um) assert quantity_allclose(sub_spectra[1].spectral_axis[[0, -1]], [0.8661616161616162, 0.8858585858585859]*u.um) sub_spectrum_0_flux_expected = FLUX_ARRAY * u.mJy sub_spectrum_1_flux_expected = [1337.65312465, 1263.48914109, 1589.81797876, 1548.46068415]*u.mJy assert quantity_allclose(sub_spectra[0].flux, sub_spectrum_0_flux_expected) assert quantity_allclose(sub_spectra[1].flux, sub_spectrum_1_flux_expected) # also ensure this works if the multi-region is expressed as a single # Quantity region2 = SpectralRegion([(0.6, 0.8), (0.86, 0.89)]*u.um) sub_spectra2 = extract_region(spectrum, region2) assert quantity_allclose(sub_spectra[0].flux, sub_spectra2[0].flux) assert quantity_allclose(sub_spectra[1].flux, sub_spectra2[1].flux) # Check that the return_single_spectrum argument works properly concatenated_spectrum = extract_region(spectrum, region2, return_single_spectrum=True) assert concatenated_spectrum.flux.shape == (34,) assert np.all(concatenated_spectrum.flux[0:30] == sub_spectra2[0].flux) assert np.all(concatenated_spectrum.flux[30:34] == sub_spectra2[1].flux) def test_bounding_region(simulated_spectra): np.random.seed(42) spectrum = simulated_spectra.s1_um_mJy_e1 uncertainty = StdDevUncertainty(0.1*np.random.random(len(spectrum.flux))*u.mJy) spectrum.uncertainty = uncertainty region = SpectralRegion([(0.6*u.um, 0.8*u.um), (0.86*u.um, 0.89*u.um)]) extracted_spectrum = extract_bounding_spectral_region(spectrum, region) # Confirm the end points are correct assert quantity_allclose(extracted_spectrum.spectral_axis[[0, -1]], [0.6035353535353536, 0.8858585858585859]*u.um) flux_expected = [FLUX_ARRAY + [948.81864554, 1197.84859443, 1069.75268943, 1118.27269184, 1301.7695563, 1206.62880648, 1518.16549319, 1256.84259015, 1638.76791267, 1562.05642302, 1337.65312465, 1263.48914109, 1589.81797876, 1548.46068415]]*u.mJy assert quantity_allclose(extracted_spectrum.flux, flux_expected) # also ensure this works if the multi-region is expressed as a single # Quantity region2 = SpectralRegion([(0.6, 0.8), (0.86, 0.89)]*u.um) extracted_spectrum2 = extract_bounding_spectral_region(spectrum, region2) assert quantity_allclose(extracted_spectrum2.spectral_axis[[0, -1]], [0.6035353535353536, 0.8858585858585859]*u.um) assert quantity_allclose(extracted_spectrum2.flux, flux_expected) def test_extract_region_pixels(): spectrum = Spectrum1D(spectral_axis=np.linspace(4000, 10000, 25)*u.AA, flux=np.arange(25)*u.Jy) region = SpectralRegion(10*u.pixel, 12*u.pixel) extracted = extract_region(spectrum, region) assert quantity_allclose(extracted.flux, [10, 11]*u.Jy) def test_extract_region_mismatched_units(): spectrum = Spectrum1D(spectral_axis=np.arange(25)*u.nm, flux=np.arange(25)*u.Jy) region = SpectralRegion(100*u.AA, 119*u.AA) extracted = extract_region(spectrum, region) assert quantity_allclose(extracted.flux, [10, 11]*u.Jy) def test_linear_excise_invert_from_spectrum(): spec = Spectrum1D(flux=np.random.sample(100) * u.Jy, spectral_axis=np.arange(100) * u.AA) inc_regs = SpectralRegion(0 * u.AA, 50 * u.AA) + \ SpectralRegion(60 * u.AA, 80 * u.AA) + \ SpectralRegion(90 * u.AA, 110 * u.AA) exc_regs = inc_regs.invert_from_spectrum(spec) excised_spec = linear_exciser(spec, exc_regs) assert quantity_allclose(np.diff(excised_spec[50:60].flux), np.diff(excised_spec[51:61].flux)) assert quantity_allclose(np.diff(excised_spec[80:90].flux), np.diff(excised_spec[81:91].flux)) def test_extract_masked(): wl = [1, 2, 3, 4]*u.nm flux = np.arange(4)*u.Jy mask = [False, False, True, True] masked_spec = Spectrum1D(spectral_axis=wl, flux=flux, mask=mask) region = SpectralRegion(1.5 * u.nm, 3.5 * u.nm) extracted = extract_region(masked_spec, region) assert np.all(extracted.mask == [False, True]) assert np.all(extracted.flux.value == [1, 2]) def test_extract_multid_flux(): flux = np.random.sample((10, 49)) * 100 spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=flux * u.Jy) region = SpectralRegion(10 * u.nm, 20 * u.nm) extracted = extract_region(spec, region) assert extracted.shape == (10, 11) assert extracted[0,0].flux == spec[0,9].flux def test_slab_multid_flux(): flux = np.random.sample((10, 49)) * 100 spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=flux * u.Jy) extracted = spectral_slab(spec, 10 * u.nm, 20 * u.nm) assert extracted.shape == (10, 11) assert extracted[0,0].flux == spec[0,9].flux ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_regions.py0000644000503700020070000001427300000000000024047 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u import numpy as np import pytest from astropy.modeling.models import Gaussian1D from astropy.tests.helper import assert_quantity_allclose from specutils.fitting import find_lines_derivative from specutils.spectra import Spectrum1D, SpectralRegion def test_lower_upper(): # Spectral region with just one range (lower and upper bound) sr = SpectralRegion(0.45*u.um, 0.6*u.um) assert sr.lower == 0.45*u.um assert sr.upper == 0.6*u.um # Spectral region with just two ranges sr = SpectralRegion([(0.45*u.um, 0.6*u.um), (0.8*u.um, 0.9*u.um)]) assert sr.lower == 0.45*u.um assert sr.upper == 0.9*u.um # Spectral region with multiple ranges and not ordered sr = SpectralRegion([(0.3*u.um, 1.0*u.um), (0.45*u.um, 0.6*u.um), (0.04*u.um, 0.05*u.um), (0.8*u.um, 0.9*u.um)]) assert sr.lower == 0.04*u.um assert sr.upper == 1.0*u.um # Get lower bound of a single sub-region: assert sr[0].lower == 0.04*u.um assert sr[0].upper == 0.05*u.um @pytest.mark.parametrize( ('center', 'width', 'lower', 'upper'), [(6563 * u.AA, 10 * u.AA, 6558.0 * u.AA, 6568.0 * u.AA), (1 * u.GHz, 0.1 * u.GHz, 1.05 * u.GHz, 0.95 * u.GHz), (0.5 * u.pix, 1 * u.pix, 0 * u.pix, 1 * u.pix)]) def test_from_center(center, width, lower, upper): # Spectral region from center with width sr = SpectralRegion.from_center(center=center, width=width) assert_quantity_allclose(sr.lower, lower) assert_quantity_allclose(sr.upper, upper) @pytest.mark.parametrize( ('center', 'width'), [(6563 * u.AA, -10 * u.AA), (6563 * u.AA, 0 * u.AA), (1 * u.GHz, -0.1 * u.GHz)]) def test_from_center_error(center, width): with pytest.raises(ValueError): SpectralRegion.from_center(center=center, width=width) def test_adding_spectral_regions(): # Combine two Spectral regions into one: sr = (SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um)) assert set(sr.subregions) == set([(0.45*u.um, 0.6*u.um), (0.8*u.um, 0.9*u.um)]) # In-place adding spectral regions: sr1 = SpectralRegion(0.45*u.um, 0.6*u.um) sr2 = SpectralRegion(0.8*u.um, 0.9*u.um) sr1 += sr2 assert set(sr1.subregions) == set([(0.45*u.um, 0.6*u.um), (0.8*u.um, 0.9*u.um)]) def test_getitem(): sr = SpectralRegion([(0.8*u.um, 0.9*u.um), (0.3*u.um, 1.0*u.um), (0.45*u.um, 0.6*u.um), (0.04*u.um, 0.05*u.um)]) assert sr[0].subregions == [(0.04*u.um, 0.05*u.um)] assert sr[1].subregions == [(0.3*u.um, 1.0*u.um)] assert sr[2].subregions == [(0.45*u.um, 0.6*u.um)] assert sr[3].subregions == [(0.8*u.um, 0.9*u.um)] assert sr[-1].subregions == [(0.8*u.um, 0.9*u.um)] def test_bounds(): # Single subregion sr = SpectralRegion(0.45*u.um, 0.6*u.um) assert sr.bounds == (0.45*u.um, 0.6*u.um) # Multiple subregions sr = SpectralRegion([(0.8*u.um, 0.9*u.um), (0.3*u.um, 1.0*u.um), (0.45*u.um, 0.6*u.um), (0.04*u.um, 0.05*u.um)]) assert sr.bounds == (0.04*u.um, 1.0*u.um) def test_delitem(): # Single subregion sr = SpectralRegion(0.45*u.um, 0.6*u.um) del sr[0] assert sr.subregions == [] # Multiple sub-regions sr = SpectralRegion([(0.8*u.um, 0.9*u.um), (0.3*u.um, 1.0*u.um), (0.45*u.um, 0.6*u.um), (0.04*u.um, 0.05*u.um)]) del sr[1] assert sr[0].subregions == [(0.04*u.um, 0.05*u.um)] assert sr[1].subregions == [(0.45*u.um, 0.6*u.um)] assert sr[2].subregions == [(0.8*u.um, 0.9*u.um)] def test_iterate(): # Create the Spectral region subregions = [(0.8*u.um, 0.9*u.um), (0.3*u.um, 1.0*u.um), (0.45*u.um, 0.6*u.um), (0.04*u.um, 0.05*u.um)] sr = SpectralRegion(subregions) # For testing, sort our subregion list. subregions.sort(key=lambda k: k[0]) for ii, s in enumerate(sr): assert s.subregions[0] == subregions[ii] def test_slicing(): sr = (SpectralRegion(0.15*u.um, 0.2*u.um) + SpectralRegion(0.3*u.um, 0.4*u.um) + SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) + SpectralRegion(1.0*u.um, 1.2*u.um) + SpectralRegion(1.3*u.um, 1.5*u.um)) subsr = sr[3:5] assert subsr[0].subregions == [(0.8*u.um, 0.9*u.um)] assert subsr[1].subregions == [(1.0*u.um, 1.2*u.um)] def test_invert(): sr = (SpectralRegion(0.15*u.um, 0.2*u.um) + SpectralRegion(0.3*u.um, 0.4*u.um) + SpectralRegion(0.45*u.um, 0.6*u.um) + SpectralRegion(0.8*u.um, 0.9*u.um) + SpectralRegion(1.0*u.um, 1.2*u.um) + SpectralRegion(1.3*u.um, 1.5*u.um)) sr_inverted_expected = [(0.05*u.um, 0.15*u.um), (0.2*u.um, 0.3*u.um), (0.4*u.um, 0.45*u.um), (0.6*u.um, 0.8*u.um), (0.9*u.um, 1.0*u.um), (1.2*u.um, 1.3*u.um), (1.5*u.um, 3.0*u.um)] # Invert from range. sr_inverted = sr.invert(0.05*u.um, 3*u.um) for ii, expected in enumerate(sr_inverted_expected): assert sr_inverted.subregions[ii] == sr_inverted_expected[ii] # Invert from spectrum. spectrum = Spectrum1D(spectral_axis=np.linspace(0.05, 3, 20)*u.um, flux=np.random.random(20)*u.Jy) sr_inverted = sr.invert_from_spectrum(spectrum) for ii, expected in enumerate(sr_inverted_expected): assert sr_inverted.subregions[ii] == sr_inverted_expected[ii] def test_from_list_list(): g1 = Gaussian1D(1, 4.6, 0.2) g2 = Gaussian1D(2.5, 5.5, 0.1) g3 = Gaussian1D(-1.7, 8.2, 0.1) x = np.linspace(0, 10, 200) y = g1(x) + g2(x) + g3(x) spectrum = Spectrum1D(flux=y * u.Jy, spectral_axis=x * u.um) lines = find_lines_derivative(spectrum, flux_threshold=0.01) spec_reg = SpectralRegion.from_line_list(lines) expected = [(4.072864321608041 * u.um, 5.072864321608041 * u.um), (4.977386934673367 * u.um, 5.977386934673367 * u.um), (7.690954773869347 * u.um, 8.690954773869347 * u.um)] for i, reg in enumerate(expected): assert_quantity_allclose(reg, (spec_reg[i].lower, spec_reg[i].upper)) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_resample.py0000644000503700020070000002000500000000000024177 0ustar00rosteenSTSCI\science00000000000000import numpy as np import pytest import astropy.units as u from astropy.nddata import InverseVariance, StdDevUncertainty from astropy.tests.helper import assert_quantity_allclose from ..spectra.spectrum1d import Spectrum1D from ..tests.spectral_examples import simulated_spectra from ..manipulation.resample import FluxConservingResampler, LinearInterpolatedResampler, SplineInterpolatedResampler @pytest.fixture(params=[FluxConservingResampler, LinearInterpolatedResampler, SplineInterpolatedResampler]) def all_resamplers(request): return request.param # todo: Should add tests for different weighting options once those # are more solidified. def test_same_grid_fluxconserving(simulated_spectra): """ Test that feeding in the original dispersion axis returns the same flux after resampling. """ input_spectra = simulated_spectra.s1_um_mJy_e1 input_spectra.uncertainty = InverseVariance([0.5]*len(simulated_spectra.s1_um_mJy_e1.flux)) inst = FluxConservingResampler() results = inst(input_spectra, simulated_spectra.s1_um_mJy_e1.spectral_axis) assert np.allclose(np.array(simulated_spectra.s1_um_mJy_e1.flux), np.array(results.flux)) assert np.allclose(input_spectra.uncertainty.array, results.uncertainty.array) def test_expanded_grid_fluxconserving(): """ New dispersion axis has more bins then input dispersion axis """ flux_val = np.array([1, 3, 7, 6, 20]) wave_val = np.array([2, 4, 12, 16, 20]) input_spectra = Spectrum1D(flux=flux_val * u.mJy, spectral_axis=wave_val * u.nm) resamp_grid = [1, 5, 9, 13, 14, 17, 21, 22, 23] * u.nm inst = FluxConservingResampler() results = inst(input_spectra, resamp_grid) assert_quantity_allclose(results.flux, np.array([np.nan, 3., 6.13043478, 7., 6.33333333, 10., 20., np.nan, np.nan])*u.mJy) def test_stddev_uncert_propogation(): """ Check uncertainty propagation if input uncertainty is InverseVariance """ flux_val = np.array([1, 3, 7, 6, 20]) wave_val = np.array([20, 30, 40, 50, 60]) input_spectra = Spectrum1D(flux=flux_val * u.mJy, spectral_axis=wave_val * u.AA, uncertainty=StdDevUncertainty([0.1, 0.25, 0.1, 0.25, 0.1])) inst = FluxConservingResampler() results = inst(input_spectra, [25, 35, 50, 55]*u.AA) assert np.allclose(results.uncertainty.array, np.array([27.5862069, 38.23529412, 17.46724891, 27.5862069])) def delta_wl(saxis): """ A helper function that computes the "size" of a bin given the bin centers for testing the flux conservation """ l_widths = (saxis[1] - saxis[0]) r_widths = (saxis[-1] - saxis[-2]) # if three bins 0,1,2; want width of central bin. width is avg of 1/2 minus # average of 0/1: (i1 + i2)/2 - (i0 + i1)/2 = (i2 - i0)/2 mid_widths = (saxis[2:] - saxis[:-2]) / 2 return np.concatenate([[l_widths.value], mid_widths.value, [r_widths.value]])*saxis.unit @pytest.mark.parametrize("specflux,specwavebins,outwavebins", [ ([1, 3, 2], [4000, 5000, 6000, 7000], np.linspace(4000, 7000, 5)), ([1, 3, 2, 1], np.linspace(4000, 7000, 5), [4000, 5000, 6000, 7000]) ]) def test_flux_conservation(specflux, specwavebins, outwavebins): """ A few simple cases to programatically ensure flux is conserved in the resampling algorithm """ specwavebins = specwavebins*u.AA outwavebins = outwavebins*u.AA specflux = specflux*u.AB specwave = (specwavebins[:-1] + specwavebins[1:])/2 outwave = (outwavebins[:-1] + outwavebins[1:])/2 in_spec = Spectrum1D(spectral_axis=specwave, flux=specflux) out_spec = FluxConservingResampler()(in_spec, outwave) in_dwl = delta_wl(in_spec.spectral_axis) out_dwl = delta_wl(out_spec.spectral_axis) flux_in = np.sum(in_spec.flux * in_dwl) flux_out = np.sum(out_spec.flux * out_dwl) assert_quantity_allclose(flux_in, flux_out) def test_multi_dim_spectrum1D(): """ Test for input spectrum1Ds that have a two dimensional flux and uncertainty. """ flux_2d = np.array([np.ones(10) * 5, np.ones(10) * 6, np.ones(10) * 7]) input_spectra = Spectrum1D(spectral_axis=np.arange(5000, 5010) * u.AA, flux=flux_2d * u.Jy, uncertainty=StdDevUncertainty(flux_2d / 10)) inst = FluxConservingResampler() results = inst(input_spectra, [5001, 5003, 5005, 5007] * u.AA) assert_quantity_allclose(results.flux, np.array([[5., 5., 5., 5.], [6., 6., 6., 6.], [7., 7., 7., 7.]]) * u.Jy) assert np.allclose(results.uncertainty.array, np.array([[4., 4., 4., 4.], [2.77777778, 2.77777778, 2.77777778, 2.77777778], [2.04081633, 2.04081633, 2.04081633, 2.04081633]] )) def test_expanded_grid_interp_linear(): """ New dispersion axis has more bins then input dispersion axis """ flux_val = np.array([1, 3, 7, 6, 20]) wave_val = np.array([2, 4, 12, 16, 20]) input_spectra = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy) resamp_grid = [1, 5, 9, 13, 14, 17, 21, 22, 23] * u.AA inst = LinearInterpolatedResampler() results = inst(input_spectra, resamp_grid) assert_quantity_allclose(results.flux, np.array([np.nan, 3.5, 5.5, 6.75, 6.5, 9.5, np.nan, np.nan, np.nan])*u.mJy) def test_expanded_grid_interp_spline(): """ New dispersion axis has more bins then input dispersion axis """ flux_val = np.array([1, 3, 7, 6, 20]) wave_val = np.array([2, 4, 12, 16, 20]) input_spectra = Spectrum1D(spectral_axis=wave_val * u.AA, flux=flux_val * u.mJy) resamp_grid = [1, 5, 9, 13, 14, 17, 21, 22, 23] * u.AA inst = SplineInterpolatedResampler() results = inst(input_spectra, resamp_grid) assert_quantity_allclose(results.flux, np.array([np.nan, 3.98808594, 6.94042969, 6.45869141, 5.89921875, 7.29736328, np.nan, np.nan, np.nan])*u.mJy) @pytest.mark.parametrize("edgetype,lastvalue", [("nan_fill", np.nan), ("zero_fill", 0)]) def test_resample_edges(edgetype, lastvalue, all_resamplers): input_spectrum = Spectrum1D(spectral_axis=[2, 4, 12, 16, 20] * u.micron, flux=[1, 3, 7, 6, 20] * u.mJy) resamp_grid = [1, 3, 7, 6, 20, 100] * u.micron resampler = all_resamplers(edgetype) resampled = resampler(input_spectrum, resamp_grid) if lastvalue is np.nan: assert np.isnan(resampled.flux[-1]) else: assert resampled.flux[-1] == lastvalue def test_resample_different_units(all_resamplers): input_spectrum = Spectrum1D(spectral_axis=[5000, 6000 ,7000] * u.AA, flux=[1, 2, 3] * u.mJy) resampler = all_resamplers("nan_fill") if all_resamplers == FluxConservingResampler: pytest.xfail('flux conserving resampler cannot yet handle differing units') resamp_grid = [5500, 6500]*u.nm resampled = resampler(input_spectrum, resamp_grid) assert np.all(np.isnan(resampled.flux)) resamp_grid = [550, 650]*u.nm resampled = resampler(input_spectrum, resamp_grid) assert not np.any(np.isnan(resampled.flux)) def test_resample_uncs(all_resamplers): sdunc = StdDevUncertainty([0.1,0.2, 0.3]*u.mJy) input_spectrum = Spectrum1D(spectral_axis=[5000, 6000 ,7000] * u.AA, flux=[1, 2, 3] * u.mJy, uncertainty=sdunc) resampled = all_resamplers()(input_spectrum, [5500, 6500]*u.AA) if all_resamplers == FluxConservingResampler: # special-cased because it switches the unc to inverse variance by construction assert resampled.uncertainty.unit == sdunc.unit**-2 else: assert resampled.uncertainty.unit == sdunc.unit assert resampled.uncertainty.uncertainty_type == sdunc.uncertainty_type ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_slicing.py0000644000503700020070000000761400000000000024032 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u import astropy.wcs as fitswcs from astropy.tests.helper import quantity_allclose import numpy as np from numpy.testing import assert_allclose from ..spectra.spectrum1d import Spectrum1D def test_spectral_axes(): spec1 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49) * 100 * u.Jy) sliced_spec1 = spec1[0:2] assert isinstance(sliced_spec1, Spectrum1D) assert_allclose(sliced_spec1.wcs.pixel_to_world(0), spec1.wcs.pixel_to_world(0)) flux2 = np.random.sample((10, 49)) * 100 spec2 = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=flux2 * u.Jy) sliced_spec2 = spec2[0] assert isinstance(sliced_spec2, Spectrum1D) assert_allclose(sliced_spec2.wcs.pixel_to_world(np.arange(10)), spec2.wcs.pixel_to_world(np.arange(10))) assert sliced_spec2.flux.shape[0] == 49 def test_slicing(): # Create the initial spectrum spec = Spectrum1D(spectral_axis=np.arange(10) * u.um, flux=2*np.arange(10)*u.Jy) # Slice it. sub_spec = spec[4:8] # Check basic spectral_axis property assert sub_spec.spectral_axis.unit == u.um assert np.allclose(sub_spec.spectral_axis.value, np.array([4, 5, 6, 7])) assert np.allclose(sub_spec.flux.value, np.array([8, 10, 12, 14])) assert sub_spec.wavelength.unit == u.AA assert np.allclose(sub_spec.wavelength.value, np.array([40000., 50000., 60000., 70000.])) assert sub_spec.frequency.unit == u.GHz assert np.allclose(sub_spec.frequency.value, np.array([74948.1145, 59958.4916, 49965.40966667, 42827.494])) # Do it a second time to confirm the original was not modified. sub_spec2 = spec[1:5] # Check basic spectral_axis property assert sub_spec2.spectral_axis.unit == u.um assert np.allclose(sub_spec2.spectral_axis.value, np.array([1, 2, 3, 4])) assert np.allclose(sub_spec2.flux.value, np.array([2, 4, 6, 8])) assert sub_spec2.wavelength.unit == u.AA assert np.allclose(sub_spec2.wavelength.value, np.array([10000., 20000., 30000., 40000.])) assert sub_spec2.frequency.unit == u.GHz assert np.allclose(sub_spec2.frequency.value, np.array([299792.458, 149896.229, 99930.81933333, 74948.1145])) # Going to repeat these to make sure the original spectrum was # not modified in some way assert spec.spectral_axis.unit == u.um assert np.allclose(spec.spectral_axis.value, np.array(np.arange(10))) assert np.allclose(spec.flux.value, np.array(2*np.arange(10))) assert spec.wavelength.unit == u.AA assert np.allclose(spec.wavelength.value, np.array(10000*np.arange(10))) assert sub_spec.frequency.unit == u.GHz assert np.allclose(sub_spec.frequency.value, np.array([74948.1145, 59958.4916, 49965.40966667, 42827.494])) def test_slicing_with_fits(): my_wcs = fitswcs.WCS(header={'CDELT1': 1, 'CRVAL1': 6562.8, 'CUNIT1': 'Angstrom', 'CTYPE1': 'WAVE', 'RESTFRQ': 1400000000, 'CRPIX1': 25}) spec = Spectrum1D(flux=[5, 6, 7, 8, 9, 10] * u.Jy, wcs=my_wcs) spec_slice = spec[1:5] assert isinstance(spec_slice, Spectrum1D) assert spec_slice.flux.size == 4 assert quantity_allclose(spec_slice.wcs.pixel_to_world([0, 1, 2, 3]), spec.wcs.pixel_to_world([1, 2, 3, 4])) def test_slicing_multidim(): spec = Spectrum1D(spectral_axis=np.arange(10) * u.AA, flux=np.random.sample((5, 10)) * u.Jy, mask=np.random.sample((5, 10)) > 0.5) spec1 = spec[0] spec2 = spec[1:3] assert spec1.flux[0] == spec.flux[0][0] assert quantity_allclose(spec1.spectral_axis, spec.spectral_axis) assert spec.flux.shape[1:] == spec1.flux.shape assert quantity_allclose(spec2.flux, spec.flux[1:3]) assert quantity_allclose(spec2.spectral_axis, spec.spectral_axis) assert spec1.mask[0] == spec.mask[0][0] assert spec1.mask.shape == (10,) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_smoothing.py0000644000503700020070000002206300000000000024404 0ustar00rosteenSTSCI\science00000000000000import numpy as np import pytest from astropy import convolution from scipy.signal import medfilt import astropy.units as u from astropy.nddata import StdDevUncertainty, VarianceUncertainty, InverseVariance from ..spectra.spectrum1d import Spectrum1D from ..tests.spectral_examples import simulated_spectra from ..manipulation.smoothing import (convolution_smooth, box_smooth, gaussian_smooth, trapezoid_smooth, median_smooth) def compare_flux(flux_smooth1, flux_smooth2, flux_original, rtol=0.01): """ There are two things to compare for each set of smoothing: 1. Compare the smoothed flux from the astropy machinery vs the smoothed flux from specutils. This is done by comparing flux_smooth1 and flux_smooth2. 2. Next we want to compare the smoothed flux to the original flux. This is a little more difficult as smoothing will make a difference for median filter, but less so for convolution based smoothing if the kernel is normalized (area under the kernel = 1). In this second case the rtol (relative tolerance) is used judiciously. """ # Compare, element by element, the two smoothed fluxes. # Workaround for astropy dev (4.2) which seems to return quantities in # convolutions if isinstance(flux_smooth1, u.Quantity): flux_smooth1 = flux_smooth1.value if isinstance(flux_smooth2, u.Quantity): flux_smooth2 = flux_smooth2.value assert np.allclose(flux_smooth1, flux_smooth2) # Compare the total spectral flux of the smoothed to the original. assert np.allclose(sum(flux_smooth1), sum(flux_original), rtol=rtol) def test_smooth_custom_kernel(simulated_spectra): """ Test CustomKernel smoothing with correct parmaeters. """ # Create the original spectrum spec1 = simulated_spectra.s1_um_mJy_e1 flux_original = spec1.flux # Create a custom kernel (some weird asymmetric-ness) numpy_kernel = np.array([0.5, 1, 2, 0.5, 0.2]) numpy_kernel = numpy_kernel / np.sum(numpy_kernel) custom_kernel = convolution.CustomKernel(numpy_kernel) flux_smoothed_astropy = convolution.convolve(flux_original, custom_kernel) # Calculate the custom smoothed spec1_smoothed = convolution_smooth(spec1, custom_kernel) compare_flux(spec1_smoothed.flux.value, flux_smoothed_astropy, flux_original.value) @pytest.mark.parametrize("width", [1, 2.3]) def test_smooth_box_good(simulated_spectra, width): """ Test Box1DKernel smoothing with correct parmaeters. Width values need to be a number greater than 0. """ # Create the original spectrum spec1 = simulated_spectra.s1_um_mJy_e1 flux_original = spec1.flux # Calculate the smoothed flux using Astropy box_kernel = convolution.Box1DKernel(width) flux_smoothed_astropy = convolution.convolve(flux_original, box_kernel) # Calculate the box smoothed spec1_smoothed = box_smooth(spec1, width) compare_flux(spec1_smoothed.flux.value, flux_smoothed_astropy, flux_original.value) # Check the input and output units assert spec1.wavelength.unit == spec1_smoothed.wavelength.unit assert spec1.flux.unit == spec1_smoothed.flux.unit assert len(spec1.meta) == len(spec1_smoothed.meta) @pytest.mark.parametrize("width", [-1, 0, 'a']) def test_smooth_box_bad(simulated_spectra, width): """ Test Box1DKernel smoothing with incorrect parmaeters. Width values need to be a number greater than 0. """ # Create the spectrum spec1 = simulated_spectra.s1_um_mJy_e1 # Test bad input parameters with pytest.raises(ValueError): box_smooth(spec1, width) @pytest.mark.parametrize("stddev", [1, 2.3]) def test_smooth_gaussian_good(simulated_spectra, stddev): """ Test Gaussian1DKernel smoothing with correct parmaeters. Standard deviation values need to be a number greater than 0. """ # Create the spectrum spec1 = simulated_spectra.s1_um_mJy_e1 flux_original = spec1.flux # Calculate the smoothed flux using Astropy gaussian_kernel = convolution.Gaussian1DKernel(stddev) flux_smoothed_astropy = convolution.convolve(flux_original, gaussian_kernel) # Test gaussian smoothing spec1_smoothed = gaussian_smooth(spec1, stddev) compare_flux(spec1_smoothed.flux.value, flux_smoothed_astropy, flux_original.value, rtol=0.02) # Check the input and output units assert spec1.wavelength.unit == spec1_smoothed.wavelength.unit assert spec1.flux.unit == spec1_smoothed.flux.unit assert len(spec1.meta) == len(spec1_smoothed.meta) @pytest.mark.parametrize("stddev", [-1, 0, 'a']) def test_smooth_gaussian_bad(simulated_spectra, stddev): """ Test MexicanHat1DKernel smoothing with incorrect parmaeters. Standard deviation values need to be a number greater than 0. """ # Create the spectrum spec1 = simulated_spectra.s1_um_mJy_e1 # Test bad input paramters with pytest.raises(ValueError): gaussian_smooth(spec1, stddev) @pytest.mark.parametrize("stddev", [1, 2.3]) def test_smooth_trapezoid_good(simulated_spectra, stddev): """ Test Trapezoid1DKernel smoothing with correct parmaeters. Standard deviation values need to be a number greater than 0. """ # Create the spectrum spec1 = simulated_spectra.s1_um_mJy_e1 flux_original = spec1.flux # Create the flux_smoothed which is what we want to compare to trapezoid_kernel = convolution.Trapezoid1DKernel(stddev) flux_smoothed_astropy = convolution.convolve(flux_original, trapezoid_kernel) # Test trapezoid smoothing spec1_smoothed = trapezoid_smooth(spec1, stddev) compare_flux(spec1_smoothed.flux.value, flux_smoothed_astropy, flux_original.value) # Check the input and output units assert spec1.wavelength.unit == spec1_smoothed.wavelength.unit assert spec1.flux.unit == spec1_smoothed.flux.unit assert len(spec1.meta) == len(spec1_smoothed.meta) @pytest.mark.parametrize("stddev", [-1, 0, 'a']) def test_smooth_trapezoid_bad(simulated_spectra, stddev): """ Test Trapezoid1DKernel smoothing with incorrect parmaeters. Standard deviation values need to be a number greater than 0. """ # Create the spectrum spec1 = simulated_spectra.s1_um_mJy_e1 # Test bad parameters with pytest.raises(ValueError): trapezoid_smooth(spec1, stddev) @pytest.mark.parametrize("width", [1, 3, 9]) def test_smooth_median_good(simulated_spectra, width): """ Test Median smoothing with correct parmaeters. Width values need to be a number greater than 0. """ # Create the spectrum spec1 = simulated_spectra.s1_um_mJy_e1 flux_original = spec1.flux # Create the flux_smoothed which is what we want to compare to flux_smoothed_astropy = medfilt(flux_original, width) # Test median smoothing spec1_smoothed = median_smooth(spec1, width) compare_flux(spec1_smoothed.flux.value, flux_smoothed_astropy, flux_original.value, rtol=0.15) # Check the input and output units assert spec1.wavelength.unit == spec1_smoothed.wavelength.unit assert spec1.flux.unit == spec1_smoothed.flux.unit assert len(spec1.meta) == len(spec1_smoothed.meta) @pytest.mark.parametrize("width", [-1, 0, 'a']) def test_smooth_median_bad(simulated_spectra, width): """ Test Median smoothing with incorrect parmaeters. Width values need to be a number greater than 0. """ # Create the spectrum spec1 = simulated_spectra.s1_um_mJy_e1 # Test bad parameters with pytest.raises(ValueError): median_smooth(spec1, width) def test_smooth_custom_kernel_uncertainty(simulated_spectra): """ Test CustomKernel smoothing with correct parmaeters. """ np.random.seed(42) # Create a custom kernel (some weird asymmetric-ness) numpy_kernel = np.array([0.5, 1, 2, 0.5, 0.2]) numpy_kernel = numpy_kernel / np.sum(numpy_kernel) custom_kernel = convolution.CustomKernel(numpy_kernel) spec1 = simulated_spectra.s1_um_mJy_e1 uncertainty = np.abs(np.random.random(spec1.flux.shape)) # Test StdDevUncertainty spec1.uncertainty = StdDevUncertainty(uncertainty) spec1_smoothed = convolution_smooth(spec1, custom_kernel) tt = convolution.convolve(1/(spec1.uncertainty.array**2), custom_kernel) uncertainty_smoothed_astropy = 1/np.sqrt(tt) assert np.allclose(spec1_smoothed.uncertainty.array, uncertainty_smoothed_astropy) # Test VarianceUncertainty spec1.uncertainty = VarianceUncertainty(uncertainty) spec1_smoothed = convolution_smooth(spec1, custom_kernel) uncertainty_smoothed_astropy = 1/convolution.convolve(1/spec1.uncertainty.array, custom_kernel) assert np.allclose(spec1_smoothed.uncertainty.array, uncertainty_smoothed_astropy) # Test InverseVariance spec1.uncertainty = InverseVariance(uncertainty) spec1_smoothed = convolution_smooth(spec1, custom_kernel) uncertainty_smoothed_astropy = convolution.convolve(spec1.uncertainty.array, custom_kernel) assert np.allclose(spec1_smoothed.uncertainty.array, uncertainty_smoothed_astropy) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_spectral_axis.py0000644000503700020070000001441200000000000025235 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u import numpy as np import pytest from astropy import time from astropy.constants import c from astropy.coordinates import (SkyCoord, EarthLocation, ICRS, GCRS, Galactic, CartesianDifferential, SpectralCoord, get_body_barycentric_posvel, FK5, CartesianRepresentation) from ..spectra.spectral_axis import SpectralAxis from ..spectra.spectrum1d import Spectrum1D from astropy.tests.helper import assert_quantity_allclose def get_greenwich_earthlocation(): """ A helper function to get an EarthLocation for greenwich (without trying to do a download) """ site_registry = EarthLocation._get_site_registry(force_builtin=True) return site_registry.get('greenwich') def test_create_spectral_axis(): site = get_greenwich_earthlocation() obstime = time.Time('2018-12-13 9:00') observer_gcrs = site.get_gcrs(obstime) wavelengths = np.linspace(500, 2500, 1001) * u.AA spectral_axis = SpectralAxis(wavelengths, observer=observer_gcrs) assert isinstance(spectral_axis, u.Quantity) assert len(spectral_axis) == 1001 assert spectral_axis.bin_edges[0] == 499*u.AA def test_create_with_bin_edges(): wavelengths = np.linspace(500, 2500, 1001) * u.AA spectral_axis = SpectralAxis(wavelengths, bin_specification="edges") assert np.all(spectral_axis.bin_edges == wavelengths) assert spectral_axis[0] == 501*u.AA # Test irregular bin edges wavelengths = np.array([500, 510, 550, 560, 590])*u.AA spectral_axis = SpectralAxis(wavelengths, bin_specification="edges") assert np.all(spectral_axis.bin_edges == wavelengths) assert np.all(spectral_axis == [505., 530., 555., 575.]*u.AA) # GENERAL TESTS # We first run through a series of cases to test different ways of initializing # the observer and target for SpectralAxis, including for example frames, # SkyCoords, and making sure that SpectralAxis is not sensitive to the actual # frame or representation class. # Local Standard of Rest LSRD = Galactic(u=0 * u.km, v=0 * u.km, w=0 * u.km, U=9 * u.km / u.s, V=12 * u.km / u.s, W=7 * u.km / u.s, representation_type='cartesian', differential_type='cartesian') LSRD_EQUIV = [ LSRD, SkyCoord(LSRD), # as a SkyCoord LSRD.transform_to(ICRS()), # different frame LSRD.transform_to(ICRS()).transform_to(Galactic()) # different representation ] @pytest.fixture(params=[None] + LSRD_EQUIV) def observer(request): return request.param # Target located in direction of motion of LSRD with no velocities LSRD_DIR_STATIONARY = Galactic(u=9 * u.km, v=12 * u.km, w=7 * u.km, representation_type='cartesian') LSRD_DIR_STATIONARY_EQUIV = [ LSRD_DIR_STATIONARY, SkyCoord(LSRD_DIR_STATIONARY), # as a SkyCoord LSRD_DIR_STATIONARY.transform_to(FK5()), # different frame LSRD_DIR_STATIONARY.transform_to(ICRS()).transform_to(Galactic()) # different representation ] @pytest.fixture(params=[None] + LSRD_DIR_STATIONARY_EQUIV) def target(request): return request.param def test_create_from_spectral_coord(observer, target): """ Checks that parameters are correctly copied from the SpectralCoord object to the SpectralAxis object """ spec_coord = SpectralCoord([100, 200, 300] * u.nm, observer=observer, target=target, doppler_convention = 'optical', doppler_rest = 6000*u.AA) spec_axis = SpectralAxis(spec_coord) assert spec_coord.observer == spec_axis.observer assert spec_coord.target == spec_axis.target assert spec_coord.radial_velocity == spec_axis.radial_velocity assert spec_coord.doppler_convention == spec_axis.doppler_convention assert spec_coord.doppler_rest == spec_axis.doppler_rest def test_create_from_spectral_axis(observer, target): """ Checks that parameters are correctly copied to the new SpectralAxis object """ spec_axis1 = SpectralAxis([100, 200, 300] * u.nm, observer=observer, target=target, doppler_convention = 'optical', doppler_rest = 6000*u.AA) spec_axis2 = SpectralAxis(spec_axis1) assert spec_axis1.observer == spec_axis2.observer assert spec_axis1.target == spec_axis2.target assert spec_axis1.radial_velocity == spec_axis2.radial_velocity assert spec_axis1.doppler_convention == spec_axis2.doppler_convention assert spec_axis1.doppler_rest == spec_axis2.doppler_rest def test_change_radial_velocity(): wave = np.linspace(100, 200, 100) * u.AA flux = np.ones(100) * u.one spec = Spectrum1D(spectral_axis=wave, flux=flux, radial_velocity=0 * u.km / u.s) assert spec.radial_velocity == 0 * u.km/u.s spec.radial_velocity = 1 * u.km / u.s assert spec.radial_velocity == 1 * u.km/u.s spec = Spectrum1D(spectral_axis=wave, flux=flux, radial_velocity=10 * u.km / u.s) assert spec.radial_velocity == 10 * u.km / u.s spec.radial_velocity = 5 * u.km / u.s assert spec.radial_velocity == 5 * u.km / u.s def test_change_redshift(): wave = np.linspace(100, 200, 100) * u.AA flux = np.ones(100) * u.one spec = Spectrum1D(spectral_axis=wave, flux=flux, redshift=0) assert spec.redshift.unit.physical_type == 'dimensionless' assert_quantity_allclose(spec.redshift, u.Quantity(0)) assert type(spec.spectral_axis) == SpectralAxis spec.redshift = 0.1 assert spec.redshift.unit.physical_type == 'dimensionless' assert_quantity_allclose(spec.redshift, u.Quantity(0.1)) assert type(spec.spectral_axis) == SpectralAxis spec = Spectrum1D(spectral_axis=wave, flux=flux, redshift=0.2) assert spec.redshift.unit.physical_type == 'dimensionless' assert_quantity_allclose(spec.redshift, u.Quantity(0.2)) assert type(spec.spectral_axis) == SpectralAxis spec.redshift = 0.4 assert spec.redshift.unit.physical_type == 'dimensionless' assert_quantity_allclose(spec.redshift, u.Quantity(0.4)) assert type(spec.spectral_axis) == SpectralAxis ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_spectrum1d.py0000644000503700020070000004427700000000000024477 0ustar00rosteenSTSCI\science00000000000000import astropy.units as u import astropy.wcs as fitswcs import gwcs import numpy as np import pytest from astropy.nddata import StdDevUncertainty from astropy.coordinates import SpectralCoord from astropy.wcs import WCS from .conftest import remote_access from ..spectra import Spectrum1D def test_empty_spectrum(): spec = Spectrum1D(spectral_axis=[]*u.um, flux=[]*u.Jy) assert isinstance(spec.spectral_axis, SpectralCoord) assert spec.spectral_axis.size == 0 assert isinstance(spec.flux, u.Quantity) assert spec.flux.size == 0 def test_create_from_arrays(): spec = Spectrum1D(spectral_axis=np.arange(50) * u.AA, flux=np.random.randn(50) * u.Jy) assert isinstance(spec.spectral_axis, SpectralCoord) assert spec.spectral_axis.size == 50 assert isinstance(spec.flux, u.Quantity) assert spec.flux.size == 50 # Test creating spectrum with unknown arguments with pytest.raises(ValueError) as e_info: spec = Spectrum1D(wavelength=np.arange(1, 50) * u.nm, flux=np.random.randn(48) * u.Jy) def test_create_from_multidimensional_arrays(): """ This is a test for a bug that was fixed by #283. It makes sure that multidimensional flux arrays are handled properly when creating Spectrum1D objects. """ freqs = np.arange(50) * u.GHz flux = np.random.random((5, len(freqs))) * u.Jy spec = Spectrum1D(spectral_axis=freqs, flux=flux) assert (spec.frequency == freqs).all() assert (spec.flux == flux).all() # Mis-matched lengths should raise an exception (unless freqs is one longer # than flux, in which case it's interpreted as bin edges) freqs = np.arange(50) * u.GHz flux = np.random.random((5, len(freqs)-10)) * u.Jy with pytest.raises(ValueError) as e_info: spec = Spectrum1D(spectral_axis=freqs, flux=flux) def test_create_from_quantities(): spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.randn(49) * u.Jy) assert isinstance(spec.spectral_axis, SpectralCoord) assert spec.spectral_axis.unit == u.nm assert spec.spectral_axis.size == 49 # Mis-matched lengths should raise an exception (unless freqs is one longer # than flux, in which case it's interpreted as bin edges) with pytest.raises(ValueError) as e_info: spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.randn(47) * u.Jy) def test_create_implicit_wcs(): spec = Spectrum1D(spectral_axis=np.arange(50) * u.AA, flux=np.random.randn(50) * u.Jy) assert isinstance(spec.wcs, gwcs.wcs.WCS) pix2world = spec.wcs.pixel_to_world(np.arange(5, 10)) assert pix2world.size == 5 assert isinstance(pix2world, np.ndarray) def test_create_implicit_wcs_with_spectral_unit(): spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.randn(49) * u.Jy) assert isinstance(spec.wcs, gwcs.wcs.WCS) pix2world = spec.wcs.pixel_to_world(np.arange(5, 10)) assert pix2world.size == 5 assert isinstance(pix2world, np.ndarray) def test_create_with_spectral_coord(): spectral_coord = SpectralCoord(np.arange(5100, 5150)*u.AA, radial_velocity=u.Quantity(1000.0, "km/s")) flux = np.random.randn(50)*u.Jy spec = Spectrum1D(spectral_axis = spectral_coord, flux=flux) assert spec.radial_velocity == u.Quantity(1000.0, "km/s") assert isinstance(spec.spectral_axis, SpectralCoord) assert spec.spectral_axis.size == 50 def test_create_from_cube(): flux = np.arange(24).reshape([2,3,4])*u.Jy wcs_dict = {"CTYPE1": "RA---TAN", "CTYPE2": "DEC--TAN", "CTYPE3": "WAVE-LOG", "CRVAL1": 205, "CRVAL2": 27, "CRVAL3": 3.622e-7, "CDELT1": -0.0001, "CDELT2": 0.0001, "CDELT3": 8e-11, "CRPIX1": 0, "CRPIX2": 0, "CRPIX3": 0} w = WCS(wcs_dict) spec = Spectrum1D(flux=flux, wcs=w) assert spec.flux.shape == (4,3,2) assert spec.flux[3,2,1] == 23*u.Jy assert np.all(spec.spectral_axis.value == np.exp(np.array([1,2])*w.wcs.cdelt[-1]/w.wcs.crval[-1])*w.wcs.crval[-1]) def test_spectral_axis_conversions(): # By default the spectral axis units should be set to angstroms spec = Spectrum1D(flux=np.array([26.0, 44.5]) * u.Jy, spectral_axis=np.array([400, 500]) * u.AA) assert np.all(spec.spectral_axis == np.array([400, 500]) * u.angstrom) assert spec.spectral_axis.unit == u.angstrom spec = Spectrum1D(spectral_axis=np.arange(50) * u.AA, flux=np.random.randn(50) * u.Jy) assert spec.wavelength.unit == u.AA spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.randn(49) * u.Jy) assert spec.frequency.unit == u.GHz with pytest.raises(ValueError) as e_info: spec.velocity spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.randn(49) * u.Jy) new_spec = spec.with_spectral_unit(u.GHz) def test_spectral_slice(): spec = Spectrum1D(spectral_axis=np.linspace(100, 1000, 10) * u.nm, flux=np.random.random(10) * u.Jy) sliced_spec = spec[300*u.nm:600*u.nm] assert np.all(sliced_spec.spectral_axis == [300, 400, 500] * u.nm) sliced_spec = spec[300*u.nm:605*u.nm] assert np.all(sliced_spec.spectral_axis == [300, 400, 500, 600] * u.nm) sliced_spec = spec[:300*u.nm] assert np.all(sliced_spec.spectral_axis == [100, 200] * u.nm) sliced_spec = spec[800*u.nm:] assert np.all(sliced_spec.spectral_axis == [800, 900, 1000] * u.nm) # Test higher dimensional slicing spec = Spectrum1D(spectral_axis=np.linspace(100, 1000, 10) * u.nm, flux=np.random.random((10, 10)) * u.Jy) sliced_spec = spec[300*u.nm:600*u.nm] assert np.all(sliced_spec.spectral_axis == [300, 400, 500] * u.nm) sliced_spec = spec[4:6, 300*u.nm:600*u.nm] assert sliced_spec.shape == (2, 3) @pytest.mark.parametrize('unit', ['micron', 'GHz', 'cm**-1', 'eV']) def test_spectral_axis_equivalencies(unit): """Test that `u.spectral` equivalencies are enabled for `spectral_axis`.""" spectral_axis=np.array([3400, 5000, 6660]) * u.AA spec = Spectrum1D(flux=np.array([26.0, 30.0, 44.5]) * u.Jy, spectral_axis=spectral_axis) new_axis = spectral_axis.to(unit, equivalencies=u.spectral()) assert u.allclose(spec.spectral_axis.to(unit), new_axis) def test_redshift(): spec = Spectrum1D(flux=np.array([26.0, 30., 44.5]) * u.Jy, spectral_axis=np.array([4000, 6000, 8000]) * u.AA, velocity_convention='optical', rest_value=6000 * u.AA) assert u.allclose(spec.velocity, [-99930.8, 0, 99930.8]*u.km/u.s, atol=0.5*u.km/u.s) spec = Spectrum1D(flux=np.array([26.0, 30., 44.5]) * u.Jy, spectral_axis=np.array([4000, 6000, 8000]) * u.AA, velocity_convention='optical', rest_value=6000 * u.AA, redshift= 0.1) assert u.allclose(spec.velocity, [-71443.75318854, 28487.0661448, 128417.88547813]*u.km/u.s, atol=0.5*u.km/u.s) #------------------------- spec = Spectrum1D(flux=np.array([26.0, 30.0, 44.5]) * u.Jy, spectral_axis=np.array([10.5, 11.0, 11.5]) * u.GHz, velocity_convention='radio', rest_value=11.0 * u.GHz) assert u.allclose(spec.velocity, [13626., 0, -13626]*u.km/u.s, atol=1*u.km/u.s) spec = Spectrum1D(flux=np.array([26.0, 30.0, 44.5]) * u.Jy, spectral_axis=np.array([10.5, 11.0, 11.5]) * u.GHz, velocity_convention='radio', rest_value=11.0 * u.GHz, redshift= 0.1) assert u.allclose(spec.velocity, [42113.99605389, 28487.0661448 , 14860.13623571]*u.km/u.s, atol=1*u.km/u.s) #------------------------- radial velocity mode spec = Spectrum1D(flux=np.array([26.0, 30., 44.5]) * u.Jy, spectral_axis=np.array([4000, 6000, 8000]) * u.AA, velocity_convention='optical', rest_value=6000 * u.AA) assert u.allclose(spec.velocity, [-99930.8, 0.0, 99930.8]*u.km/u.s, atol=0.5*u.km/u.s) spec = Spectrum1D(flux=np.array([26.0, 30., 44.5]) * u.Jy, spectral_axis=np.array([4000, 6000, 8000]) * u.AA, velocity_convention='optical', rest_value=6000 * u.AA, radial_velocity=1000.*u.km/u.s) assert u.allclose(spec.velocity, [-98930.8, 1000.0, 100930.8]*u.km/u.s, atol=0.5*u.km/u.s) def test_flux_unit_conversion(): # By default the flux units should be set to Jy s = Spectrum1D(flux=np.array([26.0, 44.5]) * u.Jy, spectral_axis=np.array([400, 500]) * u.nm) assert np.all(s.flux == np.array([26.0, 44.5]) * u.Jy) assert s.flux.unit == u.Jy # Simple Unit Conversion s = Spectrum1D(flux=np.array([26.0, 44.5]) * u.Jy, spectral_axis=np.array([400, 500])*u.nm) converted_spec = s.new_flux_unit(unit=u.uJy) assert ((26.0 * u.Jy).to(u.uJy) == converted_spec.flux[0]) # Make sure incompatible units raise UnitConversionError with pytest.raises(u.UnitConversionError): s.new_flux_unit(unit=u.m) # Pass custom equivalencies s = Spectrum1D(flux=np.array([26.0, 44.5]) * u.Jy, spectral_axis=np.array([400, 500]) * u.nm) eq = [[u.Jy, u.m, lambda x: np.full_like(np.array(x), 1000.0, dtype=np.double), lambda x: np.full_like(np.array(x), 0.001, dtype=np.double)]] converted_spec = s.new_flux_unit(unit=u.m, equivalencies=eq) assert 1000.0 * u.m == converted_spec.flux[0] # Check if suppressing the unit conversion works s = Spectrum1D(flux=np.array([26.0, 44.5]) * u.Jy, spectral_axis=np.array([400, 500]) * u.nm) new_spec = s.new_flux_unit("uJy", suppress_conversion=True) assert new_spec.flux[0] == 26.0 * u.uJy def test_wcs_transformations(): # Test with a GWCS spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.randn(49) * u.Jy) pix_axis = spec.wcs.world_to_pixel(np.arange(20, 30) * u.nm) disp_axis = spec.wcs.pixel_to_world(np.arange(20, 30)) assert isinstance(pix_axis, np.ndarray) assert isinstance(disp_axis, u.Quantity) # Test transform with different unit with u.set_enabled_equivalencies(u.spectral()): spec.wcs.world_to_pixel(np.arange(20, 30) * u.GHz) # Test with a FITS WCS my_wcs = fitswcs.WCS(header={'CDELT1': 1, 'CRVAL1': 6562.8, 'CUNIT1': 'Angstrom', 'CTYPE1': 'WAVE', 'RESTFRQ': 1400000000, 'CRPIX1': 25}, naxis=1) spec = Spectrum1D(flux=[5,6,7] * u.Jy, wcs=my_wcs) pix_axis = spec.wcs.world_to_pixel(20 * u.um) disp_axis = spec.wcs.pixel_to_world(np.arange(20, 30)) assert isinstance(pix_axis, np.ndarray) assert isinstance(disp_axis, u.Quantity) assert np.allclose(spec.wcs.world_to_pixel(7000*u.AA), [461.2]) def test_create_explicit_fitswcs(): my_wcs = fitswcs.WCS(header={'CDELT1': 1, 'CRVAL1': 6562.8, 'CUNIT1': 'Angstrom', 'CTYPE1': 'WAVE', 'RESTFRQ': 1400000000, 'CRPIX1': 25}) spec = Spectrum1D(flux=[5,6,7] * u.Jy, wcs=my_wcs) spec = spec.with_velocity_convention("relativistic") assert isinstance(spec.spectral_axis, SpectralCoord) assert spec.spectral_axis.unit.is_equivalent(u.AA) pix2world = spec.wcs.pixel_to_world(np.arange(3)) assert pix2world.size == 3 assert isinstance(spec.wavelength, u.Quantity) assert spec.wavelength.size == 3 assert spec.wavelength.unit == u.AA assert isinstance(spec.frequency, u.Quantity) assert spec.frequency.size == 3 assert spec.frequency.unit == u.GHz assert isinstance(spec.velocity, u.Quantity) assert spec.velocity.size == 3 assert spec.velocity.unit == u.Unit('km/s') def test_create_with_uncertainty(): spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49) * u.Jy, uncertainty=StdDevUncertainty(np.random.sample(49) * 0.1)) assert isinstance(spec.uncertainty, StdDevUncertainty) spec = Spectrum1D(spectral_axis=np.arange(1, 50) * u.nm, flux=np.random.sample(49) * u.Jy, uncertainty=StdDevUncertainty(np.random.sample(49) * 0.1)) assert spec.flux.unit == spec.uncertainty.unit # If flux and uncertainty are different sizes then raise exception wavelengths = np.arange(0, 10) flux=100*np.abs(np.random.randn(3, 4, 10))*u.Jy uncertainty = StdDevUncertainty(np.abs(np.random.randn(3, 2, 10))*u.Jy) with pytest.raises(ValueError) as e_info: s1d = Spectrum1D(spectral_axis=wavelengths*u.um, flux=flux, uncertainty=uncertainty) @remote_access([{'id': '1481190', 'filename': 'L5g_0355+11_Cruz09.fits'}]) def test_read_linear_solution(remote_data_path): spec = Spectrum1D.read(remote_data_path, format='wcs1d-fits') assert isinstance(spec, Spectrum1D) assert isinstance(spec.flux, u.Quantity) assert isinstance(spec.spectral_axis, SpectralCoord) assert spec.flux.size == spec.data.size assert spec.spectral_axis.size == spec.data.size def test_energy_photon_flux(): spec = Spectrum1D(spectral_axis=np.linspace(100, 1000, 10) * u.nm, flux=np.random.randn(10)*u.Jy) assert spec.energy.size == 10 assert spec.photon_flux.size == 10 assert spec.photon_flux.unit == u.photon * u.cm**-2 * u.s**-1 * u.nm**-1 def test_flux_nans_propagate_to_mask(): """Check that indices in input flux with NaNs get propagated to the mask""" flux = np.random.randn(10) nan_idx = [0, 3, 5] flux[nan_idx] = np.nan spec = Spectrum1D(spectral_axis=np.linspace(100, 1000, 10) * u.nm, flux=flux * u.Jy) assert spec.mask[nan_idx].all() == True def test_repr(): spec_with_wcs = Spectrum1D(spectral_axis=np.linspace(100, 1000, 10) * u.nm, flux=np.random.random(10) * u.Jy) result = repr(spec_with_wcs) assert result.startswith('= 50*u.um) & (frequencies <= 80*u.um)) expected_uncertainty = np.std(flux[indices])*np.ones(len(frequencies)) assert quantity_allclose(spectrum_with_uncertainty.uncertainty.array, expected_uncertainty.value) assert isinstance(spectrum_with_uncertainty.uncertainty, StdDevUncertainty) # Same idea, but now with variance. spectrum_with_uncertainty = noise_region_uncertainty(spectrum, spectral_region, np.var) indices = np.nonzero((frequencies >= 50*u.um) & (frequencies <= 80*u.um)) expected_uncertainty = np.var(flux[indices])*np.ones(len(frequencies)) assert quantity_allclose(spectrum_with_uncertainty.uncertainty.array, expected_uncertainty.value) assert isinstance(spectrum_with_uncertainty.uncertainty, VarianceUncertainty) # Same idea, but now with inverse variance. spectrum_with_uncertainty = noise_region_uncertainty(spectrum, spectral_region, lambda x: 1/np.var(x)) indices = np.nonzero((frequencies >= 50*u.um) & (frequencies <= 80*u.um)) expected_uncertainty = 1/np.var(flux[indices])*np.ones(len(frequencies)) assert quantity_allclose(spectrum_with_uncertainty.uncertainty.array, expected_uncertainty.value) assert isinstance(spectrum_with_uncertainty.uncertainty, InverseVariance) # Now try with something that does not return Std, Var or IVar type of noise estimation with pytest.raises(ValueError) as e_info: spectrum_with_uncertainty = noise_region_uncertainty(spectrum, spectral_region, lambda x: np.std(x)**3) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/tests/test_utils.py0000644000503700020070000000437100000000000023537 0ustar00rosteenSTSCI\science00000000000000import pickle import pytest import numpy as np from astropy import units as u from astropy import modeling from specutils.utils import QuantityModel from ..utils.wcs_utils import refraction_index, vac_to_air, air_to_vac wavelengths = [300, 500, 1000] * u.nm data_index_refraction = { 'Griesen2006': np.array([3.07393068, 2.9434858 , 2.8925797 ]), 'Edlen1953': np.array([2.91557413, 2.78963801, 2.74148172]), 'Edlen1966': np.array([2.91554272, 2.7895973 , 2.74156098]), 'PeckReeder1972': np.array([2.91554211, 2.78960005, 2.74152561]), 'Morton2000': np.array([2.91568573, 2.78973402, 2.74169531]), 'Ciddor1996': np.array([2.91568633, 2.78973811, 2.74166131]) } def test_quantity_model(): c = modeling.models.Chebyshev1D(3) uc = QuantityModel(c, u.AA, u.km) assert uc(10*u.nm).to(u.m) == 0*u.m def test_pickle_quantity_model(tmp_path): """ Check that a QuantityModel can roundtrip through pickling, as it would if fit in a multiprocessing pool. """ c = modeling.models.Chebyshev1D(3) uc = QuantityModel(c, u.AA, u.km) pkl_file = tmp_path / "qmodel.pkl" with open(pkl_file, "wb") as f: pickle.dump(uc, f) with open(pkl_file, "rb") as f: new_model = pickle.load(f) assert new_model.input_units == uc.input_units assert new_model.return_units == uc.return_units assert type(new_model.unitless_model) == type(uc.unitless_model) assert np.all(new_model.unitless_model.parameters == uc.unitless_model.parameters) @pytest.mark.parametrize("method", data_index_refraction.keys()) def test_refraction_index(method): tmp = (refraction_index(wavelengths, method) - 1) * 1e4 assert np.isclose(tmp, data_index_refraction[method], atol=1e-7).all() @pytest.mark.parametrize("method", data_index_refraction.keys()) def test_air_to_vac(method): tmp = refraction_index(wavelengths, method) assert np.isclose(wavelengths.value * tmp, air_to_vac(wavelengths, method=method, scheme='inversion').value, rtol=1e-6).all() assert np.isclose(wavelengths.value, air_to_vac(vac_to_air(wavelengths, method=method), method=method, scheme='iteration').value, atol=1e-12).all() ././@PaxHeader0000000000000000000000000000003400000000000011452 xustar000000000000000028 mtime=1643306919.7478664 specutils-1.6.0/specutils/utils/0000755000503700020070000000000000000000000020757 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1583343826.0 specutils-1.6.0/specutils/utils/__init__.py0000644000503700020070000000004500000000000023067 0ustar00rosteenSTSCI\science00000000000000from .quantity_model import * # noqa ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/utils/quantity_model.py0000644000503700020070000000474200000000000024376 0ustar00rosteenSTSCI\science00000000000000from astropy import units as u __all__ = ['QuantityModel'] class QuantityModel: """ The QuantityModel was created to wrap `~astropy.modeling.models` that do not have the ability to use `~astropy.units` in the parameters. Parameters ---------- unitless_model : `~astropy.modeling.Model` A model that does not have units input_units : `~astropy.units` Units for the dispersion axis return_units : `~astropy.units` Units for the flux axis Notes ----- When Astropy's modeling is updated so *all* models have the ability to have `~astropy.units.Quantity` on all parameters, then this will not be needed. """ def __init__(self, unitless_model, input_units, return_units): # should check that it's unitless somehow! self.unitless_model = unitless_model # we use the dict because now this "shadows" the unitless model's # input_units/ return_units self.__dict__['input_units'] = input_units self.__dict__['return_units'] = return_units def __hasattr_(self, nm): if nm in self.__dict__ or hasattr(self, self.unitless_model): return True return False def __getattr__(self, nm): if nm != 'unitless_model' and hasattr(self.unitless_model, nm): return getattr(self.unitless_model, nm) else: raise AttributeError("'{}' object has no attribute '{}'" "".format(self.__class__.__name__, nm)) def __setattr__(self, nm, val): if nm != 'unitless_model' and hasattr(self.unitless_model, nm): setattr(self.unitless_model, nm, val) else: super().__setattr__(nm, val) def __delattr__(self, nm): if hasattr(self.unitless_model, nm): delattr(self.unitless_model, nm) else: super().__delattr__(nm) def __dir__(self): thisdir = super().__dir__() modeldir = dir(self.unitless_model) return sorted(list(thisdir) + list(modeldir)) def __repr__(self): return (''.format(repr(self.unitless_model)[1:-1], self.input_units, self.return_units)) def __call__(self, x, *args, **kwargs): unitlessx = x.to(self.input_units).value result = self.unitless_model(unitlessx, *args, **kwargs) return u.Quantity(result, self.return_units, copy=False) ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/specutils/utils/wcs_utils.py0000644000503700020070000002545400000000000023357 0ustar00rosteenSTSCI\science00000000000000import copy import numpy as np from astropy import units as u from astropy.modeling.models import Shift from astropy.modeling.tabular import Tabular1D from gwcs import WCS as GWCS from gwcs import coordinate_frames as cf def refraction_index(wavelength, method='Griesen2006', co2=None): """ Calculates the index of refraction of dry air at standard temperature and pressure, at different wavelengths, using different methods. Parameters ---------- wavelength : `Quantity` object (number or sequence) Vacuum wavelengths with an astropy.unit. method : str, optional Method used to convert wavelengths. Options are: 'Griesen2006' (default) - from Greisen et al. (2006, A&A 446, 747), eqn. 65, standard used by International Unionof Geodesy and Geophysics 'Edlen1953' - from Edlen (1953, J. Opt. Soc. Am, 43, 339). Standard adopted by IAU (resolution No. C15, Commission 44, XXI GA, 1991), which refers to Oosterhoff (1957) that uses Edlen (1953). Also used by Morton (1991, ApJS, 77, 119), which is frequently cited as IAU source. 'Edlen1966' - from Edlen (1966, Metrologia 2, 71), rederived constants from optical and near UV data. 'PeckReeder1972' - from Peck & Reeder (1972, J. Opt. Soc. 62), derived from additional infrared measurements (up to 1700 nm). 'Morton2000' - from Morton (2000, ApJS, 130, 403), eqn 8. Used by VALD, the Vienna Atomic Line Database. Very similar to Edlen (1966). 'Ciddor1996' - from Ciddor (1996, Appl. Opt. 35, 1566). Based on Peck & Reeder (1972), but updated to account for the changes in the international temperature scale and adjust the results for CO2 concentration. Arguably most accurate conversion available. co2 : number, optional CO2 concentration in ppm. Only used for method='Ciddor1996'. If not given, a default concentration of 450 ppm is used. Returns ------- refr : number or sequence Index of refraction at each given air wavelength. """ VALID_METHODS = ['Griesen2006', 'Edlen1953', 'Edlen1966', 'Morton2000', 'PeckReeder1972', 'Ciddor1996'] assert isinstance(method, str), 'method must be a string' method = method.lower() sigma2 = (1 / wavelength.to(u.um).value)**2 if method == 'griesen2006': refr = 1e-6 * (287.6155 + 1.62887 * sigma2 + 0.01360 * sigma2**2) elif method == 'edlen1953': refr = 6.4328e-5 + 2.94981e-2 / (146 - sigma2) + 2.5540e-4 / (41 - sigma2) elif method == 'edlen1966': refr = 8.34213e-5 + 2.406030e-2 / (130 - sigma2) + 1.5997e-4 / (38.9 - sigma2) elif method == 'morton2000': refr = 8.34254e-5 + 2.406147e-2 / (130 - sigma2) + 1.5998e-4 / (38.9 - sigma2) elif method == 'peckreeder1972': refr = 5.791817e-2 / (238.0185 - sigma2) + 1.67909e-3 / (57.362 - sigma2) elif method == 'ciddor1996': refr = 5.792105e-2 / (238.0185 - sigma2) + 1.67917e-3 / (57.362 - sigma2) if co2: refr *= 1 + 0.534e-6 * (co2 - 450) else: raise ValueError("Method must be one of " + ", ".join(VALID_METHODS)) return refr + 1 def vac_to_air(wavelength, method='Griesen2006', co2=None): """ Converts vacuum to air wavelengths using different methods. Parameters ---------- wavelength : `Quantity` object (number or sequence) Vacuum wavelengths with an astropy.unit. method : str, optional One of the methods in refraction_index(). co2 : number, optional Atmospheric CO2 concentration in ppm. Only used for method='Ciddor1996'. If not given, a default concentration of 450 ppm is used. Returns ------- air_wavelength : `Quantity` object (number or sequence) Air wavelengths with the same unit as wavelength. """ refr = refraction_index(wavelength, method=method, co2=co2) return wavelength / refr def air_to_vac(wavelength, scheme='inversion', method='Griesen2006', co2=None, precision=1e-12, maxiter=30): """ Converts air to vacuum wavelengths using different methods. Parameters ---------- wavelength : `Quantity` object (number or sequence) Air wavelengths with an astropy.unit. scheme : str, optional How to convert from vacuum to air wavelengths. Options are: 'inversion' (default) - result is simply the inversion (1 / n) of the refraction index of air. Griesen et al. (2006) report that the error in naively inverting is less than 10^-9. 'Piskunov' - uses an analytical solution derived by Nikolai Piskunov and used by the Vienna Atomic Line Database (VALD). 'iteration' - uses an iterative scheme to invert the index of refraction. method : str, optional Only used if scheme is 'inversion' or 'iteration'. One of the methods in refraction_index(). co2 : number, optional Atmospheric CO2 concentration in ppm. Only used if scheme='inversion' and method='Ciddor1996'. If not given, a default concentration of 450 ppm is used. precision : float Maximum fractional value in refraction conversion beyond at which iteration will be stopped. Only used if scheme='iteration'. maxiter : integer Maximum number of iterations to run. Only used if scheme='iteration'. Returns ------- vac_wavelength : `Quantity` object (number or sequence) Vacuum wavelengths with the same unit as wavelength. """ VALID_SCHEMES = ['inversion', 'iteration', 'piskunov'] assert isinstance(scheme, str), 'scheme must be a string' scheme = scheme.lower() if scheme == 'inversion': refr = refraction_index(wavelength, method=method, co2=co2) elif scheme == 'piskunov': wlum = wavelength.to(u.angstrom).value sigma2 = (1e4 / wlum)**2 refr = (8.336624212083e-5 + 2.408926869968e-2 / (130.1065924522 - sigma2) + 1.599740894897e-4 / (38.92568793293 - sigma2)) + 1 elif scheme == 'iteration': # Refraction index is a function of vacuum wavelengths. # Iterate to get index of refraction that gives air wavelength that # is consistent with the reverse transformation. counter = 0 result = wavelength.copy() refr = refraction_index(wavelength, method=method, co2=co2) while True: counter += 1 diff = wavelength * refr - result if abs(diff.max().value) < precision: break #return wavelength * conv if counter > maxiter: raise RuntimeError("Reached maximum number of iterations " "without reaching desired precision level.") result += diff refr = refraction_index(result, method=method, co2=co2) else: raise ValueError("Method must be one of " + ", ".join(VALID_SCHEMES)) return wavelength * refr def air_to_vac_deriv(wavelength, method='Griesen2006'): """ Calculates the derivative d(wave_vacuum) / d(wave_air) using different methods. Parameters ---------- wavelength : `Quantity` object (number or sequence) Air wavelengths with an astropy.unit. method : str, optional Method used to convert wavelength derivative. Options are: 'Griesen2006' (default) - from Greisen et al. (2006, A&A 446, 747), eqn. 66. Returns ------- wave_deriv : `Quantity` object (number or sequence) Derivative d(wave_vacuum) / d(wave_air). """ assert method.lower() == 'griesen2006', "Only supported method is 'Griesen2006'" wlum = wavelength.to(u.um).value return (1 + 1e-6 * (287.6155 - 1.62887 / wlum**2 - 0.04080 / wlum**4)) def gwcs_from_array(array): """ Create a new WCS from provided tabular data. This defaults to being a GWCS object. """ orig_array = u.Quantity(array) coord_frame = cf.CoordinateFrame(naxes=1, axes_type=('SPECTRAL',), axes_order=(0,)) spec_frame = cf.SpectralFrame(unit=array.unit, axes_order=(0,)) # In order for the world_to_pixel transformation to automatically convert # input units, the equivalencies in the look up table have to be extended # with spectral unit information. SpectralTabular1D = type("SpectralTabular1D", (Tabular1D,), {'input_units_equivalencies': {'x0': u.spectral()}}) forward_transform = SpectralTabular1D(np.arange(len(array)), lookup_table=array) # If our spectral axis is in descending order, we have to flip the lookup # table to be ascending in order for world_to_pixel to work. if len(array) == 0 or array[-1] > array[0]: forward_transform.inverse = SpectralTabular1D( array, lookup_table=np.arange(len(array))) else: forward_transform.inverse = SpectralTabular1D( array[::-1], lookup_table=np.arange(len(array))[::-1]) class SpectralGWCS(GWCS): def pixel_to_world(self, *args, **kwargs): if orig_array.unit == '': return u.Quantity(super().pixel_to_world_values(*args, **kwargs)) return super().pixel_to_world(*args, **kwargs).to( orig_array.unit, equivalencies=u.spectral()) tabular_gwcs = SpectralGWCS(forward_transform=forward_transform, input_frame=coord_frame, output_frame=spec_frame) # Store the intended unit from the origin input array # tabular_gwcs._input_unit = orig_array.unit return tabular_gwcs def gwcs_slice(self, item): """ This is a bit of a hack in order to fix the slicing of the WCS in the spectral dispersion direction. The NDData slices properly but the spectral dispersion result was not. There is code slightly downstream that sets the *number* of entries in the dispersion axis, this is just needed to shift to the correct starting element. When WCS gets the ability to do slicing then we might be able to remove this code. """ # Create shift of x-axis if isinstance(item, int): shift = item elif isinstance(item, slice): shift = item.start else: raise TypeError('Unknown index type {}, must be int or slice.'.format(item)) # Create copy as we need to modify this and return it. new_wcs = copy.deepcopy(self) if shift == 0: return new_wcs shifter = Shift(shift) # Get the current forward transform forward = new_wcs.forward_transform # Set the new transform new_wcs.set_transform(new_wcs.input_frame, new_wcs.output_frame, shifter | forward) return new_wcs ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils/version.py0000644000503700020070000000052100000000000021654 0ustar00rosteenSTSCI\science00000000000000# Note that we need to fall back to the hard-coded version if either # setuptools_scm can't be imported or setuptools_scm can't determine the # version, so we catch the generic 'Exception'. try: from setuptools_scm import get_version version = get_version(root='..', relative_to=__file__) except Exception: version = '1.6.0' ././@PaxHeader0000000000000000000000000000003200000000000011450 xustar000000000000000026 mtime=1643306919.72379 specutils-1.6.0/specutils.egg-info/0000755000503700020070000000000000000000000021311 5ustar00rosteenSTSCI\science00000000000000././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils.egg-info/PKG-INFO0000644000503700020070000001202400000000000022405 0ustar00rosteenSTSCI\science00000000000000Metadata-Version: 2.1 Name: specutils Version: 1.6.0 Summary: Package for spectroscopic astronomical data Home-page: https://specutils.readthedocs.io/ Author: Specutils Developers Author-email: coordinators@astropy.org License: BSD 3-Clause Platform: UNKNOWN Requires-Python: >=3.7 Description-Content-Type: text/x-rst Provides-Extra: test Provides-Extra: docs License-File: licenses/LICENSE.rst Specutils ========= .. image:: https://github.com/astropy/specutils/workflows/CI/badge.svg :target: https://github.com/astropy/specutils/actions :alt: GitHub Actions CI Status .. image:: https://readthedocs.org/projects/specutils/badge/?version=latest :target: http://specutils.readthedocs.io/en/latest/?badge=latest :alt: Documentation Status .. image:: http://img.shields.io/badge/powered%20by-AstroPy-orange.svg?style=flat :target: http://www.astropy.org/ Specutils is an `Astropy affiliated package `_ with the goal of providing a shared set of Python representations of astronomical spectra and basic tools to operate on these spectra. The effort is also meant to be a "hub", helping to unite the Python astronomical spectroscopy community around shared effort, much as Astropy is meant to for the wider astronomy Python ecosystem. This broader effort is outlined in the `APE13 document `_. Note that Specutils is not intended to meet all possible spectroscopic analysis or reduction needs. While it provides some standard analysis functionality (following the Python philosophy of "batteries included"), it is best thought of as a "tool box" that provides pieces of functionality that can be used to build a particular scientific workflow or higher-level analysis tool. To that end, it is also meant to facilitate connecting together disparate reduction pipelines and analysis tools through shared Python representations of spectroscopic data. Getting Started with Specutils ------------------------------ For details on installing and using Specutils, see the `specutils documentation `_. Note that Specutils now only supports Python 3. While some functionality may continue to work on Python 2, it is not tested and support cannot be guaranteed (due to the sunsetting of Python 2 support by the Python and Astropy development teams). License ------- This project is Copyright (c) Specutils Developers and licensed under the terms of the BSD 3-Clause license. This package is based upon the `Astropy package template `_ which is licensed under the BSD 3-clause license. See the ``licenses`` folder for more information. Contributing ------------ We love contributions! specutils is open source, built on open source, and we'd love to have you hang out in our community. **Imposter syndrome disclaimer**: We want your help. No, really. There may be a little voice inside your head that is telling you that you're not ready to be an open source contributor; that your skills aren't nearly good enough to contribute. What could you possibly offer a project like this one? We assure you - the little voice in your head is wrong. If you can write code at all, you can contribute code to open source. Contributing to open source projects is a fantastic way to advance one's coding skills. Writing perfect code isn't the measure of a good developer (that would disqualify all of us!); it's trying to create something, making mistakes, and learning from those mistakes. That's how we all improve, and we are happy to help others learn. Being an open source contributor doesn't just mean writing code, either. You can help out by writing documentation, tests, or even giving feedback about the project (and yes - that includes giving feedback about the contribution process). Some of these contributions may be the most valuable to the project as a whole, because you're coming to the project with fresh eyes, so you can see the errors and assumptions that seasoned contributors have glossed over. Note: This disclaimer was originally written by `Adrienne Lowe `_ for a `PyCon talk `_, and was adapted by specutils based on its use in the README file for the `MetPy project `_. If you locally cloned this repo before 22 Mar 2021 -------------------------------------------------- The primary branch for this repo has been transitioned from ``master`` to ``main``. If you have a local clone of this repository and want to keep your local branch in sync with this repo, you'll need to do the following in your local clone from your terminal:: git fetch --all --prune # you can stop here if you don't use your local "master"/"main" branch git branch -m master main git branch -u origin/main main If you are using a GUI to manage your repos you'll have to find the equivalent commands as it's different for different programs. Alternatively, you can just delete your local clone and re-clone! ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils.egg-info/SOURCES.txt0000644000503700020070000001162200000000000023177 0ustar00rosteenSTSCI\science00000000000000.gitignore .readthedocs.yml CHANGES.rst CITATION MANIFEST.in README.rst conftest.py pyproject.toml setup.cfg setup.py tox.ini .github/workflows/ci_cron_weekly.yml .github/workflows/ci_workflows.yml docs/Makefile docs/analysis.rst docs/arithmetic.rst docs/conf.py docs/contributing.rst docs/custom_loading.rst docs/fitting.rst docs/identify.rst docs/index.rst docs/installation.rst docs/make.bat docs/manipulation.rst docs/nitpick-exceptions docs/releasing.rst docs/spectral_cube.rst docs/spectral_regions.rst docs/spectrum1d.rst docs/spectrum_collection.rst docs/specutils_classes_diagrams.png docs/specutils_classes_diagrams.pptx docs/types_of_spectra.rst docs/_static/logo.png docs/_static/logo_icon.ico docs/_static/logo_icon.png docs/_static/specutils.css docs/_templates/autosummary/base.rst docs/_templates/autosummary/class.rst docs/_templates/autosummary/module.rst docs/img/quick_start.png docs/img/read_1d.png licenses/LICENSE.rst licenses/README.rst licenses/TEMPLATE_LICENCE.rst specutils/CITATION specutils/__init__.py specutils/_astropy_init.py specutils/conftest.py specutils/version.py specutils.egg-info/PKG-INFO specutils.egg-info/SOURCES.txt specutils.egg-info/dependency_links.txt specutils.egg-info/entry_points.txt specutils.egg-info/not-zip-safe specutils.egg-info/requires.txt specutils.egg-info/top_level.txt specutils/analysis/__init__.py specutils/analysis/correlation.py specutils/analysis/flux.py specutils/analysis/location.py specutils/analysis/moment.py specutils/analysis/template_comparison.py specutils/analysis/uncertainty.py specutils/analysis/utils.py specutils/analysis/width.py specutils/fitting/__init__.py specutils/fitting/continuum.py specutils/fitting/fitmodels.py specutils/io/__init__.py specutils/io/_list_of_loaders.py specutils/io/parsing_utils.py specutils/io/registers.py specutils/io/asdf/__init__.py specutils/io/asdf/extension.py specutils/io/asdf/types.py specutils/io/asdf/schemas/astropy.org/specutils/spectra/spectral_coord-1.0.0.yaml specutils/io/asdf/schemas/astropy.org/specutils/spectra/spectrum1d-1.0.0.yaml specutils/io/asdf/schemas/astropy.org/specutils/spectra/spectrum_list-1.0.0.yaml specutils/io/asdf/tags/__init__.py specutils/io/asdf/tags/spectra.py specutils/io/asdf/tags/tests/__init__.py specutils/io/asdf/tags/tests/test_spectra.py specutils/io/default_loaders/__init__.py specutils/io/default_loaders/aaomega_2df.py specutils/io/default_loaders/apogee.py specutils/io/default_loaders/ascii.py specutils/io/default_loaders/dc_common.py specutils/io/default_loaders/galah.py specutils/io/default_loaders/gama.py specutils/io/default_loaders/generic_cube.py specutils/io/default_loaders/generic_ecsv_reader.py specutils/io/default_loaders/hst_cos.py specutils/io/default_loaders/hst_stis.py specutils/io/default_loaders/jwst_reader.py specutils/io/default_loaders/manga.py specutils/io/default_loaders/muscles_sed.py specutils/io/default_loaders/ozdes.py specutils/io/default_loaders/sdss.py specutils/io/default_loaders/sixdfgs_reader.py specutils/io/default_loaders/subaru_pfs_spec.py specutils/io/default_loaders/tabular_fits.py specutils/io/default_loaders/twodfgrs_reader.py specutils/io/default_loaders/twoslaq_lrg.py specutils/io/default_loaders/wcs_fits.py specutils/io/default_loaders/wigglez.py specutils/io/default_loaders/tests/__init__.py specutils/io/default_loaders/tests/test_apogee.py specutils/io/default_loaders/tests/test_jwst_reader.py specutils/manipulation/__init__.py specutils/manipulation/estimate_uncertainty.py specutils/manipulation/extract_spectral_region.py specutils/manipulation/manipulation.py specutils/manipulation/model_replace.py specutils/manipulation/resample.py specutils/manipulation/smoothing.py specutils/manipulation/utils.py specutils/spectra/__init__.py specutils/spectra/spectral_axis.py specutils/spectra/spectral_coordinate.py specutils/spectra/spectral_region.py specutils/spectra/spectrum1d.py specutils/spectra/spectrum_collection.py specutils/spectra/spectrum_list.py specutils/spectra/spectrum_mixin.py specutils/tests/__init__.py specutils/tests/conftest.py specutils/tests/coveragerc specutils/tests/setup_package.py specutils/tests/spectral_examples.py specutils/tests/test_analysis.py specutils/tests/test_arithmetic.py specutils/tests/test_continuum.py specutils/tests/test_correlation.py specutils/tests/test_dc_common_loaders.py specutils/tests/test_fitting.py specutils/tests/test_io.py specutils/tests/test_loaders.py specutils/tests/test_manipulation.py specutils/tests/test_model_replace.py specutils/tests/test_region_extract.py specutils/tests/test_regions.py specutils/tests/test_resample.py specutils/tests/test_slicing.py specutils/tests/test_smoothing.py specutils/tests/test_spectral_axis.py specutils/tests/test_spectrum1d.py specutils/tests/test_spectrum_collection.py specutils/tests/test_template_comparison.py specutils/tests/test_unc.py specutils/tests/test_utils.py specutils/utils/__init__.py specutils/utils/quantity_model.py specutils/utils/wcs_utils.py././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils.egg-info/dependency_links.txt0000644000503700020070000000000100000000000025357 0ustar00rosteenSTSCI\science00000000000000 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils.egg-info/entry_points.txt0000644000503700020070000000011600000000000024605 0ustar00rosteenSTSCI\science00000000000000[asdf_extensions] specutils = specutils.io.asdf.extension:SpecutilsExtension ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils.egg-info/not-zip-safe0000644000503700020070000000000100000000000023537 0ustar00rosteenSTSCI\science00000000000000 ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils.egg-info/requires.txt0000644000503700020070000000026300000000000023712 0ustar00rosteenSTSCI\science00000000000000astropy>=4.1 gwcs>=0.17.0 scipy asdf>=2.5 ndcube>=2.0 [docs] sphinx-astropy matplotlib graphviz [test] pytest-astropy pytest-cov matplotlib graphviz coverage asdf spectral-cube ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643306919.0 specutils-1.6.0/specutils.egg-info/top_level.txt0000644000503700020070000000001200000000000024034 0ustar00rosteenSTSCI\science00000000000000specutils ././@PaxHeader0000000000000000000000000000002600000000000011453 xustar000000000000000022 mtime=1643302783.0 specutils-1.6.0/tox.ini0000644000503700020070000000622300000000000017122 0ustar00rosteenSTSCI\science00000000000000[tox] envlist = py{37,38,39}-test{,-alldeps,-devdeps}{,-cov} py{37,38,39}-test-numpy{116,117,118} py{37,38,39}-test-astropy{30,40,lts} py{37,38,39}-test-external build_docs linkcheck codestyle requires = setuptools >= 30.3.0 pip >= 19.3.1 isolated_build = true indexserver = NIGHTLY = https://pypi.anaconda.org/scipy-wheels-nightly/simple [testenv] # Suppress display of matplotlib plots generated during docs build setenv = MPLBACKEND=agg # Disable the accelerate linear algebra library when running on macos as # latest numpy versions do not work with it NPY_BLAS_ORDER= NPY_LAPACK_ORDER= # Pass through the following environment variables which may be needed for the CI passenv = HOME WINDIR LC_ALL LC_CTYPE CC CI TRAVIS # Run the tests in a temporary directory to make sure that we don't import # this package from the source tree changedir = .tmp/{envname} # tox environments are constructed with so-called 'factors' (or terms) # separated by hyphens, e.g. test-devdeps-cov. Lines below starting with factor: # will only take effect if that factor is included in the environment name. To # see a list of example environments that can be run, along with a description, # run: # # tox -l -v # description = run tests alldeps: with all optional dependencies devdeps: with the latest developer version of key dependencies oldestdeps: with the oldest supported version of key dependencies cov: and test coverage numpy116: with numpy 1.16.* numpy117: with numpy 1.17.* numpy118: with numpy 1.18.* astropy30: with astropy 3.0.* astropy40: with astropy 4.0.* astropylts: with the latest astropy LTS external: with outside packages as dependencies # The following provides some specific pinnings for key packages deps = numpy116: numpy==1.16.* numpy117: numpy==1.17.* numpy118: numpy==1.18.* astropy30: astropy==3.0.* astropy40: astropy==4.0.* astropylts: astropy==4.0.* devdeps: :NIGHTLY:numpy devdeps: git+https://github.com/astropy/astropy.git#egg=astropy devdeps: git+https://github.com/spacetelescope/gwcs.git#egg=gwcs external: asdf external: git+https://github.com/spacetelescope/jwst@stable # The following indicates which extras_require from setup.cfg will be installed extras = test alldeps: all commands = pip freeze !cov: pytest --pyargs specutils {toxinidir}/docs {posargs} cov: pytest --pyargs specutils {toxinidir}/docs --cov specutils --cov-config={toxinidir}/setup.cfg {posargs} [testenv:build_docs] changedir = docs description = invoke sphinx-build to build the HTML docs extras = docs commands = pip freeze sphinx-build -W -b html . _build/html [testenv:linkcheck] changedir = docs description = check the links in the HTML docs extras = docs commands = pip freeze sphinx-build -W -b linkcheck . _build/html [testenv:codestyle] skip_install = true changedir = . description = check code style, e.g. with flake8 deps = flake8 commands = -flake8 specutils -qq --statistics --select=E501,W505 flake8 specutils --count --select=E101,W191,W291,W292,W293,W391,E111,E112,E113,E502,E722,E901,E902