diff --git a/CHANGES.rst b/CHANGES.rst index 473cca06ee..dda0d88d49 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -4,6 +4,14 @@ New Tools and Services ---------------------- +astroquery.linelists.exomol +^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +- Added new ``astroquery.linelists.exomol`` module for querying the ExoMol + molecular line list database. Wraps RADIS ExoMol reader into the standard + astroquery ``BaseQuery`` pattern. [#3536] + + noirlab ^^^^^^^ diff --git a/astroquery/linelists/__init__.py b/astroquery/linelists/__init__.py index cd1eae37de..3375424f84 100644 --- a/astroquery/linelists/__init__.py +++ b/astroquery/linelists/__init__.py @@ -5,3 +5,4 @@ This module contains sub-modules to support molecular and atomic line list modules and common utilities for parsing catalog files. """ +from .exomol import ExoMol # noqa: F401 diff --git a/astroquery/linelists/exomol/__init__.py b/astroquery/linelists/exomol/__init__.py new file mode 100644 index 0000000000..c6ef565305 --- /dev/null +++ b/astroquery/linelists/exomol/__init__.py @@ -0,0 +1,4 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +from .core import ExoMol, ExoMolClass + +__all__ = ["ExoMol", "ExoMolClass"] diff --git a/astroquery/linelists/exomol/core.py b/astroquery/linelists/exomol/core.py new file mode 100644 index 0000000000..b493699875 --- /dev/null +++ b/astroquery/linelists/exomol/core.py @@ -0,0 +1,283 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +ExoMol database query module for astroquery. +Queries the ExoMol database directly via its API. + +References +---------- +Tennyson et al. 2020, J. Quant. Spectrosc. Radiat. Transf. +https://www.exomol.com +RADIS: https://github.com/radis/radis (issue #925) +""" + +from astropy import units as u +from astropy.table import Table +from astroquery.query import BaseQuery +from astropy import log + +__all__ = ["ExoMol", "ExoMolClass"] + +EXOMOL_URL = "https://www.exomol.com" + + +class ExoMolClass(BaseQuery): + """ + Queries the `ExoMol `_ database for molecular + line lists used in exoplanet atmosphere modelling. + + This module queries ExoMol directly and optionally uses RADIS + (``radis``) for line list fetching via :meth:`query_lines`. + + .. note:: + + The :meth:`query_lines` and :meth:`get_partition_function` methods + require `RADIS `_ as an optional + dependency. Install it with:: + + pip install radis + + All other methods (``get_molecule_list``, ``get_databases``) + work without RADIS. + + Examples + -------- + Query CO lines between 2000-2100 cm\\ :sup:`-1`:: + + from astropy import units as u + from astroquery.linelists.exomol import ExoMol + + result = ExoMol.query_lines('CO', + wavenum_min=2000*u.cm**-1, + wavenum_max=2100*u.cm**-1) + print(result) + """ + + URL = EXOMOL_URL + TIMEOUT = 60 + + def get_molecule_list(self, *, cache=True): + """ + Retrieve list of all molecules available in ExoMol. + + Parameters + ---------- + cache : bool, optional + Cache HTTP response. Default ``True``. + + Returns + ------- + list of str + Sorted list of molecule names available in ExoMol. + """ + url = f"{self.URL}/db/exomol.all" + response = self._request("GET", url, cache=cache, timeout=self.TIMEOUT) + response.raise_for_status() + molecules = [] + for line in response.text.splitlines(): + line = line.strip() + if line and not line.startswith("#"): + parts = line.split() + if parts: + molecules.append(parts[0]) + return sorted(list(set(molecules))) + + def get_databases(self, molecule, *, cache=True): + """ + Get available line list databases for a given molecule. + + Scrapes the ExoMol website to find available databases. + + Parameters + ---------- + molecule : str + Molecule formula e.g. ``'H2O'``, ``'CO'``, ``'CH4'``. + cache : bool, optional + Cache results. Default ``True``. + + Returns + ------- + list of str + Available database names for this molecule. + + Examples + -------- + .. code-block:: python + + from astroquery.linelists.exomol import ExoMol + dbs = ExoMol.get_databases('H2O') + print(dbs) # doctest: +SKIP + + """ + try: + from bs4 import BeautifulSoup + except ImportError as e: + raise ImportError( + "The 'beautifulsoup4' package is required for get_databases(). " + "Install it with: pip install beautifulsoup4" + ) from e + + url = f"{self.URL}/data/molecules/{molecule}/" + response = self._request("GET", url, cache=cache, timeout=self.TIMEOUT) + response.raise_for_status() + + soup = BeautifulSoup(response.text, "html.parser") + databases = [] + for link in soup.find_all("a", href=True): + href = link["href"] + if ( + href.startswith(f"/data/molecules/{molecule}/") + and href != f"/data/molecules/{molecule}/" + ): + db_name = href.rstrip("/").split("/")[-1] + if db_name and db_name != molecule: + databases.append(db_name) + + return sorted(list(set(databases))) + + def query_lines( + self, + molecule, + database=None, + isotopologue="1", + wavenum_min=None, + wavenum_max=None, + broadening_species=None, + *, + cache=True, + ): + """ + Fetch ExoMol line list for a given molecule. + + Parameters + ---------- + molecule : str + Molecule formula e.g. ``'H2O'``, ``'CO'``, ``'SiO'``. + database : str, optional + ExoMol database name e.g. ``'POKAZATEL'`` for H2O. + If ``None``, uses the ExoMol-recommended database. + isotopologue : str, optional + Isotopologue number. Default ``'1'`` (most abundant). + wavenum_min : `~astropy.units.Quantity`, optional + Minimum wavenumber, e.g. ``2000*u.cm**-1``. + wavenum_max : `~astropy.units.Quantity`, optional + Maximum wavenumber, e.g. ``2100*u.cm**-1``. + broadening_species : str or list of str, optional + Pressure-broadening partner(s). + Examples: ``'H2'``, ``['H2', 'He']``, ``'air'``. + If ``None``, downloads all available broadening files. + See RADIS issue #917 for broadening parameter details. + cache : bool, optional + Cache downloaded line list files. Default ``True``. + + Returns + ------- + `~astropy.table.Table` + Line list table with columns for wavenumber (wav), + line intensity (int), Einstein A coefficient (A), + and lower/upper state energies (El, Eu). + + Examples + -------- + Query CO lines:: + + from astropy import units as u + from astroquery.linelists.exomol import ExoMol + + result = ExoMol.query_lines('CO', + wavenum_min=2000*u.cm**-1, + wavenum_max=2100*u.cm**-1) + + Query H2O with H2+He broadening:: + + result = ExoMol.query_lines('H2O', + database='POKAZATEL', + broadening_species=['H2', 'He'], + wavenum_min=1000*u.cm**-1, + wavenum_max=1100*u.cm**-1) + """ + try: + from radis.io.exomol import fetch_exomol + except ImportError as e: + raise ImportError( + "The 'radis' package is required for query_lines(). " + "Install it with: pip install radis" + ) from e + + # Convert Quantity to float (cm^-1) for RADIS + wavenum_min_value = None + wavenum_max_value = None + + if wavenum_min is not None: + if hasattr(wavenum_min, "unit"): + wavenum_min_value = wavenum_min.to(u.cm**-1).value + else: + wavenum_min_value = float(wavenum_min) + + if wavenum_max is not None: + if hasattr(wavenum_max, "unit"): + wavenum_max_value = wavenum_max.to(u.cm**-1).value + else: + wavenum_max_value = float(wavenum_max) + + log.info( + f"Querying ExoMol for {molecule} " + f"[{wavenum_min_value}-{wavenum_max_value} cm-1]" + ) + + df = fetch_exomol( + molecule=molecule, + database=database, + isotope=isotopologue, + load_wavenum_min=wavenum_min_value, + load_wavenum_max=wavenum_max_value, + broadening_species=broadening_species + if broadening_species is not None + else "air", + cache=cache, + verbose=False, + ) + return df if isinstance(df, Table) else Table.from_pandas(df) + + def get_partition_function( + self, molecule, database=None, isotopologue="1", *, cache=True + ): + """ + Get partition function Q(T) for a molecule from ExoMol. + + Parameters + ---------- + molecule : str + Molecule formula e.g. ``'CO'``, ``'H2O'``. + database : str, optional + ExoMol database name. If ``None``, uses recommended database. + isotopologue : str, optional + Isotopologue number. Default ``'1'``. + cache : bool, optional + Cache downloaded files. Default ``True``. + + Returns + ------- + `~astropy.table.Table` + Table with columns for temperature T (K) and partition + function Q(T). + """ + try: + from radis.io.exomol import fetch_exomol + except ImportError as e: + raise ImportError( + "The 'radis' package is required for get_partition_function(). " + "Install it with: pip install radis" + ) from e + + df = fetch_exomol( + molecule=molecule, + database=database, + isotope=isotopologue, + return_partition_function=True, + cache=cache, + verbose=False, + ) + return df if isinstance(df, Table) else Table.from_pandas(df) + + +ExoMol = ExoMolClass() diff --git a/astroquery/linelists/exomol/exomol.cfg b/astroquery/linelists/exomol/exomol.cfg new file mode 100644 index 0000000000..2803853658 --- /dev/null +++ b/astroquery/linelists/exomol/exomol.cfg @@ -0,0 +1,5 @@ +[linelists.exomol] +## ExoMol server base URL +server = https://www.exomol.com +## Request timeout in seconds +timeout = 60 diff --git a/astroquery/linelists/exomol/tests/__init__.py b/astroquery/linelists/exomol/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/astroquery/linelists/exomol/tests/test_exomol.py b/astroquery/linelists/exomol/tests/test_exomol.py new file mode 100644 index 0000000000..14f70b44fa --- /dev/null +++ b/astroquery/linelists/exomol/tests/test_exomol.py @@ -0,0 +1,133 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +import pytest +import numpy as np +from astropy import units as u +from astropy.table import Table +from astroquery.linelists.exomol import ExoMol + +try: + import radis # noqa: F401 + RADEX_NOT_AVAILABLE = False +except ImportError: + RADEX_NOT_AVAILABLE = True + + +@pytest.fixture +def fake_linelist_df(): + rng = np.random.default_rng(42) + return Table( + { + "wav": np.linspace(2000, 2100, 50), + "int": rng.random(50), + "A": rng.random(50), + "El": rng.random(50) * 1000, + "Eu": rng.random(50) * 1000 + 100, + } + ) + + +@pytest.fixture +def fake_pf_df(): + return Table( + { + "T": np.arange(100, 3100, 100, dtype=float), + "Q": np.linspace(10.0, 5000.0, 30), + } + ) + + +@pytest.mark.skipif(RADEX_NOT_AVAILABLE, reason="radis is required for this test") +def test_query_lines_returns_table(monkeypatch, fake_linelist_df): + monkeypatch.setattr( + "radis.io.exomol.fetch_exomol", lambda *a, **kw: fake_linelist_df + ) + result = ExoMol.query_lines( + "CO", wavenum_min=2000 * u.cm**-1, wavenum_max=2100 * u.cm**-1 + ) + assert isinstance(result, Table) + assert len(result) == 50 + + +@pytest.mark.skipif(RADEX_NOT_AVAILABLE, reason="radis is required for this test") +def test_query_lines_columns(monkeypatch, fake_linelist_df): + monkeypatch.setattr( + "radis.io.exomol.fetch_exomol", lambda *a, **kw: fake_linelist_df + ) + result = ExoMol.query_lines( + "CO", wavenum_min=2000 * u.cm**-1, wavenum_max=2100 * u.cm**-1 + ) + for col in ["wav", "int", "A", "El", "Eu"]: + assert col in result.colnames, f"Missing column: {col}" + + +@pytest.mark.skipif(RADEX_NOT_AVAILABLE, reason="radis is required for this test") +def test_query_lines_broadening_str(monkeypatch, fake_linelist_df): + captured = {} + + def mock_fetch(*a, **kw): + captured["broadening_species"] = kw.get("broadening_species") + return fake_linelist_df + + monkeypatch.setattr("radis.io.exomol.fetch_exomol", mock_fetch) + result = ExoMol.query_lines( + "CO", + wavenum_min=2000 * u.cm**-1, + wavenum_max=2100 * u.cm**-1, + broadening_species="H2", + ) + assert isinstance(result, Table) + assert captured["broadening_species"] == "H2" + + +@pytest.mark.skipif(RADEX_NOT_AVAILABLE, reason="radis is required for this test") +def test_query_lines_broadening_list(monkeypatch, fake_linelist_df): + captured = {} + + def mock_fetch(*a, **kw): + captured["broadening_species"] = kw.get("broadening_species") + return fake_linelist_df + + monkeypatch.setattr("radis.io.exomol.fetch_exomol", mock_fetch) + result = ExoMol.query_lines( + "H2O", + database="POKAZATEL", + wavenum_min=1000 * u.cm**-1, + wavenum_max=1100 * u.cm**-1, + broadening_species=["H2", "He"], + ) + assert isinstance(result, Table) + assert captured["broadening_species"] == ["H2", "He"] + + +@pytest.mark.skipif(RADEX_NOT_AVAILABLE, reason="radis is required for this test") +def test_get_partition_function_returns_table(monkeypatch, fake_pf_df): + monkeypatch.setattr( + "radis.io.exomol.fetch_exomol", lambda *a, **kw: fake_pf_df + ) + result = ExoMol.get_partition_function("CO") + assert isinstance(result, Table) + assert "T" in result.colnames + assert "Q" in result.colnames + + +def test_get_databases_returns_list(monkeypatch): + fake_html = ( + "" + 'POKAZATEL' + 'BT2' + "" + ) + + class FakeResponse: + text = fake_html + + def raise_for_status(self): + pass + + monkeypatch.setattr( + ExoMol.__class__, "_request", lambda self, *a, **kw: FakeResponse() + ) + dbs = ExoMol.get_databases("H2O") + assert isinstance(dbs, list) + assert "POKAZATEL" in dbs + assert "BT2" in dbs diff --git a/astroquery/linelists/exomol/tests/test_exomol_remote.py b/astroquery/linelists/exomol/tests/test_exomol_remote.py new file mode 100644 index 0000000000..36cff20086 --- /dev/null +++ b/astroquery/linelists/exomol/tests/test_exomol_remote.py @@ -0,0 +1,60 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +import gc +import warnings + +import pytest +from astropy import units as u +from astropy.table import Table +from astroquery.linelists.exomol import ExoMol + +try: + import radis # noqa: F401 + RADEX_NOT_AVAILABLE = False +except ImportError: + RADEX_NOT_AVAILABLE = True + + +@pytest.mark.remote_data +def test_get_molecule_list_remote(): + molecules = ExoMol.get_molecule_list() + assert isinstance(molecules, list) + assert len(molecules) > 50 + assert any("CO" in m for m in molecules) + + +@pytest.mark.remote_data +def test_get_databases_H2O_remote(): + dbs = ExoMol.get_databases("H2O") + assert isinstance(dbs, list) + assert len(dbs) > 0 + + +@pytest.mark.remote_data +@pytest.mark.skipif(RADEX_NOT_AVAILABLE, reason="radis is required for this test") +def test_query_lines_CO_remote(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = ExoMol.query_lines( + molecule="CO", + wavenum_min=2000 * u.cm**-1, + wavenum_max=2100 * u.cm**-1, + ) + gc.collect() + assert isinstance(result, Table) + assert len(result) > 0 + + +@pytest.mark.remote_data +@pytest.mark.skipif(RADEX_NOT_AVAILABLE, reason="radis is required for this test") +def test_query_lines_CO_with_H2_broadening_remote(): + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + result = ExoMol.query_lines( + molecule="CO", + wavenum_min=2000 * u.cm**-1, + wavenum_max=2050 * u.cm**-1, + broadening_species="H2", + ) + gc.collect() + assert isinstance(result, Table) + assert len(result) > 0 diff --git a/docs/conf.py b/docs/conf.py index 2178e00ff4..b8fe567707 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -160,7 +160,7 @@ def __getattr__(cls, name): return Mock() -MOCK_MODULES = ['atpy', 'beautifulsoup4', 'vo', 'lxml', 'keyring', 'bs4'] +MOCK_MODULES = ['atpy', 'beautifulsoup4', 'vo', 'lxml', 'keyring', 'bs4', 'radis'] for mod_name in MOCK_MODULES: sys.modules[mod_name] = Mock() diff --git a/docs/index.rst b/docs/index.rst index 97c3ceffb7..af0658b9cd 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -257,6 +257,7 @@ The following modules have been completed using a common API: cadc/cadc.rst casda/casda.rst linelists/cdms/cdms.rst + linelists/exomol/exomol.rst esa/euclid/euclid.rst esa/hsa/hsa.rst esa/hubble/hubble.rst diff --git a/docs/linelists/exomol/exomol.rst b/docs/linelists/exomol/exomol.rst new file mode 100644 index 0000000000..86769e9c17 --- /dev/null +++ b/docs/linelists/exomol/exomol.rst @@ -0,0 +1,62 @@ +.. _astroquery.linelists.exomol: + +ExoMol Line List Queries (`astroquery.linelists.exomol`) +********************************************************* + +.. automodapi:: astroquery.linelists.exomol + :no-inheritance-diagram: + +Overview +======== + +The `~astroquery.linelists.exomol` module provides access to the +`ExoMol database `_, the primary source of +high-temperature molecular line lists for exoplanet atmosphere modelling. + +.. note:: + + This module requires `radis `_ as a + dependency (``pip install radis``). Results are returned as + `~astropy.table.Table` objects; ``radis`` internally uses ``pandas`` + for data processing. + +Getting Started +=============== + +List available molecules:: + + from astroquery.linelists.exomol import ExoMol + + molecules = ExoMol.get_molecule_list() # doctest: +SKIP + print(molecules[:10]) # doctest: +SKIP + ["1H-35Cl", "AlCl", "AlF", "AlH", "AlO", "BeH", "C2", "CH", "CH+", "CN"] + +Get available databases for a molecule:: + + databases = ExoMol.get_databases('H2O') # doctest: +SKIP + print(databases) # doctest: +SKIP + ["POKAZATEL", "HotWat78", "HITRAN2020"] + +Query CO line list in a wavenumber range:: + + result = ExoMol.query_lines( + molecule='CO', + load_wavenum_min=2000, + load_wavenum_max=2100, + ) + print(result) # doctest: +SKIP + + wav int + float64 float64 + -------- -------- + 2000.001 1.23e-25 + +Get partition function Q(T):: + + pf = ExoMol.get_partition_function('CO') + print(pf) # doctest: +SKIP +
+ T Q + float64 float64 + ------- ------- + 100.0 12.345 diff --git a/pyproject.toml b/pyproject.toml index 4fd2f12d01..dfeeef7577 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ Documentation = "https://astroquery.readthedocs.io" test = [ "pytest>=7.4", "pytest-doctestplus>=1.4", - "pytest-timeout", + "pytest-timeout", "pytest-astropy", "matplotlib", # Temp workaround for https://github.com/RKrahl/pytest-dependency/issues/91 @@ -61,6 +61,7 @@ all = [ "boto3", "botocore", "regions>=0.5", + "radis", ] [build-system] diff --git a/tox.ini b/tox.ini index ca7173cf23..d9497ca0f9 100644 --- a/tox.ini +++ b/tox.ini @@ -34,10 +34,12 @@ deps = devdeps: astropy>=0.0.dev0 devdeps: pyerfa>=0.0.dev0 devdeps: git+https://github.com/astropy/pyvo.git#egg=pyvo + devdeps: radis # mpl while not a dependency, it's required for the tests, and would pull up a newer numpy version if not pinned. # And pillow should be pinned as well, otherwise a too new version is pulled that is not compatible with old np. + oldestdeps: radis oldestdeps: astropy==5.0.0 oldestdeps: numpy==1.22 oldestdeps: matplotlib==3.5.0