diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7aadde3..52a6262 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,7 +43,7 @@ jobs: envs: | - windows: py310 - macos: py311 - - linux: py312-devdeps + - linux: py313-devdeps secrets: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9cd302d..1c8cd7c 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,10 +1,10 @@ repos: - - repo: https://github.com/myint/docformatter - rev: v1.7.5 + - repo: https://github.com/PyCQA/docformatter + rev: master hooks: - id: docformatter args: ["--in-place", "--pre-summary-newline", "--make-summary-multi"] - - repo: https://github.com/myint/autoflake + - repo: https://github.com/PyCQA/autoflake rev: v2.3.1 hooks: - id: autoflake @@ -16,13 +16,13 @@ repos: ] exclude: ".*(.fits|.fts|.fit|.txt|tca.*|extern.*|.rst|.md|docs/conf.py)$" - repo: https://github.com/charliermarsh/ruff-pre-commit - rev: "v0.5.4" + rev: "v0.8.2" hooks: - id: ruff args: ["--fix", "--unsafe-fixes"] - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-ast - id: check-case-conflict @@ -40,7 +40,7 @@ repos: - id: codespell additional_dependencies: - tomli - - repo: https://github.com/pre-commit/mirrors-prettier - rev: v4.0.0-alpha.8 + - repo: https://github.com/rbubley/mirrors-prettier.git + rev: v3.4.2 hooks: - id: prettier diff --git a/aiapy/__init__.py b/aiapy/__init__.py index 7fb005a..6b123fd 100644 --- a/aiapy/__init__.py +++ b/aiapy/__init__.py @@ -26,4 +26,4 @@ def _get_bibtex(): __citation__ = __bibtex__ = _get_bibtex() -__all__ = ["__version__", "__citation__", "_SSW_MIRRORS"] +__all__ = ["_SSW_MIRRORS", "__citation__", "__version__"] diff --git a/aiapy/calibrate/prep.py b/aiapy/calibrate/prep.py index 2d130f5..7b87076 100644 --- a/aiapy/calibrate/prep.py +++ b/aiapy/calibrate/prep.py @@ -15,7 +15,7 @@ from aiapy.util import AiapyUserWarning from aiapy.util.decorators import validate_channel -__all__ = ["register", "correct_degradation", "degradation"] +__all__ = ["correct_degradation", "degradation", "register"] @add_common_docstring(rotation_function_names=_rotation_function_names) @@ -218,7 +218,7 @@ def degradation( ratio = np.zeros(obstime.shape) poly = np.zeros(obstime.shape) # Do this outside of the loop to avoid repeated queries - correction_table = get_correction_table(correction_table=correction_table) + correction_table = get_correction_table(correction_table=correction_table, calibration_version=calibration_version) for i, t in enumerate(obstime): table = _select_epoch_from_correction_table(channel, t, correction_table, version=calibration_version) diff --git a/aiapy/calibrate/spikes.py b/aiapy/calibrate/spikes.py index d4caec1..79ff66a 100644 --- a/aiapy/calibrate/spikes.py +++ b/aiapy/calibrate/spikes.py @@ -11,7 +11,7 @@ from aiapy.util import AiapyUserWarning -__all__ = ["respike", "fetch_spikes"] +__all__ = ["fetch_spikes", "respike"] def respike(smap, *, spikes=None): @@ -142,10 +142,14 @@ def fetch_spikes(smap, *, as_coords=False): series = r"aia.lev1_euv_12s" if smap.wavelength in (1600, 1700, 4500) * u.angstrom: series = r"aia.lev1_uv_24s" - file = drms.Client().query( - f'{series}[{smap.date}/12s][WAVELNTH={smap.meta["wavelnth"]}]', - seg="spikes", - ) + try: + file = drms.Client().query( + f'{series}[{smap.date}/12s][WAVELNTH={smap.meta["wavelnth"]}]', + seg="spikes", + ) + except Exception as e: + msg = f"Could not retrieve spikes for {smap.date} at {smap.wavelength}.\n" f"Error message: {e}" + raise OSError(msg) from e _, spikes = fits.open(f'http://jsoc.stanford.edu{file["spikes"][0]}') # Loaded as floats, but they are actually integers spikes = spikes.data.astype(np.int32) diff --git a/aiapy/calibrate/tests/test_meta.py b/aiapy/calibrate/tests/test_meta.py index 6147b36..92a4245 100644 --- a/aiapy/calibrate/tests/test_meta.py +++ b/aiapy/calibrate/tests/test_meta.py @@ -18,12 +18,12 @@ def test_fix_observer_location(aia_171_map): assert smap_fixed.meta["dsun_obs"] == smap_fixed.observer_coordinate.radius.value -@pytest.fixture() +@pytest.fixture def pointing_table(aia_171_map): return get_pointing_table(aia_171_map.date - 6 * u.h, aia_171_map.date + 6 * u.h) -@pytest.fixture() +@pytest.fixture def mock_pointing_table(): return QTable( [ @@ -45,7 +45,7 @@ def mock_pointing_table(): ) -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_fix_pointing(aia_171_map, pointing_table): keys = ["CRPIX1", "CRPIX2", "CDELT1", "CDELT2", "CROTA2"] # Remove keys to at least test that they get set @@ -61,7 +61,7 @@ def test_fix_pointing(aia_171_map, pointing_table): assert aia_map_updated.meta[k] == aia_map_updated2.meta[k] -@pytest.mark.remote_data() +@pytest.mark.remote_data @pytest.mark.parametrize( ("t_delt_factor", "expected_entry"), [ @@ -86,7 +86,7 @@ def test_update_pointing_accuracy(aia_171_map, pointing_table, t_delt_factor, ex assert aia_map_updated.reference_pixel.y == pointing_table[expected_entry]["A_171_Y0"] -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_update_pointing_submap_raises_exception(aia_171_map, pointing_table): m = aia_171_map.submap( SkyCoord(0, 0, unit="arcsec", frame=aia_171_map.coordinate_frame), @@ -96,19 +96,19 @@ def test_update_pointing_submap_raises_exception(aia_171_map, pointing_table): update_pointing(m, pointing_table=pointing_table) -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_update_pointing_resampled_raises_exception(aia_171_map, pointing_table): m = aia_171_map.resample((512, 512) * u.pixel) with pytest.raises(ValueError, match="Input must be at the full resolution"): update_pointing(m, pointing_table=pointing_table) -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_update_pointing_no_entry_raises_exception(aia_171_map, pointing_table): # This tests that an exception is thrown when entry corresponding to # T_START <= T_OBS < T_END cannot be found in the pointing table. # We explicitly set the T_OBS key - aia_171_map.meta["T_OBS"] = (aia_171_map.date + 1 * u.day).isot + aia_171_map.meta["T_OBS"] = (aia_171_map.date - 1000 * u.day).isot with pytest.raises(IndexError, match="No valid entries for"): update_pointing(aia_171_map, pointing_table=pointing_table) diff --git a/aiapy/calibrate/tests/test_prep.py b/aiapy/calibrate/tests/test_prep.py index 6a13fb7..8fcdc68 100644 --- a/aiapy/calibrate/tests/test_prep.py +++ b/aiapy/calibrate/tests/test_prep.py @@ -15,12 +15,12 @@ from aiapy.util import AiapyUserWarning -@pytest.fixture() +@pytest.fixture def lvl_15_map(aia_171_map): return register(aia_171_map) -@pytest.fixture() +@pytest.fixture def non_sdo_map(): return Map(sunpy.data.test.get_test_filepath("hsi_image_20101016_191218.fits")) @@ -53,10 +53,11 @@ def test_register_filesave(lvl_15_map): Test that adjusted header values are still correct after saving the map and reloading it. """ - afilename = tempfile.NamedTemporaryFile(suffix=".fits").name - with pytest.warns(VerifyWarning, match="The 'BLANK' keyword is only applicable to integer data"): - lvl_15_map.save(afilename, overwrite=True) - load_map = Map(afilename) + with tempfile.NamedTemporaryFile(suffix=".fits") as af: + afilename = af.name + with pytest.warns(VerifyWarning, match="The 'BLANK' keyword is only applicable to integer data"): + lvl_15_map.save(afilename, overwrite=True) + load_map = Map(afilename) # Check crpix values assert load_map.meta["crpix1"] == lvl_15_map.data.shape[1] / 2.0 + 0.5 assert load_map.meta["crpix2"] == lvl_15_map.data.shape[0] / 2.0 + 0.5 @@ -133,30 +134,30 @@ def test_correct_degradation(aia_171_map, correction_table, version): pytest.param( None, 10, - 0.9031773242843387 * u.dimensionless_unscaled, + 0.903 * u.dimensionless_unscaled, marks=pytest.mark.remote_data, ), pytest.param( None, 9, - 0.8658650561969473 * u.dimensionless_unscaled, + 0.865 * u.dimensionless_unscaled, marks=pytest.mark.remote_data, ), pytest.param( None, 8, - 0.7667012041798814 * u.dimensionless_unscaled, + 0.766 * u.dimensionless_unscaled, marks=pytest.mark.remote_data, ), ( get_test_filepath("aia_V8_20171210_050627_response_table.txt"), 8, - 0.7667108920899671 * u.dimensionless_unscaled, + 0.766 * u.dimensionless_unscaled, ), ( get_correction_table(correction_table=get_test_filepath("aia_V8_20171210_050627_response_table.txt")), 8, - 0.7667108920899671 * u.dimensionless_unscaled, + 0.766 * u.dimensionless_unscaled, ), ], ) @@ -174,7 +175,7 @@ def test_degradation(correction_table, version, time_correction_truth): calibration_version=version, correction_table=correction_table, ) - assert u.allclose(time_correction, time_correction_truth, rtol=1e-10, atol=0.0) + assert u.allclose(time_correction, time_correction_truth, atol=1e-3) @pytest.mark.parametrize( @@ -231,13 +232,14 @@ def test_degradation_all_wavelengths(wavelength, result): assert u.allclose(time_correction, result) -@pytest.mark.remote_data() +@pytest.mark.xfail(reason="JSOC is down plus the static files all lack 4500") +@pytest.mark.remote_data def test_degradation_4500(): # 4500 has a max version of 3, so by default it will error obstime = astropy.time.Time("2015-01-01T00:00:00", scale="utc") with pytest.raises( ValueError, - match="Correction table does not contain calibration for version 10 for 4500.0 Angstrom. Max version is 3", + match="Correction table does not contain calibration for version 10 for 4500.0 Angstrom. Max version is 3.", ): degradation(4500 * u.angstrom, obstime) diff --git a/aiapy/calibrate/tests/test_spikes.py b/aiapy/calibrate/tests/test_spikes.py index d8b9c0a..a0db628 100644 --- a/aiapy/calibrate/tests/test_spikes.py +++ b/aiapy/calibrate/tests/test_spikes.py @@ -10,8 +10,10 @@ from aiapy.calibrate import fetch_spikes, respike from aiapy.util import AiapyUserWarning +pytestmark = pytest.mark.xfail(reason="JSOC is down") -@pytest.fixture() + +@pytest.fixture def despiked_map(): # Need an actual 4K-by-4K map to do the spike replacement return sunpy.map.Map( @@ -19,36 +21,36 @@ def despiked_map(): ) -@pytest.fixture() +@pytest.fixture def respiked_map(despiked_map): return respike(despiked_map) -@pytest.fixture() +@pytest.fixture def spikes(despiked_map): return fetch_spikes(despiked_map) -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_respike(respiked_map, spikes): coords, values = spikes for x, y, v in zip(coords.x.value, coords.y.value, values, strict=True): assert v == respiked_map.data[int(y), int(x)] -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_respike_meta(respiked_map): assert respiked_map.meta["lvl_num"] == 0.5 assert respiked_map.meta["nspikes"] == 0 -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_fetch_with_prefetched_spikes(despiked_map, respiked_map, spikes): respiked_map_prefetched = respike(despiked_map, spikes=spikes) assert np.allclose(respiked_map.data, respiked_map_prefetched.data) -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_cutout(respiked_map, despiked_map): blc = (-500, -500) * u.arcsec trc = (500, 500) * u.arcsec @@ -65,7 +67,7 @@ def test_cutout(respiked_map, despiked_map): assert np.allclose(respiked_map_cutout.data, cutout_map_respiked.data) -@pytest.mark.remote_data() +@pytest.mark.remote_data @pytest.mark.parametrize( ("key", "value", "error", "match"), [ @@ -81,7 +83,7 @@ def test_exceptions(despiked_map, key, value, error, match): respike(sunpy.map.Map(despiked_map.data, new_meta)) -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_resample_warning(despiked_map): despiked_map_resample = despiked_map.resample((512, 512) * u.pixel) with ( @@ -91,7 +93,7 @@ def test_resample_warning(despiked_map): respike(despiked_map_resample) -@pytest.mark.remote_data() +@pytest.mark.remote_data @pytest.mark.parametrize(("as_coords", "kind"), [(True, SkyCoord), (False, PixelPair)]) def test_fetch_spikes(despiked_map, as_coords, kind): n_spikes = despiked_map.meta["nspikes"] diff --git a/aiapy/calibrate/tests/test_util.py b/aiapy/calibrate/tests/test_util.py index 4538df4..3512505 100644 --- a/aiapy/calibrate/tests/test_util.py +++ b/aiapy/calibrate/tests/test_util.py @@ -90,7 +90,7 @@ def test_obstime_out_of_range(): _select_epoch_from_correction_table(94 * u.angstrom, obstime_out_of_range, correction_table_local, version=8) -@pytest.mark.remote_data() +@pytest.mark.remote_data def test_pointing_table(): expected_columns = ["T_START", "T_STOP"] for c in ["094", "171", "193", "211", "304", "335", "1600", "1700", "4500"]: @@ -111,7 +111,7 @@ def test_pointing_table(): assert not hasattr(table[c], "mask") -@pytest.mark.remote_data() +@pytest.mark.xfail(reason="JSOC is down") def test_pointing_table_unavailable(): # Check that missing pointing data raises a nice error t = astropy.time.Time("1990-01-01") diff --git a/aiapy/calibrate/util.py b/aiapy/calibrate/util.py index 9ab836f..bd0e682 100644 --- a/aiapy/calibrate/util.py +++ b/aiapy/calibrate/util.py @@ -3,25 +3,31 @@ """ import os + +# This needs to be done before aiapy or sunpy is imported +os.environ["PARFIVE_SINGLE_DOWNLOAD"] = "True" import pathlib import warnings from urllib.parse import urljoin -import astropy.io.ascii import astropy.units as u import drms import numpy as np +from astropy.io import ascii as astropy_ascii from astropy.table import QTable from astropy.time import Time from erfa.core import ErfaWarning from sunpy import log from sunpy.data import manager -from sunpy.net import attrs, jsoc +from sunpy.net import attrs as a +from sunpy.net import jsoc from aiapy import _SSW_MIRRORS from aiapy.util.decorators import validate_channel -__all__ = ["get_correction_table", "get_pointing_table", "get_error_table"] +os.environ.pop("PARFIVE_SINGLE_DOWNLOAD") + +__all__ = ["get_correction_table", "get_error_table", "get_pointing_table"] # Default version of the degradation calibration curve to use. # This needs to be incremented as the calibration is updated in JSOC. @@ -31,7 +37,7 @@ # Most recent version number for error tables; increment as new versions become available ERROR_VERSION = 3 # URLs and SHA-256 hashes for each version of the error tables -URL_HASH = { +URL_HASH_ERROR_TABLE = { 2: ( [urljoin(mirror, AIA_ERROR_FILE.format(2)) for mirror in _SSW_MIRRORS], "ac97ccc48057809723c27e3ef290c7d78ee35791d9054b2188baecfb5c290d0a", @@ -41,9 +47,47 @@ "66ff034923bb0fd1ad20e8f30c7d909e1a80745063957dd6010f81331acaf894", ), } +URL_HASH_POINTING_TABLE = ( + "https://aia.lmsal.com/public/master_aia_pointing3h.csv", + "a2c80fa0ea3453c62c91f51df045ae04b771d5cbb51c6495ed56de0da2a5482e", +) +URL_HASH_RESPONSE_TABLE = { + 10: ( + [urljoin(mirror, "sdo/aia/response/aia_V10_20201119_190000_response_table.txt") for mirror in _SSW_MIRRORS], + "0a3f2db39d05c44185f6fdeec928089fb55d1ce1e0a805145050c6356cbc6e98", + ), + 9: ( + [urljoin(mirror, "sdo/aia/response/aia_V9_20200706_215452_response_table.txt") for mirror in _SSW_MIRRORS], + "f24b384cba9935ae2e8fd3c0644312720cb6add95c49ba46f1961ae4cf0865f9", + ), + 8: ( + [urljoin(mirror, "sdo/aia/response/aia_V8_20171210_050627_response_table.txt") for mirror in _SSW_MIRRORS], + "0e8bc6af5a69f80ca9d4fc2a27854681b76574d59eb81d7201b7f618081f0fdd", + ), + 7: ( + [urljoin(mirror, "sdo/aia/response/aia_V7_20171129_195626_response_table.txt") for mirror in _SSW_MIRRORS], + "ac2171d549bd6cc6c37e13e505eef1bf0c89fc49bffd037e4ac64f0b895063ac", + ), + 6: ( + [urljoin(mirror, "sdo/aia/response/aia_V6_20141027_230030_response_table.txt") for mirror in _SSW_MIRRORS], + "11c148f447d4538db8fd247f74c26b4ae673355e2536f63eb48f9a267e58c7c6", + ), + 4: ( + [urljoin(mirror, "sdo/aia/response/aia_V4_20130109_204835_response_table.txt") for mirror in _SSW_MIRRORS], + "7e73f4effa9a8dc55f7b4993a8d181419ef555bf295c4704703ca84d7a0fc3c1", + ), + 3: ( + [urljoin(mirror, "sdo/aia/response/aia_V3_20120926_201221_response_table.txt") for mirror in _SSW_MIRRORS], + "0a5d2c2ed1cda18bb9fbdbd51fbf3374e042d20145150632ac95350fc99de68b", + ), + 2: ( + [urljoin(mirror, "sdo/aia/response/aia_V2_20111129_000000_response_table.txt") for mirror in _SSW_MIRRORS], + "d55ccd6cb3cb4bd1c688f8663f942f8a872c918a2504e5e474aa97dff45b62c9", + ), +} -def get_correction_table(*, correction_table=None): +def get_correction_table(*, correction_table=None, calibration_version=None): """ Return table of degradation correction factors. @@ -59,7 +103,14 @@ def get_correction_table(*, correction_table=None): ---------- correction_table: `str` or `~astropy.table.QTable`, optional Path to correction table file or an existing correction table. If None, - the table will be queried from JSOC. + the table will be queried from JSOC. If that fails, the fixed V10 + response table will be used. + calibration_version : `int`, optional + The version of the calibration to use when calculating the degradation. + By default, this is the most recent version available from JSOC. If you + are using a specific calibration response file, you may need to specify + this according to the version in that file. + Defaults to None which will use the most recent version available. Returns ------- @@ -69,11 +120,11 @@ def get_correction_table(*, correction_table=None): -------- aiapy.calibrate.degradation """ - if isinstance(correction_table, astropy.table.QTable): + if isinstance(correction_table, QTable): return correction_table if correction_table is not None: if isinstance(correction_table, str | pathlib.Path): - table = QTable(astropy.io.ascii.read(correction_table)) + table = QTable(astropy_ascii.read(correction_table)) else: msg = "correction_table must be a file path, an existing table, or None." raise ValueError(msg) @@ -83,8 +134,20 @@ def get_correction_table(*, correction_table=None): # identical because the PrimeKeys for this series are WAVE_STR # and T_START. Without the !1=1! the query only returns the # latest record for each unique combination of those keywords. - table = drms.Client().query("aia.response[][!1=1!]", key="**ALL**") - table = QTable.from_pandas(table) + try: + table = drms.Client().query("aia.response[][!1=1!]", key="**ALL**") + table = QTable.from_pandas(table) + except Exception as e: # NOQA: BLE001 + log.warning("Unable to retrieve response table from JSOC.") + log.warning(f"Error: {e}") + log.warning(f"Falling back to fixed V{calibration_version} response table") + if calibration_version is None: + calibration_version = CALIBRATION_VERSION + import aiapy.calibrate.util # NOQA: PLW0406 + + table = QTable( + astropy_ascii.read(getattr(aiapy.calibrate.util, f"fetch_response_table_v{calibration_version}")()) + ) selected_cols = [ "DATE", "VER_NUM", @@ -161,6 +224,12 @@ def get_pointing_table(start, end): """ Retrieve 3-hourly master pointing table from the JSOC. + .. warning:: + + As the JSOC is currently not available, this function will + fallback to a fixed text file containing the pointing + information. + This function queries `JSOC `__ for the 3-hourly master pointing table (MPT) in the interval defined by ``start`` and ``end``. @@ -192,17 +261,23 @@ def get_pointing_table(start, end): -------- aiapy.calibrate.update_pointing """ - q = jsoc.JSOCClient().search( - attrs.Time(start, end=end), - attrs.jsoc.Series.aia_master_pointing3h, - ) - table = QTable(q) - if len(table.columns) == 0: + try: + q = jsoc.JSOCClient().search( + a.Time(start, end=end), + a.jsoc.Series.aia_master_pointing3h, + ) + table = QTable(q) + except KeyError as e: # If there's no pointing information available between these times, # JSOC will raise a cryptic KeyError # (see https://github.com/LM-SAL/aiapy/issues/71) msg = f"Could not find any pointing information between {start} and {end}" - raise RuntimeError(msg) + raise RuntimeError(msg) from e + except Exception as e: # NOQA: BLE001 + log.warning("Unable to retrieve pointing table from JSOC.") + log.warning(f"Error: {e}") + log.warning("Falling back to fixed pointing table") + table = QTable(astropy_ascii.read(fetch_pointing_table())) table["T_START"] = Time(table["T_START"], scale="utc") table["T_STOP"] = Time(table["T_STOP"], scale="utc") for c in table.colnames: @@ -227,7 +302,7 @@ def get_error_table(error_table=None): error_table = fetch_error_table() os.environ.pop("PARFIVE_DISABLE_RANGE") if isinstance(error_table, str | pathlib.Path): - table = astropy.io.ascii.read(error_table) + table = QTable(astropy_ascii.read(error_table)) elif isinstance(error_table, QTable): table = error_table else: @@ -248,6 +323,51 @@ def get_error_table(error_table=None): return table -@manager.require("error_table", *URL_HASH[ERROR_VERSION]) +@manager.require("error_table", *URL_HASH_ERROR_TABLE[ERROR_VERSION]) def fetch_error_table(): return manager.get("error_table") + + +@manager.require("pointing_table", *URL_HASH_POINTING_TABLE) +def fetch_pointing_table(): + return manager.get("pointing_table") + + +@manager.require("response_table_v10", *URL_HASH_RESPONSE_TABLE[10]) +def fetch_response_table_v10(): + return manager.get("response_table_v10") + + +@manager.require("response_table_v9", *URL_HASH_RESPONSE_TABLE[9]) +def fetch_response_table_v9(): + return manager.get("response_table_v9") + + +@manager.require("response_table_v8", *URL_HASH_RESPONSE_TABLE[8]) +def fetch_response_table_v8(): + return manager.get("response_table_v8") + + +@manager.require("response_table_v7", *URL_HASH_RESPONSE_TABLE[7]) +def fetch_response_table_v7(): + return manager.get("response_table_v7") + + +@manager.require("response_table_v6", *URL_HASH_RESPONSE_TABLE[6]) +def fetch_response_table_v6(): + return manager.get("response_table_v6") + + +@manager.require("response_table_v4", *URL_HASH_RESPONSE_TABLE[4]) +def fetch_response_table_v4(): + return manager.get("response_table_v4") + + +@manager.require("response_table_v3", *URL_HASH_RESPONSE_TABLE[3]) +def fetch_response_table_v3(): + return manager.get("response_table_v3") + + +@manager.require("response_table_v2", *URL_HASH_RESPONSE_TABLE[2]) +def fetch_response_table_v2(): + return manager.get("response_table_v2") diff --git a/aiapy/conftest.py b/aiapy/conftest.py index d95027f..f7cef81 100644 --- a/aiapy/conftest.py +++ b/aiapy/conftest.py @@ -16,24 +16,24 @@ mpl.use("Agg") -@pytest.fixture() +@pytest.fixture def aia_171_map(): m = sunpy.map.Map(sunpy.data.test.get_test_filepath("aia_171_level1.fits")) # For testing purposes, need the map to be 4K-by-4K return m.resample((4096, 4096) * u.pixel) -@pytest.fixture() +@pytest.fixture def all_channels(): return CHANNELS -@pytest.fixture() +@pytest.fixture def channels(): return CHANNELS -@pytest.fixture() +@pytest.fixture def psf_94(channels): import aiapy.psf diff --git a/aiapy/psf/psf.py b/aiapy/psf/psf.py index 0936803..6b450af 100644 --- a/aiapy/psf/psf.py +++ b/aiapy/psf/psf.py @@ -15,7 +15,7 @@ except ImportError: HAS_CUPY = False -__all__ = ["psf", "filter_mesh_parameters", "_psf"] +__all__ = ["_psf", "filter_mesh_parameters", "psf"] def filter_mesh_parameters(*, use_preflightcore=False): @@ -300,12 +300,12 @@ def _psf(meshinfo, angles, diffraction_orders, *, focal_plane=False, use_gpu=Tru # If cupy is available, cast to a cupy array if HAS_CUPY and use_gpu: psf = cupy.array(psf) - Nx, Ny = psf.shape + nx, ny = psf.shape width_x = meshinfo["width"].value width_y = meshinfo["width"].value # x and y position grids - x = np.outer(np.ones(Ny), np.arange(Nx) + 0.5) - y = np.outer(np.arange(Ny) + 0.5, np.ones(Nx)) + x = np.outer(np.ones(ny), np.arange(nx) + 0.5) + y = np.outer(np.arange(ny) + 0.5, np.ones(nx)) if HAS_CUPY and use_gpu: x = cupy.array(x) y = cupy.array(y) @@ -319,10 +319,10 @@ def _psf(meshinfo, angles, diffraction_orders, *, focal_plane=False, use_gpu=Tru continue intensity = np.sinc(order / mesh_ratio) ** 2 # I_0 for dx, dy in zip(spacing_x.value, spacing_y.value, strict=True): - x_centered = x - (0.5 * Nx + dx * order + 0.5) - y_centered = y - (0.5 * Ny + dy * order + 0.5) + x_centered = x - (0.5 * nx + dx * order + 0.5) + y_centered = y - (0.5 * ny + dy * order + 0.5) # NOTE: this step is the bottleneck and is VERY slow on a CPU psf += np.exp(-width_x * x_centered * x_centered - width_y * y_centered * y_centered) * intensity # Contribution from core - psf_core = np.exp(-width_x * (x - 0.5 * Nx - 0.5) ** 2 - width_y * (y - 0.5 * Ny - 0.5) ** 2) + psf_core = np.exp(-width_x * (x - 0.5 * nx - 0.5) ** 2 - width_y * (y - 0.5 * ny - 0.5) ** 2) return (1 - area_not_mesh) * psf / psf.sum() + area_not_mesh * psf_core / psf_core.sum() diff --git a/aiapy/psf/tests/conftest.py b/aiapy/psf/tests/conftest.py index 7cdaf6a..8084f75 100644 --- a/aiapy/psf/tests/conftest.py +++ b/aiapy/psf/tests/conftest.py @@ -7,6 +7,6 @@ import aiapy.psf -@pytest.fixture() +@pytest.fixture def psf(channels): return aiapy.psf.psf(channels[0], use_preflightcore=True, diffraction_orders=[-1, 0, 1]) diff --git a/aiapy/response/channel.py b/aiapy/response/channel.py index da07c8c..04312c0 100644 --- a/aiapy/response/channel.py +++ b/aiapy/response/channel.py @@ -69,7 +69,7 @@ class Channel: @u.quantity_input @validate_channel("channel") - def __init__(self, channel: u.angstrom, *, instrument_file=None): + def __init__(self, channel: u.angstrom, *, instrument_file=None) -> None: self._channel = channel self._instrument_data = self._get_instrument_data(instrument_file) diff --git a/aiapy/response/tests/test_channel.py b/aiapy/response/tests/test_channel.py index 620c9b2..d08fed4 100644 --- a/aiapy/response/tests/test_channel.py +++ b/aiapy/response/tests/test_channel.py @@ -22,7 +22,7 @@ def channel(request, ssw_home): # NOQA: ARG001 return Channel(94 * u.angstrom, instrument_file=instrument_file) -@pytest.fixture() +@pytest.fixture def channel_properties(): return [ "wavelength", @@ -40,7 +40,7 @@ def channel_properties(): ] -@pytest.fixture() +@pytest.fixture def required_keys(): return [ "wave", @@ -149,7 +149,7 @@ def test_eve_correction(channel, correction_table, version, eve_correction_truth # in SSW though they should be close. obstime = astropy.time.Time("2015-01-01T00:00:00", scale="utc") eve_correction = channel.eve_correction(obstime, correction_table=correction_table, calibration_version=version) - assert u.allclose(eve_correction, eve_correction_truth, rtol=1e-10, atol=0.0) + assert u.allclose(eve_correction, eve_correction_truth) def test_wavelength_response_no_idl(channel): @@ -221,7 +221,7 @@ def test_wavelength_response_time(channel, idl_environment, include_eve_correcti assert u.allclose(r, r_ssw, rtol=1e-4, atol=0.0 * u.cm**2 * u.DN / u.ph) -@pytest.mark.remote_data() +@pytest.mark.remote_data @pytest.mark.parametrize("channel_wavelength", [1600 * u.angstrom, 1700 * u.angstrom, 4500 * u.angstrom]) def test_fuv_channel(channel_wavelength, channel_properties, required_keys): # There are a few corner cases for the 1600, 1700, and 4500 channels diff --git a/aiapy/util/exceptions.py b/aiapy/util/exceptions.py index 95732a8..3bb3475 100644 --- a/aiapy/util/exceptions.py +++ b/aiapy/util/exceptions.py @@ -4,7 +4,7 @@ from astropy.utils.exceptions import AstropyWarning -__all__ = ["AiapyWarning", "AiapyUserWarning"] +__all__ = ["AiapyUserWarning", "AiapyWarning"] class AiapyWarning(AstropyWarning): diff --git a/aiapy/util/tests/test_util.py b/aiapy/util/tests/test_util.py index 82fb573..e55315e 100644 --- a/aiapy/util/tests/test_util.py +++ b/aiapy/util/tests/test_util.py @@ -4,7 +4,8 @@ import aiapy.util -@pytest.mark.remote_data() +@pytest.mark.xfail(reason="JSOC is down") +@pytest.mark.remote_data def test_sdo_location(aia_171_map): # Confirm that the queried location matches AIAMap's interpretation of the FITS file result = aiapy.util.sdo_location(aia_171_map.date) @@ -12,7 +13,8 @@ def test_sdo_location(aia_171_map): assert_quantity_allclose(result.cartesian.xyz, result.cartesian.xyz) -@pytest.mark.remote_data() +@pytest.mark.xfail(reason="JSOC is down") +@pytest.mark.remote_data def test_sdo_location_raises_error(): # Confirm that an error is raised for a time without records with pytest.raises(ValueError, match="No DRMS records near this time"): diff --git a/aiapy/util/util.py b/aiapy/util/util.py index 547ad57..6ac3fdd 100644 --- a/aiapy/util/util.py +++ b/aiapy/util/util.py @@ -34,12 +34,16 @@ def sdo_location(time): """ t = parse_time(time) # Query for +/- 3 seconds around the given time - keys = drms.Client().query( - f"aia.lev1[{(t - 3*u.s).utc.isot}/6s]", - key="T_OBS, HAEX_OBS, HAEY_OBS, HAEZ_OBS", - ) + try: + keys = drms.Client().query( + f"aia.lev1[{(t - 3*u.s).utc.isot}/6s]", + key="T_OBS, HAEX_OBS, HAEY_OBS, HAEZ_OBS", + ) + except Exception as e: + msg = "Unable to query the JSOC for SDO location.\n" f"Error message: {e}" + raise OSError(msg) from e if keys is None or len(keys) == 0: - msg = "No DRMS records near this time" + msg = f"No JSOC records near this time: {t}" raise ValueError(msg) # Linear interpolation between the nearest records within the returned set times = Time(list(keys["T_OBS"]), scale="utc") diff --git a/examples/download_specific_data.py b/examples/download_specific_data.py index 1b397f4..829bd7f 100644 --- a/examples/download_specific_data.py +++ b/examples/download_specific_data.py @@ -40,7 +40,7 @@ attrs.jsoc.Series("aia.lev1_euv_12s"), attrs.Wavelength(211 * u.AA), attrs.jsoc.Notify(jsoc_email), - attrs.jsoc.Keyword("EXPTIME") <= 2, + attrs.jsoc.Keyword("EXPTIME") <= 2, # seconds # NOQA: PLR2004 attrs.jsoc.Segment("image"), ) diff --git a/pyproject.toml b/pyproject.toml index a008b9f..d243e66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ keywords = [ "atmospheric imaging assembly", ] authors = [ - {email = "freij@baeri.org"}, + {email = "nfreij@seti.org"}, {name = "AIA Instrument Team @ LMSAL"} ] classifiers = [ @@ -38,6 +38,7 @@ classifiers = [ "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", "Topic :: Scientific/Engineering :: Astronomy", ] dependencies = [ @@ -52,9 +53,6 @@ changelog = "https://aiapy.readthedocs.io/en/stable/changelog.html" [project.optional-dependencies] all = ["aiapy"] -cupy = [ - "cupy", -] tests = [ "aiapy[all]", "hissw", diff --git a/ruff.toml b/ruff.toml index a3c7395..aa61dd0 100644 --- a/ruff.toml +++ b/ruff.toml @@ -2,80 +2,75 @@ lint.dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" target-version = "py310" line-length = 120 -exclude=[ - ".git,", +extend-exclude=[ "__pycache__", "build", "tools/**", ] lint.select = [ - "A", - "ARG", - "ASYNC", - "B", - "BLE", - "C4", -# "C90", - "COM", -# "D", - "DTZ", - "E", - "EM", - "ERA", - "EXE", - "F", - "FBT", - "FLY", -# "FURB", - "G", - "I", - "ICN", - "INP", - "INT", - "ISC", - "LOG", -# "N", - "NPY", - "PERF", - "PGH", - "PIE", -# "PL", - "PLE", - "PT", - "PTH", - "PYI", - "Q", - "RET", - "RSE", - "RUF", -# "S", - "SIM", - "SLF", - "SLOT", - "T10", - "T20", - "TCH", - "TID", - "TRIO", - "TRY", - "UP", - "W", - "YTT", + "ALL", ] lint.extend-ignore = [ - "E501", # Line too long + "ANN001", # Missing type annotation for function argument + "ANN002", # Missing type annotation for variable + "ANN003", # Missing type annotation for keyword + "ANN201", # Missing return type annotation for public function + "ANN202", # Missing return type annotation for private function + "ANN205", # Missing return type annotation for staticmethod + "ANN206", # Missing return type annotation for classmethod "COM812", # May cause conflicts when used with the formatter - "ISC001", # May cause conflicts when used with the formatter + "D200", # One-line docstring should fit on one line + "D205", # 1 blank line required between summary line and description + "D400", # First line should end with a period + "D401", # First line should be in imperative mood + "D404", # First word of the docstring should not be "This" + "E501", # Line too long + "FIX001", # Line contains FIXME, consider resolving the issue + "FIX002", # Line contains TODO, consider resolving the issue + "ISC001", # May cause conflicts when used with the formatter + "PLR0913", # Too many arguments in function definition + "TD001", # Invalid TODO tag: `FIXME` + "TD002", # Missing author in TODO + "TD003", # Missing issue link on the line following this TODO ] [lint.per-file-ignores] "examples/*.py" = [ - "INP001", # examples is part of an implicit namespace package - "T201", # We need print in our examples + "B018", # Not print but display + "D400", # First line should end with a period, question mark, or exclamation point + "ERA001", # Commented out code + "INP001", # Implicit namespace package + "T201", # Use print ] "docs/conf.py" = [ + "D100", # Missing docstring in public module "INP001", # conf.py is part of an implicit namespace package ] +"setup.py" = [ + "D100", # Missing docstring in public module +] +"*tests/__init__.py" = [ + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package +] +"*tests/*/__init__.py" = [ + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package +] +"test_*.py" = [ + "D100", # Missing docstring in public module + "D103", # Missing docstring in public function + "N806", # in function should be lowercase + "PLR2004", # Magic value used in comparison + "S101", # Use of `assert` detected +] +"*version.py" = [ + "D100", # Missing docstring in public module +] +"*conftest.py" = [ + "D100", # Missing docstring in public module + "D104", # Missing docstring in public package +] [lint.pydocstyle] convention = "numpy" diff --git a/tox.ini b/tox.ini index 2be0d6f..a0ca2d3 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] minversion = 4.0 envlist = - py{310,311,312}{,-online,-devdeps} + py{310,311,312,313}{,-online,-devdeps} build_docs codestyle @@ -21,7 +21,7 @@ changedir = .tmp/{envname} description = run tests deps = - devdeps: git+https://github.com/astropy/astropy.git + devdeps: astropy>=0.0.dev0 devdeps: sunpy>=0.0.dev0 extras = all