Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,9 @@ linelists.jplspec
^^^^^^^^^^^^^^^^^

- New location for jplspec. astroquery.jplspec is now deprecated in favor of astroquery.linelists.jplspec [#3455]
- Added ``use_getmolecule`` option to ``query_lines`` to bypass the JPL query
service and retrieve full molecule catalogs via ``get_molecule``, and added a
configurable ``ftp_cat_server`` with a Wayback Machine fallback. [#3547]

mpc
^^^
Expand Down
6 changes: 6 additions & 0 deletions astroquery/linelists/jplspec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@ class Conf(_config.ConfigNamespace):
'https://spec.jpl.nasa.gov/cgi-bin/catform',
'JPL Spectral Catalog URL.')

ftp_cat_server = _config.ConfigItem(
['https://spec.jpl.nasa.gov/ftp/pub/catalog/',
'https://web.archive.org/web/20250630185813/https://spec.jpl.nasa.gov/ftp/pub/catalog/'],
'JPL FTP Catalog URL'
)

timeout = _config.ConfigItem(
60,
'Time limit for connecting to JPL server.')
Expand Down
71 changes: 51 additions & 20 deletions astroquery/linelists/jplspec/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ class JPLSpecClass(BaseQuery):

# use the Configuration Items imported from __init__.py
URL = conf.server
FTP_CAT_URL = conf.ftp_cat_server
TIMEOUT = conf.timeout

def __init__(self):
Expand Down Expand Up @@ -142,6 +143,7 @@ def query_lines(self, min_frequency, max_frequency, *,
parse_name_locally=False,
get_query_payload=False,
fallback_to_getmolecule=True,
use_getmolecule=True,
cache=True):
"""
Query the JPLSpec service for spectral lines.
Expand All @@ -153,7 +155,20 @@ def query_lines(self, min_frequency, max_frequency, *,
governs whether `get_molecule` will be used when no results are returned
by the query service. This workaround is needed while JPLSpec's query
tool is broken.

use_getmolecule is an option to skip the query service entirely and
retrieve full molecule catalogs via `get_molecule`. It is needed when
the JPL query server is unresponsive. Frequency and strength limits
are not applied in this mode.
"""
if use_getmolecule:
if get_query_payload:
return [('Mol', tuple(self._resolve_molecules(molecule, flags=flags,
parse_name_locally=parse_name_locally)))]
mols = self._resolve_molecules(molecule, flags=flags,
parse_name_locally=parse_name_locally)
return self._build_table_from_get_molecule(mols)

response = self.query_lines_async(min_frequency=min_frequency,
max_frequency=max_frequency,
min_strength=min_strength,
Expand All @@ -170,6 +185,39 @@ def query_lines(self, min_frequency, max_frequency, *,

query_lines.__doc__ = process_asyncs.async_to_sync_docstr(query_lines_async.__doc__)

def _resolve_molecules(self, molecule, *, flags=0, parse_name_locally=False):
"""Return a list of molecule identifiers to feed to get_molecule."""
if molecule is None or molecule == 'All':
raise InvalidQueryError("use_getmolecule requires an explicit molecule "
"or regex; 'All' is not supported.")
if parse_name_locally:
self.lookup_ids = build_lookup()
mols = list(self.lookup_ids.find(molecule, flags).values())
if len(mols) == 0:
raise InvalidQueryError('No matching species found.')
return mols
if isinstance(molecule, (list, tuple)):
return list(molecule)
return [molecule]

def _build_table_from_get_molecule(self, mols):
"""Fetch full catalog tables for each molecule and combine them."""
self.lookup_ids = build_lookup()
Comment thread
keflavich marked this conversation as resolved.
tbs = [self.get_molecule(mol) for mol in mols]
if len(tbs) > 1:
for tb, mol in zip(tbs, mols):
tb['Name'] = self.lookup_ids.find(str(mol), flags=0)
for key in list(tb.meta.keys()):
tb.meta[f'{mol}_{key}'] = tb.meta.pop(key)
tb = table.vstack(tbs)
tb.meta['molecule_list'] = list(mols)
return tb
else:
tb = tbs[0]
tb.meta['molecule_id'] = mols[0]
tb.meta['molecule_name'] = self.lookup_ids.find(str(mols[0]), flags=0)
return tb

def _parse_result(self, response, *, verbose=False, fallback_to_getmolecule=False):
"""
Parse a response into an `~astropy.table.Table`
Expand Down Expand Up @@ -203,26 +251,9 @@ def _parse_result(self, response, *, verbose=False, fallback_to_getmolecule=Fals

if 'Zero lines were found' in response.text:
if fallback_to_getmolecule:
self.lookup_ids = build_lookup()
payload = parse_qs(response.request.body)
tbs = [self.get_molecule(mol) for mol in payload['Mol']]
if len(tbs) > 1:
mols = []
for tb, mol in zip(tbs, payload['Mol']):
tb['Name'] = self.lookup_ids.find(mol, flags=0)
for key in list(tb.meta.keys()):
tb.meta[f'{mol}_{key}'] = tb.meta.pop(key)
mols.append(mol)
tb = table.vstack(tbs)
tb.meta['molecule_list'] = mols
else:
tb = tbs[0]
tb.meta['molecule_id'] = payload['Mol'][0]
tb.meta['molecule_name'] = self.lookup_ids.find(payload['Mol'][0], flags=0)

return tb
else:
raise EmptyResponseError(f"Response was empty; message was '{response.text}'.")
return self._build_table_from_get_molecule(payload['Mol'])
raise EmptyResponseError(f"Response was empty; message was '{response.text}'.")

# data starts at 0 since regex was applied
# Warning for a result with more than 1000 lines:
Expand Down Expand Up @@ -320,7 +351,7 @@ def get_molecule(self, molecule_id, *, cache=True):
molecule_str = parse_molid(molecule_id)

# Construct the URL to the catalog file
url = f'https://spec.jpl.nasa.gov/ftp/pub/catalog/c{molecule_str}.cat'
url = f'{self.FTP_CAT_URL}/c{molecule_str}.cat'

# Request the catalog file
response = self._request(method='GET', url=url,
Expand Down
2 changes: 2 additions & 0 deletions astroquery/linelists/jplspec/tests/test_jplspec.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,7 @@ def test_query_lines_with_fallback():
max_frequency=200 * u.GHz,
min_strength=-500,
molecule="28001 CO",
use_getmolecule=False,
fallback_to_getmolecule=False)

# Test with fallback enabled - should call get_molecule
Expand Down Expand Up @@ -320,6 +321,7 @@ def test_query_lines_with_fallback():
max_frequency=200 * u.GHz,
min_strength=-500,
molecule="28001 CO",
use_getmolecule=False,
fallback_to_getmolecule=True)

mock_get_molecule.assert_called_once_with('28001')
Expand Down
15 changes: 9 additions & 6 deletions astroquery/linelists/jplspec/tests/test_jplspec_remote.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
from astropy.table import Table

from astroquery.linelists.jplspec import JPLSpec
from astroquery.exceptions import EmptyResponseError


@pytest.mark.xfail(reason="2025 server problems", raises=EmptyResponseError)
@pytest.mark.remote_data
def test_remote():
"""
In 2025-2026, the JPLSpec server went down and this was marked 'xfail' for a while.

As of late April 2026, it's back online again.
"""
tbl = JPLSpec.query_lines(min_frequency=500 * u.GHz,
max_frequency=1000 * u.GHz,
min_strength=-500,
molecule="18003 H2O",
use_getmolecule=False,
fallback_to_getmolecule=False)
assert isinstance(tbl, Table)
assert len(tbl) == 36
Expand All @@ -36,7 +40,7 @@ def test_remote_regex_fallback():
max_frequency=1000 * u.GHz,
min_strength=-500,
molecule=("28001", "28002", "28003"),
fallback_to_getmolecule=True)
use_getmolecule=True)
assert isinstance(tbl, Table)
tbl = tbl[((tbl['FREQ'].quantity > 500*u.GHz) & (tbl['FREQ'].quantity < 1*u.THz))]
assert len(tbl) == 16
Expand All @@ -54,14 +58,13 @@ def test_remote_regex_fallback():
assert tbl['FREQ'][15] == 946175.3151


# Starting in 2025, the JPL CGI server that did search queries broke totally. See #3363
@pytest.mark.xfail(reason="2025 server problems", raises=EmptyResponseError)
@pytest.mark.remote_data
def test_remote_regex():
tbl = JPLSpec.query_lines(min_frequency=500 * u.GHz,
max_frequency=1000 * u.GHz,
min_strength=-500,
molecule=("28001", "28002", "28003"),
use_getmolecule=False,
fallback_to_getmolecule=False)
assert isinstance(tbl, Table)
assert len(tbl) == 16
Expand Down Expand Up @@ -130,7 +133,7 @@ def test_remote_fallback():
max_frequency=1000 * u.GHz,
min_strength=-500,
molecule="18003 H2O",
fallback_to_getmolecule=True)
use_getmolecule=True)
assert isinstance(tbl, Table)
tbl = tbl[((tbl['FREQ'].quantity > 500*u.GHz) & (tbl['FREQ'].quantity < 1*u.THz))]
assert len(tbl) == 36
Expand Down
Loading