Skip to content

Commit fc9fefe

Browse files
authored
Merge pull request astropy#3547 from keflavich/jplspec_2026
JPLSpec is even more down
2 parents 15690b2 + 27510d1 commit fc9fefe

6 files changed

Lines changed: 98 additions & 44 deletions

File tree

CHANGES.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,9 @@ linelists.jplspec
185185
^^^^^^^^^^^^^^^^^
186186

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

189192
mpc
190193
^^^

astroquery/linelists/jplspec/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ class Conf(_config.ConfigNamespace):
1515
'https://spec.jpl.nasa.gov/cgi-bin/catform',
1616
'JPL Spectral Catalog URL.')
1717

18+
ftp_cat_server = _config.ConfigItem(
19+
['https://spec.jpl.nasa.gov/ftp/pub/catalog/',
20+
'https://web.archive.org/web/20250630185813/https://spec.jpl.nasa.gov/ftp/pub/catalog/'],
21+
'JPL FTP Catalog URL'
22+
)
23+
1824
timeout = _config.ConfigItem(
1925
60,
2026
'Time limit for connecting to JPL server.')

astroquery/linelists/jplspec/core.py

Lines changed: 60 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ class JPLSpecClass(BaseQuery):
2727

2828
# use the Configuration Items imported from __init__.py
2929
URL = conf.server
30+
FTP_CAT_URL = conf.ftp_cat_server
3031
TIMEOUT = conf.timeout
3132

3233
def __init__(self):
@@ -142,6 +143,7 @@ def query_lines(self, min_frequency, max_frequency, *,
142143
parse_name_locally=False,
143144
get_query_payload=False,
144145
fallback_to_getmolecule=True,
146+
use_getmolecule=True,
145147
cache=True):
146148
"""
147149
Query the JPLSpec service for spectral lines.
@@ -153,7 +155,20 @@ def query_lines(self, min_frequency, max_frequency, *,
153155
governs whether `get_molecule` will be used when no results are returned
154156
by the query service. This workaround is needed while JPLSpec's query
155157
tool is broken.
158+
159+
use_getmolecule is an option to skip the query service entirely and
160+
retrieve full molecule catalogs via `get_molecule`. It is needed when
161+
the JPL query server is unresponsive. Frequency and strength limits
162+
are not applied in this mode.
156163
"""
164+
if use_getmolecule:
165+
if get_query_payload:
166+
return [('Mol', tuple(self._resolve_molecules(molecule, flags=flags,
167+
parse_name_locally=parse_name_locally)))]
168+
mols = self._resolve_molecules(molecule, flags=flags,
169+
parse_name_locally=parse_name_locally)
170+
return self._build_table_from_get_molecule(mols)
171+
157172
response = self.query_lines_async(min_frequency=min_frequency,
158173
max_frequency=max_frequency,
159174
min_strength=min_strength,
@@ -170,6 +185,45 @@ def query_lines(self, min_frequency, max_frequency, *,
170185

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

188+
def _resolve_molecules(self, molecule, *, flags=0, parse_name_locally=False):
189+
"""Return a list of molecule identifiers to feed to get_molecule."""
190+
if molecule is None or molecule == 'All':
191+
raise InvalidQueryError("use_getmolecule requires an explicit molecule "
192+
"or regex; 'All' is not supported.")
193+
if parse_name_locally:
194+
self.lookup_ids = build_lookup()
195+
mols = list(self.lookup_ids.find(molecule, flags).values())
196+
if len(mols) == 0:
197+
raise InvalidQueryError('No matching species found.')
198+
return mols
199+
if isinstance(molecule, (list, tuple)):
200+
return list(molecule)
201+
return [molecule]
202+
203+
def _build_table_from_get_molecule(self, mols):
204+
"""
205+
Fetch full catalog tables for each molecule and combine them.
206+
207+
``mols`` should be passed through ``_resolve_molecules`` before being
208+
sent to this function if it is user-specified, but if it comes directly
209+
from a query, it should be trusted as-is.
210+
"""
211+
self.lookup_ids = build_lookup()
212+
tbs = [self.get_molecule(mol) for mol in mols]
213+
if len(tbs) > 1:
214+
for tb, mol in zip(tbs, mols):
215+
tb['Name'] = self.lookup_ids.find(str(mol), flags=0)
216+
for key in list(tb.meta.keys()):
217+
tb.meta[f'{mol}_{key}'] = tb.meta.pop(key)
218+
tb = table.vstack(tbs)
219+
tb.meta['molecule_list'] = list(mols)
220+
return tb
221+
else:
222+
tb = tbs[0]
223+
tb.meta['molecule_id'] = mols[0]
224+
tb.meta['molecule_name'] = self.lookup_ids.find(str(mols[0]), flags=0)
225+
return tb
226+
173227
def _parse_result(self, response, *, verbose=False, fallback_to_getmolecule=False):
174228
"""
175229
Parse a response into an `~astropy.table.Table`
@@ -203,26 +257,12 @@ def _parse_result(self, response, *, verbose=False, fallback_to_getmolecule=Fals
203257

204258
if 'Zero lines were found' in response.text:
205259
if fallback_to_getmolecule:
206-
self.lookup_ids = build_lookup()
207260
payload = parse_qs(response.request.body)
208-
tbs = [self.get_molecule(mol) for mol in payload['Mol']]
209-
if len(tbs) > 1:
210-
mols = []
211-
for tb, mol in zip(tbs, payload['Mol']):
212-
tb['Name'] = self.lookup_ids.find(mol, flags=0)
213-
for key in list(tb.meta.keys()):
214-
tb.meta[f'{mol}_{key}'] = tb.meta.pop(key)
215-
mols.append(mol)
216-
tb = table.vstack(tbs)
217-
tb.meta['molecule_list'] = mols
218-
else:
219-
tb = tbs[0]
220-
tb.meta['molecule_id'] = payload['Mol'][0]
221-
tb.meta['molecule_name'] = self.lookup_ids.find(payload['Mol'][0], flags=0)
222-
223-
return tb
224-
else:
225-
raise EmptyResponseError(f"Response was empty; message was '{response.text}'.")
261+
return self._build_table_from_get_molecule(
262+
[payload['Mol']]
263+
if isinstance(payload['Mol'], str)
264+
else payload['Mol'])
265+
raise EmptyResponseError(f"Response was empty; message was '{response.text}'.")
226266

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

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

325365
# Request the catalog file
326366
response = self._request(method='GET', url=url,

astroquery/linelists/jplspec/tests/test_jplspec.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,7 @@ def test_query_lines_with_fallback():
290290
max_frequency=200 * u.GHz,
291291
min_strength=-500,
292292
molecule="28001 CO",
293+
use_getmolecule=False,
293294
fallback_to_getmolecule=False)
294295

295296
# Test with fallback enabled - should call get_molecule
@@ -320,6 +321,7 @@ def test_query_lines_with_fallback():
320321
max_frequency=200 * u.GHz,
321322
min_strength=-500,
322323
molecule="28001 CO",
324+
use_getmolecule=False,
323325
fallback_to_getmolecule=True)
324326

325327
mock_get_molecule.assert_called_once_with('28001')

astroquery/linelists/jplspec/tests/test_jplspec_remote.py

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@
33
from astropy.table import Table
44

55
from astroquery.linelists.jplspec import JPLSpec
6-
from astroquery.exceptions import EmptyResponseError
76

87

9-
@pytest.mark.xfail(reason="2025 server problems", raises=EmptyResponseError)
108
@pytest.mark.remote_data
119
def test_remote():
10+
"""
11+
In 2025-2026, the JPLSpec server went down and this was marked 'xfail' for a while.
12+
13+
As of late April 2026, it's back online again.
14+
"""
1215
tbl = JPLSpec.query_lines(min_frequency=500 * u.GHz,
1316
max_frequency=1000 * u.GHz,
1417
min_strength=-500,
1518
molecule="18003 H2O",
19+
use_getmolecule=False,
1620
fallback_to_getmolecule=False)
1721
assert isinstance(tbl, Table)
1822
assert len(tbl) == 36
@@ -36,7 +40,7 @@ def test_remote_regex_fallback():
3640
max_frequency=1000 * u.GHz,
3741
min_strength=-500,
3842
molecule=("28001", "28002", "28003"),
39-
fallback_to_getmolecule=True)
43+
use_getmolecule=True)
4044
assert isinstance(tbl, Table)
4145
tbl = tbl[((tbl['FREQ'].quantity > 500*u.GHz) & (tbl['FREQ'].quantity < 1*u.THz))]
4246
assert len(tbl) == 16
@@ -54,14 +58,13 @@ def test_remote_regex_fallback():
5458
assert tbl['FREQ'][15] == 946175.3151
5559

5660

57-
# Starting in 2025, the JPL CGI server that did search queries broke totally. See #3363
58-
@pytest.mark.xfail(reason="2025 server problems", raises=EmptyResponseError)
5961
@pytest.mark.remote_data
6062
def test_remote_regex():
6163
tbl = JPLSpec.query_lines(min_frequency=500 * u.GHz,
6264
max_frequency=1000 * u.GHz,
6365
min_strength=-500,
6466
molecule=("28001", "28002", "28003"),
67+
use_getmolecule=False,
6568
fallback_to_getmolecule=False)
6669
assert isinstance(tbl, Table)
6770
assert len(tbl) == 16
@@ -130,7 +133,7 @@ def test_remote_fallback():
130133
max_frequency=1000 * u.GHz,
131134
min_strength=-500,
132135
molecule="18003 H2O",
133-
fallback_to_getmolecule=True)
136+
use_getmolecule=True)
134137
assert isinstance(tbl, Table)
135138
tbl = tbl[((tbl['FREQ'].quantity > 500*u.GHz) & (tbl['FREQ'].quantity < 1*u.THz))]
136139
assert len(tbl) == 36

docs/linelists/jplspec/jplspec.rst

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@ what each setting yields:
4646
... molecule="28001 CO",
4747
... get_query_payload=False)
4848
>>> response.pprint(max_lines=10)
49-
FREQ ERR LGINT DR ELO GUP TAG QNFMT QN' QN" Lab
50-
MHz MHz nm2 MHz 1 / cm
49+
FREQ ERR LGINT DR ELO GUP TAG QNFMT QN' QN" Lab
50+
MHz MHz nm2 MHz 1 / cm
5151
------------ ------ -------- --- ---------- --- ----- ----- --- --- -----
5252
115271.2018 0.0005 -5.0105 2 0.0 3 28001 101 1 0 True
5353
230538.0 0.0005 -4.1197 2 3.845 5 28001 101 2 1 True
@@ -69,7 +69,7 @@ The following example, with ``get_query_payload = True``, returns the payload:
6969
... molecule="28001 CO",
7070
... get_query_payload=True)
7171
>>> print(response)
72-
[('MinNu', 100.0), ('MaxNu', 1000.0), ('MaxLines', 2000), ('UnitNu', 'GHz'), ('StrLim', -500), ('Mol', '28001 CO')]
72+
[('Mol', ('28001 CO',))]
7373

7474
The units of the columns of the query can be displayed by calling
7575
``response.info``:
@@ -82,19 +82,19 @@ The units of the columns of the query can be displayed by calling
8282
... molecule="28001 CO")
8383
>>> print(response.info)
8484
<Table length=91>
85-
name dtype unit
85+
name dtype unit
8686
----- ------- -------
8787
FREQ float64 MHz
8888
ERR float64 MHz
8989
LGINT float64 nm2 MHz
90-
DR int64
90+
DR int64
9191
ELO float64 1 / cm
92-
GUP int64
93-
TAG int64
94-
QNFMT int64
95-
QN' int64
96-
QN" int64
97-
Lab bool
92+
GUP int64
93+
TAG int64
94+
QNFMT int64
95+
QN' int64
96+
QN" int64
97+
Lab bool
9898
<BLANKLINE>
9999

100100
These come in handy for converting to other units easily, an example using a
@@ -103,8 +103,8 @@ simplified version of the data above is shown below:
103103
.. doctest-remote-data::
104104

105105
>>> response['FREQ', 'ERR', 'ELO'].pprint(max_lines=10)
106-
FREQ ERR ELO
107-
MHz MHz 1 / cm
106+
FREQ ERR ELO
107+
MHz MHz 1 / cm
108108
------------ ------ ----------
109109
115271.2018 0.0005 0.0
110110
230538.0 0.0005 3.845
@@ -226,8 +226,8 @@ to retrieve data from JPLSpec via astroquery.
226226
>>> print(f"Retrieved {len(table)} lines for CO")
227227
Retrieved 91 lines for CO
228228
>>> table[:5].pprint()
229-
FREQ ERR LGINT DR ELO GUP TAG QNFMT QN' QN" Lab
230-
MHz MHz nm2 MHz 1 / cm
229+
FREQ ERR LGINT DR ELO GUP TAG QNFMT QN' QN" Lab
230+
MHz MHz nm2 MHz 1 / cm
231231
----------- ------ ------- --- ------- --- ----- ----- --- --- ----
232232
115271.2018 0.0005 -5.0105 2 0.0 3 28001 101 1 0 True
233233
230538.0 0.0005 -4.1197 2 3.845 5 28001 101 2 1 True
@@ -272,8 +272,8 @@ to query these directly.
272272
... molecule="H2O",
273273
... parse_name_locally=True)
274274
>>> result.pprint(max_lines=10)
275-
FREQ ERR LGINT DR ELO GUP TAG QNFMT QN'1 QN"1 QN'2 QN"2 QN'3 QN"3 QN'4 QN"4 Lab
276-
MHz MHz nm2 MHz 1 / cm
275+
FREQ ERR LGINT DR ELO GUP TAG QNFMT QN'1 QN"1 QN'2 QN"2 QN'3 QN"3 QN'4 QN"4 Lab
276+
MHz MHz nm2 MHz 1 / cm
277277
------------ ------ -------- --- --------- --- ----- ----- ---- ---- ---- ---- ---- ---- ---- ---- -----
278278
8006.5805 2.851 -18.6204 3 6219.6192 45 18003 1404 22 21 4 7 18 15 0 0 False
279279
12478.2535 0.2051 -13.1006 3 3623.7652 31 18003 1404 15 16 7 4 9 12 0 0 False
@@ -375,7 +375,7 @@ If you are repeatedly getting failed queries, or bad/out-of-date results, try cl
375375
>>> from astroquery.linelists.jplspec import JPLSpec
376376
>>> JPLSpec.clear_cache()
377377
378-
If this function is unavailable, upgrade your version of astroquery.
378+
If this function is unavailable, upgrade your version of astroquery.
379379
The ``clear_cache`` function was introduced in version 0.4.7.dev8479.
380380

381381

0 commit comments

Comments
 (0)