blastwave 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blastwave/__init__.py +7 -0
- blastwave/errors.py +9 -0
- blastwave/models/__init__.py +8 -0
- blastwave/models/constants.py +0 -0
- blastwave/models/observation.py +61 -0
- blastwave/models/parquet.py +55 -0
- blastwave/models/query.py +38 -0
- blastwave/models/source.py +432 -0
- blastwave/projections/__init__.py +6 -0
- blastwave/projections/lsst.py +27 -0
- blastwave/projections/ztf.py +34 -0
- blastwave/query/__init__.py +8 -0
- blastwave/query/alert.py +252 -0
- blastwave/query/boom.py +339 -0
- blastwave/query/lsst.py +16 -0
- blastwave/query/timeout.py +32 -0
- blastwave/query/ztf.py +16 -0
- blastwave/utils/__init__.py +12 -0
- blastwave/utils/cache.py +100 -0
- blastwave/utils/combine.py +35 -0
- blastwave/utils/photometry.py +108 -0
- blastwave/utils/plot.py +42 -0
- blastwave-1.0.0.dist-info/METADATA +87 -0
- blastwave-1.0.0.dist-info/RECORD +27 -0
- blastwave-1.0.0.dist-info/WHEEL +5 -0
- blastwave-1.0.0.dist-info/licenses/LICENSE +21 -0
- blastwave-1.0.0.dist-info/top_level.txt +1 -0
blastwave/query/alert.py
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base Class for an Alert Survey Client
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from abc import ABC, abstractmethod
|
|
6
|
+
from typing import Callable
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from astropy.coordinates import SkyCoord
|
|
11
|
+
from babamul.models import LsstAlert, ZtfAlert
|
|
12
|
+
|
|
13
|
+
from blastwave.models import BOOMQuery, Source
|
|
14
|
+
from blastwave.query.boom import BoomClient
|
|
15
|
+
from blastwave.utils.photometry import (
|
|
16
|
+
deduplicate_lsst_photometry,
|
|
17
|
+
deduplicate_ztf_photometry,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
parse_map = {
|
|
21
|
+
"ZTF_alerts": deduplicate_ztf_photometry,
|
|
22
|
+
"LSST_alerts": deduplicate_lsst_photometry,
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
GenerateFunc = Callable[[dict, pd.DataFrame], Source]
|
|
26
|
+
generator_mapping: dict[str, GenerateFunc] = {
|
|
27
|
+
"ZTF_alerts": Source.from_ztf,
|
|
28
|
+
"LSST_alerts": Source.from_lsst,
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class AlertClient(BoomClient, ABC):
|
|
33
|
+
"""
|
|
34
|
+
Base class for Alert Survey Client
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
@abstractmethod
|
|
39
|
+
def base_catalog(self) -> str:
|
|
40
|
+
"""The BOOM catalog name for this survey."""
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
@abstractmethod
|
|
44
|
+
def base_model(self) -> type[LsstAlert] | type[ZtfAlert]:
|
|
45
|
+
"""The Babamul model for this survey."""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def catalog(self) -> str:
|
|
49
|
+
"""
|
|
50
|
+
The BOOM catalog name for this survey.
|
|
51
|
+
"""
|
|
52
|
+
return f"{self.base_catalog}_alerts"
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def aux_table(self) -> str:
|
|
56
|
+
"""
|
|
57
|
+
The BOOM aux catalog name for this survey.
|
|
58
|
+
|
|
59
|
+
:return: Name of the BOOM aux catalog
|
|
60
|
+
"""
|
|
61
|
+
return f"{self.catalog}_aux"
|
|
62
|
+
|
|
63
|
+
def get_latest_alert(
|
|
64
|
+
self,
|
|
65
|
+
object_id: str | int,
|
|
66
|
+
catalog: str | None = None,
|
|
67
|
+
projection: dict | None = None,
|
|
68
|
+
) -> dict:
|
|
69
|
+
"""
|
|
70
|
+
Get latest alert for a given object.
|
|
71
|
+
|
|
72
|
+
:param object_id: Object ID
|
|
73
|
+
:param catalog: Catalog name (e.g. ZTF_alerts or LSST_alerts)
|
|
74
|
+
:param projection: Mongodb projection for fields to return
|
|
75
|
+
:return: Nested dictionary of the latest alert for the given object ID
|
|
76
|
+
"""
|
|
77
|
+
catalog = catalog or self.catalog
|
|
78
|
+
query = BOOMQuery(
|
|
79
|
+
catalog_name=catalog,
|
|
80
|
+
filter={"objectId": {"$eq": str(object_id)}},
|
|
81
|
+
limit=1,
|
|
82
|
+
projection=projection,
|
|
83
|
+
sort={"candidate.jd": -1},
|
|
84
|
+
)
|
|
85
|
+
return self.query(query)[0]
|
|
86
|
+
|
|
87
|
+
def get_aux_data(
|
|
88
|
+
self,
|
|
89
|
+
object_id: str | int,
|
|
90
|
+
catalog: str | None = None,
|
|
91
|
+
projection: dict | None = None,
|
|
92
|
+
) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Get aux data for a given object.
|
|
95
|
+
|
|
96
|
+
:param object_id: Object ID
|
|
97
|
+
:param catalog: Parent catalog name (e.g. ZTF_alerts or LSST_alerts)
|
|
98
|
+
:param projection: Projection for fields to return
|
|
99
|
+
:return: Dictionary of the aux data for the given object ID
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
aux_table = f"{catalog}_aux" if catalog is not None else self.aux_table
|
|
103
|
+
query = BOOMQuery(
|
|
104
|
+
catalog_name=aux_table,
|
|
105
|
+
filter={"_id": {"$eq": str(object_id)}},
|
|
106
|
+
projection=projection,
|
|
107
|
+
)
|
|
108
|
+
return self.query(query)[0]
|
|
109
|
+
|
|
110
|
+
def get_full_data(
|
|
111
|
+
self,
|
|
112
|
+
object_id: str | int,
|
|
113
|
+
catalog: str | None = None,
|
|
114
|
+
alert_projection: dict | None = None,
|
|
115
|
+
aux_projection: dict | None = None,
|
|
116
|
+
) -> dict:
|
|
117
|
+
"""
|
|
118
|
+
Get full data for a given object, combining the latest alert and aux data.
|
|
119
|
+
|
|
120
|
+
:param object_id: Object ID
|
|
121
|
+
:param catalog: Parent catalog name (e.g. ZTF_alerts or LSST_alerts)
|
|
122
|
+
:param alert_projection: Projection for fields to return for alert
|
|
123
|
+
:param aux_projection: Projection for fields to return for aux data
|
|
124
|
+
:return: Dictionary of the aux data for the given object ID
|
|
125
|
+
"""
|
|
126
|
+
latest = self.get_latest_alert(
|
|
127
|
+
object_id, catalog=catalog, projection=alert_projection
|
|
128
|
+
)
|
|
129
|
+
aux_data = self.get_aux_data(
|
|
130
|
+
object_id, catalog=catalog, projection=aux_projection
|
|
131
|
+
)
|
|
132
|
+
aux_data.update(latest)
|
|
133
|
+
aux_data = self.update_crossmatches(aux_data)
|
|
134
|
+
return aux_data
|
|
135
|
+
|
|
136
|
+
def update_crossmatches(self, aux_data: dict) -> dict:
|
|
137
|
+
"""
|
|
138
|
+
Update the cross-matches in the aux data to include the latest alert data.
|
|
139
|
+
|
|
140
|
+
:param aux_data: Dictionary of aux data
|
|
141
|
+
:return: Updated aux data with cross-matches
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
src_position = SkyCoord(
|
|
145
|
+
aux_data["candidate"]["ra"], aux_data["candidate"]["dec"], unit="deg"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
for cat in ["LSPSC", "NED"]:
|
|
149
|
+
matches = self.cone_search(
|
|
150
|
+
src_position.ra.deg, src_position.dec.deg, catalog=cat, limit=1
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
# Should be zero or 1 for now
|
|
154
|
+
for match in matches:
|
|
155
|
+
pos = match.pop("coordinates")["radec_geojson"]
|
|
156
|
+
sep = float(
|
|
157
|
+
src_position.separation(
|
|
158
|
+
SkyCoord(
|
|
159
|
+
pos["coordinates"][0] + 180.0,
|
|
160
|
+
pos["coordinates"][1],
|
|
161
|
+
unit="deg",
|
|
162
|
+
)
|
|
163
|
+
).arcsec
|
|
164
|
+
)
|
|
165
|
+
match["distance_arcsec"] = sep
|
|
166
|
+
|
|
167
|
+
aux_data["cross_matches"][cat] = matches
|
|
168
|
+
return aux_data
|
|
169
|
+
|
|
170
|
+
def get_match_photometry(self, catalog: str, aliases: dict) -> pd.DataFrame:
|
|
171
|
+
"""
|
|
172
|
+
Get photometry for a given survey.
|
|
173
|
+
|
|
174
|
+
:param catalog: Catalog name
|
|
175
|
+
:param aliases: Dictionary of aliases
|
|
176
|
+
:return: DataFrame of photometry for the survey
|
|
177
|
+
"""
|
|
178
|
+
parse_f = parse_map[catalog]
|
|
179
|
+
survey = catalog.split("_")[0]
|
|
180
|
+
try:
|
|
181
|
+
match_name = aliases[survey][0]
|
|
182
|
+
match = self.get_full_data(match_name, catalog=catalog)
|
|
183
|
+
photometry = parse_f(match)
|
|
184
|
+
new_df = pd.DataFrame(photometry)
|
|
185
|
+
new_df["survey"] = survey
|
|
186
|
+
except (KeyError, IndexError):
|
|
187
|
+
new_df = pd.DataFrame()
|
|
188
|
+
return new_df
|
|
189
|
+
|
|
190
|
+
def get_all_match_photometry(self, aliases: dict) -> pd.DataFrame:
|
|
191
|
+
"""
|
|
192
|
+
Get photometry for all cross-matched surveys.
|
|
193
|
+
|
|
194
|
+
:param aliases: Aliases
|
|
195
|
+
:return: DataFrame of photometry for all cross-matched surveys
|
|
196
|
+
"""
|
|
197
|
+
all_match_photometry = []
|
|
198
|
+
for survey in parse_map:
|
|
199
|
+
all_match_photometry.append(self.get_match_photometry(survey, aliases))
|
|
200
|
+
return pd.concat(all_match_photometry, ignore_index=True)
|
|
201
|
+
|
|
202
|
+
def get_all_photometry(
|
|
203
|
+
self, full_alert: dict, catalog: str | None = None
|
|
204
|
+
) -> pd.DataFrame:
|
|
205
|
+
"""
|
|
206
|
+
Get photometry for the base survey and all cross-matched surveys.
|
|
207
|
+
|
|
208
|
+
:param full_alert: Full alert from get_full_data
|
|
209
|
+
:param catalog: Catalog name
|
|
210
|
+
:return: DataFrame of photometry for all cross-matched surveys
|
|
211
|
+
"""
|
|
212
|
+
catalog = self.resolve_catalog(catalog)
|
|
213
|
+
parse_f = parse_map[catalog]
|
|
214
|
+
photometry_df = parse_f(full_alert)
|
|
215
|
+
photometry_df["survey"] = catalog.split("_")[0]
|
|
216
|
+
match_photometry = self.get_all_match_photometry(full_alert["aliases"])
|
|
217
|
+
|
|
218
|
+
if len(match_photometry) > 0:
|
|
219
|
+
photometry_df = pd.concat(
|
|
220
|
+
[photometry_df, match_photometry], ignore_index=True
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
photometry_df = (
|
|
224
|
+
photometry_df.sort_values(by="jd").reset_index(drop=True)
|
|
225
|
+
).replace({None: np.nan})
|
|
226
|
+
return photometry_df
|
|
227
|
+
|
|
228
|
+
def get_source(
|
|
229
|
+
self,
|
|
230
|
+
object_id: str | int,
|
|
231
|
+
catalog: str | None = None,
|
|
232
|
+
alert_projection: dict | None = None,
|
|
233
|
+
aux_projection: dict | None = None,
|
|
234
|
+
) -> Source:
|
|
235
|
+
"""
|
|
236
|
+
Get the full source data for a given object ID.
|
|
237
|
+
|
|
238
|
+
:param object_id: Object ID
|
|
239
|
+
:param catalog: Catalog name
|
|
240
|
+
:param alert_projection: Projection for fields to return for alert
|
|
241
|
+
:param aux_projection: Projection for fields to return for aux data
|
|
242
|
+
:return: Dictionary of source data
|
|
243
|
+
"""
|
|
244
|
+
catalog = self.resolve_catalog(catalog)
|
|
245
|
+
full_data = self.get_full_data(
|
|
246
|
+
object_id,
|
|
247
|
+
catalog=catalog,
|
|
248
|
+
alert_projection=alert_projection,
|
|
249
|
+
aux_projection=aux_projection,
|
|
250
|
+
)
|
|
251
|
+
photometry_df = self.get_all_photometry(full_data, catalog=catalog)
|
|
252
|
+
return generator_mapping[catalog](full_data, photometry_df)
|
blastwave/query/boom.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Client for querying BOOM
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import os
|
|
7
|
+
from typing import Callable, Mapping, Optional
|
|
8
|
+
from urllib.parse import urljoin
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
import requests
|
|
12
|
+
from dotenv import load_dotenv
|
|
13
|
+
from urllib3.util import Retry
|
|
14
|
+
|
|
15
|
+
from blastwave.errors import BOOMCredentialsError
|
|
16
|
+
from blastwave.models.query import BOOMQuery, CatalogQuery, FilterQuery
|
|
17
|
+
from blastwave.query.timeout import DEFAULT_TIMEOUT, TimeoutHTTPAdapter
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
BASE_URL = "https://api.kaboom.caltech.edu"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class BoomClient:
|
|
25
|
+
"""
|
|
26
|
+
Basic Boom client class for executing functions
|
|
27
|
+
|
|
28
|
+
:param base_url: Base URL to query (default: https://api.kaboom.caltech.edu)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
base_url: str = BASE_URL,
|
|
34
|
+
):
|
|
35
|
+
self.base_url = base_url
|
|
36
|
+
self._session: None | requests.Session = None
|
|
37
|
+
self._session_headers: None | dict = None
|
|
38
|
+
self._catalogs: None | list[str] = None
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def catalog(self) -> str | None:
|
|
42
|
+
"""Functions as a default catalog name for queries."""
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
@staticmethod
|
|
46
|
+
def set_up_session() -> requests.Session:
|
|
47
|
+
"""
|
|
48
|
+
Set up a session for sending requests to BOOM.
|
|
49
|
+
|
|
50
|
+
:return: None
|
|
51
|
+
"""
|
|
52
|
+
# session to talk to SkyPortal
|
|
53
|
+
session = requests.Session()
|
|
54
|
+
|
|
55
|
+
retries = Retry(
|
|
56
|
+
total=5,
|
|
57
|
+
backoff_factor=2,
|
|
58
|
+
status_forcelist=[405, 429, 500, 502, 503, 504],
|
|
59
|
+
allowed_methods=["HEAD", "GET", "PUT", "POST", "PATCH"],
|
|
60
|
+
)
|
|
61
|
+
adapter = TimeoutHTTPAdapter(max_retries=retries)
|
|
62
|
+
session.mount("https://", adapter)
|
|
63
|
+
session.mount("http://", adapter)
|
|
64
|
+
return session
|
|
65
|
+
|
|
66
|
+
def get_session_headers(self) -> dict:
|
|
67
|
+
"""
|
|
68
|
+
Get session headers
|
|
69
|
+
|
|
70
|
+
:return: Session headers
|
|
71
|
+
"""
|
|
72
|
+
if self._session_headers is None:
|
|
73
|
+
self._session_headers = {
|
|
74
|
+
"Content-Type": "application/json",
|
|
75
|
+
"Authorization": f"Bearer {self._get_boom_token()}",
|
|
76
|
+
}
|
|
77
|
+
assert self._session_headers is not None
|
|
78
|
+
return self._session_headers
|
|
79
|
+
|
|
80
|
+
def get_session(self) -> requests.Session:
|
|
81
|
+
"""
|
|
82
|
+
Wrapper for getting the session.
|
|
83
|
+
If the session is not set up, it will be set up.
|
|
84
|
+
|
|
85
|
+
:return: Session
|
|
86
|
+
"""
|
|
87
|
+
if self._session is None:
|
|
88
|
+
self._session = self.set_up_session()
|
|
89
|
+
assert self._session is not None
|
|
90
|
+
return self._session
|
|
91
|
+
|
|
92
|
+
def _get_boom_token(self) -> str:
|
|
93
|
+
"""
|
|
94
|
+
Get Boom token from environment variable.
|
|
95
|
+
|
|
96
|
+
:return: Boom token
|
|
97
|
+
"""
|
|
98
|
+
load_dotenv()
|
|
99
|
+
|
|
100
|
+
user = os.getenv("BOOM_API_USER")
|
|
101
|
+
|
|
102
|
+
if user is None:
|
|
103
|
+
err = (
|
|
104
|
+
"No Boom API user specified. "
|
|
105
|
+
"Add this to .env or set BOOM_API_USER environment variable."
|
|
106
|
+
)
|
|
107
|
+
logger.error(err)
|
|
108
|
+
raise BOOMCredentialsError(err)
|
|
109
|
+
|
|
110
|
+
password = os.getenv("BOOM_API_PASSWORD")
|
|
111
|
+
|
|
112
|
+
if password is None:
|
|
113
|
+
err = (
|
|
114
|
+
"No Boom API password specified. "
|
|
115
|
+
"Add this to .env or set BOOM_API_PASSWORD environment variable."
|
|
116
|
+
)
|
|
117
|
+
logger.error(err)
|
|
118
|
+
raise BOOMCredentialsError(err)
|
|
119
|
+
|
|
120
|
+
boom_token = self.get_fresh_token(user, password)
|
|
121
|
+
return boom_token
|
|
122
|
+
|
|
123
|
+
def api(
|
|
124
|
+
self, method: str, endpoint: str, data: Optional[Mapping] = None
|
|
125
|
+
) -> requests.Response:
|
|
126
|
+
"""
|
|
127
|
+
Make an API call to a BOOM instance
|
|
128
|
+
|
|
129
|
+
headers = {'Authorization': f'token {self.token}'}
|
|
130
|
+
response = requests.request(method, endpoint, json_dict=data, headers=headers)
|
|
131
|
+
|
|
132
|
+
:param method: HTTP method
|
|
133
|
+
:param endpoint: API endpoint
|
|
134
|
+
:param data: JSON data to send
|
|
135
|
+
:return: response from API call
|
|
136
|
+
"""
|
|
137
|
+
method = method.lower()
|
|
138
|
+
|
|
139
|
+
session = self.get_session()
|
|
140
|
+
headers = self.get_session_headers()
|
|
141
|
+
|
|
142
|
+
methods: dict[str, Callable[..., requests.Response]] = {
|
|
143
|
+
"head": session.head,
|
|
144
|
+
"get": session.get,
|
|
145
|
+
"post": session.post,
|
|
146
|
+
"put": session.put,
|
|
147
|
+
"patch": session.patch,
|
|
148
|
+
"delete": session.delete,
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if endpoint is None:
|
|
152
|
+
raise ValueError("Endpoint not specified")
|
|
153
|
+
if method not in ["head", "get", "post", "put", "patch", "delete"]:
|
|
154
|
+
raise ValueError(f"Unsupported method: {method}")
|
|
155
|
+
|
|
156
|
+
url = urljoin(self.base_url, endpoint)
|
|
157
|
+
|
|
158
|
+
if method == "get":
|
|
159
|
+
response = methods[method](
|
|
160
|
+
url,
|
|
161
|
+
params=data,
|
|
162
|
+
headers=headers,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
response = methods[method](
|
|
166
|
+
url,
|
|
167
|
+
json=data,
|
|
168
|
+
headers=headers,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
return response
|
|
172
|
+
|
|
173
|
+
def query(self, query: BOOMQuery | dict) -> list[dict]:
|
|
174
|
+
"""
|
|
175
|
+
Make a find query call
|
|
176
|
+
|
|
177
|
+
:param query: Query to execute
|
|
178
|
+
:return: List of results
|
|
179
|
+
"""
|
|
180
|
+
boom_query = BOOMQuery.model_validate(query)
|
|
181
|
+
res = self.api(
|
|
182
|
+
"post", "queries/find", data=boom_query.model_dump(exclude_none=True)
|
|
183
|
+
)
|
|
184
|
+
res.raise_for_status()
|
|
185
|
+
return res.json()["data"]
|
|
186
|
+
|
|
187
|
+
def count(self, query: CatalogQuery | FilterQuery | BOOMQuery | dict) -> int:
|
|
188
|
+
"""
|
|
189
|
+
Make a count call
|
|
190
|
+
|
|
191
|
+
:param query: Query to count
|
|
192
|
+
:return: Number of records
|
|
193
|
+
"""
|
|
194
|
+
boom_query = FilterQuery.model_validate(query)
|
|
195
|
+
res = self.api("post", "queries/count", data=boom_query.model_dump())
|
|
196
|
+
res.raise_for_status()
|
|
197
|
+
return res.json()["data"]
|
|
198
|
+
|
|
199
|
+
def get_fresh_token(self, username: str, password: str) -> str:
|
|
200
|
+
"""
|
|
201
|
+
Get a fresh token from the Boom API.
|
|
202
|
+
|
|
203
|
+
:param username: Username for Boom API
|
|
204
|
+
:param password: Password for Boom API
|
|
205
|
+
:return: New token from Boom API
|
|
206
|
+
"""
|
|
207
|
+
response = requests.post(
|
|
208
|
+
urljoin(self.base_url, "auth"),
|
|
209
|
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
|
210
|
+
data={"username": username, "password": password},
|
|
211
|
+
timeout=DEFAULT_TIMEOUT,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if response.status_code != 200:
|
|
215
|
+
logger.error(f"Failed to get new token: {response.text}")
|
|
216
|
+
raise BOOMCredentialsError(f"Failed to get new token: {response.text}")
|
|
217
|
+
|
|
218
|
+
return response.json()["access_token"]
|
|
219
|
+
|
|
220
|
+
def ping(self) -> requests.Response:
|
|
221
|
+
"""
|
|
222
|
+
Make a ping call
|
|
223
|
+
|
|
224
|
+
:return: Response from ping call
|
|
225
|
+
"""
|
|
226
|
+
return self.api("get", "")
|
|
227
|
+
|
|
228
|
+
def get_catalogs(self) -> list[str]:
|
|
229
|
+
"""
|
|
230
|
+
Get a list of catalogs available in BOOM
|
|
231
|
+
|
|
232
|
+
:return: List of catalogs
|
|
233
|
+
"""
|
|
234
|
+
if self._catalogs is None:
|
|
235
|
+
res = self.api("get", "catalogs")
|
|
236
|
+
res.raise_for_status()
|
|
237
|
+
self._catalogs = [x["name"] for x in res.json()["data"]]
|
|
238
|
+
|
|
239
|
+
assert self._catalogs is not None
|
|
240
|
+
return self._catalogs
|
|
241
|
+
|
|
242
|
+
def resolve_catalog(self, catalog: str | None = None) -> str:
|
|
243
|
+
"""
|
|
244
|
+
Resolve a catalog name
|
|
245
|
+
|
|
246
|
+
:param catalog: Catalog name
|
|
247
|
+
:return: Catalog name to use in query
|
|
248
|
+
"""
|
|
249
|
+
name = catalog or self.catalog
|
|
250
|
+
if name is None:
|
|
251
|
+
raise ValueError("No catalog specified")
|
|
252
|
+
return name
|
|
253
|
+
|
|
254
|
+
def get_entry_count(self, catalog: str | None = None) -> int:
|
|
255
|
+
"""
|
|
256
|
+
Get the number of entries in a catalog
|
|
257
|
+
|
|
258
|
+
:param catalog: Catalog name
|
|
259
|
+
:return: Number of entries in catalog
|
|
260
|
+
"""
|
|
261
|
+
query = CatalogQuery(catalog_name=self.resolve_catalog(catalog))
|
|
262
|
+
res = self.api(
|
|
263
|
+
"post",
|
|
264
|
+
"queries/estimated_count",
|
|
265
|
+
data=query.model_dump(exclude_none=True),
|
|
266
|
+
)
|
|
267
|
+
res.raise_for_status()
|
|
268
|
+
return res.json()["data"]
|
|
269
|
+
|
|
270
|
+
def get_catalog_indexes(self, catalog: str | None = None) -> int:
|
|
271
|
+
"""
|
|
272
|
+
Get the indexes columns for a catalog
|
|
273
|
+
|
|
274
|
+
:param catalog: Catalog name
|
|
275
|
+
:return: Example json
|
|
276
|
+
"""
|
|
277
|
+
res = self.api("get", f"/catalogs/{self.resolve_catalog(catalog)}/indexes")
|
|
278
|
+
res.raise_for_status()
|
|
279
|
+
return res.json()["data"]
|
|
280
|
+
|
|
281
|
+
def get_sample_data(self, catalog: str | None = None) -> dict:
|
|
282
|
+
"""
|
|
283
|
+
Get a sample data entry from a catalog
|
|
284
|
+
|
|
285
|
+
:param catalog: Catalog name
|
|
286
|
+
:return: Example json
|
|
287
|
+
"""
|
|
288
|
+
res = self.api("get", f"/catalogs/{self.resolve_catalog(catalog)}/sample")
|
|
289
|
+
res.raise_for_status()
|
|
290
|
+
return res.json()["data"][0]
|
|
291
|
+
|
|
292
|
+
@staticmethod
|
|
293
|
+
def get_near_sphere_dist(radius_arcsec: float) -> float:
|
|
294
|
+
"""
|
|
295
|
+
Convert a radius in arcseconds to a radius
|
|
296
|
+
in meters (on Earth surface) for use in a $nearSphere query
|
|
297
|
+
|
|
298
|
+
:param radius_arcsec: Radius in arcseconds
|
|
299
|
+
:return: Radius in meters
|
|
300
|
+
"""
|
|
301
|
+
return ((radius_arcsec / 3600.0) * np.pi / 180.0) * 6371008.8
|
|
302
|
+
|
|
303
|
+
def cone_search(
|
|
304
|
+
self,
|
|
305
|
+
ra: float,
|
|
306
|
+
dec: float,
|
|
307
|
+
radius_arcsec: float = 1.0,
|
|
308
|
+
catalog: str | None = None,
|
|
309
|
+
limit: int | None = 1,
|
|
310
|
+
) -> list[dict]:
|
|
311
|
+
"""
|
|
312
|
+
Function to do a cone search
|
|
313
|
+
|
|
314
|
+
:param ra: Ra (decimal degrees)
|
|
315
|
+
:param dec: Declination (decimal degrees)
|
|
316
|
+
:param radius_arcsec: Search radius (arcsec)
|
|
317
|
+
:param catalog: Catalog name
|
|
318
|
+
:param limit: Number of results to return
|
|
319
|
+
:return: List of search results (possibly of length 0 if there are no results)
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
res = self.query(
|
|
323
|
+
BOOMQuery(
|
|
324
|
+
catalog_name=self.resolve_catalog(catalog),
|
|
325
|
+
filter={
|
|
326
|
+
"coordinates.radec_geojson": {
|
|
327
|
+
"$nearSphere": {
|
|
328
|
+
"$geometry": {
|
|
329
|
+
"type": "Point",
|
|
330
|
+
"coordinates": [ra - 180.0, dec],
|
|
331
|
+
},
|
|
332
|
+
"$maxDistance": self.get_near_sphere_dist(radius_arcsec),
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
limit=limit,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
return res
|
blastwave/query/lsst.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Timeout HTTP Adapter
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from requests.adapters import HTTPAdapter
|
|
6
|
+
|
|
7
|
+
DEFAULT_TIMEOUT = 60
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TimeoutHTTPAdapter(HTTPAdapter):
|
|
11
|
+
"""
|
|
12
|
+
HTTP adapter that sets a default timeout for all requests.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
def __init__(self, *args, **kwargs):
|
|
16
|
+
self.timeout = DEFAULT_TIMEOUT
|
|
17
|
+
if "timeout" in kwargs:
|
|
18
|
+
self.timeout = kwargs["timeout"]
|
|
19
|
+
del kwargs["timeout"]
|
|
20
|
+
super().__init__(*args, **kwargs)
|
|
21
|
+
|
|
22
|
+
def send(self, request, *args, **kwargs):
|
|
23
|
+
"""
|
|
24
|
+
Send a request with a default timeout.
|
|
25
|
+
|
|
26
|
+
:param request: request to send
|
|
27
|
+
:param args: args to pass to super().send
|
|
28
|
+
:param kwargs: kwargs to pass to super().send
|
|
29
|
+
:return: response from request
|
|
30
|
+
"""
|
|
31
|
+
kwargs.setdefault("timeout", self.timeout)
|
|
32
|
+
return super().send(request, *args, **kwargs)
|
blastwave/query/ztf.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Util classes for blastwave
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from blastwave.utils.cache import (
|
|
6
|
+
get_crossmatch_path,
|
|
7
|
+
get_data_dir,
|
|
8
|
+
get_photometry_path,
|
|
9
|
+
get_source_path,
|
|
10
|
+
)
|
|
11
|
+
from blastwave.utils.combine import combine_sources, load_consolidated_sources
|
|
12
|
+
from blastwave.utils.plot import plot_lightcurve
|