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.
@@ -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)
@@ -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
@@ -0,0 +1,16 @@
1
+ """
2
+ LSST Client
3
+ """
4
+
5
+ from babamul.models import LsstAlert
6
+
7
+ from blastwave.query.alert import AlertClient
8
+
9
+
10
+ class LSSTClient(AlertClient):
11
+ """
12
+ LSST Client
13
+ """
14
+
15
+ base_catalog = "LSST"
16
+ base_model = LsstAlert
@@ -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,16 @@
1
+ """
2
+ ZTF Client
3
+ """
4
+
5
+ from babamul.models import ZtfAlert
6
+
7
+ from blastwave.query.alert import AlertClient
8
+
9
+
10
+ class ZTFClient(AlertClient):
11
+ """
12
+ ZTF Client
13
+ """
14
+
15
+ base_catalog = "ZTF"
16
+ base_model = ZtfAlert
@@ -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