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 ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ blastwave is a python client for querying BOOM
3
+ """
4
+
5
+ from blastwave.errors import BOOMCredentialsError
6
+ from blastwave.models import BOOMQuery, CatalogQuery, FilterQuery, Observation, Source
7
+ from blastwave.query import BoomClient, LSSTClient, ZTFClient
blastwave/errors.py ADDED
@@ -0,0 +1,9 @@
1
+ """
2
+ Custom Exceptions for blastwave
3
+ """
4
+
5
+
6
+ class BOOMCredentialsError(Exception):
7
+ """
8
+ Exception raised when no credentials are provided.
9
+ """
@@ -0,0 +1,8 @@
1
+ """
2
+ Module for various models
3
+ """
4
+
5
+ from blastwave.models.observation import Observation
6
+ from blastwave.models.parquet import pydantic_to_arrow_schema
7
+ from blastwave.models.query import BOOMQuery, CatalogQuery, FilterQuery
8
+ from blastwave.models.source import Source
File without changes
@@ -0,0 +1,61 @@
1
+ """
2
+ Observation model
3
+ """
4
+
5
+ import logging
6
+ from typing import Literal
7
+
8
+ import numpy as np
9
+ import pyarrow as pa
10
+ from pydantic import BaseModel, computed_field
11
+
12
+ from blastwave.models.parquet import pydantic_to_arrow_schema
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ ObsClass = Literal["alert", "fp", "ul"]
17
+
18
+
19
+ class Observation(BaseModel):
20
+ """
21
+ Model for an observation
22
+ """
23
+
24
+ jd: float
25
+ magpsf: float
26
+ sigmapsf: float
27
+ diffmaglim: float
28
+ ra: float | None
29
+ dec: float | None
30
+ snr: float
31
+ band: str
32
+ survey: str
33
+ det_type: ObsClass = "alert"
34
+ isdiffpos: bool
35
+
36
+ @computed_field()
37
+ @property
38
+ def estdiffmaglim(self) -> float:
39
+ """
40
+ Estimate the difference magnitude limit based on snr and magpsf
41
+
42
+ :return:
43
+ """
44
+ return self.magpsf - 2.5 * float(np.log10(5.0 / self.snr))
45
+
46
+ @computed_field()
47
+ @property
48
+ def mjd(self) -> float:
49
+ """
50
+ Convert JD to MJD
51
+ """
52
+ return self.jd - 2400000.5
53
+
54
+ @classmethod
55
+ def get_arrow_schema(cls) -> pa.Schema:
56
+ """
57
+ Get arrow schema from pydantic model
58
+
59
+ :return: Arrow schema
60
+ """
61
+ return pydantic_to_arrow_schema(cls)
@@ -0,0 +1,55 @@
1
+ """
2
+ Script for converting Pydantic models to PyArrow schemas for Parquet serialization
3
+ """
4
+
5
+ import pyarrow as pa
6
+ from pydantic import BaseModel
7
+
8
+ PYDANTIC_TO_ARROW = {
9
+ float: pa.float64(),
10
+ int: pa.int64(),
11
+ str: pa.string(),
12
+ bool: pa.bool_(),
13
+ }
14
+
15
+
16
+ def pydantic_to_arrow_schema(
17
+ model: type[BaseModel], include_computed: bool = False
18
+ ) -> pa.Schema:
19
+ """
20
+ Convert a Pydantic model to an Arrow schema
21
+
22
+ :param model: BaseModel
23
+ :param include_computed: bool indicating whether to include computed fields
24
+ :return: PyArrow schema
25
+ """
26
+ fields = []
27
+
28
+ for name, field in model.model_fields.items():
29
+ annotation = field.annotation
30
+ if annotation is None:
31
+ continue
32
+
33
+ args = getattr(annotation, "__args__", None)
34
+ if args:
35
+ annotation = next(a for a in args if not isinstance(a, type(None)))
36
+
37
+ arrow_type = PYDANTIC_TO_ARROW.get(annotation, pa.string())
38
+ fields.append(pa.field(name, arrow_type, nullable=True))
39
+
40
+ if include_computed:
41
+ for name, computed_field in model.model_computed_fields.items():
42
+ annotation = computed_field.return_type
43
+
44
+ if annotation not in PYDANTIC_TO_ARROW:
45
+ continue
46
+
47
+ args = getattr(annotation, "__args__", None)
48
+ if args:
49
+ annotation = next(a for a in args if not isinstance(a, type(None)))
50
+ # Skip derived lists etc
51
+ if annotation not in PYDANTIC_TO_ARROW:
52
+ continue
53
+ fields.append(pa.field(name, PYDANTIC_TO_ARROW[annotation], nullable=True))
54
+
55
+ return pa.schema(fields)
@@ -0,0 +1,38 @@
1
+ """
2
+ Model for BOOM queries
3
+ """
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class CatalogQuery(BaseModel):
12
+ """
13
+ Model for BOOM queries using only catalog name
14
+ """
15
+
16
+ catalog_name: str
17
+
18
+ model_config = {"from_attributes": True}
19
+
20
+
21
+ class FilterQuery(CatalogQuery):
22
+ """
23
+ Model for BOOM queries using catalog name and a filter
24
+ """
25
+
26
+ filter: Mapping[str, Any] | None = None
27
+
28
+
29
+ class BOOMQuery(FilterQuery):
30
+ """
31
+ Model for a full BOOM query
32
+ """
33
+
34
+ limit: int | None = None
35
+ max_time_ms: int | None = None
36
+ projection: Mapping[str, Any] | None = None
37
+ skip: int | None = None
38
+ sort: Mapping[str, Any] | None = None
@@ -0,0 +1,432 @@
1
+ """
2
+ Model for a Source
3
+ """
4
+
5
+ import json
6
+ import logging
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import pyarrow as pa
12
+ import pyarrow.parquet as pq
13
+ from matplotlib import pyplot as plt
14
+ from pydantic import BaseModel, computed_field
15
+
16
+ from blastwave.models.observation import Observation
17
+ from blastwave.models.parquet import pydantic_to_arrow_schema
18
+ from blastwave.utils import (
19
+ get_crossmatch_path,
20
+ get_photometry_path,
21
+ get_source_path,
22
+ plot_lightcurve,
23
+ )
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+ FID_MAPPING = {
28
+ "g": "1",
29
+ "r": "2",
30
+ "i": "3",
31
+ "u": "-1",
32
+ "z": "-2",
33
+ "y": "-3",
34
+ }
35
+
36
+
37
+ class Source(BaseModel):
38
+ """
39
+ Model for a Source
40
+ """
41
+
42
+ objectid: str
43
+ jd: float
44
+ ztfid: str | None = None
45
+ lsstid: str | None = None
46
+ tns_name: str | None = None
47
+ ra: float
48
+ dec: float
49
+ offset: float | None
50
+ host_origin: str | None
51
+ redshift: float | None = None
52
+ redshift_error: float | None = None
53
+ redshift_origin: str | None = None
54
+ photometry: list[Observation]
55
+ crossmatches: dict[str, list[dict]] | None = None
56
+
57
+ @computed_field
58
+ @property
59
+ def ndethist(self) -> int:
60
+ """
61
+ Get number of positive detections
62
+
63
+ :return: Number of positive detections
64
+ """
65
+ return len(self.get_detections())
66
+
67
+ @computed_field
68
+ @property
69
+ def filters(self) -> list[str]:
70
+ """
71
+ Get list of unique filters in detections
72
+
73
+ :return: List of unique filters
74
+ """
75
+ return list(set(self.get_detections()["band"]))
76
+
77
+ @computed_field
78
+ @property
79
+ def ndetfilters(self) -> int:
80
+ """
81
+ Get number of unique filters in detections
82
+
83
+ :return: Number of unique filters
84
+ """
85
+ return len(self.filters)
86
+
87
+ @computed_field
88
+ @property
89
+ def jdstarthist(self) -> float:
90
+ """
91
+ Get JD of first positive detection
92
+
93
+ :return: JD of first positive detection
94
+ """
95
+ return float(self.get_detections()["jd"].min())
96
+
97
+ @computed_field
98
+ @property
99
+ def jdendhist(self) -> float:
100
+ """
101
+ Get JD of last positive detection
102
+
103
+ :return: JD of last positive detection
104
+ """
105
+ return float(self.get_detections()["jd"].max())
106
+
107
+ @computed_field
108
+ @property
109
+ def age(self) -> float:
110
+ """
111
+ Get age of source in days (time between first and last positive detection)
112
+
113
+ :return: Age of source in days
114
+ """
115
+ return self.jdendhist - self.jdstarthist
116
+
117
+ @computed_field
118
+ @property
119
+ def peak_mag(self) -> float:
120
+ """
121
+ Get peak magnitude of source (brightest positive detection)
122
+
123
+ :return: Peak magnitude of source
124
+ """
125
+ return float(self.get_detections()["magpsf"].min())
126
+
127
+ def get_photometry(self) -> pd.DataFrame:
128
+ """
129
+ Get photometry as DataFrame
130
+
131
+ :return: DataFrame of photometry
132
+ """
133
+ return pd.DataFrame([x.model_dump() for x in self.photometry])
134
+
135
+ def get_crossmatches(self) -> dict[str, list[dict]]:
136
+ """
137
+ Get crossmatches as dict
138
+
139
+ :return: Dictionary of crossmatches
140
+ """
141
+ return self.crossmatches if self.crossmatches else {}
142
+
143
+ def get_detections(
144
+ self,
145
+ min_snr: float = 3.0,
146
+ include_lsst_fp: bool = False,
147
+ ) -> pd.DataFrame:
148
+ """
149
+ Get positive detections from photometry
150
+
151
+ :param min_snr: Minimum SNR of positive detections
152
+ :param include_lsst_fp: Include LSST FP detections
153
+ :return: DataFrame of positive detections
154
+ """
155
+ df = self.get_photometry()
156
+ if len(df) > 0:
157
+ positive_det_mask = (
158
+ df["isdiffpos"].astype(bool)
159
+ & (pd.notnull(df["magpsf"]))
160
+ & (df["snr"] > min_snr)
161
+ & ~(df["magpsf"] > (df["diffmaglim"] + 1.0))
162
+ )
163
+ if not include_lsst_fp:
164
+ lsst_mask = (df["det_type"] == "fp") & (df["survey"] == "LSST")
165
+ positive_det_mask &= ~lsst_mask
166
+ df = df[positive_det_mask].reset_index(drop=True)
167
+ return df
168
+
169
+ @classmethod
170
+ def get_arrow_schema(cls, include_computed: bool = False) -> pa.Schema:
171
+ """
172
+ Function to return Arrow schema
173
+
174
+ :param include_computed: Include computed fields from pydantic
175
+ :return: Schema
176
+ """
177
+ return pydantic_to_arrow_schema(cls, include_computed=include_computed)
178
+
179
+ @classmethod
180
+ def from_lsst(cls, full_data: dict, photometry_df: pd.DataFrame) -> "Source":
181
+ """
182
+ Create Source object from raw ZTF alert
183
+
184
+ :param full_data: Full alert data
185
+ :param photometry_df: Photometry data
186
+ :return: Source object
187
+ """
188
+
189
+ object_id = full_data["objectId"]
190
+
191
+ try:
192
+ ztf_id = full_data["aliases"]["ZTF"][0]
193
+ except (IndexError, KeyError):
194
+ ztf_id = None
195
+
196
+ photometry = [
197
+ Observation(**row) for row in photometry_df.to_dict(orient="records")
198
+ ]
199
+
200
+ # Add in something
201
+ offset = None
202
+ offset_origin = None
203
+ redshift = None
204
+ redshift_error = None
205
+ redshift_origin = None
206
+
207
+ crossmatches = full_data["cross_matches"]
208
+ for key in ["NED", "LSPSC", "PS1_DR1"]:
209
+ if key in crossmatches:
210
+ if len(crossmatches[key]) > 0:
211
+ match = crossmatches[key][0]
212
+ offset = match["distance_arcsec"]
213
+ offset_origin = key
214
+ if "z" in match:
215
+ redshift = match["z"]
216
+ redshift_error = match.get("z_unc", None)
217
+ redshift_origin = f"{key}_{match['z_tech']}"
218
+ break
219
+
220
+ return cls(
221
+ objectid=object_id,
222
+ lsstid=object_id,
223
+ ztfid=ztf_id,
224
+ photometry=photometry,
225
+ offset=offset,
226
+ host_origin=offset_origin,
227
+ ra=full_data["candidate"]["ra"],
228
+ dec=full_data["candidate"]["dec"],
229
+ jd=full_data["candidate"]["jd"],
230
+ crossmatches=crossmatches,
231
+ redshift=redshift,
232
+ redshift_error=redshift_error,
233
+ redshift_origin=redshift_origin,
234
+ )
235
+
236
+ @classmethod
237
+ def from_ztf(cls, full_data: dict, photometry_df: pd.DataFrame) -> "Source":
238
+ """
239
+ Create Source object from raw ZTF alert
240
+
241
+ :param full_data: Full alert data
242
+ :param photometry_df: Photometry data
243
+ :return: Source object
244
+ """
245
+ object_id = full_data["objectId"]
246
+
247
+ try:
248
+ lsst_id = full_data["aliases"]["LSST"][0]
249
+ except (IndexError, KeyError):
250
+ lsst_id = None
251
+
252
+ photometry = [
253
+ Observation(**row) for row in photometry_df.to_dict(orient="records")
254
+ ]
255
+
256
+ return cls(
257
+ objectid=object_id,
258
+ lsstid=lsst_id,
259
+ ztfid=object_id,
260
+ photometry=photometry,
261
+ offset=full_data["candidate"]["distpsnr1"],
262
+ host_origin="PS1",
263
+ ra=full_data["candidate"]["ra"],
264
+ dec=full_data["candidate"]["dec"],
265
+ jd=full_data["candidate"]["jd"],
266
+ crossmatches=full_data["cross_matches"],
267
+ )
268
+
269
+ def show_lightcurve(self, min_snr: float = 3.0) -> plt.Figure:
270
+ """
271
+ Helper function for plotting lightcurve of source.
272
+
273
+ :param min_snr: Minimum SNR of positive detections
274
+ :return: Figure of light curve
275
+ """
276
+ df = self.get_detections(min_snr=min_snr)
277
+ return plot_lightcurve(df)
278
+
279
+ def get_trimmed_photometry(self, min_snr: float = 3.0) -> list[Observation]:
280
+ """
281
+ Trim photometry from source
282
+
283
+ :param min_snr: Minimum SNR of positive detections
284
+ :return: list of trimmed Observations
285
+ """
286
+ photometry_df = self.get_detections(min_snr=min_snr)
287
+ return [Observation(**row) for row in photometry_df.to_dict(orient="records")]
288
+
289
+ def to_json(self, trim_photometry: bool = True) -> str:
290
+ """
291
+ Convert to JSON
292
+
293
+ :return: JSON representation of Source
294
+ """
295
+ data = self.model_dump(
296
+ mode="json", exclude_defaults=True, exclude_computed_fields=True
297
+ )
298
+ df = (
299
+ self.get_detections(min_snr=3.0)
300
+ if trim_photometry
301
+ else self.get_photometry()
302
+ )
303
+ df = df[list(Observation.model_fields.keys())]
304
+ data["photometry"] = df.to_dict(orient="list")
305
+ return json.dumps(data)
306
+
307
+ @classmethod
308
+ def from_json(cls, data: str) -> "Source":
309
+ """
310
+ Create Source object from JSON
311
+
312
+ :param data: JSON representation of Source
313
+ :return: Source object
314
+ """
315
+ d = json.loads(data)
316
+ photometry_df = pd.DataFrame(d.pop("photometry"))
317
+ photometry = [
318
+ Observation(**row) for row in photometry_df.to_dict(orient="records")
319
+ ]
320
+ return cls(**d, photometry=photometry)
321
+
322
+ def to_parquet(
323
+ self, base_path: Path | str | None = None, trim_photometry: bool = True
324
+ ) -> None:
325
+ """
326
+ Export photometry as parquet file
327
+
328
+ :param base_path: Base directory
329
+ :param trim_photometry: Store only positive detections
330
+ :return: None
331
+ """
332
+ source_path = get_source_path(self.objectid, base_path)
333
+ crossmatch_path = get_crossmatch_path(self.objectid, base_path)
334
+ photometry_path = get_photometry_path(self.objectid, base_path)
335
+
336
+ source_path.parent.mkdir(parents=True, exist_ok=True)
337
+ crossmatch_path.parent.mkdir(parents=True, exist_ok=True)
338
+ photometry_path.parent.mkdir(parents=True, exist_ok=True)
339
+
340
+ # Split out photometry and crossmatches
341
+ del_cols = ["photometry", "crossmatches"]
342
+
343
+ # source metadata row
344
+ meta = pd.DataFrame(
345
+ [{k: v for k, v in self.model_dump().items() if k not in del_cols}]
346
+ )
347
+
348
+ schema = Source.get_arrow_schema(include_computed=True)
349
+ for field in del_cols:
350
+ schema = schema.remove(schema.get_field_index(field))
351
+ # meta = meta[[x for x in schema.names]]
352
+ table = pa.Table.from_pandas(meta, schema=schema)
353
+ pq.write_table(table, source_path, compression="zstd")
354
+
355
+ # Dump crossmatches to json
356
+ crossmatch_path.write_text(json.dumps(self.crossmatches))
357
+
358
+ # photometry
359
+ df = (
360
+ self.get_detections(min_snr=3.0)
361
+ if trim_photometry
362
+ else self.get_photometry()
363
+ )
364
+
365
+ # Clip derived columns
366
+ df = df[list(Observation.model_fields.keys())]
367
+
368
+ table = pa.Table.from_pandas(df, schema=Observation.get_arrow_schema())
369
+ pq.write_table(table, photometry_path, compression="zstd")
370
+
371
+ @classmethod
372
+ def from_parquet(
373
+ cls, object_id: str, base_path: Path | str | None = None
374
+ ) -> "Source":
375
+ """
376
+ Load a Source object from parquet files.
377
+
378
+ :param object_id: Object ID
379
+ :param base_path: Base path to parquet files
380
+ :return: Source object
381
+ """
382
+ meta = pd.read_parquet(get_source_path(object_id, base_path))
383
+
384
+ meta = meta.replace({np.nan: None})
385
+
386
+ photometry_df = pd.read_parquet(get_photometry_path(object_id, base_path))
387
+ crossmatches = json.loads(get_crossmatch_path(object_id, base_path).read_text())
388
+
389
+ photometry = [
390
+ Observation(**row) for row in photometry_df.to_dict(orient="records")
391
+ ]
392
+
393
+ return cls(
394
+ **meta.iloc[0].to_dict(), photometry=photometry, crossmatches=crossmatches
395
+ )
396
+
397
+ # def to_archive(self):
398
+ # """
399
+ # Dump Source object to dictionary in archive format,
400
+ # with photometry as list of dictionaries
401
+ #
402
+ # :return: Dictionary in archive format
403
+ # """
404
+ #
405
+ # output_path = babamul_cache_dir / f"{self.objectid}.json"
406
+ # res = self.model_dump()
407
+ # with open(output_path, "w") as f:
408
+ # json.dump(res, f, indent=4)
409
+ #
410
+ def convert_to_ztfstyle(self) -> dict:
411
+ """
412
+ Convert Source object to ZTF-style dictionary,
413
+ with candidate and previous candidates
414
+
415
+ :return: ZTF-style dictionary (like Kowalski)
416
+ """
417
+ res = self.model_dump()
418
+ cand = {key: val for key, val in res.items() if key not in ["photometry"]}
419
+ res["candidate"] = cand
420
+ res["objectId"] = self.objectid
421
+
422
+ df = self.get_detections()
423
+
424
+ df["fid"] = df["band"].map(FID_MAPPING).astype("Int8")
425
+ df["filter"] = df["band"]
426
+
427
+ match = df.iloc[-1]
428
+
429
+ res["candidate"].update(match.to_dict())
430
+ res["prv_candidates"] = df[:-1].to_dict(orient="records")
431
+
432
+ return res
@@ -0,0 +1,6 @@
1
+ """
2
+ Projections for BOOM queries
3
+ """
4
+
5
+ from blastwave.projections.lsst import lsst_aux_projection
6
+ from blastwave.projections.ztf import ztf_alert_projection, ztf_aux_projection
@@ -0,0 +1,27 @@
1
+ """
2
+ LSST alert projections
3
+ """
4
+
5
+ # pylint: disable=duplicate-code
6
+
7
+ prefixes = ["prv_candidates", "fp_hists"]
8
+ fields = [
9
+ "jd",
10
+ "magpsf",
11
+ "sigmapsf",
12
+ "diffmaglim",
13
+ "ra",
14
+ "dec",
15
+ "snr",
16
+ "band",
17
+ "isdiffpos",
18
+ "psfFlux",
19
+ "psfFluxErr",
20
+ "reliability",
21
+ "isDipole",
22
+ ]
23
+
24
+ lsst_aux_projection = {
25
+ f"{prefix}.{field}": 1 for prefix in prefixes for field in fields
26
+ }
27
+ lsst_aux_projection["aliases"] = 1
@@ -0,0 +1,34 @@
1
+ """
2
+ Minimal projections for ZTF
3
+ """
4
+
5
+ # pylint: disable=duplicate-code
6
+
7
+ ztf_alert_projection = {
8
+ "objectId": 1,
9
+ "candidate.distpsnr1": 1,
10
+ "candidate.jd": 1,
11
+ "candidate.fid": 1,
12
+ "candidate.magpsf": 1,
13
+ "candidate.sigmapsf": 1,
14
+ "candidate.ra": 1,
15
+ "candidate.dec": 1,
16
+ }
17
+
18
+ prefixes = ["prv_candidates", "fp_hists", "prv_nondetections"]
19
+ fields = [
20
+ "jd",
21
+ "magpsf",
22
+ "sigmapsf",
23
+ "diffmaglim",
24
+ "ra",
25
+ "dec",
26
+ "band",
27
+ "isdiffpos",
28
+ "psfFlux",
29
+ "psfFluxErr",
30
+ "snr_psf",
31
+ ]
32
+
33
+ ztf_aux_projection = {f"{prefix}.{field}": 1 for prefix in prefixes for field in fields}
34
+ ztf_aux_projection["aliases"] = 1
@@ -0,0 +1,8 @@
1
+ """
2
+ Module for querying BOOM
3
+ """
4
+
5
+ from blastwave.query.boom import BoomClient
6
+ from blastwave.query.lsst import LSSTClient
7
+ from blastwave.query.timeout import TimeoutHTTPAdapter
8
+ from blastwave.query.ztf import ZTFClient