diff --git a/CHANGES.rst b/CHANGES.rst index cb42a7d2..533fc56f 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -1,3 +1,11 @@ +1.7.1 (2025-11-2x) +------------------ + +Other changes +^^^^^^^^^^^^^ + +- Changed to use ``sphinx_astropy.conf.v2`` and revised the documentation. + 1.7.0 (2025-11-13) ------------------ diff --git a/docs/_static/specreduce.css b/docs/_static/specreduce.css index 229d8787..e69de29b 100644 --- a/docs/_static/specreduce.css +++ b/docs/_static/specreduce.css @@ -1,15 +0,0 @@ -@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; -} diff --git a/docs/api.rst b/docs/api.rst index 4f27c629..d0f52177 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1,7 +1,7 @@ .. _api_index: -API Index -========= +API Reference +============= .. automodapi:: specreduce :no-inheritance-diagram: diff --git a/docs/background.rst b/docs/background.rst new file mode 100644 index 00000000..52747d4a --- /dev/null +++ b/docs/background.rst @@ -0,0 +1,34 @@ +Background correction +===================== + +The `specreduce.background` module generates and subtracts a background image from +the input 2D spectral image. The `~specreduce.background.Background` object is +defined by one or more windows, where each window is a region parallel to a +`~specreduce.tracing.Trace`, offset from that `~specreduce.tracing.Trace` by a +specified separation in the cross-dispersion direction, and extending over a +specified width (also measured along the cross-dispersion axis) in pixels. The +object can be generated with: + +* `~specreduce.background.Background` +* `Background.one_sided ` +* `Background.two_sided ` + +The center of the window can either be passed as a float/integer or as a +`~specreduce.tracing.Trace`. + +.. code-block:: python + + bg = specreduce.background.Background.one_sided(image, trace, separation=5, width=2) + +or, equivalently + +.. code-block:: python + + bg = specreduce.background.Background.one_sided(image, 15, separation=5, width=2) + +The background image can be accessed via `~specreduce.background.Background.bkg_image` +and the background-subtracted image via `~specreduce.background.Background.sub_image` +(or ``image - bg``). + +The background and trace steps can be done iteratively, to refine an automated +trace using the background-subtracted image as input. \ No newline at end of file diff --git a/docs/conf.py b/docs/conf.py index efc1a09f..17afccf5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -27,21 +27,20 @@ import sys import datetime - import sphinx from specreduce import __version__ try: - from sphinx_astropy.conf.v1 import * # noqa + from sphinx_astropy.conf.v2 import * # noqa + from sphinx_astropy.conf.v2 import extensions # noqa except ImportError: - print('ERROR: the documentation requires the sphinx-astropy package to be installed') + print("ERROR: the documentation requires the sphinx-astropy package to be installed") sys.exit(1) # xref: https://github.com/sphinx-doc/sphinx/issues/13232#issuecomment-2608708175 if sys.version_info[:2] >= (3, 13) and sphinx.version_info[:2] < (8, 2): import pathlib - from sphinx.util.typing import _INVALID_BUILTIN_CLASSES _INVALID_BUILTIN_CLASSES[pathlib.Path] = "pathlib.Path" @@ -49,10 +48,10 @@ # -- General configuration ---------------------------------------------------- # By default, highlight as Python 3. -highlight_language = 'python3' +highlight_language = "python3" # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.2' +needs_sphinx = "3.0" # To perform a Sphinx version check that needs to be more specific than # major.minor, call `check_sphinx_version("x.y.z")` here. @@ -60,83 +59,94 @@ # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns.append('_templates') +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 += """ -""" +#rst_epilog += """ +#.. _Astropy: https://www.astropy.org/ +#""" + +extensions.extend( + [ + "sphinx_design", + "nbsphinx", + ] +) # -- Project information ------------------------------------------------------ # This does not *have* to match the package name, but typically does project = "specreduce" author = "Astropy Specreduce contributors" -copyright = '{0}, {1}'.format( - datetime.datetime.now().year, author) +copyright = "{0}, {1}".format(datetime.datetime.now().year, 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. # The short X.Y version. -version = __version__.split('-', 1)[0] +version = __version__.split("-", 1)[0] # The full version, including alpha/beta/rc tags. release = __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_theme = None -html_static_path = ['_static'] # html_theme = None -html_style = 'specreduce.css' +html_static_path = ["_static"] # html_theme = None +html_style = "specreduce.css" - -html_theme_options = { - 'logotext1': 'spec', # white, semi-bold - 'logotext2': 'reduce', # orange, light - 'logotext3': ':docs' # white, light +html_theme_options.update( + { + "github_url": "https://github.com/astropy/specreduce", + "use_edit_page_button": False, + "navigation_with_keys": False, + "logo": { + "text": f"{project}", + "image_light": "_static/logo_icon.png", + "image_dark": "_static/logo_icon.png", + }, + "secondary_sidebar_items": {"**": ["page-toc"], "index": []}, } +) + +html_context = { + "default_mode": "light", + "version_slug": os.environ.get("READTHEDOCS_VERSION") or "", + "to_be_indexed": ["stable", "latest"], + "github_user": "astropy", + "github_repo": "specreduce", + "github_version": "main", + "doc_path": "docs", + "edit_page_url_template": "{{ astropy_custom_edit_url(github_user, github_repo, github_version, doc_path, file_name, default_edit_page_url_template) }}", + "default_edit_page_url_template": "https://github.com/{github_user}/{github_repo}/edit/{github_version}/{doc_path}{file_name}", + # Tell Jinja2 templates the build is running on Read the Docs + "READTHEDOCS": os.environ.get("READTHEDOCS", "") == "True", +} # Custom sidebar templates, maps document names to template names. -#html_sidebars = {} -html_sidebars['**'] = ['localtoc.html'] -html_sidebars['index'] = ['globaltoc.html', 'localtoc.html'] +html_sidebars = {} +html_sidebars['index'] = [] +html_sidebars["contributing"] = [] -# The name of an image file (relative to this directory) to place at the top -# of the sidebar. -#html_logo = '' +# html_sidebars['**'] = ['localtoc.html'] +# html_sidebars['index'] = [] #['globaltoc.html', 'localtoc.html'] # 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' +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 = '' +# 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) +html_title = "{0} v{1}".format(project, release) # Output file base name for HTML help builder. -htmlhelp_basename = project + 'doc' +htmlhelp_basename = project + "doc" # Prefixes that are ignored for sorting the Python module index modindex_common_prefix = ["specreduce."] @@ -145,18 +155,18 @@ # 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')] +latex_documents = [("index", project + ".tex", project + " 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)] +man_pages = [("index", project.lower(), project + " Documentation", [author], 1)] + +# -- Options for numpydoc extension ------------------------------------------- +numpydoc_xref_param_type = False -extensions.append('nbsphinx') # -- Options for the edit_on_github extension --------------------------------- @@ -165,10 +175,10 @@ nitpicky = True intersphinx_mapping.update( { - 'astropy': ('https://docs.astropy.org/en/stable/', None), - 'ccdproc': ('https://ccdproc.readthedocs.io/en/stable/', None), - 'specutils': ('https://specutils.readthedocs.io/en/stable/', None), - 'gwcs': ('https://gwcs.readthedocs.io/en/stable/', None) + "astropy": ("https://docs.astropy.org/en/stable/", None), + "ccdproc": ("https://ccdproc.readthedocs.io/en/stable/", None), + "specutils": ("https://specutils.readthedocs.io/en/stable/", None), + "gwcs": ("https://gwcs.readthedocs.io/en/stable/", None), } ) # diff --git a/docs/contributing.rst b/docs/contributing.rst new file mode 100644 index 00000000..b8c67a54 --- /dev/null +++ b/docs/contributing.rst @@ -0,0 +1,102 @@ +.. _contributors_guide: + +Contributing +============ + +As with all `Astropy `_ coordinated packages, **Specreduce** is +developed *by* the astrophysics community, *for* the astrophysics community. We warmly welcome +contributions of all kinds, whether it’s sharing feedback, reporting bugs, suggesting new +features, or improving code and documentation. Every contribution, big or small, helps make +Specreduce more useful and reliable for everyone. + +Specreduce follows the `Astropy development and contribution guidelines +`_ and the +`Astropy Community Code of Conduct `_. +By contributing, you agree to uphold these community standards, which help ensure that the +project remains a welcoming and inclusive space for all contributors. + +Getting Started +--------------- + +If you’re new to open-source development or to the Astropy ecosystem, do not worry. The best place +to start is by visiting the +`Specreduce GitHub repository `_, +where you can find current issues, pull requests, and the latest development activity. + +Before you begin contributing code, you may want to: + +* Read through the Astropy developer documentation linked above. +* Explore the existing documentation and tutorials to get a sense of how Specreduce works. +* Comment on an issue to let others know you’d like to work on it — or open a new issue to + discuss an idea or feature you’d like to propose. + +Roadmap +------- + +.. image:: + roadmap.png + :align: center + :width: 100% + +Contribute feedback +------------------- + +The Specreduce team values feedback from all users. If something doesn’t work as expected, +if you encounter a bug, or if you have an idea for an improvement, please let us know by opening +a new issue on the +`Specreduce GitHub issue tracker `_. + +For bug reports, please include: + +* A short description of the problem. +* The version of Specreduce and Python you are using. +* A minimal example (if possible) that reproduces the issue. + +For feature requests or usability feedback, describe your idea clearly and explain how it would +improve the user experience. Even short notes are valuable and help guide development priorities. + + +Contribute Code or Documentation +-------------------------------- + +If you see an open issue you’d like to work on, or if you’ve spotted an error or missing detail +in the documentation, you can submit your own improvements through GitHub. +To get started: + +1. Fork the Specreduce repository and create a new branch for your changes. +2. Make your edits or additions, whether it’s a bug fix, a new feature, or a documentation update. +3. Commit your changes with a clear message describing what you did. +4. Open a pull request to the main repository. + +Contribute Tutorials and Examples +--------------------------------- + +Tutorials and worked examples are among the most valuable contributions. They help other users +learn from real data and see how different tools fit together in practice. + +While the main steps of spectroscopic reduction (for example, tracing, extraction, and wavelength +calibration) are similar across most instruments, the best workflow can still depend on the +setup and science goals. In the long term, we aim to build a library of example reduction recipes +for different instruments that users can adapt for their own observations. + +If you have a reduction example, a notebook, or a teaching resource that might help others, +please share it, either by opening a pull request or by discussing it in an issue first. +We’re happy to help with formatting and integration into the documentation. + +Staying in Touch +---------------- + +Development discussions happen mainly on GitHub, but you can also connect with the wider Astropy +community through the `Astropy Discussion Forum `_, +where you can ask questions, share ideas, and get advice from other developers and users. + +Your contributions help make Specreduce and the Astropy ecosystem stronger and more sustainable. +Whether you fix a typo, improve a function, or share a new example, you are helping build a tool +that benefits the entire astrophysics community. Thank you for being part of it! + +.. toctree:: + :maxdepth: 1 + :hidden: + + process/index + terms \ No newline at end of file diff --git a/docs/extraction.rst b/docs/extraction.rst new file mode 100644 index 00000000..789d12b8 --- /dev/null +++ b/docs/extraction.rst @@ -0,0 +1,120 @@ +.. _extraction_quickstart: + +Spectrum Extraction +=================== + +The `specreduce.extract` module extracts a 1D spectrum from an input 2D spectrum +(likely a background-extracted spectrum from the previous step) and a defined +window, using one of the following implemented methods: + +* `~specreduce.extract.BoxcarExtract` +* `~specreduce.extract.HorneExtract` + +Each of these takes the input image and trace as inputs (see the :ref:`api_index` for +other required and optional parameters) + +.. code-block:: python + + extract = specreduce.extract.BoxcarExtract(image-bg, trace, width=3) + +or + +.. code-block:: python + + extract = specreduce.extract.HorneExtract(image-bg, trace) + +For the Horne algorithm, the variance array is required. If the input image is +an `~astropy.nddata.NDData` object with ``image.uncertainty`` provided, +then this will be used. Otherwise, the ``variance`` parameter must be set. + +.. code-block:: python + + extract = specreduce.extract.HorneExtract(image-bg, trace, variance=var_array) + +An optional mask array for the image may be supplied to HorneExtract as well. +This follows the same convention and can either be attached to ``image`` if it +is an `~astropy.nddata.NDData` object, or supplied as a keyword argument. + +The extraction methods automatically detect non-finite pixels in the input +image and combine them with the user-supplied mask to prevent them from biasing the +extraction. In the boxcar extraction, the treatment of these pixels is controlled by +the ``mask_treatment`` option. When set to ``exclude`` (the default), non-finite +pixels within the extraction window are excluded from the extraction, and the extracted +flux is scaled according to the effective number of unmasked pixels. When using other +options (``filter`` or ``omit``), the non-finite values may be propagated or treated +differently as documented in the API. + +The previous examples in this section show how to initialize the BoxcarExtract +or HorneExtract objects with their required parameters. To extract the 1D +spectrum + +.. code-block:: python + + spectrum = extract.spectrum + +The ``extract`` object contains all the set options. The extracted 1D spectrum +can be accessed via the ``spectrum`` property or by calling (e.g ``extract()``) +the ``extract`` object (which also allows temporarily overriding any values) + +.. code-block:: python + + spectrum2 = extract(width=6) + +or, for example to override the original ``trace_object`` + +.. code-block:: python + + spectrum2 = extract(trace_object=new_trace) + +Spatial profile options +----------------------- +The Horne algorithm provides two options for fitting the spatial profile to the +cross dispersion direction of the source: a Gaussian fit (default), +or an empirical ``interpolated_profile`` option. + +If the default Gaussian option is used, an optional background model may be +supplied as well (default is a 2D Polynomial) to account +for residual background in the spatial profile. This option is not supported for +``interpolated_profile``. + +If the ``interpolated_profile`` option is used, the image will be sampled in various +wavelength bins (set by ``n_bins_interpolated_profile``), averaged in those bins, and +samples are then interpolated between (linear by default, interpolation degree can +be set with ``interp_degree_interpolated_profile``, which defaults to linear in +x and y) to generate an empirical interpolated spatial profile. Since this option +has two optional parameters to control the fit, the input can either be a string +to indicate that ``interpolated_profile`` should be used for the spatial profile +and to use the defaults for bins and interpolation degree, or to override these +defaults a dictionary can be passed in. + +For example, to use the ``interpolated_profile`` option with default bins and +interpolation degree + +.. code-block:: python + + interp_profile_extraction = extract(spatial_profile='interpolated_profile') + +Or, to override the default of 10 samples and use 20 samples + +.. code-block:: python + + interp_profile_extraction = extract( + spatial_profile={ + 'name': 'interpolated_profile', + 'n_bins_interpolated_profile': 20 + } + ) + +Or, to do a cubic interpolation instead of the default linear + +.. code-block:: python + + interp_profile_extraction = extract( + spatial_profile={ + "name": "interpolated_profile", + "interp_degree_interpolated_profile": 3, + } + ) + +As usual, parameters can either be set when instantiating the HorneExtraxt object, +or supplied/overridden when calling the extraction method on that object. \ No newline at end of file diff --git a/docs/extraction_quickstart.rst b/docs/extraction_quickstart.rst deleted file mode 100644 index febd4198..00000000 --- a/docs/extraction_quickstart.rst +++ /dev/null @@ -1,175 +0,0 @@ -.. _extraction_quickstart: - -=============================== -Spectral Extraction Quick Start -=============================== - -Specreduce provides flexible functionality for extracting a 1D spectrum from a -2D spectral image, including steps for determining the trace of a spectrum, -background subtraction, and extraction. - - -Tracing -======= - -The `specreduce.tracing` module defines the trace of a spectrum on the 2D image. -These traces can either be determined semi-automatically or manually, and are -provided as the inputs for the remaining steps of the extraction process. -Supported trace types include: - -* `~specreduce.tracing.ArrayTrace` -* `~specreduce.tracing.FlatTrace` -* `~specreduce.tracing.FitTrace` - - -Each of these trace classes takes the 2D spectral image as input, as well as -additional information needed to define or determine the trace (see the API docs -above for required parameters for each of the available trace classes):: - - trace = specreduce.tracing.FlatTrace(image, 15) - -.. note:: - The fit for `~specreduce.tracing.FitTrace` may be adversely affected by noise where the spectrum - is faint. Narrowing the window parameter or lowering the order of the fitting function may - improve the result for noisy data. - - -Background -========== - -The `specreduce.background` module generates and subtracts a background image from -the input 2D spectral image. The `~specreduce.background.Background` object is -defined by one or more windows, and can be generated with: - -* `~specreduce.background.Background` -* `Background.one_sided ` -* `Background.two_sided ` - -The center of the window can either be passed as a float/integer or as a trace:: - - bg = specreduce.background.Background.one_sided(image, trace, separation=5, width=2) - - -or, equivalently:: - - bg = specreduce.background.Background.one_sided(image, 15, separation=5, width=2) - - -The background image can be accessed via `~specreduce.background.Background.bkg_image` -and the background-subtracted image via `~specreduce.background.Background.sub_image` -(or ``image - bg``). - -The background and trace steps can be done iteratively, to refine an automated -trace using the background-subtracted image as input. - -Extraction -========== - -The `specreduce.extract` module extracts a 1D spectrum from an input 2D spectrum -(likely a background-extracted spectrum from the previous step) and a defined -window, using one of the following implemented methods: - -* `~specreduce.extract.BoxcarExtract` -* `~specreduce.extract.HorneExtract` - -Each of these takes the input image and trace as inputs (see the API above for -other required and optional parameters):: - - extract = specreduce.extract.BoxcarExtract(image-bg, trace, width=3) - -or:: - - extract = specreduce.extract.HorneExtract(image-bg, trace) - -For the Horne algorithm, the variance array is required. If the input image is -an ``astropy.NDData`` object with ``image.uncertainty`` provided, -then this will be used. Otherwise, the ``variance`` parameter must be set.:: - - extract = specreduce.extract.HorneExtract(image-bg, trace, variance=var_array) - -An optional mask array for the image may be supplied to HorneExtract as well. -This follows the same convention and can either be attached to ``image`` if it -is an ``astropy.NDData`` object, or supplied as a keyword argument. - -The extraction methods automatically detect non-finite pixels in the input -image and combine them with the user-supplied mask to prevent them from biasing the -extraction. In the boxcar extraction, the treatment of these pixels is controlled by -the ``mask_treatment`` option. When set to ``exclude`` (the default), non-finite -pixels within the extraction window are excluded from the extraction, and the extracted -flux is scaled according to the effective number of unmasked pixels. When using other -options (``filter`` or ``omit``), the non-finite values may be propagated or treated -differently as documented in the API. - -The previous examples in this section show how to initialize the BoxcarExtract -or HorneExtract objects with their required parameters. To extract the 1D -spectrum:: - - spectrum = extract.spectrum - -The ``extract`` object contains all the set options. The extracted 1D spectrum -can be accessed via the ``spectrum`` property or by calling (e.g ``extract()``) -the ``extract`` object (which also allows temporarily overriding any values):: - - spectrum2 = extract(width=6) - -or, for example to override the original ``trace_object``:: - spectrum2 = extract(trace_object=new_trace) - -Spatial profile options ------------------------ -The Horne algorithm provides two options for fitting the spatial profile to the -cross dispersion direction of the source: a Gaussian fit (default), -or an empirical ``interpolated_profile`` option. - -If the default Gaussian option is used, an optional background model may be -supplied as well (default is a 2D Polynomial) to account -for residual background in the spatial profile. This option is not supported for -``interpolated_profile``. - - -If the ``interpolated_profile`` option is used, the image will be sampled in various -wavelength bins (set by ``n_bins_interpolated_profile``), averaged in those bins, and -samples are then interpolated between (linear by default, interpolation degree can -be set with ``interp_degree_interpolated_profile``, which defaults to linear in -x and y) to generate an empirical interpolated spatial profile. Since this option -has two optional parameters to control the fit, the input can either be a string -to indicate that ``interpolated_profile`` should be used for the spatial profile -and to use the defaults for bins and interpolation degree, or to override these -defaults a dictionary can be passed in. - -For example, to use the ``interpolated_profile`` option with default bins and -interpolation degree:: - - interp_profile_extraction = extract(spatial_profile='interpolated_profile') - -Or, to override the default of 10 samples and use 20 samples:: - - interp_profile_extraction = extract(spatial_profile={'name': 'interpolated_profile', - 'n_bins_interpolated_profile': 20) - -Or, to do a cubic interpolation instead of the default linear:: - - interp_profile_extraction = extract(spatial_profile={'name': 'interpolated_profile', - 'interp_degree_interpolated_profile': 3) - -As usual, parameters can either be set when instantiating the HorneExtraxt object, -or supplied/overridden when calling the extraction method on that object. - -Example Workflow -================ - -This will produce a 1D spectrum, with flux in units of the 2D spectrum. The -wavelength units will be pixels. Wavelength and flux calibration steps are not -included here. - -Putting all these steps together, a simple extraction process might look -something like:: - - from specreduce.tracing import FlatTrace - from specreduce.background import Background - from specreduce.extract import BoxcarExtract - - trace = FlatTrace(image, 15) - bg = Background.two_sided(image, trace, separation=5, width=2) - extract = BoxcarExtract(image-bg, trace, width=3) - spectrum = extract.spectrum diff --git a/docs/getting_started/index.rst b/docs/getting_started/index.rst new file mode 100644 index 00000000..bf4c86cb --- /dev/null +++ b/docs/getting_started/index.rst @@ -0,0 +1,10 @@ +.. _getting-started: + +Getting started +=============== + +.. toctree:: + :maxdepth: 2 + + installation.rst + quickstart.ipynb \ No newline at end of file diff --git a/docs/getting_started/installation.rst b/docs/getting_started/installation.rst new file mode 100644 index 00000000..dd531292 --- /dev/null +++ b/docs/getting_started/installation.rst @@ -0,0 +1,24 @@ +.. _installation: + +Installation +============ + +Specreduce can be installed from PyPI using ``pip`` + +.. code-block:: console + + pip install specreduce + +from the conda-forge repository using ``conda`` + +.. code-block:: console + + conda install conda-forge::specreduce + +or by cloning the repository from `GitHub `_ + +.. code-block:: console + + git clone https://github.com/astropy/specreduce.git + cd specreduce + pip install . \ No newline at end of file diff --git a/docs/getting_started/quickstart.ipynb b/docs/getting_started/quickstart.ipynb new file mode 100644 index 00000000..b1d10a43 --- /dev/null +++ b/docs/getting_started/quickstart.ipynb @@ -0,0 +1,754 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "d43db469-374f-494f-be9d-aa15caf30045", + "metadata": {}, + "source": [ + "# Spectrum Extraction Quickstart\n", + "\n", + "Specreduce provides a flexible toolset for extracting a 1D spectrum from a\n", + "2D spectral image, including steps for determining the trace of a spectrum,\n", + "background subtraction, extraction, and wavelength calibration.\n", + "\n", + "This quickstart covers the basic spectrum extraction and calibration steps for\n", + "a synthetic science spectrum accompanied by He and Ne arc-lamp\n", + "spectra for wavelength calibration.\n", + "\n", + "This tutorial is written as a Jupyter Notebook. You can run it\n", + "by copying it (and the `quickstart.py` module) from GitHub,\n", + "or from your own specreduce installation under the `docs/getting_started` directory.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1d462ab0-6bc7-4e8b-9bbb-702b43a39241", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T20:37:50.961773Z", + "start_time": "2025-10-21T20:37:49.431235Z" + } + }, + "outputs": [], + "source": [ + "import warnings\n", + "import numpy as np\n", + "import matplotlib.pyplot as plt\n", + "import astropy.units as u\n", + "\n", + "from astropy.modeling import models\n", + "\n", + "from specreduce.tracing import FitTrace\n", + "from specreduce.background import Background\n", + "from specreduce.extract import BoxcarExtract, HorneExtract\n", + "from specreduce.wavecal1d import WavelengthCalibration1D\n", + "\n", + "from quickstart import make_science_and_arcs, plot_2d_spectrum\n", + "\n", + "plt.rcParams['figure.constrained_layout.use'] = True" + ] + }, + { + "cell_type": "markdown", + "id": "1ba4ac1f-0742-4a0b-81dc-6197f82a9c17", + "metadata": {}, + "source": [ + "## Data preparation\n", + "\n", + "First, we read in our science and arc spectra. We use synthetic data\n", + "created by the `quickstart.make_science_and_arcs` utility function, which itself calls\n", + "`specreduce.utils.synth_data.make_2d_arc_image` and `specreduce.utils.synth_data.make_2d_spec_image`.\n", + "The science frame (`sci`) and the two arc frames (`arc_he` and `arc_ne`)\n", + "are returned as `astropy.nddata.CCDData` objects with `astropy.nddata.StdDevUncertainty`\n", + "uncertainties.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2e616221-6e7d-426d-8fe0-9568845d27f4", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:13:17.955893Z", + "start_time": "2025-10-21T21:13:17.049304Z" + } + }, + "outputs": [], + "source": [ + "sci2d, (arc2d_he, arc2d_ne) = make_science_and_arcs(1000, 300)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4ce01aed-67c2-4d86-9e9a-5c230e276271", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:13:19.389105Z", + "start_time": "2025-10-21T21:13:18.997903Z" + } + }, + "outputs": [], + "source": [ + "fig, axs = plt.subplots(3, 1, figsize=(6, 4), sharex='all')\n", + "plot_2d_spectrum(sci2d, ax=axs[0], label='Science')\n", + "for i, (arc, label) in enumerate(zip((arc2d_he, arc2d_ne), \"HeI NeI\".split())):\n", + " plot_2d_spectrum(arc, ax=axs[i+1], label=label)\n", + "plt.setp(axs[:-1], xlabel='')\n", + "plt.setp(axs, ylabel='C.D. axis [pix]');" + ] + }, + { + "cell_type": "markdown", + "id": "abbbccbd06a57afd", + "metadata": {}, + "source": [ + "## Spectrum Tracing\n", + "\n", + "The `specreduce.tracing` module provides classes for calculating the trace of a spectrum\n", + "on a 2D image. Traces can be determined semi-automatically or manually and are used as inputs\n", + "for the remaining extraction steps. Supported trace types include:\n", + "\n", + "- `specreduce.tracing.ArrayTrace`\n", + "- `specreduce.tracing.FlatTrace`\n", + "- `specreduce.tracing.FitTrace`\n", + "\n", + "Each trace class takes the 2D spectral image as input, along with any additional information\n", + "needed to define or determine the trace (see the API docs for required parameters).\n", + "\n", + "In this example, the spectrum exhibits significant curvature along the cross-dispersion axis,\n", + "so `FlatTrace` is not suitable. Instead, we use `FitTrace`, which fits an Astropy model to the trace.\n", + "\n", + "We choose to fit a third-degree polynomial and bin the spectrum into 40 bins along the dispersion\n", + "axis. `FitTrace` estimates the PSF centroid along the cross-dispersion axis for each bin, and then\n", + "fits the model to those centroids. Binning along the dispersion axis helps stabilize the fit against\n", + "noise if the spectrum is faint." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cab67d99-23c8-44a9-9c73-7638a804bfff", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:13:27.808661Z", + "start_time": "2025-10-21T21:13:27.796122Z" + } + }, + "outputs": [], + "source": [ + "trace = FitTrace(sci2d, bins=40, guess=200, trace_model=models.Polynomial1D(3))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3bde4d81-8df5-4742-93c0-cbd35d399679", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:13:28.172408Z", + "start_time": "2025-10-21T21:13:27.984481Z" + } + }, + "outputs": [], + "source": [ + "fig, ax = plot_2d_spectrum(sci2d)\n", + "ax.plot(trace.trace, 'k', ls='--');" + ] + }, + { + "cell_type": "markdown", + "id": "69dc8cb2-1b42-4406-9869-e354c765ec14", + "metadata": {}, + "source": [ + "## Background Subtraction\n", + "\n", + "The `specreduce.background` module contains tools to generate and subtract a \n", + "background image from the input 2D spectral image. The `specreduce.background.Background` \n", + "object is defined by one or more windows, and can be generated with:\n", + "\n", + "* `specreduce.background.Background.one_sided`\n", + "* `specreduce.background.Background.two_sided`\n", + " \n", + "The center of the window can either be passed as a float, integer, or trace\n", + "\n", + "```Python\n", + "bg = Background.one_sided(sci, trace, separation=5, width=2)\n", + "```\n", + "\n", + "or, equivalently\n", + "\n", + "```Python\n", + "bg = Background.one_sided(sci, 15, separation=5, width=2)\n", + "```\n", + "\n", + "The estimated background image can be accessed via `specreduce.background.Background.bkg_image`\n", + "and the background-subtracted image via `specreduce.background.Background.sub_image`.\n", + "\n", + "Let's calculate and remove a two-sided background estimate." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "178de389-ca6c-4468-8f86-4b056ae82d00", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:13:35.434329Z", + "start_time": "2025-10-21T21:13:35.401616Z" + } + }, + "outputs": [], + "source": [ + "background = Background.two_sided(sci2d, trace, separation=50, width=30)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "af59b997-0692-4855-8e1a-5d0b6ee9a0dd", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T20:37:53.279056Z", + "start_time": "2025-10-21T20:37:53.085413Z" + } + }, + "outputs": [], + "source": [ + "fig, ax = plot_2d_spectrum(sci2d)\n", + "ax.plot(trace.trace, 'k--')\n", + "for bkt in background.traces:\n", + " ax.fill_between(np.arange(sci2d.shape[1]),\n", + " bkt.trace - background.width / 2,\n", + " bkt.trace + background.width / 2,\n", + " fc='w', alpha=0.2)\n", + " ax.plot(bkt.trace+background.width/2, 'w--', lw=1)\n", + " ax.plot(bkt.trace-background.width/2, 'w--', lw=1)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "13510b0a-6f9e-4bda-aaf2-431ae42a2564", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:14:37.598262Z", + "start_time": "2025-10-21T21:14:37.589326Z" + } + }, + "outputs": [], + "source": [ + "sci2d_clean = background.sub_image()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "9e9b21c9-d948-43bc-882f-7c7b0a3aa86d", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:14:37.986257Z", + "start_time": "2025-10-21T21:14:37.801907Z" + } + }, + "outputs": [], + "source": [ + "fig, ax = plot_2d_spectrum(sci2d_clean.flux.value)" + ] + }, + { + "cell_type": "markdown", + "id": "6b68f282-99d8-41ab-b6cd-5080f855bc69", + "metadata": {}, + "source": [ + "## Spectrum Extraction\n", + "\n", + "The `specreduce.extract` module extracts a 1D spectrum from an input 2D spectrum\n", + "(likely a background-extracted spectrum from the previous step) and a defined\n", + "window, using one of the following implemented methods:\n", + "\n", + "* `specreduce.extract.BoxcarExtract`\n", + "* `specreduce.extract.HorneExtract`\n", + "\n", + "The methods take the input image and trace as inputs, an optional mask treatment\n", + "agrumtn, and a set of method-specific arguments fine-tuning the behavior of each\n", + "method.\n", + "\n", + "### Boxcar Extraction\n", + "\n", + "Boxcar extraction requires a 2D spectrum, a trace, and the extraction aperture width." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "b7b1f2f2-0fe7-48e2-9952-205977c76d62", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:14:38.982259Z", + "start_time": "2025-10-21T21:14:38.969168Z" + } + }, + "outputs": [], + "source": [ + "aperture_width = 20\n", + "e = BoxcarExtract(sci2d_clean, trace, width=aperture_width)\n", + "sci1d_boxcar = e.spectrum" + ] + }, + { + "cell_type": "markdown", + "id": "6ff894f4-88d2-4431-a09f-17005fcb4053", + "metadata": {}, + "source": [ + "The extracted spectrum can be accessed via the `spectrum` property or by calling\n", + "the `extract` object, which also allows you to override the values the object\n", + "was initialized with." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3f5e6c0c-621e-4852-9141-921b6b663793", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:14:42.631810Z", + "start_time": "2025-10-21T21:14:42.374783Z" + } + }, + "outputs": [], + "source": [ + "fig, axs = plt.subplots(2, 1, figsize=(6, 4), sharex='all')\n", + "plot_2d_spectrum(sci2d_clean.flux.value, ax=axs[0])\n", + "axs[0].fill_between(np.arange(sci2d_clean.shape[1]),\n", + " trace.trace - aperture_width / 2,\n", + " trace.trace + aperture_width / 2,\n", + " fc='w', alpha=0.25, ec='k', ls='--')\n", + "\n", + "axs[1].plot(sci1d_boxcar.flux)\n", + "plt.setp(axs[1],\n", + " ylim = (0, 1500),\n", + " xlabel=f'Wavelength [{sci1d_boxcar.spectral_axis.unit}]',\n", + " ylabel=f'Flux [{sci1d_boxcar.flux.unit}]');\n", + "fig.align_ylabels()" + ] + }, + { + "cell_type": "markdown", + "id": "d92ea556-6ae6-4584-825d-78ecaabce8b3", + "metadata": {}, + "source": [ + "### Horne Extraction\n", + "\n", + "\n", + "Horne extraction (a.k.a. optimal extraction) fits the source’s spatial profile across the\n", + "cross-dispersion direction using one of two approaches:\n", + "- A Gaussian profile (default). Optionally, you may include a background model (default is\n", + " a 2D polynomial) to account for residual background in the spatial profile. This\n", + " background-model option is not supported with the interpolated profile.\n", + "- An empirical interpolated profile, enabled via `interpolated_profile`.\n", + "\n", + "Using the `interpolated_profile` option:\n", + "- The image is binned along the wavelength axis (number of bins set by `n_bins_interpolated_profile`),\n", + " averaged within each bin, and then interpolated (linear by default; the interpolation degree in\n", + " x and y can be set via `interp_degree_interpolated_profile`) to form an empirical spatial profile.\n", + "- You can select this mode by passing a string to use default settings, or a dictionary to override the defaults.\n", + "\n", + "Examples:\n", + "Use the interpolated profile with default bins and interpolation degree:\n", + " \n", + " interp_profile_extraction = extract(spatial_profile='interpolated_profile')\n", + "\n", + "Use 20 bins instead of the default of 10:\n", + "\n", + " interp_profile_extraction = extract(spatial_profile={\n", + " 'name': 'interpolated_profile',\n", + " 'n_bins_interpolated_profile': 20\n", + " })\n", + "\n", + "Use cubic interpolation instead of the default linear:\n", + "\n", + " interp_profile_extraction = extract(spatial_profile={\n", + " 'name': 'interpolated_profile',\n", + " 'interp_degree_interpolated_profile': 3\n", + " })\n", + "\n", + "The Horne extraction algorithm requires a variance array. If the input image is\n", + "an `astropy.nddata.NDData` object with `image.uncertainty` provided, that uncertainty\n", + "will be used. Otherwise, you must supply the `variance` parameter.\n", + "\n", + " extract = HorneExtract(image - bg, trace, variance=var_array)\n", + "\n", + "As usual, parameters can be set when creating the `HorneExtract` object or overridden when calling the object’s extraction method.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c356be3a-7358-4f3f-888d-b75832e48548", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:14:58.294487Z", + "start_time": "2025-10-21T21:14:58.135177Z" + } + }, + "outputs": [], + "source": [ + "e = HorneExtract(sci2d_clean, trace)\n", + "sci1d_horne = e.spectrum" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c05bcd15-ac47-4e81-a2e5-d725e86099a6", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:14:58.729932Z", + "start_time": "2025-10-21T21:14:58.639898Z" + } + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(1, 1, figsize=(6, 2), sharex='all')\n", + "ax.plot(sci1d_horne.flux)\n", + "plt.setp(ax,\n", + " ylim = (0, 1500),\n", + " xlabel=f'Wavelength [{sci1d_horne.spectral_axis.unit}]',\n", + " ylabel=f'Flux [{sci1d_horne.flux.unit}]')\n", + "ax.autoscale(axis='x', tight=True)" + ] + }, + { + "cell_type": "markdown", + "id": "d9fa857a-b6bb-4057-8476-1ffc328a0b24", + "metadata": {}, + "source": [ + "## Wavelength Calibration\n", + "\n", + "The `specreduce.wavecal1d.WavelengthCalibration1D` class provides tools for\n", + "one-dimensional wavelength calibration given a number of arc calibration spectra and\n", + "corresponding catalog line lists.\n", + "\n", + "First, we need to extract the arc spectra from the 2D arc frames using the trace\n", + "we calculated from our science spectrum. Next, we instantiate a `WavelengthCalibration1D`\n", + "object and pass the arc spectra and the line lists to use. We also pass it the wavelength\n", + "unit we want to use, the line list bounds (we know that our instrument setup gives\n", + "a spectrum that covers approximately the range of 500-1000 nm), and the number of strongest\n", + "lines to include from the catalog line lists." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "2f5a6050-d396-45bd-92e7-120a8a0d570d", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:15:32.791564Z", + "start_time": "2025-10-21T21:15:32.768331Z" + } + }, + "outputs": [], + "source": [ + "arc1d_he = BoxcarExtract(arc2d_he, trace, width=aperture_width).spectrum\n", + "arc1d_ne = BoxcarExtract(arc2d_ne, trace, width=aperture_width).spectrum" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "05e0d6e5-b493-4ca7-a25d-e22d7ecfc9d2", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:15:33.337471Z", + "start_time": "2025-10-21T21:15:33.329630Z" + } + }, + "outputs": [], + "source": [ + "wc = WavelengthCalibration1D([arc1d_he, arc1d_ne],\n", + " line_lists=[\"HeI\", \"NeI\"],\n", + " line_list_bounds=(500, 1000),\n", + " n_strogest_lines=15,\n", + " unit=u.nm)" + ] + }, + { + "cell_type": "markdown", + "id": "734f8364a6c86f1e", + "metadata": {}, + "source": [ + "### Line Finding\n", + "\n", + "Next, we wse `WavelengthCalibration1D.find_lines` to detect emission lines in the\n", + "arc spectra. Then, we visualize the catalog lines alongside the arc spectra and the\n", + "detected lines with `WavelengthCalibration1D.plot_fit`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "844e3994-367e-4936-8f4f-245215a4ece7", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:15:35.352789Z", + "start_time": "2025-10-21T21:15:35.300727Z" + } + }, + "outputs": [], + "source": [ + "with warnings.catch_warnings():\n", + " warnings.simplefilter('ignore')\n", + " wc.find_lines(3, noise_factor=25)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "dceca5d1-1872-42ef-b6b4-5eacca755c41", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:15:36.018727Z", + "start_time": "2025-10-21T21:15:35.515189Z" + } + }, + "outputs": [], + "source": [ + "fig = wc.plot_fit(figsize=(6, 6))\n", + "plt.setp(fig.axes[::2], xlim=(490, 1000));" + ] + }, + { + "cell_type": "markdown", + "id": "8c97a1c9-0d2c-4947-96b2-1288b128ee38", + "metadata": {}, + "source": [ + "### Wavelength Solution Fitting\n", + "\n", + "With the observed and catalog lines identified, we can now compute the wavelength\n", + "solution, represented by a `specreduce.wavesol1d.WavelengthSolution1D` object. The\n", + "wavelength calibration class provides several methods; here we use the interactive\n", + "`fit_lines` method. This method:\n", + "- takes a list of matched pixel positions and their corresponding wavelengths,\n", + "- fits a polynomial to these pairs, and\n", + "- refines the solution by incorporating all observed and catalog lines within a\n", + " specified pixel distance of the initial fit." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "4b77b125-5f15-4e49-b6e4-29c6607a28d7", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:15:38.870556Z", + "start_time": "2025-10-21T21:15:38.863758Z" + } + }, + "outputs": [], + "source": [ + "ws = wc.fit_lines([72, 295, 403, 772], [588, 668, 707, 838], \n", + " degree=3, refine_max_distance=5)" + ] + }, + { + "cell_type": "markdown", + "id": "e10c3e233717766d", + "metadata": {}, + "source": [ + "Now we can visualize the observed and catalog lines again. Set `obs_to_wav=True`\n", + "to display the observed arc spectra and lines in wavelength space. Matched fitted\n", + "observed and catalog lines are shown with solid lines; unmatched lines are shown\n", + "with dashed lines.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a62b4b71-789e-4df5-8ec2-61ea4d350ceb", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:15:40.398662Z", + "start_time": "2025-10-21T21:15:39.874303Z" + } + }, + "outputs": [], + "source": [ + "fig = wc.plot_fit(figsize=(6, 6), obs_to_wav=True)\n", + "plt.setp(fig.axes, xlim=ws.p2w([0, 1000]));" + ] + }, + { + "cell_type": "markdown", + "id": "ad7df493fdef8432", + "metadata": {}, + "source": [ + "We can also visualize the residuals between the observed and catalog lines\n", + "(either in wavelength or pixel space) to assess the quality of the fit.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "77063157-9cd8-4ec8-8082-80b836efaa24", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:15:43.910595Z", + "start_time": "2025-10-21T21:15:43.693771Z" + } + }, + "outputs": [], + "source": [ + "fig, axs = plt.subplots(2, 1, figsize=(6, 4), constrained_layout=True)\n", + "wc.plot_residuals(ax=axs[0], space=\"wavelength\")\n", + "wc.plot_residuals(ax=axs[1], space=\"pixel\")\n", + "fig.align_ylabels()" + ] + }, + { + "cell_type": "markdown", + "id": "ddac0171bbb9b7a7", + "metadata": {}, + "source": [ + "## Spectrum Resampling\n", + "\n", + "With a satisfactory wavelength solution in hand, we can finalize the reduction. You have two options:\n", + "- Attach the `gwcs` object from the `WavelengthSolution1D` to the science spectrum, or\n", + "- Resample the science spectrum onto a defined wavelength grid using `WavelengthSolution1D.resample`.\n", + "\n", + "Here, we choose to resample the spectrum.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f800abf7-442d-4919-8652-1f4eba3d22dd", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:10:08.038359Z", + "start_time": "2025-10-21T21:10:08.026830Z" + } + }, + "outputs": [], + "source": [ + "sci1d_resampled = ws.resample(sci1d_boxcar)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "549f06a81dfeb7b5", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:10:29.633337Z", + "start_time": "2025-10-21T21:10:29.514489Z" + } + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(6, 3))\n", + "ax.plot(sci1d_resampled.spectral_axis, sci1d_resampled.flux)\n", + "plt.setp(ax,\n", + " ylim = (0, 1500),\n", + " xlabel=f\"Wavelength [{sci1d_resampled.spectral_axis.unit}]\",\n", + " ylabel=f\"Flux density [{sci1d_resampled.flux.unit}]\")\n", + "ax.autoscale(axis='x', tight=True)" + ] + }, + { + "cell_type": "markdown", + "id": "d21011efe913e431", + "metadata": {}, + "source": [ + "By default, the `resample` method creates a linear wavelength grid spanning the\n", + "spectrum’s range, with as many bins as there are input pixels. However, you can\n", + "also fully customize the grid by supplying explicit `bin_edges`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6ecfdfd0eb9b87ed", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:16:29.849432Z", + "start_time": "2025-10-21T21:16:29.839531Z" + } + }, + "outputs": [], + "source": [ + "bin_edges = np.concatenate([np.geomspace(ws.pix_to_wav(0), 650, num=100), \n", + " np.linspace(660, 800, 50),\n", + " np.linspace(810, ws.pix_to_wav(999), 400)])\n", + "\n", + "sci1d_resampled = ws.resample(sci1d_boxcar, bin_edges=bin_edges)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "68b062f209cde4a9", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:16:31.032973Z", + "start_time": "2025-10-21T21:16:30.920578Z" + } + }, + "outputs": [], + "source": [ + "fig, ax = plt.subplots(figsize=(6, 3))\n", + "ax.plot(sci1d_resampled.spectral_axis, sci1d_resampled.flux, '-')\n", + "plt.setp(ax,\n", + " ylim = (0, 1500),\n", + " xlabel=f\"Wavelength [{sci1d_resampled.spectral_axis.unit}]\",\n", + " ylabel=f\"Flux density [{sci1d_resampled.flux.unit}]\")\n", + "ax.autoscale(axis='x', tight=True)" + ] + }, + { + "cell_type": "markdown", + "id": "9e52e79e95c73038", + "metadata": {}, + "source": [ + "Finally, we can save the spectrum as fits." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8ab90caa-0453-4c36-b66f-5b28dea205db", + "metadata": { + "ExecuteTime": { + "end_time": "2025-10-21T21:00:44.342319Z", + "start_time": "2025-10-21T21:00:44.319720Z" + } + }, + "outputs": [], + "source": [ + "sci1d_resampled.write('science_spectrum.fits', overwrite=True)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.10" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/docs/getting_started/quickstart.py b/docs/getting_started/quickstart.py new file mode 100644 index 00000000..d3adbd62 --- /dev/null +++ b/docs/getting_started/quickstart.py @@ -0,0 +1,197 @@ +from typing import Sequence + +import numpy as np +import matplotlib.pyplot as plt +from astropy.modeling import models, Model + +from astropy.wcs import WCS +from astropy.nddata import StdDevUncertainty, CCDData, VarianceUncertainty +import astropy.units as u +from photutils.datasets import apply_poisson_noise + +from specreduce.utils.synth_data import make_2d_arc_image, make_2d_trace_image + + +def make_2d_spec_image( + nx: int = 1000, + ny: int = 300, + wcs: WCS | None = None, + extent: Sequence[int | float] = (6500, 9500), + wave_unit: u.Unit = u.Angstrom, + wave_air: bool = False, + background: int | float = 5, + line_fwhm: float = 5.0, + linelists: list[str] = ("OH_GMOS"), + airglow_amplitude: float = 1.0, + spectrum_amplitude: float = 1.0, + tilt_func: Model = models.Legendre1D(degree=0), + trace_center: int | float | None = None, + trace_order: int = 3, + trace_coeffs: None | dict[str, int | float] = None, + source_profile: Model = models.Moffat1D(amplitude=10, alpha=0.1), + add_noise: bool = True, +) -> CCDData: + """ + Generate a simulated 2D spectroscopic image. + + This function creates a two-dimensional synthetic spectroscopic image, combining + arc (wavelength calibration) data and trace (spatial profile) data. The image simulates + a realistic spectral profile with contributions from background, airglow, and a modeled + source profile. Noise can optionally be added to the resulting image. It employs models + for traces and tilts, and allows customization of wavelength range, amplitude scales, + and noise addition. + + Parameters + ---------- + nx : int, optional + Number of columns (spatial dimension) in the generated image, by default 1000. + ny : int, optional + Number of rows (dispersion dimension) in the generated image, by default 300. + wcs : WCS or None, optional + World Coordinate System (WCS) object for the image. Specifies spectral coordinates. + If None, WCS is not assigned, by default None. + extent : Sequence[int or float], optional + Wavelength range for the generated image in units determined by `wave_unit`. + Defined as a tuple (min, max), by default (6500, 9500). + wave_unit : Unit, optional + Unit of the generated wavelength axis, by default astropy.units.Angstrom. + wave_air : bool, optional + If True, use air wavelengths. If False, use vacuum wavelengths, by default False. + background : int or float, optional + Constant background level added to the image, by default 5. + line_fwhm : float, optional + Full-width half maximum (in pixels) for spectral lines in the image, by default 5.0. + linelists : list of str, optional + Names of line lists (e.g., emission lines) to simulate in the arc image, + by default ["OH_GMOS"]. + airglow_amplitude : float, optional + Scaling factor for the airglow contribution to the image, by default 1.0. + spectrum_amplitude : float, optional + Scaling factor for the primary spectrum contribution (trace image), by default 1.0. + tilt_func : Model, optional + Astropy model representing the spectral tilt across the spatial axis. By default, + a zero-degree Legendre polynomial (no tilt) is used. + trace_center : int, float, or None, optional + Central position of the trace in spatial pixels. If None, defaults to trace modeled + by coefficients in `trace_coeffs`, by default None. + trace_order : int, optional + Polynomial order to model the trace profile spatial variation, by default 3. + trace_coeffs : None or dict of str to int or float, optional + Coefficients for modeling the trace position (spatial axis). If None, defaults to + {"c0": 0, "c1": 50, "c2": 100}, by default None. + source_profile : Model, optional + Astropy model to simulate the source profile along the trace, by default Moffat1D with + amplitude=10 and alpha=0.1. + add_noise : bool, optional + If True, adds Poisson noise to the generated spectral image, by default True. + + Returns + ------- + CCDData + A CCDData object containing the simulated 2D spectroscopic image. The data includes + contributions from arc lines, traces, airglow, and background, with optional noise + added. The WCS information, if provided, is preserved. + """ + + if trace_coeffs is None: + trace_coeffs = {"c0": 0, "c1": 50, "c2": 100} + + arc_image = make_2d_arc_image( + nx=nx, + ny=ny, + wcs=wcs, + extent=extent, + wave_unit=wave_unit, + wave_air=wave_air, + background=0, + line_fwhm=line_fwhm, + linelists=linelists, + tilt_func=tilt_func, + add_noise=False, + ) + + trace_image = make_2d_trace_image( + nx=nx, + ny=ny, + background=0, + trace_center=trace_center, + trace_order=trace_order, + trace_coeffs=trace_coeffs, + profile=source_profile, + add_noise=False, + ) + + wl = wcs.spectral.pixel_to_world(np.arange(nx)).to(u.nm).value + signal = 0.8 + 0.2 * np.abs(np.sin((wl - 650) / 100 * 2 * np.pi)) ** 5 + + n = lambda a: a / a.max() + spec_image = ( + airglow_amplitude * n(arc_image.data) + + spectrum_amplitude * n(trace_image.data) * signal + + background + ) + + if add_noise: + from photutils.datasets import apply_poisson_noise + + spec_image = apply_poisson_noise(spec_image) + + return CCDData( + spec_image, + unit=u.count, + wcs=arc_image.wcs, + uncertainty=StdDevUncertainty(np.sqrt(spec_image)), + ) + + +def make_science_and_arcs(ndisp: int = 1000, ncross: int = 300): + refx = ndisp // 2 + wcs = WCS( + header={ + "CTYPE1": "AWAV-GRA", # Grating dispersion function with air wavelengths + "CUNIT1": "Angstrom", # Dispersion units + "CRPIX1": refx, # Reference pixel [pix] + "CRVAL1": 7410, # Reference value [Angstrom] + "CDELT1": 3 * 1.19, # Linear dispersion [Angstrom/pix] + "PV1_0": 5.0e5, # Grating density [1/m] + "PV1_1": 1, # Diffraction order + "PV1_2": 8.05, # Incident angle [deg] + "CTYPE2": "PIXEL", # Spatial detector coordinates + "CUNIT2": "pix", # Spatial units + "CRPIX2": 1, # Reference pixel + "CRVAL2": 0, # Reference value + "CDELT2": 1, # Spatial units per pixel + } + ) + + science = make_2d_spec_image( + ndisp, + ncross, + add_noise=True, + background=10, + wcs=wcs, + airglow_amplitude=40.0, + spectrum_amplitude=160.0, + trace_coeffs={"c0": 0, "c1": 30, "c2": 40}, + source_profile=models.Moffat1D(amplitude=1, alpha=0.3), + ) + + arcargs = dict(wcs=wcs, line_fwhm=3, background=0, add_noise=False) + arcs = [] + for linelist in ["HeI", "NeI"]: + arc = make_2d_arc_image(ndisp, ncross, linelists=[linelist], **arcargs) + arc.data = apply_poisson_noise(200*(arc.data / arc.data.max()) + 10) - 10 + arcs.append(arc) + return science, arcs + + +def plot_2d_spectrum(spec, ax=None, label=None): + if ax is None: + fig, ax = plt.subplots(figsize=(6, 2), constrained_layout=True) + else: + fig = ax.figure + ax.imshow(spec, origin="lower", aspect="auto") + if label is not None: + ax.text(0.98, 0.9, label, va="top", ha="right", transform=ax.transAxes, c="w") + plt.setp(ax, xlabel="Dispersion axis [pix]", ylabel="Cross-disp. axis [pix]") + return fig, ax diff --git a/docs/index.rst b/docs/index.rst index 45f39c04..66cdb81b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,84 +1,121 @@ -######################## -Specreduce Documentation -######################## - -The `specreduce `_ package -aims to provide a data reduction toolkit for optical -and infrared spectroscopy, on which applications such as pipeline processes for -specific instruments can be built. The scope of its functionality is limited to -basic spectroscopic reduction, currently encompassing the following tasks: - -#. Determining the trace of a spectrum dispersed in a 2D image, either by setting a flat - trace, providing a custom trace array, or fitting a spline, polynomial, or other model - to the positions of the dispersed spectrum. -#. Generating a background based on a region on one or both sides of this trace, and making - available the background image, 1D spectrum of the background, and the - background-subtracted image. -#. Performing either a Horne (a.k.a. "optimal") or boxcar extraction on either the original - or background-subtracted 2D spectrum, using the trace generated by the first task to - generate a 1D spectrum. -#. Calculating a 1D wavelength solution from arc spectra that can be used to resample the - science spectra to produce wavelength-calibrated spectra, or stored to be used by other tools. - -Further details about these capabilities are detailed in the sections linked below. -Beyond these tasks, basic *image* processing steps (such as bias subtraction) are covered by -`ccdproc `_ -and other packages, data analysis by `specutils `_, -and visualization by `matplotlib `_. A few -examples will be provided that feature ``specreduce`` in conjunction with these -complementary packages. +.. _docroot: -.. note:: - - Specreduce is currently an incomplete work-in-progress and is liable to - change. Please feel free to contribute code and suggestions through github. +########## +Specreduce +########## +| **Version**: |release| +| **Date**: |today| -.. _spectral-extraction: +:ref:`Specreduce ` is an `Astropy `_ +`coordinated package `_ that +provides a toolkit for reducing optical and infrared spectroscopic data. It +offers the building blocks for basic spectroscopic reduction, and is designed to serve as a +foundation upon which instrument-specific pipelines and analysis tools can be built. -******************* -Spectral Extraction -******************* +Specreduce includes tools for determining and modeling spectral traces, performing +background subtraction, extracting one-dimensional spectra using both optimal and boxcar methods, +and applying wavelength correction derived from calibration data. +Beyond these tasks, basic image processing steps, data analysis, and visualisation are covered by +other Astropy ecosystem packages like `ccdproc `_, +`specutils `_, and +`matplotlib `_. The documentation includes examples demonstrating how these +tools can be combined to create complete spectroscopic workflows. -.. toctree:: - :maxdepth: 2 +.. image:: + roadmap.png - extraction_quickstart.rst +.. note:: -*********** -Calibration -*********** + Specreduce is an active, community-driven project, and we warmly welcome contributions of all kinds, + from reporting bugs and suggesting new features to improving documentation or writing code. + Whether you are an experienced developer or just getting started, your input helps make Specreduce + better for everyone. If you would like to get involved, see the :ref:`contributor's guide + ` for details on how to participate and become part of the wider Astropy + community. .. toctree:: :maxdepth: 1 + :hidden: - wavelength_calibration/wavelength_calibration.rst - extinction.rst - specphot_standards.rst - mask_treatment/mask_treatment.rst + getting_started/index + user_guide.rst + contributing.rst + api.rst -***** -Index -***** +.. grid:: 2 + :gutter: 2 3 4 4 -.. toctree:: - :maxdepth: 1 + .. grid-item-card:: + :text-align: center - api.rst + **Getting Started** + ^^^^^^^^^^^^^^^^^^^ -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + New to Specreduce? Check out the getting started guides. + +++ -**************** -Development Docs -**************** + .. button-ref:: getting_started/index + :expand: + :color: primary + :click-parent: -.. toctree:: - :maxdepth: 1 + To the getting started guides + + .. grid-item-card:: + :text-align: center + + **User Guide** + ^^^^^^^^^^^^^^ + + The user guide provides in-depth information on the key concepts + of Specreduce with useful background information and explanation. + + +++ + + .. button-ref:: user_guide + :expand: + :color: primary + :click-parent: + + To the user guide + + .. grid-item-card:: + :text-align: center + + **API Reference** + ^^^^^^^^^^^^^^^^^ + + The API reference contains a detailed description of the + functions, modules, and objects included in Specreduce. It + assumes that you have an understanding of the key concepts. + + +++ + + .. button-ref:: api + :expand: + :color: primary + :click-parent: + + To the API reference + + .. grid-item-card:: + :text-align: center + + **Contributor's Guide** + ^^^^^^^^^^^^^^^^^^^^^^^ + + Want to contribute to Specreduce? Found a bug? The contributing guidelines will + show you how to improve specreduce. + + +++ + + .. button-ref:: contributing + :expand: + :color: primary + :click-parent: - process/index - terms + To the contributor's guide diff --git a/docs/roadmap.png b/docs/roadmap.png new file mode 100644 index 00000000..2a06fe14 Binary files /dev/null and b/docs/roadmap.png differ diff --git a/docs/terms.rst b/docs/terms.rst index 815233e0..304bdfac 100644 --- a/docs/terms.rst +++ b/docs/terms.rst @@ -476,10 +476,9 @@ Processing Steps what redshift?) ----- Mentioned but not defined -------------------------- +========================= - WCS & Database archive - Cloud archiving diff --git a/docs/trace.rst b/docs/trace.rst new file mode 100644 index 00000000..13f104ab --- /dev/null +++ b/docs/trace.rst @@ -0,0 +1,169 @@ +.. _tracing: + +Tracing +======= + +The `specreduce.tracing` module provides three ``Trace`` classes that are used to define the +spatial position (trace) of a spectrum across a 2D detector image: `~specreduce.tracing.FlatTrace`, +`~specreduce.tracing.FitTrace`, and `~specreduce.tracing.ArrayTrace`. Each trace class requires +the 2D spectral image as input, along with trace-specific parameters that control how the trace +is determined. + +FlatTrace +--------- + +`~specreduce.tracing.FlatTrace` assumes that the spectrum follows a straight line across the +detector, and is best for well-aligned spectrographs with minimal optical distortion. To +initialize a `~specreduce.tracing.FlatTrace`, specify the fixed cross-dispersion pixel +position with the ``trace_pos`` argument: + +.. code-block:: python + + trace = specreduce.tracing.FlatTrace(image, trace_pos=6) + + +.. plot:: + + import numpy as np + import matplotlib.pyplot as plt + from scipy.stats import norm + + fw = 10 + nd, ncd = 31, 13 + xd, xcd = np.arange(nd), np.arange(ncd) + + spectrum = np.zeros((ncd, nd)) + spectrum[:,:] = norm(6.0, 1.5).pdf(xcd)[:, None] + + plt.rc('font', size=13) + fig, ax = plt.subplots(figsize=(fw, fw*(ncd/nd)), constrained_layout=True) + ax.imshow(spectrum, origin='lower') + ax.plot((0, nd-1), (6, 6), c='k') + ax.set_xticks(xd+0.5, minor=True) + ax.set_yticks(xcd+0.5, minor=True) + ax.grid(alpha=0.25, lw=1, which='minor') + plt.setp(ax, xlabel='Dispersion axis', ylabel='Cross-dispersion axis') + fig.show() + +FitTrace +-------- + +`~specreduce.tracing.FitTrace` fits a polynomial function to automatically detected spectrum +positions, and is suitable for typical spectra with smooth, continuous trace profiles. The trace +model can be chosen from `~astropy.modeling.polynomial.Chebyshev1D`, +`~astropy.modeling.polynomial.Legendre1D`, `~astropy.modeling.polynomial.Polynomial1D`, +or `~astropy.modeling.spline.Spline1D`, and the fitting can be optimized by binning the spectrum +along the dispersion axis. + +.. code-block:: python + + trace = specreduce.tracing.FitTrace(image, bins=10, trace_model=Polynomial1D(3)) + +.. plot:: + + import numpy as np + import matplotlib.pyplot as plt + from scipy.stats import norm + plt.rc('font', size=13) + + fw = 10 + nd, ncd = 31, 13 + xd, xcd = np.arange(nd), np.arange(ncd) + + tr = np.poly1d([-0.01, 0.2, 7.0]) + spectrum = np.zeros((ncd, nd)) + for i,x in enumerate(xd): + spectrum[:,i] = norm(tr(x), 1.0).pdf(xcd) + + fig, ax = plt.subplots(figsize=(fw, fw*(ncd/nd)), constrained_layout=True) + ax.imshow(spectrum, origin='lower') + ax.plot(xd, tr(xd), 'k') + ax.set_xticks(xd+0.5, minor=True) + ax.set_yticks(xcd+0.5, minor=True) + ax.grid(alpha=0.25, lw=1, which='minor') + plt.setp(ax, xlabel='Dispersion axis', ylabel='Cross-dispersion axis') + fig.show() + +The method works by (optionally) binning the 2D spectrum along the dispersion axis, finding +the PSF peak position along the cross-dispersion for each bin, and then fitting a 1D polynomial to +the cross-dispersion and dispersion axis positions. Binning is optional, and the native image +resolution is used by default. Binning can significantly increase the reliability of the fitting +with low SNR spectra, and always increases the speed. + +The peak detection method can be chosen from ``max``, ``centroid``, and ``gaussian``. Of these +methods, ``max`` is the fastest but yields an integer pixel precision. Both ``centroid`` and +``gaussian`` can be used when sub-pixel precision is required, and ``gaussian``, while being the +slowest method of the three, is the best option if the data is significantly contaminated by +non-finite values. + +.. plot:: + + import numpy as np + import matplotlib.pyplot as plt + from scipy.stats import norm + from numpy.random import seed, normal + from astropy.modeling.models import Gaussian1D + from astropy.modeling.fitting import DogBoxLSQFitter + plt.rc('font', size=13) + seed(5) + + fw = 10 + nd, ncd = 31, 13 + xd, xcd = np.arange(nd), np.arange(ncd) + + psf = norm(5.4, 1.5).pdf(xcd) + normal(0, 0.01, ncd) + fitter = DogBoxLSQFitter() + m = fitter(Gaussian1D(), xcd, psf) + + fig, ax = plt.subplots(figsize=(fw, fw*(ncd/nd)), constrained_layout=True) + ax.step(xcd, psf, where='mid', c='k') + ax.axvline(xcd[np.argmax(psf)], label='max') + ax.axvline(np.average(xcd, weights=psf), ls='--', label='centroid') + ax.axvline(m.mean.value, ls=':', label='gaussian') + ax.plot(xcd, m(xcd), ls=':') + ax.legend() + plt.setp(ax, yticks=[], ylabel='Flux', xlabel='Cross-dispersion axis [pix]', xlim=(0, ncd-1)) + fig.show() + +ArrayTrace +---------- + +`~specreduce.tracing.ArrayTrace` uses a pre-defined array of positions for maximum flexibility +and is ideal for complex or unusual trace shapes that are difficult to model mathematically. +To initialize `~specreduce.tracing.ArrayTrace`, provide a 1D array of cross-dispersion pixel +positions via the ``trace`` argument. The size of this array must match the number of +pixels along the dispersion axis of the image. + +.. code-block:: python + + trace = specreduce.tracing.ArrayTrace(image, trace=positions) + +.. plot:: + + import numpy as np + import matplotlib.pyplot as plt + from scipy.stats import norm + plt.rc('font', size=13) + + fw = 10 + nd, ncd = 31, 13 + xd, xcd = np.arange(nd), np.arange(ncd) + + tr = np.full_like(xd, 6) + tr[:6] = 4 + tr[15:23] = 8 + + spectrum = np.zeros((ncd, nd)) + + for i,x in enumerate(xd): + spectrum[:,i] = norm(tr[i], 1.0).pdf(xcd) + + plt.rc('font', size=13) + fig, ax = plt.subplots(figsize=(fw, fw*(ncd/nd)), constrained_layout=True) + ax.imshow(spectrum, origin='lower') + ax.plot(xd, tr, 'k') + ax.set_xticks(xd+0.5, minor=True) + ax.set_yticks(xcd+0.5, minor=True) + ax.grid(alpha=0.25, lw=1, which='minor') + plt.setp(ax, xlabel='Dispersion axis', ylabel='Cross-dispersion axis') + fig.show() diff --git a/docs/user_guide.rst b/docs/user_guide.rst new file mode 100644 index 00000000..2fab4e82 --- /dev/null +++ b/docs/user_guide.rst @@ -0,0 +1,20 @@ +User Guide +========== + +.. toctree:: + :maxdepth: 1 + :caption: Core Functionality + + trace + background + extraction + wavelength_calibration/wavelength_calibration + + +.. toctree:: + :maxdepth: 1 + :caption: Additional Topics + + extinction + specphot_standards + mask_treatment/mask_treatment \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a76fb41f..1640fc88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,10 @@ test = [ "tox", ] docs = [ - "sphinx-astropy", + "sphinx-astropy[confv2]", + "sphinx-copybutton", + "sphinx-design", + "matplotlib>=3.7", "photutils>=1.0", "synphot", "nbsphinx", diff --git a/specreduce/background.py b/specreduce/background.py index 2390f199..2288fb31 100644 --- a/specreduce/background.py +++ b/specreduce/background.py @@ -210,13 +210,13 @@ def two_sided(cls, image, trace_object, separation, **kwargs): Image with 2-D spectral image data. Assumes cross-dispersion (spatial) direction is axis 0 and dispersion (wavelength) direction is axis 1. - trace_object: `~specreduce.tracing.Trace` + trace_object : `~specreduce.tracing.Trace` estimated trace of the spectrum to center the background traces - separation: float + separation : float separation from ``trace_object`` for the background regions width : float width of each background aperture in pixels - statistic: string + statistic : string statistic to use when computing the background. 'average' will account for partial pixel weights, 'median' will include all partial pixels. @@ -258,21 +258,21 @@ def one_sided(cls, image, trace_object, separation, **kwargs): Image with 2-D spectral image data. Assumes cross-dispersion (spatial) direction is axis 0 and dispersion (wavelength) direction is axis 1. - trace_object: `~specreduce.tracing.Trace` - estimated trace of the spectrum to center the background traces - separation: float - separation from ``trace_object`` for the background, positive will be + trace_object : `~specreduce.tracing.Trace` + Estimated trace of the spectrum to center the background traces + separation : float + Separation from ``trace_object`` for the background, positive will be above the trace, negative below. width : float - width of each background aperture in pixels - statistic: string - statistic to use when computing the background. 'average' will + Width of each background aperture in pixels + statistic : string + Statistic to use when computing the background. 'average' will account for partial pixel weights, 'median' will include all partial pixels. disp_axis : int - dispersion axis + Dispersion axis crossdisp_axis : int - cross-dispersion axis + Cross-dispersion axis mask_treatment : string The method for handling masked or non-finite data. Choice of ``filter``, ``omit``, or ``zero_fill``. If `filter` is chosen, masked/non-finite data @@ -344,17 +344,17 @@ def bkg_spectrum(self, image=None, bkg_statistic=None): def sub_image(self, image=None): """ - Subtract the computed background from ``image``. + Subtract the computed background from image. Parameters ---------- image : nddata-compatible image or None - image with 2-D spectral image data. If None, will extract + image with 2D spectral image data. If None, will extract the background from ``image`` used to initialize the class. Returns ------- - spec : `~specutils.Spectrum1D` + spec : `~specutils.Spectrum` Spectrum object with same shape as ``image``. """ image = self._parse_image(image) @@ -376,14 +376,14 @@ def sub_spectrum(self, image=None): Parameters ---------- - image : nddata-compatible image or None - image with 2-D spectral image data. If None, will extract + image : `~astropy.nddata.NDData`-compatible image or None + image with 2D spectral image data. If None, will extract the background from ``image`` used to initialize the class. Returns ------- - spec : `~specutils.Spectrum1D` - The background 1-D spectrum, with flux expressed in the same + spec : `~specutils.Spectrum` + The background 1D spectrum, with flux expressed in the same units as the input image (or u.DN if none were provided) and the spectral axis expressed in pixel units. """ diff --git a/specreduce/extract.py b/specreduce/extract.py index 9db444f9..8553124f 100644 --- a/specreduce/extract.py +++ b/specreduce/extract.py @@ -307,11 +307,11 @@ class HorneExtract(SpecreduceOperation): spatial_profile : str or dict, optional The shape of the object profile. The first option is 'gaussian' to fit a uniform 1D gaussian to the average of pixels in the cross-dispersion - direction. The other option is 'interpolated_profile' - when this - option is used, the profile is sampled in bins and these samples are + direction. The other option is 'interpolated_profile' - when this + option is used, the profile is sampled in bins, and these samples are interpolated between to construct a continuously varying, empirical spatial profile for extraction. For this option, if passed in as a - string (i.e spatial_profile='interpolated_profile') the default values + string (i.e., spatial_profile='interpolated_profile') the default values for the number of bins used (10) and degree of interpolation (linear in x and y, by default) will be used. To set these parameters, pass in a dictionary with the keys 'n_bins_interpolated_profile' (which @@ -420,12 +420,6 @@ def _parse_image(self, image, variance=None, mask=None, unit=None, disp_axis=1): if image.uncertainty.uncertainty_type == "var": variance = image.uncertainty.array elif image.uncertainty.uncertainty_type == "std": - warnings.warn( - "image NDData object's uncertainty " - "interpreted as standard deviation. if " - "incorrect, use VarianceUncertainty when " - "assigning image object's uncertainty." - ) variance = image.uncertainty.array**2 elif image.uncertainty.uncertainty_type == "ivar": variance = 1 / image.uncertainty.array @@ -669,7 +663,7 @@ def __call__( n_bins_interpolated_profile = profile.get("n_bins_interpolated_profile", 10) interp_degree_interpolated_profile = profile.get("interp_degree_interpolated_profile", 1) - if bkgrd_prof is None and profile_type == 'gaussian': + if bkgrd_prof is None and profile_type == "gaussian": bkgrd_prof = models.Polynomial1D(2) self.image = self._parse_image(image, variance, mask, unit, disp_axis) diff --git a/specreduce/line_matching.py b/specreduce/line_matching.py index aa9ff5c6..d54de1c2 100644 --- a/specreduce/line_matching.py +++ b/specreduce/line_matching.py @@ -1,8 +1,10 @@ import warnings +from copy import deepcopy from typing import Sequence import astropy.units as u import numpy as np +from astropy.nddata import StdDevUncertainty from astropy.stats import gaussian_fwhm_to_sigma, gaussian_sigma_to_fwhm from astropy.modeling import models from astropy.table import QTable @@ -12,17 +14,14 @@ from specreduce.compat import Spectrum -__all__ = [ - "find_arc_lines", - "match_lines_wcs" -] +__all__ = ["find_arc_lines", "match_lines_wcs"] def find_arc_lines( spectrum: Spectrum, fwhm: float | u.Quantity = 5.0 * u.pix, window: float = 3.0, - noise_factor: float = 5.0 + noise_factor: float = 5.0, ) -> QTable: """ Find arc lines in a spectrum using `~specutils.fitting.find_lines_threshold` and @@ -33,11 +32,14 @@ def find_arc_lines( spectrum : The extracted arc spectrum to search for lines. It should be background-subtracted and must have an "uncertainty" attribute. - fwhm : Estimated full-width half-maximum of the lines in pixels. + fwhm + Estimated full-width half-maximum of the lines in pixels. - window : The window size in units of fwhm to use for the gaussian fits. + window + The window size in units of fwhm to use for the gaussian fits. - noise_factor : The factor to multiply the uncertainty by to determine the noise threshold + noise_factor + The factor to multiply the uncertainty by to determine the noise threshold in the `~specutils.fitting.find_lines_threshold` routine. Returns @@ -53,26 +55,30 @@ def find_arc_lines( if fwhm.unit != spectrum.spectral_axis.unit: raise ValueError("fwhm must have the same units as spectrum.spectral_axis.") + if spectrum.uncertainty is None: + spectrum = deepcopy(spectrum) + spectrum.uncertainty = StdDevUncertainty(np.sqrt(np.abs(spectrum.flux.value))) + detected_lines = find_lines_threshold(spectrum, noise_factor=noise_factor) - detected_lines = detected_lines[detected_lines['line_type'] == 'emission'] + detected_lines = detected_lines[detected_lines["line_type"] == "emission"] centroids = [] widths = [] amplitudes = [] for r in detected_lines: g_init = models.Gaussian1D( - amplitude=spectrum.flux[r['line_center_index']], - mean=r['line_center'], - stddev=fwhm * gaussian_fwhm_to_sigma + amplitude=spectrum.flux[r["line_center_index"]], + mean=r["line_center"], + stddev=fwhm * gaussian_fwhm_to_sigma, ) g_fit = fit_lines(spectrum, g_init, window=window * fwhm) centroids.append(g_fit.mean.value * g_fit.mean.unit) widths.append(g_fit.stddev * gaussian_sigma_to_fwhm) amplitudes.append(g_fit.amplitude.value * g_fit.amplitude.unit) line_table = QTable() - line_table['centroid'] = centroids - line_table['fwhm'] = widths - line_table['amplitude'] = amplitudes + line_table["centroid"] = centroids + line_table["fwhm"] = widths + line_table["amplitude"] = amplitudes return line_table @@ -89,13 +95,17 @@ def match_lines_wcs( Parameters ---------- - pixel_positions : The pixel positions of the lines in the calibration spectrum. + pixel_positions + The pixel positions of the lines in the calibration spectrum. - catalog_wavelengths : The wavelengths of the lines in the catalog. + catalog_wavelengths + The wavelengths of the lines in the catalog. - spectral_wcs : The spectral WCS of the calibration spectrum. + spectral_wcs + The spectral WCS of the calibration spectrum. - tolerance : The matching tolerance in pixels + tolerance + The matching tolerance in pixels Returns ------- diff --git a/specreduce/tests/test_extract.py b/specreduce/tests/test_extract.py index 4dd88a49..069996e3 100644 --- a/specreduce/tests/test_extract.py +++ b/specreduce/tests/test_extract.py @@ -2,7 +2,7 @@ import pytest from astropy import units as u from astropy.modeling import models -from astropy.nddata import NDData, VarianceUncertainty, UnknownUncertainty, StdDevUncertainty +from astropy.nddata import NDData, VarianceUncertainty, UnknownUncertainty from astropy.tests.helper import assert_quantity_allclose from specreduce.background import Background @@ -137,12 +137,6 @@ def test_horne_image_validation(mk_test_img): with pytest.raises(ValueError, match=r".*NDData object lacks uncertainty"): ext = extract(image=image) - # a warning should be raised if uncertainty is StdDevUncertainty - with pytest.warns(UserWarning, match="image NDData object's uncertainty"): - err = StdDevUncertainty(np.ones_like(image)) - image.uncertainty = err - ext = extract(image=image) - # an NDData-type image's uncertainty must be of type VarianceUncertainty # or type StdDevUncertainty with pytest.raises(ValueError, match=r".*unexpected uncertainty type.*"): diff --git a/specreduce/tests/test_line_matching.py b/specreduce/tests/test_line_matching.py index f2a0ce0f..e55f1a1c 100644 --- a/specreduce/tests/test_line_matching.py +++ b/specreduce/tests/test_line_matching.py @@ -1,5 +1,7 @@ import numpy as np import pytest +import astropy.units as u + from astropy.wcs import WCS from astropy.modeling import models from astropy.nddata import StdDevUncertainty @@ -87,6 +89,13 @@ def test_find_arc_lines(mk_test_data): lines = find_arc_lines(arc_sub, fwhm=5, window=5, noise_factor=5) assert len(lines) > 1 + with pytest.raises(ValueError, match="fwhm must have"): + find_arc_lines(arc_sub, fwhm=5*u.angstrom, window=5, noise_factor=5) + + arc_sub.uncertainty = None + lines = find_arc_lines(arc_sub, fwhm=5, window=5, noise_factor=5) + assert len(lines) > 1 + @pytest.mark.remote_data @pytest.mark.filterwarnings("ignore:No observer defined on WCS") diff --git a/specreduce/utils/synth_data.py b/specreduce/utils/synth_data.py index 64b3dacb..a270db93 100644 --- a/specreduce/utils/synth_data.py +++ b/specreduce/utils/synth_data.py @@ -24,7 +24,7 @@ def make_2d_trace_image( background: int | float = 5, trace_center: int | float | None = None, trace_order: int = 3, - trace_coeffs: dict[str, int | float] = {'c0': 0, 'c1': 50, 'c2': 100}, + trace_coeffs: None | dict[str, int | float] = None, profile: Model = models.Moffat1D(amplitude=10, alpha=0.1), add_noise: bool = True ) -> CCDData: @@ -35,27 +35,37 @@ def make_2d_trace_image( Parameters ---------- - nx : Size of image in X axis which is assumed to be the dispersion axis + nx + Size of image in X axis which is assumed to be the dispersion axis - ny : Size of image in Y axis which is assumed to be the spatial axis + ny + Size of image in Y axis which is assumed to be the spatial axis - background : Level of constant background in counts + background + Level of constant background in counts - trace_center : Zeropoint of the trace. If None, then use center of Y (spatial) axis. + trace_center + Zeropoint of the trace. If None, then use center of Y (spatial) axis. - trace_order : Order of the Chebyshev polynomial used to model the source's trace + trace_order + Order of the Chebyshev polynomial used to model the source's trace - trace_coeffs : Dict containing the Chebyshev polynomial coefficients to use in the trace model + trace_coeffs + Dict containing the Chebyshev polynomial coefficients to use in the trace model - profile : Model to use for the source's spatial profile - - add_noise : If True, add Poisson noise to the image + profile + Model to use for the source's spatial profile + add_noise + If True, add Poisson noise to the image Returns ------- - ccd_im : CCDData instance containing synthetic 2D spectroscopic image + ccd_im + `~astropy.nddata.CCDData` instance containing synthetic 2D spectroscopic image """ + if trace_coeffs is None: + trace_coeffs = {'c0': 0, 'c1': 50, 'c2': 100} x = np.arange(nx) y = np.arange(ny) xx, yy = np.meshgrid(x, y) @@ -87,7 +97,7 @@ def make_2d_arc_image( wave_air: bool = False, background: int | float = 5, line_fwhm: float = 5., - linelists: list[str] = ['HeI'], + linelists: list[str] = ('HeI',), amplitude_scale: float = 1., tilt_func: Model = models.Legendre1D(degree=0), add_noise: bool = True @@ -101,36 +111,50 @@ def make_2d_arc_image( Parameters ---------- - nx : Size of image in X axis which is assumed to be the dispersion axis + nx + Size of image in X axis which is assumed to be the dispersion axis - ny : Size of image in Y axis which is assumed to be the spatial axis + ny + Size of image in Y axis which is assumed to be the spatial axis - wcs : 2D WCS to apply to the image. Must have a spectral axis defined along with + wcs + 2D WCS to apply to the image. Must have a spectral axis defined along with appropriate spectral wavelength units. - extent : If ``wcs`` is not provided, this defines the beginning and end wavelengths + extent + If ``wcs`` is not provided, this defines the beginning and end wavelengths of the dispersion axis. - wave_unit : If ``wcs`` is not provided, this defines the wavelength units of the + wave_unit + If ``wcs`` is not provided, this defines the wavelength units of the dispersion axis. - wave_air : If True, convert the vacuum wavelengths used by ``pypeit`` to air wavelengths. + wave_air + If True, convert the vacuum wavelengths used by ``pypeit`` to air wavelengths. - background : Level of constant background in counts + background + Level of constant background in counts - line_fwhm : Gaussian FWHM of the emission lines in pixels + line_fwhm + Gaussian FWHM of the emission lines in pixels - linelists : Specification for linelists to load from ``pypeit`` + linelists + Specification for linelists to load from ``pypeit`` - amplitude_scale : Scale factor to apply to amplitudes provided in the linelists + amplitude_scale + Scale factor to apply to amplitudes provided in the linelists - tilt_func : The tilt function to apply along the cross-dispersion axis to simulate + tilt_func + The tilt function to apply along the cross-dispersion axis to simulate tilted or curved emission lines. - add_noise : If True, add Poisson noise to the image; requires ``photutils`` to be installed. + + add_noise + If True, add Poisson noise to the image; requires ``photutils`` to be installed. Returns ------- - ccd_im : CCDData instance containing synthetic 2D spectroscopic image + ccd_im + `~astropy.nddata.CCDData` instance containing synthetic 2D spectroscopic image Examples -------- @@ -326,12 +350,12 @@ def make_2d_spec_image( wave_air: bool = False, background: int | float = 5, line_fwhm: float = 5., - linelists: list[str] = ['OH_GMOS'], + linelists: list[str] = ('OH_GMOS',), amplitude_scale: float = 1., tilt_func: Model = models.Legendre1D(degree=0), trace_center: int | float | None = None, trace_order: int = 3, - trace_coeffs: dict[str, int | float] = {'c0': 0, 'c1': 50, 'c2': 100}, + trace_coeffs: None | dict[str, int | float] = None, source_profile: Model = models.Moffat1D(amplitude=10, alpha=0.1), add_noise: bool = True ) -> CCDData: @@ -341,47 +365,67 @@ def make_2d_spec_image( Parameters ---------- - nx : Number of pixels in the dispersion direction. + nx + Number of pixels in the dispersion direction. - ny : Number of pixels in the spatial direction. + ny + Number of pixels in the spatial direction. - wcs : 2D WCS to apply to the image. Must have a spectral axis defined along with + wcs + 2D WCS to apply to the image. Must have a spectral axis defined along with appropriate spectral wavelength units. - extent : If ``wcs`` is not provided, this defines the beginning and end wavelengths + extent + If ``wcs`` is not provided, this defines the beginning and end wavelengths of the dispersion axis. - wave_unit : If ``wcs`` is not provided, this defines the wavelength units of the + wave_unit + If ``wcs`` is not provided, this defines the wavelength units of the dispersion axis. - wave_air : If True, convert the vacuum wavelengths used by ``pypeit`` to air wavelengths. + wave_air + If True, convert the vacuum wavelengths used by ``pypeit`` to air wavelengths. - background : Constant background level in counts. + background + Constant background level in counts. - line_fwhm : Gaussian FWHM of the emission lines in pixels + line_fwhm + Gaussian FWHM of the emission lines in pixels - linelists : Specification for linelists to load from ``pypeit`` + linelists + Specification for linelists to load from ``pypeit`` - amplitude_scale : Scale factor to apply to amplitudes provided in the linelists + amplitude_scale + Scale factor to apply to amplitudes provided in the linelists - tilt_func : The tilt function to apply along the cross-dispersion axis to simulate + tilt_func + The tilt function to apply along the cross-dispersion axis to simulate tilted or curved emission lines. - trace_center : Zeropoint of the trace. If None, then use center of Y (spatial) axis. + trace_center + Zeropoint of the trace. If None, then use center of Y (spatial) axis. - trace_order : Order of the Chebyshev polynomial used to model the source's trace + trace_order + Order of the Chebyshev polynomial used to model the source's trace - trace_coeffs : Dict containing the Chebyshev polynomial coefficients to use in the trace model + trace_coeffs + Dict containing the Chebyshev polynomial coefficients to use in the trace model - source_profile : Model to use for the source's spatial profile + source_profile + Model to use for the source's spatial profile - add_noise : If True, add Poisson noise to the image; requires ``photutils`` to be installed. + add_noise + If True, add Poisson noise to the image; requires ``photutils`` to be installed. Returns ------- - ccd_im : CCDData instance containing synthetic 2D spectroscopic image + ccd_im + `~astropy.nddata.CCDData` instance containing synthetic 2D spectroscopic image """ + if trace_coeffs is None: + trace_coeffs = {'c0': 0, 'c1': 50, 'c2': 100} + arc_image = make_2d_arc_image( nx=nx, ny=ny,