Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding support for query by Solar Distance #137

Merged
merged 17 commits into from
Aug 18, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions examples/solar_distance_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
"""
===============================================================
Searching for Solar Orbiter data using Solar Distance attribute
===============================================================

This example demonstrates how to search and download Solar Orbiter data using ``sunpy.net.Fido``.
To do this, we can build a query based on several attributes.

The ``Distance`` attribute allows us to specify a range of distances from the Sun in astronomical units (AU).

"""

import astropy.units as u
import sunpy.net.attrs as a
from sunpy.net import Fido

###############################################################################
# Importing sunpy_soar registers the client with sunpy Fido
import sunpy_soar # NOQA: F401

###############################################################################
# We shall start with constructing a search query with instrument, level, detector, and distance.

instrument = a.Instrument("EUI")
time = a.Time("2022-10-29 05:00:00", "2022-10-29 06:00:00")
level = a.Level(2)
detector = a.Detector("HRI_EUV")
distance = a.soar.Distance(0.45 * u.AU, 0.46 * u.AU)

###############################################################################
# Now do the search without time attribute.

result = Fido.search(instrument & level & detector & distance)
result

###############################################################################
# Now do the search with time attribute.
result = Fido.search(instrument & level & detector & distance & time)
result

###############################################################################
# To then download the data, you would use Fido.fetch(result), which will download the data locally.
hayesla marked this conversation as resolved.
Show resolved Hide resolved
60 changes: 59 additions & 1 deletion sunpy_soar/attrs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@

import warnings

import astropy.units as u
import sunpy.net.attrs as a
from sunpy.net.attr import AttrAnd, AttrOr, AttrWalker, DataAttr, SimpleAttr
from astropy.units import quantity_input
from sunpy.net.attr import AttrAnd, AttrOr, AttrWalker, DataAttr, Range, SimpleAttr
from sunpy.util.exceptions import SunpyUserWarning

__all__ = ["Product", "SOOP"]
Expand All @@ -29,6 +31,43 @@ class SOOP(SimpleAttr):
"""


class Distance(Range):
type_name = "distance"

hayesla marked this conversation as resolved.
Show resolved Hide resolved
@quantity_input(dist_min=u.m, dist_max=u.m)
def __init__(self, dist_min: u.Quantity, dist_max: u.Quantity): # NOQA: ANN204
nabobalis marked this conversation as resolved.
Show resolved Hide resolved
"""
Specifies the distance range.

Parameters
----------
dist_min : `~astropy.units.Quantity`
The lower bound of the range.
dist_max : `~astropy.units.Quantity`
The upper bound of the range.

Notes
-----
The valid units for distance are AU, km, and mm. Any unit directly
convertible to these units is valid input. This class filters the query
by solar distance without relying on a specific distance column.
"""
# Ensure both dist_min and dist_max are scalar values
if not all([dist_min.isscalar, dist_max.isscalar]):
msg = "Both dist_min and dist_max must be scalar values."
raise ValueError(msg)

target_unit = u.AU
# Convert both dist_min and dist_max to the target unit
dist_min = dist_min.to(target_unit)
dist_max = dist_max.to(target_unit)

super().__init__(dist_min, dist_max)

def collides(self, other):
return isinstance(other, self.__class__)


walker = AttrWalker()


Expand Down Expand Up @@ -145,3 +184,22 @@ def _(wlk, attr, params) -> None: # NOQA: ARG001
wavemin = attr.min.value
wavemax = attr.max.value
params.append(f"Wavemin='{wavemin}'+AND+Wavemax='{wavemax}'")


@walker.add_applier(Distance)
def _(wlk, attr, params): # NOQA: ARG001
# The `Distance` attribute is used to filter the query by solar distance
# without relying on a specific distance column. It is commonly used
# to filter the query without time consideration.
dmin = attr.min.value
dmax = attr.max.value
min_possible = 0.28
max_possible = 1.0

if not (min_possible <= dmin <= max_possible) or not (min_possible <= dmax <= max_possible):
warnings.warn(
"Distance values must be within the range 0.28 AU to 1.0 AU.",
SunpyUserWarning,
stacklevel=2,
)
params.append(f"DISTANCE({dmin},{dmax})")
41 changes: 29 additions & 12 deletions sunpy_soar/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from sunpy.net.base_client import BaseClient, QueryResponseTable
from sunpy.time import parse_time

from sunpy_soar.attrs import SOOP, Product, walker
from sunpy_soar.attrs import SOOP, Distance, Product, walker

__all__ = ["SOARClient"]

Expand Down Expand Up @@ -122,7 +122,7 @@ def add_join_to_query(query: list[str], data_table: str, instrument_table: str):
return where_part, from_part, select_part

@staticmethod
def _construct_payload(query):
def _construct_payload(query): # NOQA: C901
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is C901?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Its about the function being too complex.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that should be added to the global ignore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

alright!

"""
Construct search payload.

Expand All @@ -139,6 +139,7 @@ def _construct_payload(query):
# Default data table
data_table = "v_sc_data_item"
instrument_table = None
query_method = "doQuery"
# Mapping is established between the SOAR instrument names and its corresponding SOAR instrument table alias.
instrument_mapping = {
"SOLOHI": "SHI",
Expand All @@ -150,12 +151,24 @@ def _construct_payload(query):
}

instrument_name = None
for q in query:
if q.startswith("instrument") or q.startswith("descriptor") and not instrument_name:
instrument_name = q.split("=")[1][1:-1].split("-")[0].upper()
elif q.startswith("level") and q.split("=")[1][1:3] == "LL":
data_table = "v_ll_data_item"
distance_parameter = []
non_distance_parameters = []
query_method = "doQuery"
instrument_name = None

for q in query:
if "DISTANCE" in str(q):
distance_parameter.append(q)
else:
non_distance_parameters.append(q)
if q.startswith("instrument") or (q.startswith("descriptor") and not instrument_name):
instrument_name = q.split("=")[1][1:-1].split("-")[0].upper()
elif q.startswith("level") and q.split("=")[1][1:3] == "LL":
data_table = "v_ll_data_item"

query = non_distance_parameters + distance_parameter
if distance_parameter:
query_method = "doQueryFilteredByDistance"
if instrument_name:
if instrument_name in instrument_mapping:
instrument_name = instrument_mapping[instrument_name]
Expand All @@ -172,9 +185,12 @@ def _construct_payload(query):
where_part = "+AND+".join(query)

adql_query = {"SELECT": select_part, "FROM": from_part, "WHERE": where_part}

adql_query_str = "+".join([f"{key}+{value}" for key, value in adql_query.items()])
return {"REQUEST": "doQuery", "LANG": "ADQL", "FORMAT": "json", "QUERY": adql_query_str}
if query_method == "doQueryFilteredByDistance":
adql_query_str = adql_query_str.replace("+AND+h1.DISTANCE", "&DISTANCE").replace(
"+AND+DISTANCE", "&DISTANCE"
)
return {"REQUEST": query_method, "LANG": "ADQL", "FORMAT": "json", "QUERY": adql_query_str}

@staticmethod
def _do_search(query):
Expand All @@ -199,13 +215,13 @@ def _do_search(query):
r = requests.get(f"{tap_endpoint}/sync", params=payload, timeout=60)
log.debug(f"Sent query: {r.url}")
r.raise_for_status()

try:
response_json = r.json()
except JSONDecodeError as err:
msg = "The SOAR server returned an invalid JSON response. It may be down or not functioning correctly."
raise RuntimeError(msg) from err

# Do some list/dict wrangling
names = [m["name"] for m in response_json["metadata"]]
info = {name: [] for name in names}

Expand Down Expand Up @@ -280,8 +296,9 @@ def _can_handle_query(cls, *query) -> bool:
bool
True if this client can handle the given query.
"""
required = {a.Time}
optional = {a.Instrument, a.Detector, a.Wavelength, a.Level, a.Provider, Product, SOOP}
required = {Distance} if any(isinstance(q, Distance) for q in query) else {a.Time}

optional = {a.Instrument, a.Detector, a.Wavelength, a.Level, a.Provider, Product, SOOP, Distance, a.Time}
if not cls.check_attr_types_in_query(query, required, optional):
return False
# check to make sure the instrument attr passed is one provided by the SOAR.
Expand Down
83 changes: 82 additions & 1 deletion sunpy_soar/tests/test_sunpy_soar.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
import pytest
import responses
import sunpy.map
from requests.exceptions import HTTPError
from sunpy.net import Fido
from sunpy.net import attrs as a
from sunpy.util.exceptions import SunpyUserWarning

from sunpy_soar.client import SOARClient

Expand Down Expand Up @@ -272,6 +274,85 @@ def test_join_low_latency_query() -> None:
)

NucleonGodX marked this conversation as resolved.
Show resolved Hide resolved

def test_distance_query():
result = SOARClient._construct_payload( # NOQA: SLF001
[
"instrument='RPW'",
"DISTANCE(0.28,0.30)",
"level='L2'",
]
)

assert result["QUERY"] == ("SELECT+*+FROM+v_sc_data_item+WHERE+instrument='RPW'+AND+level='L2'&DISTANCE(0.28,0.30)")


def test_distance_join_query():
result = SOARClient._construct_payload( # NOQA: SLF001
[
"instrument='EUI'",
"DISTANCE(0.28,0.30)",
"level='L2'",
"descriptor='eui-fsi174-image'",
]
)

assert result["QUERY"] == (
"SELECT+h1.instrument, h1.descriptor, h1.level, h1.begin_time, h1.end_time, "
"h1.data_item_id, h1.filesize, h1.filename, h1.soop_name, h2.detector, h2.wavelength, "
"h2.dimension_index+FROM+v_sc_data_item AS h1 JOIN v_eui_sc_fits AS h2 USING (data_item_oid)"
"+WHERE+h1.instrument='EUI'+AND+h1.level='L2'+AND+h1.descriptor='eui-fsi174-image'&DISTANCE(0.28,0.30)"
)


def test_distance_search_remote_sensing():
instrument = a.Instrument("RPW")
product = a.soar.Product("rpw-tnr-surv")
level = a.Level(2)
distance = a.soar.Distance(0.28 * u.AU, 0.30 * u.AU)
res = Fido.search(distance & instrument & product & level)
assert res.file_num == 21


def test_distance_search_insitu():
instrument = a.Instrument("METIS")
level = a.Level(2)
product = a.soar.Product("metis-vl-pol-angle")
distance = a.soar.Distance(0.45 * u.AU, 0.46 * u.AU)
res = Fido.search(distance & instrument & product & level)
assert res.file_num == 172
nabobalis marked this conversation as resolved.
Show resolved Hide resolved


def test_distance_time_search():
instrument = a.Instrument("EUI")
time = a.Time("2023-04-27", "2023-04-28")
level = a.Level(2)
product = a.soar.Product("eui-fsi174-image")
distance = a.soar.Distance(0.45 * u.AU, 0.46 * u.AU)
res = Fido.search(instrument & product & level & time)
assert res.file_num == 96
# To check if we get different value when distance parameter is added in search.
res = Fido.search(distance & instrument & product & level & time)
assert res.file_num == 48


def test_distance_out_of_bounds_warning(recwarn):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the recwarn here for?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, its a pytest fixture provided by the pytest library that allows you to capture warnings emitted during the execution of a test.

instrument = a.Instrument("EUI")
time = a.Time("2023-04-27", "2023-04-28")
level = a.Level(2)
product = a.soar.Product("eui-fsi174-image")
distance = a.soar.Distance(0.45 * u.AU, 1.2 * u.AU)
# Run the search and ensure it raises an HTTPError
with pytest.raises(HTTPError):
Fido.search(distance & instrument & product & level & time)
# Check if the warning was raised
warnings_list = recwarn.list
assert any(
warning.message.args[0] == "Distance values must be within the range 0.28 AU to 1.0 AU."
and issubclass(warning.category, SunpyUserWarning)
for warning in warnings_list
)


@responses.activate
def test_soar_server_down() -> None:
# As the SOAR server is expected to be down in this test, a JSONDecodeError is expected
Expand All @@ -289,6 +370,6 @@ def test_soar_server_down() -> None:

with pytest.raises(
RuntimeError,
match=("The SOAR server returned an invalid JSON response. " "It may be down or not functioning correctly."),
match=("The SOAR server returned an invalid JSON response. It may be down or not functioning correctly."),
):
Fido.search(time, level, product)