From 203a0a50cc274f505be060d62aef055ff5303991 Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Wed, 5 Nov 2025 14:50:22 -0500 Subject: [PATCH 1/2] Refactored JPLspec and CDMS to put both in the linelists/ directory. The commit messages below come from individual commits that were squashed into one. A lot of other messages were redundant and manually edited out: factor pseudo-common code into linelists from cdms/jplspec refactor jplspec to not use async machinery refactor to use absolute imports --- CHANGES.rst | 11 + astroquery/jplspec/__init__.py | 2 +- astroquery/linelists/__init__.py | 11 + astroquery/linelists/cdms/core.py | 148 ++++++---- astroquery/linelists/cdms/setup_package.py | 1 + .../linelists/cdms/tests/data/c058501.cat | 3 + astroquery/linelists/cdms/tests/test_cdms.py | 134 +++++++++- .../linelists/cdms/tests/test_cdms_remote.py | 30 ++- astroquery/linelists/core.py | 33 +++ astroquery/linelists/jplspec/__init__.py | 1 + astroquery/linelists/jplspec/core.py | 253 +++++++++++++++++- astroquery/linelists/jplspec/setup_package.py | 1 + .../jplspec/tests/data/H2O_sample.cat | 52 ++++ .../linelists/jplspec/tests/test_jplspec.py | 210 ++++++++++++++- .../jplspec/tests/test_jplspec_remote.py | 219 ++++++++++++++- docs/linelists/cdms/cdms.rst | 87 +++--- docs/linelists/jplspec/jplspec.rst | 151 ++++++----- 17 files changed, 1177 insertions(+), 170 deletions(-) create mode 100644 astroquery/linelists/cdms/tests/data/c058501.cat create mode 100644 astroquery/linelists/core.py create mode 100644 astroquery/linelists/jplspec/tests/data/H2O_sample.cat diff --git a/CHANGES.rst b/CHANGES.rst index 32f4816fb6..75173895b5 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -52,16 +52,27 @@ mast - Raise informative error if ``MastMissions`` query radius is too large. [#3447] + jplspec ^^^^^^^ +- Refactored to use linelists.core [#3456] - Moved to linelists/. astroquery.jplspec is now deprecated in favor of astroquery.linelists.jplspec [#3455] + linelists.jplspec ^^^^^^^^^^^^^^^^^ - New location for jplspec. astroquery.jplspec is now deprecated in favor of astroquery.linelists.jplspec [#3455] + +linelists +^^^^^^^^^ + +- General tools for both CDMS/JPL moved to linelists.core [#3456] +- Added jplspec, moved from its previous location (astroquery.jplspec to astroquery.linelists.jplspec) [#3455] + + xmatch ^^^^^^ diff --git a/astroquery/jplspec/__init__.py b/astroquery/jplspec/__init__.py index 1ab0e72d1c..5824fefaf8 100644 --- a/astroquery/jplspec/__init__.py +++ b/astroquery/jplspec/__init__.py @@ -1,7 +1,7 @@ # Licensed under a 3-clause BSD style license - see LICENSE.rst """ JPL Spectral Catalog (Deprecated Location) -------------------------------------------- +------------------------------------------ .. deprecated:: 0.4.12 The `astroquery.jplspec` module has been moved to `astroquery.linelists.jplspec`. diff --git a/astroquery/linelists/__init__.py b/astroquery/linelists/__init__.py index e69de29bb2..b0c50327a8 100644 --- a/astroquery/linelists/__init__.py +++ b/astroquery/linelists/__init__.py @@ -0,0 +1,11 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +Linelists module +---------------- +This module contains sub-modules for various molecular and atomic line list databases, +as well as common utilities for parsing catalog files. +""" + +from astroquery.linelists.core import parse_letternumber + +__all__ = ['parse_letternumber'] diff --git a/astroquery/linelists/cdms/core.py b/astroquery/linelists/cdms/core.py index 2f3e7d7e89..6474d7a5be 100644 --- a/astroquery/linelists/cdms/core.py +++ b/astroquery/linelists/cdms/core.py @@ -8,14 +8,14 @@ from astropy import table from astropy.io import ascii from astroquery.query import BaseQuery -from astroquery.utils import async_to_sync # import configurable items declared in __init__.py from astroquery.linelists.cdms import conf from astroquery.exceptions import InvalidQueryError, EmptyResponseError +from ..core import parse_letternumber +from astroquery.utils import process_asyncs from astroquery import log import re -import string __all__ = ['CDMS', 'CDMSClass'] @@ -25,7 +25,6 @@ def data_path(filename): return os.path.join(data_dir, filename) -@async_to_sync class CDMSClass(BaseQuery): # use the Configuration Items imported from __init__.py URL = conf.search @@ -34,6 +33,74 @@ class CDMSClass(BaseQuery): TIMEOUT = conf.timeout MALFORMATTED_MOLECULE_LIST = ['017506 NH3-wHFS', '028528 H2NC', '058501 H2C2S', '064527 HC3HCN'] + def __init__(self, fallback_to_getmolecule=False): + super().__init__() + + def _mol_to_payload(self, molecule, parse_name_locally, flags): + if parse_name_locally: + self.lookup_ids = build_lookup() + luts = self.lookup_ids.find(molecule, flags) + if len(luts) == 0: + raise InvalidQueryError('No matching species found. Please ' + 'refine your search or read the Docs ' + 'for pointers on how to search.') + return tuple(f"{val:06d} {key}" + for key, val in luts.items())[0] + else: + return molecule + + def query_lines(self, min_frequency, max_frequency, *, + min_strength=-500, molecule='All', + temperature_for_intensity=300, flags=0, + parse_name_locally=False, get_query_payload=False, + fallback_to_getmolecule=False, + verbose=False, + cache=True): + + # Check if a malformatted molecule was requested and use fallback if enabled + # accounts for three formats, e.g.: '058501' or 'H2C2S' or '058501 H2C2S' + badlist = (self.MALFORMATTED_MOLECULE_LIST + + [y for x in self.MALFORMATTED_MOLECULE_LIST for y in x.split()]) + + # extract molecule from the response or request + requested_molecule = self._mol_to_payload(molecule, parse_name_locally, flags) if molecule != 'All' else None + + if requested_molecule and requested_molecule in badlist and not get_query_payload: + if fallback_to_getmolecule: + try: + return self.get_molecule(requested_molecule[:6]) + except ValueError as ex: + # try to give the users good guidance on which parameters will work + if "molecule_id should be a length-6 string of numbers" in str(ex): + if parse_name_locally: + raise ValueError(f"Molecule {molecule} could not be parsed or identified." + " Check that the name was correctly specified.") + else: + raise ValueError(f"Molecule {molecule} needs to be formatted as" + " a 6-digit string ID for the get_molecule fallback to work." + " Try setting parse_name_locally=True " + "to turn your molecule name into a CDMS number ID.") + else: + raise ex + else: + raise ValueError(f"Molecule {requested_molecule} is known not to comply with standard CDMS format. " + f"Try get_molecule({requested_molecule}) instead or set " + f"CDMS.fallback_to_getmolecule = True.") + else: + response = self.query_lines_async(min_frequency=min_frequency, + max_frequency=max_frequency, + min_strength=min_strength, + molecule=molecule, + temperature_for_intensity=temperature_for_intensity, + flags=flags, + parse_name_locally=parse_name_locally, + get_query_payload=get_query_payload, + cache=cache) + if get_query_payload: + return response + else: + return self._parse_result(response, molname=molecule, verbose=verbose) + def query_lines_async(self, min_frequency, max_frequency, *, min_strength=-500, molecule='All', temperature_for_intensity=300, flags=0, @@ -140,17 +207,7 @@ def query_lines_async(self, min_frequency, max_frequency, *, payload['Moleculesgrp'] = 'all species' else: if molecule is not None: - if parse_name_locally: - self.lookup_ids = build_lookup() - luts = self.lookup_ids.find(molecule, flags) - if len(luts) == 0: - raise InvalidQueryError('No matching species found. Please ' - 'refine your search or read the Docs ' - 'for pointers on how to search.') - payload['Molecules'] = tuple(f"{val:06d} {key}" - for key, val in luts.items())[0] - else: - payload['Molecules'] = molecule + payload['Molecules'] = self._mol_to_payload(molecule, parse_name_locally, flags) if get_query_payload: return payload @@ -182,16 +239,11 @@ def query_lines_async(self, min_frequency, max_frequency, *, response2 = self._request(method='GET', url=fullurl, timeout=self.TIMEOUT, cache=cache) - # accounts for three formats, e.g.: '058501' or 'H2C2S' or '058501 H2C2S' - badlist = (self.MALFORMATTED_MOLECULE_LIST + # noqa - [y for x in self.MALFORMATTED_MOLECULE_LIST for y in x.split()]) - if 'Moleculesgrp' not in payload.keys() and payload['Molecules'] in badlist: - raise ValueError(f"Molecule {payload['Molecules']} is known not to comply with standard CDMS format. " - f"Try get_molecule({payload['Molecules']}) instead.") - return response2 - def _parse_result(self, response, *, verbose=False): + query_lines.__doc__ = process_asyncs.async_to_sync_docstr(query_lines_async.__doc__) + + def _parse_result(self, response, *, verbose=False, molname=None): """ Parse a response into an `~astropy.table.Table` @@ -238,6 +290,8 @@ def _parse_result(self, response, *, verbose=False): soup = BeautifulSoup(response.text, 'html.parser') text = soup.find('pre').text + # this is a different workaround to try to make _some_ of the bad molecules parseable + # (it doesn't solve all of them, which is why the above fallback exists) need_to_filter_bad_molecules = False for bad_molecule in self.MALFORMATTED_MOLECULE_LIST: if text.find(bad_molecule.split()[1]) > -1: @@ -316,7 +370,7 @@ def _parse_result(self, response, *, verbose=False): except ValueError as ex: # Give users a more helpful exception when parsing fails new_message = ("Failed to parse CDMS response. This may be caused by a malformed search return. " - "You can check this by running `CDMS.get_molecule('')` instead; if it works, the " + f"You can check this by running `CDMS.get_molecule('{molname}')` instead; if it works, the " "problem is caused by the CDMS search interface and cannot be worked around.") raise ValueError(new_message) from ex @@ -456,14 +510,25 @@ def get_molecule(self, molecule_id, *, cache=True, return_response=False): def _parse_cat(self, text, *, verbose=False): """ - Parse a catalog response into an `~astropy.table.Table` + Parse a CDMS-format catalog file into an `~astropy.table.Table`. + + The catalog data files are composed of 80-character card images. + Format: [F13.4, 2F8.4, I2, F10.4, I3, I7, I4, 12I2]: + FREQ, ERR, LGINT, DR, ELO, GUP, TAG, QNFMT, QN - See details in _parse_response; this is a very similar function, - but the catalog responses have a slightly different format. + Parameters + ---------- + text : str + The catalog file text content. + verbose : bool, optional + Not used currently. + + Returns + ------- + Table : `~astropy.table.Table` + Parsed catalog data. """ - # notes about the format - # [F13.4, 2F8.4, I2, F10.4, I3, I7, I4, 12I2]: FREQ, ERR, LGINT, DR, ELO, GUP, TAG, QNFMT, QN noqa - # 13 21 29 31 41 44 51 55 57 59 61 63 65 67 69 71 73 75 77 79 noqa + # Column start positions starts = {'FREQ': 0, 'ERR': 14, 'LGINT': 22, @@ -494,7 +559,9 @@ def _parse_cat(self, text, *, verbose=False): col_starts=list(starts.values()), format='fixed_width', fast_reader=False) + # Ensure TAG is integer type for computation # int truncates - which is what we want + result['TAG'] = result['TAG'].astype(int) result['MOLWT'] = [int(x/1e3) for x in result['TAG']] result['FREQ'].unit = u.MHz @@ -527,29 +594,6 @@ def _parse_cat(self, text, *, verbose=False): CDMS = CDMSClass() -def parse_letternumber(st): - """ - Parse CDMS's two-letter QNs into integers. - - Masked values are converted to -999999. - - From the CDMS docs: - "Exactly two characters are available for each quantum number. Therefore, half - integer quanta are rounded up ! In addition, capital letters are used to - indicate quantum numbers larger than 99. E. g. A0 is 100, Z9 is 359. Lower case characters - are used similarly to signal negative quantum numbers smaller than –9. e. g., a0 is –10, b0 is –20, etc." - """ - if np.ma.is_masked(st): - return -999999 - - asc = string.ascii_lowercase - ASC = string.ascii_uppercase - newst = ''.join(['-' + str((asc.index(x)+1)) if x in asc else - str((ASC.index(x)+10)) if x in ASC else - x for x in st]) - return int(newst) - - class Lookuptable(dict): def find(self, st, flags): diff --git a/astroquery/linelists/cdms/setup_package.py b/astroquery/linelists/cdms/setup_package.py index 9aa4bd311e..64d5cbb99f 100644 --- a/astroquery/linelists/cdms/setup_package.py +++ b/astroquery/linelists/cdms/setup_package.py @@ -9,6 +9,7 @@ def get_package_data(): paths_test = [os.path.join('data', '028503 CO, v=0.data'), os.path.join('data', '117501 HC7S.data'), os.path.join('data', '099501 HC7N, v=0.data'), + os.path.join('data', 'c058501.cat'), os.path.join('data', 'post_response.html'), ] diff --git a/astroquery/linelists/cdms/tests/data/c058501.cat b/astroquery/linelists/cdms/tests/data/c058501.cat new file mode 100644 index 0000000000..3c7acdb7f6 --- /dev/null +++ b/astroquery/linelists/cdms/tests/data/c058501.cat @@ -0,0 +1,3 @@ + 114.9627 0.0001-10.6817 3 9.7413 9 58501 303 1 1 0 1 1 1 + 344.8868 0.0002 -9.9842 3 10.4849 15 58501 303 2 1 1 2 1 2 + 689.7699 0.0004 -9.5394 3 11.6003 21 58501 303 3 1 2 3 1 3 \ No newline at end of file diff --git a/astroquery/linelists/cdms/tests/test_cdms.py b/astroquery/linelists/cdms/tests/test_cdms.py index 0b8059105f..bfa654ff79 100644 --- a/astroquery/linelists/cdms/tests/test_cdms.py +++ b/astroquery/linelists/cdms/tests/test_cdms.py @@ -7,6 +7,7 @@ from astropy.table import Table from astroquery.linelists.cdms.core import CDMS, parse_letternumber, build_lookup from astroquery.utils.mocks import MockResponse +from astroquery.exceptions import InvalidQueryError colname_set = set(['FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', 'TAG', 'QNFMT', 'Ju', 'Jl', "vu", "F1u", "F2u", "F3u", "vl", "Ku", "Kl", @@ -21,10 +22,18 @@ def data_path(filename): def mockreturn(*args, method='GET', data={}, url='', **kwargs): if method == 'GET': - molecule = url.split('cdmstab')[1].split('.')[0] - with open(data_path(molecule+".data"), 'rb') as fh: - content = fh.read() - return MockResponse(content=content) + # Handle get_molecule requests (classic URL format) + if '/entries/c' in url: + molecule = url.split('/entries/c')[1].split('.')[0] + with open(data_path(f"c{molecule}.cat"), 'rb') as fh: + content = fh.read() + return MockResponse(content=content) + # Handle regular query_lines requests + else: + molecule = url.split('cdmstab')[1].split('.')[0] + with open(data_path(molecule+".data"), 'rb') as fh: + content = fh.read() + return MockResponse(content=content) elif method == 'POST': molecule = dict(data)['Molecules'] with open(data_path("post_response.html"), 'r') as fh: @@ -205,3 +214,120 @@ def test_lut_literal(): assert thirteenco['13CO'] == 29501 thirteencostar = lut.find('13CO*', 0) assert len(thirteencostar) >= 252 + + +def test_malformatted_molecule_raises_error(patch_post): + """ + Test that querying a malformatted molecule raises an error when + fallback_to_getmolecule is False (default behavior) + """ + # H2C2S is in the MALFORMATTED_MOLECULE_LIST + with pytest.raises(ValueError, match="is known not to comply with standard CDMS format"): + CDMS.query_lines(min_frequency=100 * u.GHz, + max_frequency=300 * u.GHz, + molecule='058501 H2C2S', + fallback_to_getmolecule=False) + + +def test_malformatted_molecule_with_fallback(patch_post): + """ + Test that querying a malformatted molecule with fallback_to_getmolecule=True + successfully falls back to get_molecule + """ + # H2C2S is in the MALFORMATTED_MOLECULE_LIST + tbl = CDMS.query_lines(min_frequency=100 * u.GHz, + max_frequency=300 * u.GHz, + molecule='058501 H2C2S', + fallback_to_getmolecule=True) + + assert isinstance(tbl, Table) + assert len(tbl) == 3 + assert tbl['FREQ'][0] == 114.9627 + assert tbl['FREQ'][1] == 344.8868 + assert tbl['FREQ'][2] == 689.7699 + assert tbl['TAG'][0] == 58501 + assert tbl['GUP'][0] == 9 + + +def test_malformatted_molecule_id_only_with_fallback(patch_post): + """ + Test that querying with just the molecule ID (058501) also works with fallback + """ + # Just the ID is also in the badlist + tbl = CDMS.query_lines(min_frequency=100 * u.GHz, + max_frequency=300 * u.GHz, + molecule='058501', + fallback_to_getmolecule=True) + + assert isinstance(tbl, Table) + assert len(tbl) == 3 + assert tbl['FREQ'][0] == 114.9627 + + +def test_malformatted_molecule_name_only_with_fallback_error(patch_post): + """ + Test that querying with just the molecule name (H2C2S) without parse_name_locally + raises an error because H2C2S (5 chars) is not a valid 6-digit molecule ID. + + When parse_name_locally=False, "H2C2S" is passed as-is to _mol_to_payload, + which returns "H2C2S". This is in the badlist, so fallback is triggered, + but get_molecule("H2C2S") fails because it's not a 6-digit ID. + """ + # Just the name is also in the badlist, but it's not a 6-digit ID + with pytest.raises(ValueError, match="needs to be formatted as.*6-digit string ID"): + CDMS.query_lines(min_frequency=100 * u.GHz, + max_frequency=300 * u.GHz, + molecule='H2C2S', + parse_name_locally=False, + fallback_to_getmolecule=True) + + +def test_malformatted_molecule_name_with_parse_locally_success(patch_post): + """ + Test that querying with just the molecule name (H2C2S) WITH parse_name_locally=True + successfully resolves to "058501 H2C2S" and then falls back to get_molecule. + + When parse_name_locally=True, "H2C2S" is looked up and converted to "058501 H2C2S", + which is in the badlist, so fallback is triggered and succeeds. + """ + tbl = CDMS.query_lines(min_frequency=100 * u.GHz, + max_frequency=300 * u.GHz, + molecule='H2C2S', + parse_name_locally=True, + fallback_to_getmolecule=True) + + assert isinstance(tbl, Table) + assert len(tbl) == 3 + assert tbl['TAG'][0] == 58501 + + +def test_get_query_payload_skips_fallback(patch_post): + """ + Test that when get_query_payload=True, the fallback is not triggered + even for malformatted molecules + """ + # This should return the payload without triggering fallback or error + payload = CDMS.query_lines(min_frequency=100 * u.GHz, + max_frequency=300 * u.GHz, + molecule='058501 H2C2S', + get_query_payload=True) + + assert isinstance(payload, dict) + assert 'Molecules' in payload + assert payload['Molecules'] == '058501 H2C2S' + + +def test_malformatted_with_parse_name_locally_and_fallback_error(): + """ + Test that when parse_name_locally=True with a malformatted molecule + and fallback is enabled, but molecule can't be resolved, we get + proper error message about parsing failure + """ + # First, the lookup will fail to find 'NOTREALMOLECULE' and raise InvalidQueryError + # before we even get to the fallback logic + with pytest.raises(InvalidQueryError, match="No matching species found"): + CDMS.query_lines(min_frequency=100 * u.GHz, + max_frequency=300 * u.GHz, + molecule='NOTREALMOLECULE', + parse_name_locally=True, + fallback_to_getmolecule=True) diff --git a/astroquery/linelists/cdms/tests/test_cdms_remote.py b/astroquery/linelists/cdms/tests/test_cdms_remote.py index 1d9f97fccf..bb22ffcac6 100644 --- a/astroquery/linelists/cdms/tests/test_cdms_remote.py +++ b/astroquery/linelists/cdms/tests/test_cdms_remote.py @@ -109,13 +109,41 @@ def test_h2nc(): assert tbl['TAG'][0] == 28528 +@pytest.mark.remote_data +def test_fallback_to_getmolecule_parameter(): + """ + Test that fallback_to_getmolecule attribute controls query behavior. + + When fallback_to_getmolecule is True, query_lines should use get_molecule + internally for malformed molecules. + """ + + # Test with a malformed molecule and fallback enabled + tbl_fallback = CDMS.query_lines( + min_frequency=100 * u.GHz, + max_frequency=200 * u.GHz, + min_strength=-500, + molecule="028528 H2NC", + fallback_to_getmolecule=True + ) + + assert isinstance(tbl_fallback, Table) + assert len(tbl_fallback) > 0 + + # I don't think the state set within this module affects the rest of the + # tests but just in case + CDMS.fallback_to_getmolecule = False + + @pytest.mark.remote_data def test_remote_regex(): tbl = CDMS.query_lines(min_frequency=500 * u.GHz, max_frequency=600 * u.GHz, min_strength=-500, - molecule=('028501 HC-13-N, v=0', '028502 H2CN', '028503 CO, v=0')) + molecule=('028501 HC-13-N, v=0', + '028502 H2CN', + '028503 CO, v=0')) assert isinstance(tbl, Table) # regression test fix: there's 1 CO line that got missed because of a missing comma diff --git a/astroquery/linelists/core.py b/astroquery/linelists/core.py new file mode 100644 index 0000000000..ba5bc5b6f9 --- /dev/null +++ b/astroquery/linelists/core.py @@ -0,0 +1,33 @@ +# Licensed under a 3-clause BSD style license - see LICENSE.rst +""" +Base classes and common utilities for linelist queries (JPLSpec, CDMS, etc.) +""" +import numpy as np +import string + +__all__ = ['parse_letternumber'] + + +def parse_letternumber(st): + """ + Parse CDMS's two-letter QNs into integers. + + Masked values are converted to -999999. + + From the CDMS docs: + "Exactly two characters are available for each quantum number. Therefore, half + integer quanta are rounded up ! In addition, capital letters are used to + indicate quantum numbers larger than 99. E. g. A0 is 100, Z9 is 359. Lower case characters + are used similarly to signal negative quantum numbers smaller than –9. e. g., a0 is –10, b0 is –20, etc." + """ + if isinstance(st, (np.int32, np.int64, int)): + return st + if np.ma.is_masked(st): + return -999999 + + asc = string.ascii_lowercase + ASC = string.ascii_uppercase + newst = ''.join(['-' + str((asc.index(x)+1)) if x in asc else + str((ASC.index(x)+10)) if x in ASC else + x for x in st]) + return int(newst) diff --git a/astroquery/linelists/jplspec/__init__.py b/astroquery/linelists/jplspec/__init__.py index 71aedd7259..5b03985e7f 100644 --- a/astroquery/linelists/jplspec/__init__.py +++ b/astroquery/linelists/jplspec/__init__.py @@ -2,6 +2,7 @@ """ JPL Spectral Catalog -------------------- + """ from astropy import config as _config diff --git a/astroquery/linelists/jplspec/core.py b/astroquery/linelists/jplspec/core.py index 58287680ec..61b453563e 100644 --- a/astroquery/linelists/jplspec/core.py +++ b/astroquery/linelists/jplspec/core.py @@ -3,12 +3,19 @@ import warnings import astropy.units as u +import numpy as np from astropy.io import ascii from astroquery.query import BaseQuery from astroquery.utils import async_to_sync +from astropy import table +from astroquery.query import BaseQuery +from astroquery.linelists.core import parse_letternumber # import configurable items declared in __init__.py from astroquery.linelists.jplspec import conf, lookup_table from astroquery.exceptions import EmptyResponseError, InvalidQueryError +from astroquery.linelists.core import LineListClass +from astroquery.utils import process_asyncs +from urllib.parse import parse_qs __all__ = ['JPLSpec', 'JPLSpecClass'] @@ -19,18 +26,21 @@ def data_path(filename): return os.path.join(data_dir, filename) -@async_to_sync class JPLSpecClass(BaseQuery): # use the Configuration Items imported from __init__.py URL = conf.server TIMEOUT = conf.timeout + def __init__(self): + super().__init__() + def query_lines_async(self, min_frequency, max_frequency, *, min_strength=-500, max_lines=2000, molecule='All', flags=0, parse_name_locally=False, - get_query_payload=False, cache=True): + get_query_payload=False, cache=True + ): """ Creates an HTTP POST request based on the desired parameters and returns a response. @@ -125,10 +135,45 @@ def query_lines_async(self, min_frequency, max_frequency, *, # built-in caching system response = self._request(method='POST', url=self.URL, data=payload, timeout=self.TIMEOUT, cache=cache) + response.raise_for_status() return response - def _parse_result(self, response, *, verbose=False): + def query_lines(self, min_frequency, max_frequency, *, + min_strength=-500, + max_lines=2000, molecule='All', flags=0, + parse_name_locally=False, + get_query_payload=False, + fallback_to_getmolecule=True, + cache=True): + """ + Query the JPLSpec service for spectral lines. + + This is a synchronous version of `query_lines_async`. + See `query_lines_async` for full parameter documentation. + + fallback_to_getmolecule is a unique parameter to this method that + 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. + """ + response = self.query_lines_async(min_frequency=min_frequency, + max_frequency=max_frequency, + min_strength=min_strength, + max_lines=max_lines, + molecule=molecule, + flags=flags, + parse_name_locally=parse_name_locally, + get_query_payload=get_query_payload, + cache=cache) + if get_query_payload: + return response + else: + return self._parse_result(response, fallback_to_getmolecule=fallback_to_getmolecule) + + query_lines.__doc__ = process_asyncs.async_to_sync_docstr(query_lines_async.__doc__) + + def _parse_result(self, response, *, verbose=False, fallback_to_getmolecule=False): """ Parse a response into an `~astropy.table.Table` @@ -160,7 +205,27 @@ def _parse_result(self, response, *, verbose=False): """ if 'Zero lines were found' in response.text: - raise EmptyResponseError(f"Response was empty; message was '{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}'.") # data starts at 0 since regex was applied # Warning for a result with more than 1000 lines: @@ -233,6 +298,186 @@ def get_species_table(self, *, catfile='catdir.cat'): return result + def get_molecule(self, molecule_id, *, cache=True): + """ + Retrieve the whole molecule table for a given molecule id from the JPL catalog. + + Parameters + ---------- + molecule_id : int or str + The molecule tag/identifier. Can be an integer (e.g., 18003 for H2O) + or a zero-padded 6-character string (e.g., '018003'). + cache : bool + Defaults to True. If set overrides global caching behavior. + + Returns + ------- + Table : `~astropy.table.Table` + Table containing all spectral lines for the requested molecule. + + Examples + -------- + >>> table = JPLSpec.get_molecule(18003) # doctest: +SKIP + >>> print(table) # doctest: +SKIP + """ + # Convert to string and zero-pad to 6 digits + if isinstance(molecule_id, (int, np.int32, np.int64)): + molecule_str = f'{molecule_id:06d}' + if len(molecule_str) > 6: + raise ValueError("molecule_id should be an integer with" + " fewer than 6 digits or a length-6 " + "string of numbers") + elif isinstance(molecule_id, str): + # this is for the common case where the molecule is specified e.g. as 028001 CO + try: + molecule_id = f"{int(molecule_id[:6]):06d}" + except ValueError: + raise ValueError("molecule_id should be an integer or a length-6 string of numbers") + molecule_str = molecule_id + else: + raise ValueError("molecule_id should be an integer or a length-6 string of numbers") + + # Construct the URL to the catalog file + url = f'https://spec.jpl.nasa.gov/ftp/pub/catalog/c{molecule_str}.cat' + + # Request the catalog file + response = self._request(method='GET', url=url, + timeout=self.TIMEOUT, cache=cache) + response.raise_for_status() + + if 'The requested URL was not found on this server.' in response.text: + raise EmptyResponseError(f"No data found for molecule ID {molecule_id}.") + + # Parse the catalog file + result = self._parse_cat(response) + + # Add metadata from species table + species_table = self.get_species_table() + # Find the row matching this molecule_id + int_molecule_id = int(molecule_str) + matching_rows = species_table[species_table['TAG'] == int_molecule_id] + if len(matching_rows) > 0: + # Add metadata as a dictionary + result.meta = dict(zip(matching_rows.colnames, matching_rows[0])) + + return result + + def _parse_cat(self, response, *, verbose=False): + """ + Parse a JPL-format catalog file into an `~astropy.table.Table`. + + The catalog data files are composed of 80-character card images, with + one card image per spectral line. The format of each card image is: + FREQ, ERR, LGINT, DR, ELO, GUP, TAG, QNFMT, QN', QN" + (F13.4,F8.4, F8.4, I2,F10.4, I3, I7, I4, 6I2, 6I2) + + https://spec.jpl.nasa.gov/ftp/pub/catalog/doc/catintro.pdf + + Parameters + ---------- + text : str + The catalog file text content. + verbose : bool, optional + Not used currently. + + Returns + ------- + Table : `~astropy.table.Table` + Parsed catalog data. + """ + text = response.text + if 'Zero lines were found' in text or len(text.strip()) == 0: + raise EmptyResponseError(f"Response was empty; message was '{text}'.") + + # Parse the catalog file with fixed-width format + # Format: FREQ(13.4), ERR(8.4), LGINT(8.4), DR(2), ELO(10.4), GUP(3), TAG(7), QNFMT(4), QN'(12), QN"(12) + result = ascii.read(text, header_start=None, data_start=0, + comment=r'THIS|^\s{12,14}\d{4,6}.*', + names=('FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', + 'TAG', 'QNFMT', 'QN\'', 'QN"'), + col_starts=(0, 13, 21, 29, 31, 41, 44, 51, 55, 67), + format='fixed_width', fast_reader=False) + + # Ensure TAG is integer type + result['TAG'] = result['TAG'].astype(int) + + # Add units + result['FREQ'].unit = u.MHz + result['ERR'].unit = u.MHz + result['LGINT'].unit = u.nm**2 * u.MHz + result['ELO'].unit = u.cm**(-1) + + # split table by qnfmt; each chunk must be separately parsed. + qnfmts = np.unique(result['QNFMT']) + tables = [result[result['QNFMT'] == qq] for qq in qnfmts] + + # some tables have +/-/blank entries in QNs + # pm_is_ok should be True when the QN columns contain '+' or '-'. + # (can't do a str check on np.integer dtype so have to filter that out first) + pm_is_ok = ((not np.issubdtype(result["QN'"].dtype, np.integer)) + and any(('+' in str(line) or '-' in str(line)) for line in result["QN'"])) + + def int_or_pm(st): + try: + return int(st) + except ValueError: + try: + return parse_letternumber(st) + except ValueError: + if pm_is_ok and (st.strip() == '' or st.strip() == '+' or st.strip() == '-'): + return st.strip() + else: + raise ValueError(f'"{st}" is not a valid +/-/blank entry') + + # At least this molecule, NH, claims 5 QNs but has only 4 + bad_qnfmt_dict = { + 15001: 1234, + } + mol_tag = result['TAG'][0] + + if mol_tag in (32001,): + raise NotImplementedError("Molecule O2 (32001) does not follow the format standard.") + + for tbl in tables: + if mol_tag in bad_qnfmt_dict: + n_qns = bad_qnfmt_dict[mol_tag] % 10 + else: + n_qns = tbl['QNFMT'][0] % 10 + if n_qns > 1: + qnlen = 2 * n_qns + for ii in range(n_qns): + if tbl["QN'"].dtype in (int, np.int32, np.int64): + # for the case where it was already parsed as int + # (53005 is an example) + tbl[f"QN'{ii+1}"] = tbl["QN'"] + tbl[f'QN"{ii+1}'] = tbl['QN"'] + else: + # string parsing can truncate to length=2n or 2n-1 depending + # on whether there are any two-digit QNs in the column + ind1 = ii * 2 + ind2 = ii * 2 + 2 + # rjust(qnlen) is needed to enforce that all strings retain their exact original shape + qnp = [int_or_pm(line.rjust(qnlen)[ind1: ind2].strip()) for line in tbl['QN\'']] + qnpp = [int_or_pm(line.rjust(qnlen)[ind1: ind2].strip()) for line in tbl['QN"']] + dtype = str if any('+' in str(x) for x in qnp) else int + tbl[f"QN'{ii+1}"] = np.array(qnp, dtype=dtype) + tbl[f'QN"{ii+1}'] = np.array(qnpp, dtype=dtype) + del tbl['QN\''] + del tbl['QN"'] + else: + tbl['QN\''] = np.array(list(map(parse_letternumber, tbl['QN\''])), dtype=int) + tbl['QN"'] = np.array(list(map(parse_letternumber, tbl['QN"'])), dtype=int) + + result = table.vstack(tables) + + # Add laboratory measurement flag + # A negative TAG value indicates laboratory-measured frequency + result['Lab'] = result['TAG'] < 0 + # Convert TAG to absolute value + result['TAG'] = abs(result['TAG']) + + return result + JPLSpec = JPLSpecClass() diff --git a/astroquery/linelists/jplspec/setup_package.py b/astroquery/linelists/jplspec/setup_package.py index 585e27fa4b..7439815548 100644 --- a/astroquery/linelists/jplspec/setup_package.py +++ b/astroquery/linelists/jplspec/setup_package.py @@ -8,6 +8,7 @@ def get_package_data(): paths_test = [os.path.join('data', 'CO.data'), os.path.join('data', 'CO_6.data'), + os.path.join('data', 'H2O_sample.cat'), os.path.join('data', 'multi.data')] paths_data = [os.path.join('data', 'catdir.cat')] diff --git a/astroquery/linelists/jplspec/tests/data/H2O_sample.cat b/astroquery/linelists/jplspec/tests/data/H2O_sample.cat new file mode 100644 index 0000000000..bd13a08689 --- /dev/null +++ b/astroquery/linelists/jplspec/tests/data/H2O_sample.cat @@ -0,0 +1,52 @@ + 8006.5805 2.8510-18.6204 3 6219.6192 45 18003140422 418 0 21 715 0 + 12478.2535 0.2051-13.1006 3 3623.7652 31 18003140415 7 9 0 16 412 0 + 22235.0798 0.0001 -5.8825 3 446.5107 39 -180031404 6 1 6 0 5 2 3 0 + 27206.4582 6.3643-19.1265 3 7210.5493141 18003140423 617 0 24 520 0 + 71592.4316 0.5310-13.4989 3 4606.1683 39 18003140419 416 0 18 513 0 + 115542.5692 0.6588-13.2595 3 4606.1683 35 18003140417 810 0 18 513 0 + 139614.2930 0.1500 -9.3636 3 3080.1788 87 -18003140414 6 9 0 15 312 0 + 177317.0680 0.1500-10.3413 3 3437.2774 31 -18003140415 610 0 16 313 0 + 183310.0870 0.0010 -3.6463 3 136.1639 7 -180031404 3 1 3 0 2 2 0 0 + 247440.0960 0.1500 -9.0097 3 2872.5806 29 -18003140414 410 0 15 313 0 + 259952.1820 0.2000 -8.6690 3 2739.4286 27 -18003140413 6 8 0 14 311 0 + 266574.0983 1.8473-14.1089 3 5739.2279129 18003140421 417 0 20 714 0 + 289008.0871 2.7396-15.1447 3 6167.7109129 18003140421 615 0 20 912 0 + 294805.1937 4.1586-16.0382 3 6707.3362135 18003140422 716 0 23 419 0 + 321225.6770 0.0006 -5.0909 3 1282.9191 63 -18003140410 2 9 0 9 3 6 0 + 325152.8990 0.0010 -3.5711 3 315.7795 11 -180031404 5 1 5 0 4 2 2 0 + 339043.9960 0.1500-10.0708 3 3810.9369 99 -18003140416 611 0 17 314 0 + 354808.5800 0.2000-10.4028 3 4006.0734105 -18003140417 413 0 16 710 0 + 373514.7088 6.1926-17.5865 3 7386.7750135 180031404221013 0 23 716 0 + 380197.3598 0.0001 -2.6152 3 212.1564 27 -180031404 4 1 4 0 3 2 1 0 + 390134.5100 0.0500 -6.0290 3 1525.1360 21 -18003140410 3 7 0 11 210 0 + 437346.6640 0.0020 -4.8220 3 1045.0584 15 -180031404 7 5 3 0 6 6 0 0 + 439150.7948 0.0003 -3.6615 3 742.0763 39 -180031404 6 4 3 0 5 5 0 0 + 443018.3546 0.0008 -4.3337 3 1045.0580 45 -180031404 7 5 2 0 6 6 1 0 + 448001.0775 0.0005 -2.5935 3 285.4186 27 -180031404 4 2 3 0 3 3 0 0 + 458682.8454 1.1313-13.1673 3 5276.8018 41 18003140420 416 0 19 713 0 + 470888.9030 0.0020 -4.0778 3 742.0730 13 -180031404 6 4 2 0 5 5 1 0 + 474689.1080 0.0010 -3.4856 3 488.1342 11 -180031404 5 3 3 0 4 4 0 0 + 488491.1280 0.0030 -4.1739 3 586.4792 13 -180031404 6 2 4 0 7 1 7 0 + 503568.5200 0.0200 -4.9916 3 1394.8142 51 -180031404 8 6 3 0 7 7 0 0 + 504482.6900 0.0500 -5.4671 3 1394.8142 17 -180031404 8 6 2 0 7 7 1 0 + 525890.1638 0.8432-12.2048 3 5035.1266117 18003140419 514 0 18 811 0 + 530342.8600 0.2000 -7.1006 3 2533.7932 87 -18003140414 312 0 13 4 9 0 + 534240.4544 0.3469-11.2954 3 4409.3446 37 18003140418 414 0 17 711 0 + 556935.9877 0.0003 -0.8189 3 23.7944 9 -180031404 1 1 0 0 1 0 1 0 + 557985.4794 0.6432-11.6213 3 4833.2084117 18003140419 415 0 18 712 0 + 558017.0036 12.4193-18.1025 3 7729.4622 49 18003140424 618 0 25 521 0 + 571913.6860 0.1000 -6.9705 3 2414.7235 75 -18003140412 6 7 0 13 310 0 + 591693.4339 0.2120 -8.6820 3 3244.6008 87 18003140414 7 8 0 15 411 0 + 593113.7249 7.4502-18.5975 3 7924.4438 49 18003140424 717 0 231014 0 + 593227.8163 0.4197-10.8822 3 4201.2514 35 18003140417 612 0 18 315 0 + 596308.5878 4.5348-15.8345 3 6687.8251 47 18003140423 519 0 22 616 0 + 614309.5658 2.1666-14.1672 3 5680.7868 39 18003140419 911 0 20 614 0 + 620293.9651 1.1653-12.0811 3 5031.9777117 18003140419 514 0 20 417 0 + 620700.9549 0.0006 -2.7692 3 488.1077 33 -180031404 5 3 2 0 4 4 1 0 + 624732.7750 5.8384-16.9250 3 7210.3271 47 18003140423 717 0 24 420 0 + 645766.1230 0.0300 -6.1081 3 1789.0429 19 -180031404 9 7 3 0 8 8 0 0 + 645905.7060 0.0500 -5.6308 3 1789.0429 57 -180031404 9 7 2 0 8 8 1 0 + 723142.3610 9.8873-19.4330 3 8554.6415 53 18003140426 521 0 25 818 0 + 752033.1430 0.1000 -0.9985 3 70.0908 5 -180031404 2 1 1 0 2 0 2 0 + 766793.5950 0.1000 -6.2559 3 1960.2074 23 -18003140411 5 7 0 12 210 0 + 826549.8880 0.2000 -9.9788 3 4174.2875111 -18003140418 415 0 17 512 0 \ No newline at end of file diff --git a/astroquery/linelists/jplspec/tests/test_jplspec.py b/astroquery/linelists/jplspec/tests/test_jplspec.py index 623c995e6d..b933b60cb2 100644 --- a/astroquery/linelists/jplspec/tests/test_jplspec.py +++ b/astroquery/linelists/jplspec/tests/test_jplspec.py @@ -1,10 +1,14 @@ import numpy as np +import pytest + +from unittest.mock import Mock, MagicMock, patch +from astroquery.exceptions import EmptyResponseError import os from astropy import units as u from astropy.table import Table -from astroquery.linelists.jplspec import JPLSpec +from astroquery.linelists.jplspec.core import JPLSpec file1 = 'CO.data' file2 = 'CO_6.data' @@ -118,3 +122,207 @@ def test_query_multi(): assert tbl['TAG'][0] == -18003 assert tbl['TAG'][38] == -19002 assert tbl['TAG'][207] == 21001 + + +def test_parse_cat(): + """Test parsing of catalog files with _parse_cat method.""" + + response = MockResponseSpec('H2O_sample.cat') + tbl = JPLSpec._parse_cat(response) + + # Check table structure + assert isinstance(tbl, Table) + assert len(tbl) > 0 + assert set(tbl.keys()) == set(['FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', + 'TAG', 'QNFMT', 'Lab', + 'QN"1', 'QN"2', 'QN"3', 'QN"4', + "QN'1", "QN'2", "QN'3", "QN'4" + ]) + + # Check units + assert tbl['FREQ'].unit == u.MHz + assert tbl['ERR'].unit == u.MHz + assert tbl['LGINT'].unit == u.nm**2 * u.MHz + assert tbl['ELO'].unit == u.cm**(-1) + + # Check Lab flag exists and is boolean + assert 'Lab' in tbl.colnames + assert tbl['Lab'].dtype == bool + + # Check TAG values are positive (absolute values) + assert all(tbl['TAG'] > 0) + + +def test_get_molecule_input_validation(): + """Test input validation for get_molecule method.""" + + # Test invalid string format + with pytest.raises(ValueError): + JPLSpec.get_molecule('invalid') + + # Test invalid type + with pytest.raises(ValueError): + JPLSpec.get_molecule(12.34) + + # Test wrong length string + with pytest.raises(ValueError): + JPLSpec.get_molecule(1234567) + + +# Helper functions for fallback tests +def _create_empty_response(molecules): + """Create a mock response with 'Zero lines were found'.""" + mock_response = Mock() + mock_response.text = "Zero lines were found" + mock_request = Mock() + if isinstance(molecules, str): + mock_request.body = f"Mol={molecules}" + else: + mock_request.body = "&".join(f"Mol={mol}" for mol in molecules) + mock_response.request = mock_request + return mock_response + + +def _setup_fallback_mocks(molecules_dict): + """ + Set up mocks for fallback testing. + + Parameters + ---------- + molecules_dict : dict + Dictionary mapping molecule IDs to (name, table_data) tuples. + table_data should be a dict with 'FREQ' and optionally other columns. + + Returns + ------- + mock_get_molecule, mock_build_lookup + The mock objects that can be used in assertions. + """ + # Mock build_lookup + mock_lookup = MagicMock() + if len(molecules_dict) == 1: + mol_id = list(molecules_dict.keys())[0] + mock_lookup.find.return_value = molecules_dict[mol_id][0] + else: + mock_lookup.find.side_effect = lambda mol_id, **kwargs: molecules_dict.get(mol_id, (None, None))[0] + + # Mock get_molecule + def get_molecule_side_effect(mol_id): + if mol_id not in molecules_dict: + raise ValueError(f"Unexpected molecule ID: {mol_id}") + name, table_data = molecules_dict[mol_id] + mock_table = Table() + mock_table['FREQ'] = table_data.get('FREQ', [100.0, 200.0]) + mock_table['TAG'] = [int(mol_id)] * len(mock_table['FREQ']) + # Add any additional columns from table_data + for key, value in table_data.items(): + if key != 'FREQ' and key not in mock_table.colnames: + mock_table[key] = value + mock_table.meta = table_data.get('meta', {}) + return mock_table + + return get_molecule_side_effect, mock_lookup + + +def test_fallback_to_getmolecule_with_empty_response(): + """Test that fallback_to_getmolecule works when query returns zero lines.""" + mock_response = _create_empty_response('18003') + + # Test with fallback disabled - should raise EmptyResponseError + with pytest.raises(EmptyResponseError, match="Response was empty"): + JPLSpec._parse_result(mock_response, fallback_to_getmolecule=False) + + # Test with fallback enabled - should call get_molecule + molecules = {'18003': ('H2O', {'FREQ': [100.0, 200.0]})} + + with patch.object(JPLSpec, 'get_molecule') as mock_get_molecule, \ + patch('astroquery.linelists.jplspec.core.build_lookup') as mock_build_lookup: + + get_mol_func, mock_lookup = _setup_fallback_mocks(molecules) + mock_get_molecule.side_effect = get_mol_func + mock_build_lookup.return_value = mock_lookup + + result = JPLSpec._parse_result(mock_response, fallback_to_getmolecule=True) + + mock_get_molecule.assert_called_once_with('18003') + assert isinstance(result, Table) + assert len(result) == 2 + assert result.meta['molecule_id'] == '18003' + assert result.meta['molecule_name'] == 'H2O' + + +def test_fallback_to_getmolecule_with_multiple_molecules(): + """Test fallback with multiple molecules in the request.""" + mock_response = _create_empty_response(['18003', '28001']) + + molecules = { + '18003': ('H2O', {'FREQ': [100.0, 200.0]}), + '28001': ('CO', {'FREQ': [300.0, 400.0]}) + } + + with patch.object(JPLSpec, 'get_molecule') as mock_get_molecule, \ + patch('astroquery.linelists.jplspec.core.build_lookup') as mock_build_lookup: + + get_mol_func, mock_lookup = _setup_fallback_mocks(molecules) + mock_get_molecule.side_effect = get_mol_func + mock_build_lookup.return_value = mock_lookup + + result = JPLSpec._parse_result(mock_response, fallback_to_getmolecule=True) + + assert mock_get_molecule.call_count == 2 + assert isinstance(result, Table) + assert len(result) == 4 # 2 rows from each molecule + assert 'molecule_list' in result.meta + assert 'Name' in result.colnames + + +def test_query_lines_with_fallback(): + """Test that query_lines uses fallback when server returns empty result.""" + + # Test with fallback disabled - should raise EmptyResponseError + with patch.object(JPLSpec, '_request') as mock_request: + mock_response = _create_empty_response('28001') + mock_response.raise_for_status = Mock() + mock_request.return_value = mock_response + + with pytest.raises(EmptyResponseError, match="Response was empty"): + JPLSpec.query_lines(min_frequency=100 * u.GHz, + max_frequency=200 * u.GHz, + min_strength=-500, + molecule="28001 CO", + fallback_to_getmolecule=False) + + # Test with fallback enabled - should call get_molecule + molecules = {'28001': ('CO', { + 'FREQ': [115271.2018, 230538.0000], + 'ERR': [0.0005, 0.0010], + 'LGINT': [-5.0105, -4.5], + 'DR': [2, 2], + 'ELO': [0.0, 3.845], + 'GUP': [3, 5], + 'QNFMT': [1, 1] + })} + + with patch.object(JPLSpec, '_request') as mock_request, \ + patch.object(JPLSpec, 'get_molecule') as mock_get_molecule, \ + patch('astroquery.linelists.jplspec.core.build_lookup') as mock_build_lookup: + + mock_response = _create_empty_response('28001') + mock_response.raise_for_status = Mock() + mock_request.return_value = mock_response + + get_mol_func, mock_lookup = _setup_fallback_mocks(molecules) + mock_get_molecule.side_effect = get_mol_func + mock_build_lookup.return_value = mock_lookup + + result = JPLSpec.query_lines( + min_frequency=100 * u.GHz, + max_frequency=200 * u.GHz, + min_strength=-500, + molecule="28001 CO", + fallback_to_getmolecule=True) + + mock_get_molecule.assert_called_once_with('28001') + assert isinstance(result, Table) + assert len(result) > 0 + assert 'molecule_id' in result.meta diff --git a/astroquery/linelists/jplspec/tests/test_jplspec_remote.py b/astroquery/linelists/jplspec/tests/test_jplspec_remote.py index fc9f612be0..63750cef07 100644 --- a/astroquery/linelists/jplspec/tests/test_jplspec_remote.py +++ b/astroquery/linelists/jplspec/tests/test_jplspec_remote.py @@ -3,14 +3,17 @@ 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(): tbl = JPLSpec.query_lines(min_frequency=500 * u.GHz, max_frequency=1000 * u.GHz, min_strength=-500, - molecule="18003 H2O") + molecule="18003 H2O", + fallback_to_getmolecule=False) assert isinstance(tbl, Table) assert len(tbl) == 36 assert set(tbl.keys()) == set(['FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', @@ -23,19 +26,229 @@ def test_remote(): assert tbl['FREQ'][35] == 987926.7590 +@pytest.mark.remote_data +def test_remote_regex_fallback(): + """ + CO, H13CN, HC15N + Some of these have different combinations of QNs + """ + tbl = JPLSpec.query_lines(min_frequency=500 * u.GHz, + max_frequency=1000 * u.GHz, + min_strength=-500, + molecule=("28001", "28002", "28003"), + fallback_to_getmolecule=True) + assert isinstance(tbl, Table) + tbl = tbl[((tbl['FREQ'].quantity > 500*u.GHz) & (tbl['FREQ'].quantity < 1*u.THz))] + assert len(tbl) == 16 + # there are more QN formats than the original query had + assert set(tbl.keys()) == set(['FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', + 'TAG', 'QNFMT', 'QN\'', 'QN"', 'Lab', + 'QN"1', 'QN"2', "QN'", "QN'1", "QN'2", + 'Name' + ]) + + assert tbl['FREQ'][0] == 576267.9305 + assert tbl['ERR'][0] == .0005 + assert tbl['LGINT'][0] == -3.0118 + assert tbl['ERR'][7] == 8.3063 + assert tbl['FREQ'][15] == 946175.3151 + + +@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")) + molecule=("28001", "28002", "28003"), + fallback_to_getmolecule=False) assert isinstance(tbl, Table) assert len(tbl) == 16 assert set(tbl.keys()) == set(['FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', - 'TAG', 'QNFMT', 'QN\'', 'QN"']) + 'TAG', 'QNFMT', 'QN\'', 'QN"', + ]) assert tbl['FREQ'][0] == 576267.9305 assert tbl['ERR'][0] == .0005 assert tbl['LGINT'][0] == -3.0118 assert tbl['ERR'][7] == 8.3063 assert tbl['FREQ'][15] == 946175.3151 + + +@pytest.mark.remote_data +def test_get_molecule_remote(): + """Test get_molecule with remote data retrieval.""" + # Test with H2O + tbl = JPLSpec.get_molecule(18003) + + assert isinstance(tbl, Table) + assert len(tbl) > 0 + + # Check expected columns including Lab flag + expected_cols = {'FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', + 'TAG', 'QNFMT', 'Lab', + 'QN"1', 'QN"2', 'QN"3', 'QN"4', + "QN'1", "QN'2", "QN'3", "QN'4"} + assert set(tbl.keys()) == expected_cols + + # Check units + assert tbl['FREQ'].unit == u.MHz + assert tbl['ERR'].unit == u.MHz + assert tbl['LGINT'].unit == u.nm**2 * u.MHz + assert tbl['ELO'].unit == u.cm**(-1) + + # Check metadata was attached + assert 'NAME' in tbl.meta + assert tbl.meta['NAME'].strip() == 'H2O' + assert 'TAG' in tbl.meta + assert tbl.meta['TAG'] == 18003 + + # Check Lab flag + assert 'Lab' in tbl.colnames + assert tbl['Lab'].dtype == bool + + # H2O should have some lab measurements + assert sum(tbl['Lab']) > 0 + + +@pytest.mark.remote_data +def test_get_molecule_string_id(): + """Test get_molecule with string ID format.""" + # Test with CO using string ID + tbl = JPLSpec.get_molecule('028001') + + assert isinstance(tbl, Table) + assert len(tbl) > 0 + assert 'NAME' in tbl.meta + assert 'CO' in tbl.meta['NAME'] + + +@pytest.mark.remote_data +def test_remote_fallback(): + tbl = JPLSpec.query_lines(min_frequency=500 * u.GHz, + max_frequency=1000 * u.GHz, + min_strength=-500, + molecule="18003 H2O", + fallback_to_getmolecule=True) + assert isinstance(tbl, Table) + tbl = tbl[((tbl['FREQ'].quantity > 500*u.GHz) & (tbl['FREQ'].quantity < 1*u.THz))] + assert len(tbl) == 36 + assert set(tbl.keys()) == set(['FREQ', 'ERR', 'LGINT', 'DR', 'ELO', 'GUP', + 'TAG', 'QNFMT', 'Lab', + 'QN"1', 'QN"2', 'QN"3', 'QN"4', + "QN'1", "QN'2", "QN'3", "QN'4" + ]) + + assert tbl['FREQ'][0] == 503568.5200 + assert tbl['ERR'][0] == 0.0200 + assert tbl['LGINT'][0] == -4.9916 + assert tbl['ERR'][7] == 12.4193 + assert tbl['FREQ'][35] == 987926.7590 + + +@pytest.mark.remote_data +@pytest.mark.parametrize('mol_id,expected_name', [ + (28001, 'CO'), # Simple diatomic + (32003, 'CH3OH'), # Complex organic + (13002, 'CH'), # another simple molecule w/5 QNs + (14004, 'CD'), # no 2-digit QNs in first col + (15001, 'NH'), # incorrect QNFMT, says there are 5 QNs, only 4 + (18004, 'NH2D'), # highlighted a mismatch between qnlen & n_qns + # (32001, 'O2'), # masked second QN set? +]) +def test_get_molecule_various(mol_id, expected_name): + """ + Test get_molecule with various molecules. + + CH & CD are both regression tests for difficult molecules with >4 QNs and + missing 2-digit QNs (i.e., columns with _only_ 1-digit QNs at the start of + the columns with QNs). + """ + tbl = JPLSpec.get_molecule(mol_id) + assert isinstance(tbl, Table) + assert len(tbl) > 0 + assert 'NAME' in tbl.meta + assert expected_name in tbl.meta['NAME'] + + # Verify TAG values are positive + assert all(tbl['TAG'] > 0) + + +@pytest.mark.remote_data +def test_get_molecule_qn1(): + tbl = JPLSpec.get_molecule(28001) + assert isinstance(tbl, Table) + assert len(tbl) > 0 + assert 'QN"' in tbl.colnames + assert 'QN1"' not in tbl.colnames + assert "QN'" in tbl.colnames + assert "QN1'" not in tbl.colnames + + +@pytest.mark.remote_data +def test_get_molecule_qn4(): + """ CN has 4 QNs """ + tbl = JPLSpec.get_molecule(26001) + assert isinstance(tbl, Table) + assert len(tbl) > 0 + for ii in range(1, 5): + assert f'QN"{ii}' in tbl.colnames + assert f"QN'{ii}" in tbl.colnames + + +@pytest.mark.remote_data +def test_get_molecule_parser_details(): + """ + Verifying a known hard-to-parse row + 982.301 0.174 -17.8172 3 464.3000 9 320031304 4-2 2 5-5 2 + 991.369 0.003 -9.8234 3 310.3570 37 32003130418 3 - 0 18 3 + 0 + """ + tbl = JPLSpec.get_molecule(32003) + testrow = tbl[5] + assert testrow['FREQ'] == 982.301 + assert testrow["QN'1"] == 4 + assert testrow["QN'2"] == -2 + assert testrow["QN'3"] == '' + assert testrow["QN'4"] == 2 + + assert testrow['QN"1'] == 5 + assert testrow['QN"2'] == -5 + assert testrow['QN"3'] == '' + assert testrow['QN"4'] == 2 + + testrow = tbl[6] + assert testrow['FREQ'] == 991.369 + assert testrow["QN'1"] == 18 + assert testrow["QN'2"] == 3 + assert testrow["QN'3"] == '-' + assert testrow["QN'4"] == 0 + + assert testrow['QN"1'] == 18 + assert testrow['QN"2'] == 3 + assert testrow['QN"3'] == '+' + assert testrow['QN"4'] == 0 + + +@pytest.mark.bigdata +@pytest.mark.remote_data +class TestRegressionAllMolecules: + """Test that we can get each molecule in JPL database""" + species_table = JPLSpec.get_species_table() + + @pytest.mark.parametrize('row', species_table) + def test_regression_all_molecules(self, row): + """ + Expensive test - try all the molecules + """ + mol_id = row['TAG'] + # O2 has masked QNs making it hard to test automatically (32...) + # 34001, 39003, 44004, 44009, 44012 are missing or corrupt molecules + # 81001 may be fine? not entirely sure what's wrong + if mol_id in (32001, 32002, 32005, + 34001, 39003, 44004, 44009, 44012, + 81001): + # N2O = 44009 is just not there + pytest.skip("Skipping O2 due to masked QNs") + tbl = JPLSpec.get_molecule(mol_id) + assert isinstance(tbl, Table) + assert len(tbl) > 0 diff --git a/docs/linelists/cdms/cdms.rst b/docs/linelists/cdms/cdms.rst index 8bdb3cee82..dc08bcfb8d 100644 --- a/docs/linelists/cdms/cdms.rst +++ b/docs/linelists/cdms/cdms.rst @@ -33,18 +33,18 @@ each setting yields: ... min_strength=-500, ... molecule="028503 CO", ... get_query_payload=False) - >>> response.pprint(max_width=150) - FREQ ERR LGINT DR ELO GUP TAG QNFMT Ju Ku vu F1u F2u F3u Jl Kl vl F1l F2l F3l name MOLWT Lab - MHz MHz nm2 MHz 1 / cm u - ----------- ------ ------- --- -------- --- ------ ----- --- --- --- --- --- --- --- --- --- --- --- --- ------- ----- ---- - 115271.2018 0.0005 -5.0105 2 0.0 3 -28503 101 1 -- -- -- -- -- 0 -- -- -- -- -- CO, v=0 28 True - 230538.0 0.0005 -4.1197 2 3.845 5 -28503 101 2 -- -- -- -- -- 1 -- -- -- -- -- CO, v=0 28 True - 345795.9899 0.0005 -3.6118 2 11.535 7 -28503 101 3 -- -- -- -- -- 2 -- -- -- -- -- CO, v=0 28 True - 461040.7682 0.0005 -3.2657 2 23.0695 9 -28503 101 4 -- -- -- -- -- 3 -- -- -- -- -- CO, v=0 28 True - 576267.9305 0.0005 -3.0118 2 38.4481 11 -28503 101 5 -- -- -- -- -- 4 -- -- -- -- -- CO, v=0 28 True - 691473.0763 0.0005 -2.8193 2 57.6704 13 -28503 101 6 -- -- -- -- -- 5 -- -- -- -- -- CO, v=0 28 True - 806651.806 0.005 -2.6716 2 80.7354 15 -28503 101 7 -- -- -- -- -- 6 -- -- -- -- -- CO, v=0 28 True - 921799.7 0.005 -2.559 2 107.6424 17 -28503 101 8 -- -- -- -- -- 7 -- -- -- -- -- CO, v=0 28 True + >>> response.pprint(max_width=120) + FREQ ERR LGINT DR ELO GUP TAG QNFMT Ju Ku vu ... F3u Jl Kl vl F1l F2l F3l name MOLWT Lab + MHz MHz nm2 MHz 1 / cm ... u + ----------- ------ ------- --- -------- --- ------ ----- --- --- --- ... --- --- --- --- --- --- --- ------- ----- ---- + 115271.2018 0.0005 -5.0105 2 0.0 3 -28503 101 1 -- -- ... -- 0 -- -- -- -- -- CO, v=0 28 True + 230538.0 0.0005 -4.1197 2 3.845 5 -28503 101 2 -- -- ... -- 1 -- -- -- -- -- CO, v=0 28 True + 345795.9899 0.0005 -3.6118 2 11.535 7 -28503 101 3 -- -- ... -- 2 -- -- -- -- -- CO, v=0 28 True + 461040.7682 0.0005 -3.2657 2 23.0695 9 -28503 101 4 -- -- ... -- 3 -- -- -- -- -- CO, v=0 28 True + 576267.9305 0.0005 -3.0118 2 38.4481 11 -28503 101 5 -- -- ... -- 4 -- -- -- -- -- CO, v=0 28 True + 691473.0763 0.0005 -2.8193 2 57.6704 13 -28503 101 6 -- -- ... -- 5 -- -- -- -- -- CO, v=0 28 True + 806651.806 0.005 -2.6716 2 80.7354 15 -28503 101 7 -- -- ... -- 6 -- -- -- -- -- CO, v=0 28 True + 921799.7 0.005 -2.559 2 107.6424 17 -28503 101 8 -- -- ... -- 7 -- -- -- -- -- CO, v=0 28 True @@ -71,32 +71,33 @@ The units of the columns of the query can be displayed by calling ... molecule="028503 CO", ... get_query_payload=False) >>> print(response.info) - - name dtype unit class n_bad - ----- ------- ------- ------------ ----- - FREQ float64 MHz Column 0 - ERR float64 MHz Column 0 - LGINT float64 nm2 MHz Column 0 - DR int64 Column 0 - ELO float64 1 / cm Column 0 - GUP int64 Column 0 - TAG int64 Column 0 - QNFMT int64 Column 0 - Ju int64 Column 0 - Ku int64 MaskedColumn 8 - vu int64 MaskedColumn 8 - F1u int64 MaskedColumn 8 - F2u int64 MaskedColumn 8 - F3u int64 MaskedColumn 8 - Jl int64 Column 0 - Kl int64 MaskedColumn 8 - vl int64 MaskedColumn 8 - F1l int64 MaskedColumn 8 - F2l int64 MaskedColumn 8 - F3l int64 MaskedColumn 8 - name str7 Column 0 - MOLWT int64 u Column 0 - Lab bool Column 0 +
+ name dtype unit class n_bad + ----- ------- ------- ------------ ----- + FREQ float64 MHz Column 0 + ERR float64 MHz Column 0 + LGINT float64 nm2 MHz Column 0 + DR int64 Column 0 + ELO float64 1 / cm Column 0 + GUP int64 Column 0 + TAG int64 Column 0 + QNFMT int64 Column 0 + Ju int64 Column 0 + Ku int64 MaskedColumn 8 + vu int64 MaskedColumn 8 + F1u int64 MaskedColumn 8 + F2u int64 MaskedColumn 8 + F3u int64 MaskedColumn 8 + Jl int64 Column 0 + Kl int64 MaskedColumn 8 + vl int64 MaskedColumn 8 + F1l int64 MaskedColumn 8 + F2l int64 MaskedColumn 8 + F3l int64 MaskedColumn 8 + name str7 Column 0 + MOLWT int64 u Column 0 + Lab bool Column 0 + These come in handy for converting to other units easily, an example using a simplified version of the data above is shown below: @@ -303,7 +304,15 @@ It can be valuable to check this for any given molecule. Querying the Catalog with Regexes and Relative names ---------------------------------------------------- -The regular expression parsing is analogous to that in the JPLSpec module. +The regular expression parsing is analogous to that in +:mod:`astroquery.linelists.jplspec`. See :ref:`regex_querying_linelists`. + +Handling Malformatted Molecules +------------------------------- + +There are some entries in the CDMS catalog that get mangled by the query tool, +but the underlying data are still good. This seems to affect primarily those +molecules with excessive numbers of quantum numbers such as H2NC. Troubleshooting diff --git a/docs/linelists/jplspec/jplspec.rst b/docs/linelists/jplspec/jplspec.rst index 9e8a233a1b..2ef9f3ad12 100644 --- a/docs/linelists/jplspec/jplspec.rst +++ b/docs/linelists/jplspec/jplspec.rst @@ -14,6 +14,18 @@ module outputs the results that would arise from the `browser form using similar search criteria as the ones found in the form, and presents the output as a `~astropy.table.Table`. + +.. warning:: + Starting in mid-2025, the JPL web interface query tool went down for a + prolonged period. As of November 2025, it is still not up, but JPL staff are + aware of and seeking solutions to the problem. Until that web interface is + restored, the astroquery.jplspec module relies on workarounds that involve + downloading the full catalog files, which results in slightly larger data + transfers and un-filtered full-table results. Some metadata may also be + different. The examples and documents have been updated to show what to + expect in the current, partially-functional state. + + Examples ======== @@ -33,18 +45,19 @@ what each setting yields: ... min_strength=-500, ... molecule="28001 CO", ... get_query_payload=False) - >>> print(response) - FREQ ERR LGINT DR ELO GUP TAG QNFMT QN' QN" - MHz MHz nm2 MHz 1 / cm - ----------- ------ ------- --- -------- --- ------ ----- --- --- - 115271.2018 0.0005 -5.0105 2 0.0 3 -28001 101 1 0 - 230538.0 0.0005 -4.1197 2 3.845 5 -28001 101 2 1 - 345795.9899 0.0005 -3.6118 2 11.535 7 -28001 101 3 2 - 461040.7682 0.0005 -3.2657 2 23.0695 9 -28001 101 4 3 - 576267.9305 0.0005 -3.0118 2 38.4481 11 -28001 101 5 4 - 691473.0763 0.0005 -2.8193 2 57.6704 13 -28001 101 6 5 - 806651.806 0.005 -2.6716 2 80.7354 15 -28001 101 7 6 - 921799.7 0.005 -2.559 2 107.6424 17 -28001 101 8 7 + >>> response.pprint(max_lines=10) + FREQ ERR LGINT DR ELO GUP TAG QNFMT QN' QN" Lab + MHz MHz nm2 MHz 1 / cm + ------------ ------ -------- --- ---------- --- ----- ----- --- --- ----- + 115271.2018 0.0005 -5.0105 2 0.0 3 28001 101 1 0 True + 230538.0 0.0005 -4.1197 2 3.845 5 28001 101 2 1 True + ... ... ... ... ... ... ... ... ... ... ... + 9747448.9491 3.0112 -31.6588 2 14684.516 179 28001 101 89 88 False + 9845408.2504 3.1938 -32.4351 2 15009.6559 181 28001 101 90 89 False + 9942985.9145 3.3849 -33.2361 2 15338.0634 183 28001 101 91 90 False + Length = 91 rows + >>> response.meta + {'TAG': 28001, 'NAME': 'CO', 'NLINE': 91, 'QLOG1': 2.0369, 'QLOG2': 1.9123, 'QLOG3': 1.737, 'QLOG4': 1.4386, 'QLOG5': 1.1429, 'QLOG6': 0.8526, 'QLOG7': 0.5733, 'VER': '4*', 'molecule_id': '28001 CO', 'molecule_name': {}} The following example, with ``get_query_payload = True``, returns the payload: @@ -68,54 +81,58 @@ The units of the columns of the query can be displayed by calling ... min_strength=-500, ... molecule="28001 CO") >>> print(response.info) -
- name dtype unit - ----- ------- ------- - FREQ float64 MHz - ERR float64 MHz - LGINT float64 nm2 MHz - DR int64 - ELO float64 1 / cm - GUP int64 - TAG int64 - QNFMT int64 - QN' int64 - QN" int64 +
+ name dtype unit + ----- ------- ------- + FREQ float64 MHz + ERR float64 MHz + LGINT float64 nm2 MHz + DR int64 + ELO float64 1 / cm + GUP int64 + TAG int64 + QNFMT int64 + QN' int64 + QN" int64 + Lab bool + These come in handy for converting to other units easily, an example using a simplified version of the data above is shown below: .. doctest-remote-data:: - >>> print (response['FREQ', 'ERR', 'ELO']) - FREQ ERR ELO - MHz MHz 1 / cm - ----------- ------ -------- - 115271.2018 0.0005 0.0 - 230538.0 0.0005 3.845 - 345795.9899 0.0005 11.535 - 461040.7682 0.0005 23.0695 - 576267.9305 0.0005 38.4481 - 691473.0763 0.0005 57.6704 - 806651.806 0.005 80.7354 - 921799.7 0.005 107.6424 - >>> response['FREQ'].quantity - - >>> response['FREQ'].to('GHz') - + >>> response['FREQ', 'ERR', 'ELO'].pprint(max_lines=10) + FREQ ERR ELO + MHz MHz 1 / cm + ------------ ------ ---------- + 115271.2018 0.0005 0.0 + 230538.0 0.0005 3.845 + ... ... ... + 9747448.9491 3.0112 14684.516 + 9845408.2504 3.1938 15009.6559 + 9942985.9145 3.3849 15338.0634 + Length = 91 rows + >>> response['FREQ'][:10].quantity + + >>> response['FREQ'][:10].to('GHz') + The parameters and response keys are described in detail under the Reference/API section. Looking Up More Information from the catdir.cat file ------------------------------------------------------- +---------------------------------------------------- -If you have found a molecule you are interested in, the TAG field -in the results provides enough information to access specific -molecule information such as the partition functions at different -temperatures. Keep in mind that a negative TAG value signifies that -the line frequency has been measured in the laboratory +If you have found a molecule you are interested in, the TAG field in the results +provides enough information to access specific molecule information such as the +partition functions at different temperatures. A negative TAG value signifies +that the line frequency has been measured in the laboratory. .. doctest-remote-data:: @@ -139,11 +156,9 @@ through metadata: {'Temperature (K)': [300, 225, 150, 75, 37.5, 18.5, 9.375]} -One of the advantages of using JPLSpec is the availability in the catalog -of the partition function at different temperatures for the molecules. As a -continuation of the example above, an example that accesses and plots the -partition function against the temperatures found in the metadata is shown -below: +JPLSpec catalogs the partition function at several temperatures for each +molecule. This example accesses and plots the partition function against the +temperatures found in the metadata: .. doctest-skip:: @@ -153,7 +168,7 @@ below: >>> plt.scatter(temp,part) >>> plt.xlabel('Temperature (K)') >>> plt.ylabel('Partition Function Value') - >>> plt.title('Parititon Fn vs Temp') + >>> plt.title('Partition Fn vs Temp') >>> plt.show() @@ -194,6 +209,8 @@ other temperatures using curve fitting models: The resulting plot from the example above +.. _regex_querying_linelists: + Querying the Catalog with Regexes and Relative names ---------------------------------------------------- @@ -217,23 +234,27 @@ to query these directly. ... min_strength=-500, ... molecule="H2O", ... parse_name_locally=True) - >>> print(result) - FREQ ERR LGINT DR ELO GUP TAG QNFMT QN' QN" - MHz MHz nm2 MHz 1 / cm - ----------- -------- -------- --- --------- --- ------ ----- -------- -------- - 115542.5692 0.6588 -13.2595 3 4606.1683 35 18003 1404 17 810 0 18 513 0 - 139614.293 0.15 -9.3636 3 3080.1788 87 -18003 1404 14 6 9 0 15 312 0 - 177317.068 0.15 -10.3413 3 3437.2774 31 -18003 1404 15 610 0 16 313 0 - 183310.087 0.001 -3.6463 3 136.1639 7 -18003 1404 3 1 3 0 2 2 0 0 - ... - Length = 2000 rows + >>> result.pprint(max_lines=10) + 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 + MHz MHz nm2 MHz 1 / cm + ------------ ------ -------- --- --------- --- ----- ----- ---- ---- ---- ---- ---- ---- ---- ---- ----- + 8006.5805 2.851 -18.6204 3 6219.6192 45 18003 1404 22 21 4 7 18 15 0 0 False + 12478.2535 0.2051 -13.1006 3 3623.7652 31 18003 1404 15 16 7 4 9 12 0 0 False + ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... + 9981215.769 6.1776 -12.0101 3 5271.3682 45 18003 1404 22 23 2 1 20 23 0 0 False + 9981323.7676 6.1773 -11.5329 3 5271.3682 135 18003 1404 22 23 3 0 20 23 0 0 False + 9992065.9213 0.0482 -5.528 3 882.8904 15 18003 1404 7 8 6 1 2 7 0 0 False + Length = 1376 rows + Searches like these can lead to very broad queries, and may be limited in response length: .. doctest-remote-data:: - >>> print(result.meta['comments']) + >>> # the 'comments' metadata field is only populated if the query tool is run + >>> # the get-whole-table workaround (November 2025) will not populate it + >>> print(result.meta['comments']) # doctest: +SKIP ['', '', '', '', '', 'form is currently limilted to 2000 lines. Please limit your search.'] Inspecting the returned molecules shows that the 'H2O' string was processed as a @@ -247,7 +268,7 @@ combination of characters 'H2O': ... for (species, tag) in JPLSpec.lookup_ids.items() ... if tag in tags} >>> print(species) - {'H2O': 18003, 'H2O v2,2v2,v': 18005, 'H2O-17': 19003, 'H2O-18': 20003, 'H2O2': 34004} + {'H2O': 18003} A few examples that show the power of the regex option are the following: From 4843bf0f8ac9089528b2a0fed8563ca8b5411b7d Mon Sep 17 00:00:00 2001 From: "Adam Ginsburg (keflavich)" Date: Sat, 29 Nov 2025 07:36:33 -0500 Subject: [PATCH 2/2] remove unneeded imports [should fix all codestyle failures] --- astroquery/linelists/jplspec/core.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/astroquery/linelists/jplspec/core.py b/astroquery/linelists/jplspec/core.py index 61b453563e..db0a4d691f 100644 --- a/astroquery/linelists/jplspec/core.py +++ b/astroquery/linelists/jplspec/core.py @@ -5,15 +5,12 @@ import astropy.units as u import numpy as np from astropy.io import ascii -from astroquery.query import BaseQuery -from astroquery.utils import async_to_sync from astropy import table from astroquery.query import BaseQuery from astroquery.linelists.core import parse_letternumber # import configurable items declared in __init__.py from astroquery.linelists.jplspec import conf, lookup_table from astroquery.exceptions import EmptyResponseError, InvalidQueryError -from astroquery.linelists.core import LineListClass from astroquery.utils import process_asyncs from urllib.parse import parse_qs