diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 65f2a1c..93c17b7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -27,7 +27,7 @@ jobs: run: | pip install -e .[test,docs] - name: Test - run: pytest + run: pytest --remote-data=any - name: Build docs run: | cd docs diff --git a/docs/code_reference/eispac_net.rst b/docs/code_reference/eispac_net.rst new file mode 100644 index 0000000..129b596 --- /dev/null +++ b/docs/code_reference/eispac_net.rst @@ -0,0 +1,8 @@ +eispc net +========= + +.. automodapi:: eispac.net + :no-heading: + +.. automodapi:: eispac.net.attrs + :headings: ^" diff --git a/eispac/net/__init__.py b/eispac/net/__init__.py new file mode 100644 index 0000000..ea0f4ca --- /dev/null +++ b/eispac/net/__init__.py @@ -0,0 +1,3 @@ +from .client import * + +__all__ = ['EISClient'] diff --git a/eispac/net/attrs.py b/eispac/net/attrs.py new file mode 100644 index 0000000..09fa26e --- /dev/null +++ b/eispac/net/attrs.py @@ -0,0 +1,28 @@ +from sunpy.net.attr import SimpleAttr + +__all__ = ['FileType'] + + +class FileType(SimpleAttr): + """ + Specifies the type of EIS level 1 file + + Parameters + ---------- + value: `str` + Possible values are "HDF5 data" or "HDF5 header" to retrieve the + data and header files, respectively, in HDF5 format, or "FITS" to + retrieve the FITS files. Inputs are not case sensitive. + """ + + def __init__(self, value): + if not isinstance(value, str): + raise ValueError('File type must be a string') + value = value.lower() + if 'hdf5' in value: + value = '.'.join([value[5:], 'h5']) + if value == 'header.h5': + value = 'head.h5' + if value not in ['data.h5', 'head.h5', 'fits']: + raise ValueError(f'File type {value} must be either "HDF5 data", "HDF5 header", or "FITS".') + super().__init__(value) diff --git a/eispac/net/client.py b/eispac/net/client.py new file mode 100644 index 0000000..fe154b3 --- /dev/null +++ b/eispac/net/client.py @@ -0,0 +1,137 @@ +from sunpy.net import attrs as a +from sunpy.net.dataretriever import GenericClient, QueryResponse +from sunpy.net.scraper import Scraper +from sunpy.time import TimeRange + +from eispac.net.attrs import FileType + +__all__ = ['EISClient'] + + +class EISClient(GenericClient): + """ + Provides access to the level 1 EIS data in HDF5 and FITS format. + + This data is hosted by the `Naval Research Laboratory `__. + + Examples + -------- + >>> from sunpy.net import Fido, attrs as a + >>> import eispac.net + >>> from eispac.net.attrs import FileType + >>> results = Fido.search(a.Time('2020-11-09 00:00:00','2020-11-09 01:00:00'), + ... a.Instrument('EIS'), + ... a.Physobs.intensity, + ... a.Source('Hinode'), + ... a.Provider('NRL'), + ... a.Level('1')) #doctest: +REMOTE_DATA + >>> results #doctest: +REMOTE_DATA + + Results from 1 Provider: + + 3 Results from the EISClient: + Source: https://eis.nrl.navy.mil/ + + Start Time End Time ... Level FileType + ----------------------- ----------------------- ... ----- ----------- + 2020-11-09 00:10:12.000 2020-11-09 00:10:12.999 ... 1 HDF5 data + 2020-11-09 00:10:12.000 2020-11-09 00:10:12.999 ... 1 HDF5 header + 2020-11-09 00:10:12.000 2020-11-09 00:10:12.999 ... 1 FITS + + + >>> results = Fido.search(a.Time('2020-11-09 00:00:00','2020-11-09 01:00:00'), + ... a.Instrument('EIS'), + ... a.Physobs.intensity, + ... a.Source('Hinode'), + ... a.Provider('NRL'), + ... a.Level('1'), + ... FileType('HDF5 header')) #doctest: +REMOTE_DATA + >>> results #doctest: +REMOTE_DATA + + Results from 1 Provider: + + 1 Results from the EISClient: + Source: https://eis.nrl.navy.mil/ + + Start Time End Time ... Level FileType + ----------------------- ----------------------- ... ----- ----------- + 2020-11-09 00:10:12.000 2020-11-09 00:10:12.999 ... 1 HDF5 header + + + """ + baseurl_hdf5 = r'https://eis.nrl.navy.mil/level1/hdf5/%Y/%m/%d/eis_%Y%m%d_%H%M%S.(\w){4}.h5' + pattern_hdf5 = '{}/{year:4d}/{month:2d}/{day:2d}/eis_{:8d}_{hour:2d}{minute:2d}{second:2d}.{FileType}' + baseurl_fits = r'https://eis.nrl.navy.mil/level1/fits/%Y/%m/%d/eis_er_%Y%m%d_%H%M%S.fits' + pattern_fits = '{}/{year:4d}/{month:2d}/{day:2d}/eis_er_{:8d}_{hour:2d}{minute:2d}{second:2d}.{FileType}' + + @property + def info_url(self): + return 'https://eis.nrl.navy.mil/' + + def search(self, *args, **kwargs): + # NOTE: Search is overridden because URL and pattern depending on the filetype. + # This enables multiple filetypes to be returned in the same query. + metalist = [] + matchdict = self._get_match_dict(*args, **kwargs) + all_filetypes = matchdict.get('FileType') + for ft in all_filetypes: + if 'h5' in ft: + baseurl = self.baseurl_hdf5 + pattern = self.pattern_hdf5 + else: + baseurl = self.baseurl_fits + pattern = self.pattern_fits + + scraper = Scraper(baseurl, regex=True) + tr = TimeRange(matchdict['Start Time'], matchdict['End Time']) + filesmeta = scraper._extract_files_meta(tr, extractor=pattern, matcher={'FileType': ft}) + filesmeta = sorted(filesmeta, key=lambda k: k['url']) + for i in filesmeta: + rowdict = self.post_search_hook(i, matchdict) + metalist.append(rowdict) + + return QueryResponse(metalist, client=self) + + def post_search_hook(self, i, matchdict): + # This makes the final display names of the file types nicer + filetype_mapping = { + 'data.h5': 'HDF5 data', + 'head.h5': 'HDF5 header', + 'fits': 'FITS', + } + rd = super().post_search_hook(i, matchdict) + rd['FileType'] = filetype_mapping[rd['FileType']] + return rd + + @classmethod + def register_values(cls): + return { + a.Instrument: [('EIS', 'Extreme Ultraviolet Imaging Spectrometer')], + a.Physobs: [('intensity', 'Spectrally resolved intensity in detector units')], + a.Source: [('Hinode', 'The Hinode mission is a partnership between JAXA, NASA, and UKSA')], + a.Provider: [('NRL', 'U.S. Naval Research Laboratory')], + a.Level: [ + ('1', 'EIS: The EIS client can only return level 1 data. Level 0 EIS data is available from the VSO.') + ], + FileType: [('data.h5', 'These files contain the actual intensity data in HDF5 format.'), + ('head.h5', 'These files contain only the header metadata in HDF5 format.'), + ('fits', 'These files contain both data and metadata in FITS format')], + } + + @classmethod + def _attrs_module(cls): + # Register EIS specific attributes with Fido + return 'eispac', 'eispac.net.attrs' + + @classmethod + def _can_handle_query(cls, *query): + """ + Check if this client can handle a given Fido query. + Returns + ------- + bool + True if this client can handle the given query. + """ + required = {a.Time, a.Instrument, a.Source} + optional = {a.Provider, a.Physobs, a.Level, FileType} + return cls.check_attr_types_in_query(query, required, optional) diff --git a/eispac/net/tests/test_eis_client.py b/eispac/net/tests/test_eis_client.py new file mode 100644 index 0000000..8d51374 --- /dev/null +++ b/eispac/net/tests/test_eis_client.py @@ -0,0 +1,61 @@ +import pytest +from sunpy.net import Fido, attrs as a +import eispac.net + + +@pytest.fixture +def eis_query(): + time = a.Time('2022-03-29 22:21:00','2022-03-29 23:21:00') + instr = a.Instrument('EIS') + obs = a.Physobs.intensity + source = a.Source('Hinode') + provider = a.Provider('NRL') + level = a.Level('1') + return time, instr, obs, source, provider, level + + +@pytest.mark.remote_data +def test_search_all_types(eis_query): + q = Fido.search(*eis_query) + assert len(q) == 1 + assert len(q[0]) == 3 + assert q[0,0]['url'] == 'https://eis.nrl.navy.mil/level1/hdf5/2022/03/29/eis_20220329_222113.data.h5' + + +@pytest.mark.remote_data +def test_search_fits_only(eis_query): + q = Fido.search(*eis_query, a.eispac.FileType('FITS')) + assert len(q) == 1 + assert len(q[0]) == 1 + assert q[0,0]['url'] == 'https://eis.nrl.navy.mil/level1/fits/2022/03/29/eis_er_20220329_222113.fits' + + +@pytest.mark.parametrize('file_type, file_url', [ + ('FITS', 'https://eis.nrl.navy.mil/level1/fits/2022/03/29/eis_er_20220329_222113.fits'), + ('HDF5 data', 'https://eis.nrl.navy.mil/level1/hdf5/2022/03/29/eis_20220329_222113.data.h5'), + ('HDF5 header', 'https://eis.nrl.navy.mil/level1/hdf5/2022/03/29/eis_20220329_222113.head.h5') +]) +@pytest.mark.remote_data +def test_search_individual_filetypes(eis_query, file_type, file_url): + q = Fido.search(*eis_query, a.eispac.FileType(file_type)) + assert len(q) == 1 + assert len(q[0]) == 1 + assert q[0,0]['url'] == file_url + assert q[0,0]['FileType'] == file_type + + +@pytest.mark.remote_data +def test_combined_hdf5_search(eis_query): + q = Fido.search(*eis_query, + a.eispac.FileType('HDF5 data') | a.eispac.FileType('HDF5 header')) + assert len(q) == 2 + assert len(q[0]) == 1 + assert len(q[1]) == 1 + assert q[0,0]['FileType'] == 'HDF5 data' + assert q[1,0]['FileType'] == 'HDF5 header' + + +def test_registered_attrs(): + attr_names = ['fits', 'data_h5', 'head_h5'] + for an in attr_names: + assert hasattr(a.eispac.FileType, an) diff --git a/eispac/util/rot_xy.py b/eispac/util/rot_xy.py index 4afbd66..3055113 100644 --- a/eispac/util/rot_xy.py +++ b/eispac/util/rot_xy.py @@ -33,7 +33,7 @@ def rot_xy(xcen, ycen, start_time, end_time): Examples -------- - >>> new = rot_xy(0, 0, start_time='01-JAN-2021 00:00', end_time='01-JAN-2021 01:00') + >>> new = rot_xy(0, 0, start_time='2021-JAN-01 00:00', end_time='2021-JAN-01 01:00') >>> print(new.Tx, new.Ty) 9.47188arcsec 0.0809565arcsec """ diff --git a/setup.cfg b/setup.cfg index e8e9494..a9d22f8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -31,7 +31,7 @@ install_requires = matplotlib>=3.1 h5py>=2.9 astropy>=3.1 - sunpy[map]>=2.1 + sunpy[net,map]>=2.1 ndcube>=2.0.0 parfive>=1.5 python-dateutil>=2.8 @@ -39,6 +39,7 @@ install_requires = [options.extras_require] test = pytest>=4.6.3 + pytest-astropy docs = sphinx==4.0.2 # why is this pinned so strictly? sphinx-automodapi>=0.13 @@ -47,3 +48,12 @@ docs = [options.package_data] eispac.data.test = *.h5 eispac.data.templates = *.template.* + +[tool:pytest] +testpaths = "eispac" +norecursedirs = ".tox" "build" "docs[\/]_build" "docs[\/]generated" "*.egg-info" "examples" ".jupyter" ".history" "tools" +doctest_plus = enabled +text_file_format = rst +addopts = --doctest-rst +doctest_optionflags = NORMALIZE_WHITESPACE FLOAT_CMP ELLIPSIS +remote_data_strict = True