astro-otter 0.6.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,1045 @@
1
+ """
2
+ Host object that stores information on the Transient DataFinder and provides utility
3
+ methods for pulling in data corresponding to that host
4
+ """
5
+
6
+ from __future__ import annotations
7
+ import os
8
+ import csv
9
+ import io
10
+ import re
11
+ import time
12
+ import math
13
+ from urllib.request import urlopen
14
+ import requests
15
+
16
+ from astropy import units as u
17
+ from astropy.coordinates import SkyCoord
18
+ from astropy.time import Time
19
+ from astropy.table import Table
20
+ from astropy.io.votable import parse_single_table
21
+ from astropy.io import ascii
22
+
23
+ import numpy as np
24
+ import pandas as pd
25
+ import logging
26
+
27
+ from fundamentals.stats import rolling_window_sigma_clip
28
+ from operator import itemgetter
29
+
30
+ from ..util import VIZIER_LARGE_CATALOGS
31
+ from ..exceptions import MissingEnvVarError
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ class DataFinder(object):
37
+ def __init__(
38
+ self,
39
+ ra: str | float,
40
+ dec: str | float,
41
+ ra_units: str | u.Unit,
42
+ dec_units: str | u.Unit,
43
+ name: str = None,
44
+ redshift: float = None,
45
+ reference: list[str] = None,
46
+ **kwargs,
47
+ ) -> None:
48
+ """
49
+ Object to store DataFinder info to query public data sources of host galaxies
50
+
51
+ Args:
52
+ ra (str|float) : The RA of the host to be passed to an astropy SkyCoord
53
+ dec (str|float) : The declination of the host to be passed to an
54
+ astropy SkyCoord
55
+ ra_units (str|astropy.units.Unit) : units of the RA, to be passed to
56
+ the unit keyword of SkyCoord
57
+ dec_units (str|astropy.units.Unit) : units of the declination, to be
58
+ passed to the unit keyword of
59
+ SkyCoord
60
+ name (str) : The name of the host galaxy
61
+ redshift (float) : The redshift of the host galaxy
62
+ reference (list[str]) : a list of bibcodes that found this to be the host
63
+ kwargs : Just here so we can pass **Transient['host'] into this constructor
64
+ and any extraneous properties will be ignored.
65
+ """
66
+ self.coord = SkyCoord(ra, dec, unit=(ra_units, dec_units))
67
+ self.name = name
68
+ self.z = redshift
69
+ self.redshift = redshift # just here for ease of use
70
+ self.bibcodes = reference
71
+
72
+ def __repr__(self) -> str:
73
+ """
74
+ String representation of the DataFinder for printing
75
+ """
76
+
77
+ if self.name is None:
78
+ print_name = "No Name DataFinder"
79
+ else:
80
+ print_name = self.name
81
+
82
+ return f"{print_name} @ (RA, Dec)=({self.coord.ra},{self.coord.dec})"
83
+
84
+ def __iter__(self) -> dict:
85
+ """
86
+ Provides an iterator for the properties of this DataFinder. Yields (key, value)
87
+ """
88
+ out = dict(
89
+ host_ra=self.coord.ra.value,
90
+ host_dec=self.coord.dec.value,
91
+ host_ra_units="deg",
92
+ host_dec_units="deg",
93
+ )
94
+
95
+ if self.name is not None:
96
+ out["host_name"] = self.name
97
+
98
+ if self.z is not None:
99
+ out["host_redshift"] = self.z
100
+
101
+ if self.bibcodes is not None:
102
+ out["reference"] = self.bibcodes
103
+
104
+ for k, v in out.items():
105
+ yield (k, v)
106
+
107
+ ###################################################################################
108
+ ################### CONVENIENCE METHODS FOR QUERYING HOST METADATA ################
109
+ ###################################################################################
110
+
111
+ @staticmethod
112
+ def _wrap_astroquery(module, *args, **kwargs):
113
+ """
114
+ Private convenience method that just standardizes how we call the query_region
115
+ method in astroquery
116
+ """
117
+ return module.query_region(*args, **kwargs)
118
+
119
+ def query_simbad(self, radius="5 arcsec", **kwargs):
120
+ """
121
+ Query SIMBAD through astroquery to provide any other "meta" information on this
122
+ host that may not be stored in the OTTER
123
+
124
+ Args:
125
+ radius (str|astropy.quantity.Quantity) : search radius for astroquery
126
+ **kwargs : any other arguments for astroquery.vizier.Vizier.query_region
127
+
128
+ Returns:
129
+ astropy Table of the simbad results.
130
+ """
131
+ from astroquery.simbad import Simbad
132
+
133
+ return DataFinder._wrap_astroquery(Simbad, self.coord, radius=radius, **kwargs)
134
+
135
+ def query_vizier(self, radius="5 arcsec", **kwargs):
136
+ """
137
+ Query the ViZier catalog for TIME-AVERAGED data from their major/large catalogs.
138
+
139
+ ViZier Catalogs Queried:
140
+ - 2MASS-PSC
141
+ - 2MASX
142
+ - AC2000.2
143
+ - AKARI
144
+ - ALLWISE
145
+ - ASCC-2.5
146
+ - B/DENIS
147
+ - CMC14
148
+ - Gaia-DR1
149
+ - GALEX
150
+ - GLIMPSE
151
+ - GSC-ACT
152
+ - GSC1.2
153
+ - GSC2.2
154
+ - GSC2.3
155
+ - HIP
156
+ - HIP2
157
+ - IRAS
158
+ - NOMAD1
159
+ - NVSS
160
+ - PanSTARRS-DR1
161
+ - PGC
162
+ - Planck-DR1
163
+ - PPMX
164
+ - PPMXL
165
+ - SDSS-DR12
166
+ - SDSS-DR7
167
+ - SDSS-DR9
168
+ - Tycho-2
169
+ - UCAC2
170
+ - UCAC3
171
+ - UCAC4
172
+ - UKIDSS
173
+ - USNO-A2
174
+ - USNO-B1
175
+ - WISE
176
+
177
+ Args:
178
+ radius (str|astropy.quantity.Quantity) : search radius for astroquery
179
+ **kwargs : any other arguments for astroquery.vizier.Vizier.query_region
180
+
181
+ Returns:
182
+ astropy TableList of the time-averaged photometry associated with this host.
183
+ """
184
+ from astroquery.vizier import Vizier
185
+
186
+ return DataFinder._wrap_astroquery(
187
+ Vizier, self.coord, radius=radius, catalog=VIZIER_LARGE_CATALOGS
188
+ )
189
+
190
+ ###################################################################################
191
+ ######### CONVENIENCE METHODS FOR QUERYING HOST TIME SERIES PHOTOMETRY ###########
192
+ ###################################################################################
193
+
194
+ def query_atlas(
195
+ self, days_ago: int = 365, disc_date: float = None, clip_sigma: float = 2.0
196
+ ) -> pd.DataFrame:
197
+ """
198
+ Query ATLAS forced photometry for photometry for this host
199
+
200
+ Args:
201
+ days_ago (int) : Number of days before the transients discovery date
202
+ (or today if no disc_date is given) to get ATLAS
203
+ forced photometry for.
204
+ disc_date (float) : The discovery date of the transient in MJD.
205
+ clip_sigma (float) : amount to sigma clip the ATLAS data by
206
+
207
+ Return:
208
+ pandas DataFrame of the ATLAS forced photometry for this host
209
+ """
210
+ base_url = "https://fallingstar-data.com/forcedphot"
211
+
212
+ token = os.environ.get("ATLAS_API_TOKEN", None)
213
+ if token is None:
214
+ logger.warn(
215
+ "Getting your token from ATLAS. Please add ATLAS_API_TOKEN to your \
216
+ environment variables to avoid this!"
217
+ )
218
+
219
+ uname = os.environ.get("ATLAS_UNAME", default=None)
220
+ pword = os.environ.get("ATLAS_PWORD", default=None)
221
+
222
+ if uname is None and pword is None:
223
+ raise MissingEnvVarError(["ATLAS_UNAME", "ATLAS_PWORD"], base_url)
224
+ elif uname is None and pword is not None:
225
+ raise MissingEnvVarError(["ATLAS_UNAME"], base_url)
226
+ elif uname is not None and pword is None:
227
+ raise MissingEnvVarError(["ATLAS_PWORD"], base_url)
228
+
229
+ resp = requests.post(
230
+ url=f"{base_url}/api-token-auth/",
231
+ data={"username": uname, "password": pword},
232
+ )
233
+
234
+ token = resp.json()["token"]
235
+
236
+ headers = {"Authorization": f"Token {token}", "Accept": "application/json"}
237
+
238
+ # compute the query start
239
+ if disc_date is None:
240
+ t_queryend = Time.now().mjd
241
+ logger.warn(
242
+ "Since no transient name is given we are using today \
243
+ as the query end!"
244
+ )
245
+ else:
246
+ t_queryend = Time(disc_date, format="mjd").mjd
247
+
248
+ t_querystart = t_queryend - days_ago
249
+
250
+ # submit the query to the ATLAS forced photometry server
251
+ task_url = None
252
+ while not task_url:
253
+ with requests.Session() as s:
254
+ resp = s.post(
255
+ f"{base_url}/queue/",
256
+ headers=headers,
257
+ data={
258
+ "ra": self.coord.ra.value,
259
+ "dec": self.coord.ra.value,
260
+ "send_email": False,
261
+ "mjd_min": t_querystart,
262
+ "mjd_max": t_queryend,
263
+ "use_reduced": False,
264
+ },
265
+ )
266
+ if resp.status_code == 201: # success
267
+ task_url = resp.json()["url"]
268
+ logger.info(f"The task URL is {task_url}")
269
+ elif resp.status_code == 429: # throttled
270
+ message = resp.json()["detail"]
271
+ logger.info(f"{resp.status_code} {message}")
272
+ t_sec = re.findall(r"available in (\d+) seconds", message)
273
+ t_min = re.findall(r"available in (\d+) minutes", message)
274
+ if t_sec:
275
+ waittime = int(t_sec[0])
276
+ elif t_min:
277
+ waittime = int(t_min[0]) * 60
278
+ else:
279
+ waittime = 10
280
+ logger.info(f"Waiting {waittime} seconds")
281
+ time.sleep(waittime)
282
+ else:
283
+ raise Exception(f"ERROR {resp.status_code}\n{resp.text}")
284
+
285
+ # Now wait for the result
286
+ result_url = None
287
+ taskstarted_printed = False
288
+ while not result_url:
289
+ with requests.Session() as s:
290
+ resp = s.get(task_url, headers=headers)
291
+
292
+ if resp.status_code == 200: # HTTP OK
293
+ if resp.json()["finishtimestamp"]:
294
+ result_url = resp.json()["result_url"]
295
+ logger.info(
296
+ f"Task is complete with results available at {result_url}"
297
+ )
298
+ elif resp.json()["starttimestamp"]:
299
+ if not taskstarted_printed:
300
+ print(
301
+ f"Task is running (started at\
302
+ {resp.json()['starttimestamp']})"
303
+ )
304
+ taskstarted_printed = True
305
+ time.sleep(2)
306
+ else:
307
+ # print(f"Waiting for job to start (queued at {timestamp})")
308
+ time.sleep(4)
309
+ else:
310
+ raise Exception(f"ERROR {resp.status_code}\n{resp.text}")
311
+
312
+ # get and clean up the result
313
+ with requests.Session() as s:
314
+ textdata = s.get(result_url, headers=headers).text
315
+
316
+ atlas_phot = DataFinder._atlas_stack(textdata, clipping_sigma=clip_sigma)
317
+
318
+ return pd.DataFrame(atlas_phot)
319
+
320
+ def query_ptf(self, radius: str | u.Quantity = "5 arcsec", **kwargs) -> Table:
321
+ """
322
+ Query the palomer transient facility's light curve catalog for this host
323
+
324
+ Args:
325
+ radius (str|astropy.quantity.Quantity) : search radius
326
+ **kwargs : other optional arguments for astroquery's query_region
327
+
328
+ Returns:
329
+ An astropy Table of the resulting light curve
330
+ """
331
+ from astroquery.ipac.irsa import Irsa
332
+
333
+ ptf_lc_catalog = "ptf_lightcurves"
334
+ return DataFinder._wrap_astroquery(
335
+ Irsa, self.coord, radius=radius, catalog=ptf_lc_catalog
336
+ )
337
+
338
+ def query_ztf(self, radius: float = 5):
339
+ """
340
+ Query ZTF photometry/forced photometry for photometry for this host
341
+
342
+ Args:
343
+ radius (float) : The search radius in arcseconds
344
+
345
+ Returns:
346
+ An astropy table of the time series data from the cone search in ZTF
347
+ """
348
+
349
+ base_url = "https://irsa.ipac.caltech.edu/cgi-bin/ZTF/nph_light_curves?"
350
+
351
+ ra, dec = self.coord.ra.value, self.coord.dec.value
352
+ search_radius_arcseconds = radius # in arcseconds
353
+ search_radius_degree = search_radius_arcseconds / 3600
354
+
355
+ query_url = f"{base_url}POS=CIRCLE%20{ra}%20{dec}%20{search_radius_degree}"
356
+
357
+ resp = urlopen(query_url)
358
+
359
+ votab = parse_single_table(io.BytesIO(resp.read()))
360
+
361
+ return Table(votab.array)
362
+
363
+ def query_asassn(self, radius: float = 5.0, nthreads: int = 2) -> pd.DataFrame:
364
+ """
365
+ Query ASASSN photometry/forced photometry for photometry for this host
366
+
367
+ Args:
368
+ radius (float) : search radius in arcseconds
369
+ nthreads (int) : number of threads to utilize during download, default is 2
370
+
371
+ Returns:
372
+ A pandas dataframe with the ASASSN lightcurve for this object
373
+ """
374
+ from pyasassn.client import SkyPatrolClient
375
+
376
+ client = SkyPatrolClient()
377
+ light_curve = client.cone_search(
378
+ self.coord.ra.value,
379
+ self.coord.dec.value,
380
+ radius=radius,
381
+ units="arcsec",
382
+ download=True,
383
+ threads=nthreads,
384
+ )
385
+ return light_curve.data
386
+
387
+ def query_wise(
388
+ self,
389
+ radius: float = 5,
390
+ datadir: str = "ipac/",
391
+ overwrite: bool = False,
392
+ verbose=False,
393
+ **kwargs,
394
+ ) -> pd.DataFrame:
395
+ """
396
+ Query NEOWISE for their multiepoch photometry
397
+
398
+ The method used to query wise here was taken from this github repo:
399
+ https://github.com/HC-Hwang/wise_light_curves/tree/master
400
+ and you should cite this other paper that the authors of this code developed
401
+ it for: https://ui.adsabs.harvard.edu/abs/2020MNRAS.493.2271H/abstract
402
+
403
+ This will download the ipac data files to the "datadir" argument. by default,
404
+ these will go into os.getcwd()/ipac
405
+
406
+ Args:
407
+ radius (float) : The cone search radius in arcseconds
408
+ overwrite (bool) : Overwrite the existing datasets downloaded from wise
409
+ **kwargs : Other optional arguments for the astroquery query_region
410
+ Returns:
411
+ An astropy Table of the multiepoch wise data for this host
412
+ """
413
+ # from https://www.cambridge.org/core/journals/
414
+ # publications-of-the-astronomical-society-of-australia/article/
415
+ # recalibrating-the-widefield-infrared-survey-explorer-wise-w4-filter/
416
+ # B238BFFE19A533A2D2638FE88CCC2E89
417
+ band_vals = {"w1": 3.4, "w2": 4.6, "w3": 12, "w4": 22} # in um
418
+
419
+ ra, dec = self.coord.ra.value, self.coord.dec.value
420
+
421
+ fbasename = f"wise_{self.name}"
422
+ allwise_name = f"{fbasename}_allwise.ipac"
423
+ neowise_name = f"{fbasename}_neowise.ipac"
424
+
425
+ if not os.path.exists(datadir):
426
+ os.makedirs(datadir)
427
+
428
+ self._download_single_data(
429
+ name=fbasename,
430
+ ra=ra,
431
+ dec=dec,
432
+ root_path=datadir,
433
+ radius=radius,
434
+ overwrite=overwrite,
435
+ )
436
+
437
+ allwise = ascii.read(f"ipac/{allwise_name}", format="ipac")
438
+ neowise = ascii.read(f"ipac/{neowise_name}", format="ipac")
439
+
440
+ allwise, neowise = self._only_good_data(allwise, neowise, verbose=verbose)
441
+ if verbose and (allwise is None or neowise is None):
442
+ print(f"Limited good infrared data for {self.name}, skipping!")
443
+
444
+ mjd, mag, mag_err, filts = self._make_full_lightcurve_multibands(
445
+ allwise, neowise, bands=["w1", "w2", "w3", "w4"]
446
+ )
447
+
448
+ df = pd.DataFrame(
449
+ dict(
450
+ name=[self.name] * len(mjd),
451
+ date_mjd=mjd,
452
+ filter=filts,
453
+ filter_eff=[band_vals[f] for f in filts],
454
+ filter_eff_unit=["um"] * len(mjd),
455
+ flux=mag,
456
+ flux_err=mag_err,
457
+ flux_unit=["mag(AB)"] * len(mjd),
458
+ upperlimit=[False] * len(mjd),
459
+ )
460
+ )
461
+
462
+ # clean up the wise data by filtering out negative flux
463
+ wise = df[df.flux > 0].reset_index(drop=True)
464
+ return wise
465
+
466
+ def query_alma(self, radius: float = 5, **kwargs) -> Table:
467
+ """
468
+ Query ALMA to see if there are observations of this host.
469
+
470
+ NOTE: Since this is radio/mm data, it is unlikely that the output table will
471
+ simply have fluxes in it. Instead you will need to use the access_url column
472
+ to download and reduce this data.
473
+
474
+ Args:
475
+ radius (float) : The cone search radius in arcseconds
476
+ **kwargs : Other optional arguments for the astroquery query_region
477
+ Returns:
478
+ An astropy Table of the multiepoch wise data for this host
479
+ """
480
+
481
+ logger.warn(
482
+ "This method may not work if you are using a conda environment!\
483
+ This is a known issue in setuptools that is not resolved!"
484
+ )
485
+
486
+ from astroquery.alma import Alma
487
+
488
+ res = DataFinder._wrap_astroquery(
489
+ Alma, self.coord, radius=5 * u.arcsec, **kwargs
490
+ )
491
+ return res
492
+
493
+ def query_first(
494
+ self, radius: u.Quantity = 5 * u.arcmin, get_image: bool = False, **kwargs
495
+ ) -> list:
496
+ """
497
+ Query the FIRST radio survey and return an astropy table of the flux density
498
+
499
+ This queries Table 2 from Ofek & Frail (2011); 2011ApJ...737...45O
500
+
501
+ Args:
502
+ radius (u.Quantity) : An astropy Quantity with the image height/width
503
+ get_image (bool) : If True, download and return a list of the associated
504
+ images too.
505
+ **kwargs : any other arguments to pass to the astroquery.image_cutouts
506
+ get_images method
507
+
508
+ Returns:
509
+ Astropy table of the flux densities. If get_image is True, it also returns
510
+ a list of FIRST radio survey images
511
+ """
512
+ from astroquery.vizier import Vizier
513
+
514
+ res = DataFinder._wrap_astroquery(
515
+ Vizier, self.coord, radius=radius, catalog="J/ApJ/737/45/table2"
516
+ )
517
+
518
+ if get_image:
519
+ from astroquery.image_cutouts.first import First
520
+
521
+ res_img = First.get_images(self.coord, image_size=radius, **kwargs)
522
+ return res, res_img
523
+
524
+ return res
525
+
526
+ def query_nvss(self, radius: u.Quantity = 5 * u.arcsec, **kwargs) -> Table:
527
+ """
528
+ Query the NRAO VLA Sky Survey (NVSS) and return a table list of the
529
+ result
530
+
531
+ This queries Table 1 from Ofek & Frail (2011); 2011ApJ...737...45O
532
+
533
+ Args:
534
+ radius (u.Quantity) : An astropy Quantity with the radius
535
+ **kwargs : Any other arguments to pass to query_region
536
+ """
537
+ from astroquery.vizier import Vizier
538
+
539
+ res = DataFinder._wrap_astroquery(
540
+ Vizier, self.coord, radius=radius, catalog="J/ApJ/737/45/table1"
541
+ )
542
+ return res
543
+
544
+ def query_heasarc(self, radius: u.Quantity = 5 * u.arcsec, **kwargs) -> Table:
545
+ """
546
+ Query Heasarc by the argument "heasarc_key" for the ra/dec associated with this
547
+ DataLoader object.
548
+
549
+ Args:
550
+ radius (u.Quantity) : An astropy Quantity with the radius
551
+ heasarc_table (str) : String with name of heasarc table to query. Default is
552
+ 'xray' which queries the heasarc master x-ray catalog,
553
+ 'radio' will query the heasarc master radio catalog. See
554
+ https://heasarc.gsfc.nasa.gov/cgi-bin/W3Browse/w3catindex.pl
555
+ for a complete list.
556
+ **kwargs : Any other arguments to pass to query_region
557
+
558
+ Returns:
559
+ Astropy table of the rows in `heasarc_table` that match self.coord.
560
+ """
561
+ from astroquery.heasarc import Heasarc
562
+
563
+ res = DataFinder._wrap_astroquery(Heasarc, self.coord, radius=radius, **kwargs)
564
+
565
+ return res
566
+
567
+ ###################################################################################
568
+ ######### CONVENIENCE METHODS FOR QUERYING HOST SPECTR ###########################
569
+ ###################################################################################
570
+
571
+ def query_sparcl(
572
+ self, radius: u.Quantity = 5 * u.arcsec, include: str | list = "DEFAULT"
573
+ ) -> Table:
574
+ """
575
+ Query the NOIRLab DataLabs Sparcl database for spectra for this host
576
+
577
+ Args:
578
+ radius (Quantity) : search radius as an Astropy.unit.Quantity
579
+ include [list|str] : list or string of columns to include in the result. See
580
+ the sparcl client documentation for more info. The
581
+ default returns specid, ra, dec, sparcl_id, flux,
582
+ wavelength, and the spectroscopic surveyu (_dr)
583
+
584
+ Returns:
585
+ astropy Table of the results, one row per spectrum
586
+ """
587
+
588
+ from sparcl.client import SparclClient
589
+ from dl import queryClient as qc # noqa: N813
590
+
591
+ client = SparclClient()
592
+
593
+ # first do a cone search on sparcl.main
594
+ ra, dec = self.coord.ra.value, self.coord.dec.value
595
+ radius_deg = radius.to(u.deg).value
596
+
597
+ adql = f"""
598
+ SELECT *
599
+ FROM sparcl.main
600
+ WHERE 't'=Q3C_RADIAL_QUERY(ra,dec,{ra},{dec},{radius_deg})
601
+ """
602
+ cone_search_res = qc.query(adql=adql, fmt="pandas")
603
+
604
+ # then retrieve all of the spectra corresponding to those sparcl_ids
605
+ spec_ids = cone_search_res.targetid.tolist()
606
+ if len(spec_ids) == 0:
607
+ logger.warn("Object not found in Sparcl!")
608
+ return
609
+
610
+ res = client.retrieve_by_specid(spec_ids, include=include)
611
+ if res.count == 0:
612
+ logger.warn("No Spectra available in sparcl!")
613
+ return
614
+
615
+ all_spec = pd.concat([pd.DataFrame([record]) for record in res.records])
616
+ return Table.from_pandas(all_spec)
617
+
618
+ ###################################################################################
619
+ ######### PRIVATE HELPER METHODS FOR THE QUERYING #################################
620
+ ###################################################################################
621
+ @staticmethod
622
+ def _atlas_stack(filecontent, clipping_sigma, log=logger):
623
+ """
624
+ Function adapted from David Young's :func:`plotter.plot_single_result`
625
+ https://github.com/thespacedoctor/plot-results-from-atlas-force-photometry-service/blob/main/plot_atlas_fp.py
626
+
627
+ And again adapted from https://github.com/SAGUARO-MMA/kne-cand-vetting/blob/master/kne_cand_vetting/survey_phot.py
628
+ """
629
+ epochs = DataFinder._atlas_read_and_sigma_clip_data(
630
+ filecontent, log=log, clipping_sigma=clipping_sigma
631
+ )
632
+
633
+ # c = cyan, o = arange
634
+ magnitudes = {
635
+ "c": {"mjds": [], "mags": [], "magErrs": [], "lim5sig": []},
636
+ "o": {"mjds": [], "mags": [], "magErrs": [], "lim5sig": []},
637
+ "I": {"mjds": [], "mags": [], "magErrs": [], "lim5sig": []},
638
+ }
639
+
640
+ # SPLIT BY FILTER
641
+ for epoch in epochs:
642
+ if epoch["F"] in ["c", "o", "I"]:
643
+ magnitudes[epoch["F"]]["mjds"].append(epoch["MJD"])
644
+ magnitudes[epoch["F"]]["mags"].append(epoch["uJy"])
645
+ magnitudes[epoch["F"]]["magErrs"].append(epoch["duJy"])
646
+ magnitudes[epoch["F"]]["lim5sig"].append(epoch["mag5sig"])
647
+
648
+ # STACK PHOTOMETRY IF REQUIRED
649
+ stacked_magnitudes = DataFinder._stack_photometry(magnitudes, binningdays=1)
650
+
651
+ return stacked_magnitudes
652
+
653
+ @staticmethod
654
+ def _atlas_read_and_sigma_clip_data(filecontent, log, clipping_sigma=2.2):
655
+ """
656
+ Function adapted from David Young's :func:`plotter.read_and_sigma_clip_data`
657
+ https://github.com/thespacedoctor/plot-results-from-atlas-force-photometry-service/blob/main/plot_atlas_fp.py
658
+
659
+ And again adapted from
660
+ https://github.com/SAGUARO-MMA/kne-cand-vetting/blob/master/kne_cand_vetting/survey_phot.py
661
+
662
+ *clean up rouge data from the files by performing some basic clipping*
663
+ **Key Arguments:**
664
+ - `fpFile` -- path to single force photometry file
665
+ - `clippingSigma` -- the level at which to clip flux data
666
+ **Return:**
667
+ - `epochs` -- sigma clipped and cleaned epoch data
668
+ """
669
+
670
+ # CLEAN UP FILE FOR EASIER READING
671
+ fpdata = (
672
+ filecontent.replace("###", "")
673
+ .replace(" ", ",")
674
+ .replace(",,", ",")
675
+ .replace(",,", ",")
676
+ .replace(",,", ",")
677
+ .replace(",,", ",")
678
+ .splitlines()
679
+ )
680
+
681
+ # PARSE DATA WITH SOME FIXED CLIPPING
682
+ oepochs = []
683
+ cepochs = []
684
+ csvreader = csv.DictReader(
685
+ fpdata, dialect="excel", delimiter=",", quotechar='"'
686
+ )
687
+
688
+ for row in csvreader:
689
+ for k, v in row.items():
690
+ try:
691
+ row[k] = float(v)
692
+ except Exception:
693
+ pass
694
+ # REMOVE VERY HIGH ERROR DATA POINTS, POOR CHI SQUARED, OR POOR EPOCHS
695
+ if row["duJy"] > 4000 or row["chi/N"] > 100 or row["mag5sig"] < 17.0:
696
+ continue
697
+ if row["F"] == "c":
698
+ cepochs.append(row)
699
+ if row["F"] == "o":
700
+ oepochs.append(row)
701
+
702
+ # SORT BY MJD
703
+ cepochs = sorted(cepochs, key=itemgetter("MJD"), reverse=False)
704
+ oepochs = sorted(oepochs, key=itemgetter("MJD"), reverse=False)
705
+
706
+ # SIGMA-CLIP THE DATA WITH A ROLLING WINDOW
707
+ cdataflux = []
708
+ cdataflux[:] = [row["uJy"] for row in cepochs]
709
+ odataflux = []
710
+ odataflux[:] = [row["uJy"] for row in oepochs]
711
+
712
+ masklist = []
713
+ for flux in [cdataflux, odataflux]:
714
+ fullmask = rolling_window_sigma_clip(
715
+ log=log, array=flux, clippingSigma=clipping_sigma, windowSize=11
716
+ )
717
+ masklist.append(fullmask)
718
+
719
+ try:
720
+ cepochs = [e for e, m in zip(cepochs, masklist[0]) if m == False]
721
+ except Exception:
722
+ cepochs = []
723
+
724
+ try:
725
+ oepochs = [e for e, m in zip(oepochs, masklist[1]) if m == False]
726
+ except Exception:
727
+ oepochs = []
728
+
729
+ logger.info("Completed the ``read_and_sigma_clip_data`` function")
730
+ # Returns ordered dictionary of all parameters
731
+ return cepochs + oepochs
732
+
733
+ @staticmethod
734
+ def _stack_photometry(magnitudes, binningdays=1.0):
735
+ """
736
+ Function adapted from David Young's :func:`plotter.stack_photometry`
737
+ https://github.com/thespacedoctor/plot-results-from-atlas-force-photometry-service/blob/main/plot_atlas_fp.py
738
+
739
+ And again adapted from
740
+ https://github.com/SAGUARO-MMA/kne-cand-vetting/blob/master/kne_cand_vetting/survey_phot.py
741
+
742
+ *stack the photometry for the given temporal range*
743
+ **Key Arguments:**
744
+ - `magnitudes` -- dictionary of photometry divided into filter sets
745
+ - `binningDays` -- the binning to use (in days)
746
+ **Return:**
747
+ - `summedMagnitudes` -- the stacked photometry
748
+ """
749
+
750
+ # IF WE WANT TO 'STACK' THE PHOTOMETRY
751
+ summed_magnitudes = {
752
+ "c": {"mjds": [], "mags": [], "magErrs": [], "n": [], "lim5sig": []},
753
+ "o": {"mjds": [], "mags": [], "magErrs": [], "n": [], "lim5sig": []},
754
+ "I": {"mjds": [], "mags": [], "magErrs": [], "n": [], "lim5sig": []},
755
+ }
756
+
757
+ # MAGNITUDES/FLUXES ARE DIVIDED IN UNIQUE FILTER SETS - SO ITERATE OVER
758
+ # FILTERS
759
+ alldata = []
760
+ for fil, data in list(magnitudes.items()):
761
+ # WE'RE GOING TO CREATE FURTHER SUBSETS FOR EACH UNQIUE MJD
762
+ # (FLOORED TO AN INTEGER)
763
+ # MAG VARIABLE == FLUX (JUST TO CONFUSE YOU)
764
+ distinctmjds = {}
765
+ for mjd, flx, err, lim in zip(
766
+ data["mjds"], data["mags"], data["magErrs"], data["lim5sig"]
767
+ ):
768
+ # DICT KEY IS THE UNIQUE INTEGER MJD
769
+ key = str(int(math.floor(mjd / float(binningdays))))
770
+ # FIRST DATA POINT OF THE NIGHTS? CREATE NEW DATA SET
771
+ if key not in distinctmjds:
772
+ distinctmjds[key] = {
773
+ "mjds": [mjd],
774
+ "mags": [flx],
775
+ "magErrs": [err],
776
+ "lim5sig": [lim],
777
+ }
778
+ # OR NOT THE FIRST? APPEND TO ALREADY CREATED LIST
779
+ else:
780
+ distinctmjds[key]["mjds"].append(mjd)
781
+ distinctmjds[key]["mags"].append(flx)
782
+ distinctmjds[key]["magErrs"].append(err)
783
+ distinctmjds[key]["lim5sig"].append(lim)
784
+
785
+ # ALL DATA NOW IN MJD SUBSETS. SO FOR EACH SUBSET (I.E. INDIVIDUAL
786
+ # NIGHTS) ...
787
+ for k, v in list(distinctmjds.items()):
788
+ # GIVE ME THE MEAN MJD
789
+ meanmjd = sum(v["mjds"]) / len(v["mjds"])
790
+ summed_magnitudes[fil]["mjds"].append(meanmjd)
791
+ # GIVE ME THE MEAN FLUX
792
+ meanflux = sum(v["mags"]) / len(v["mags"])
793
+ summed_magnitudes[fil]["mags"].append(meanflux)
794
+ # GIVE ME THE COMBINED ERROR
795
+ sum_of_squares = sum(x**2 for x in v["magErrs"])
796
+ comberror = math.sqrt(sum_of_squares) / len(v["magErrs"])
797
+ summed_magnitudes[fil]["magErrs"].append(comberror)
798
+ # 5-sigma limits
799
+ comb5siglimit = 23.9 - 2.5 * math.log10(5.0 * comberror)
800
+ summed_magnitudes[fil]["lim5sig"].append(comb5siglimit)
801
+ # GIVE ME NUMBER OF DATA POINTS COMBINED
802
+ n = len(v["mjds"])
803
+ summed_magnitudes[fil]["n"].append(n)
804
+ alldata.append(
805
+ {
806
+ "mjd": meanmjd,
807
+ "uJy": meanflux,
808
+ "duJy": comberror,
809
+ "F": fil,
810
+ "n": n,
811
+ "mag5sig": comb5siglimit,
812
+ }
813
+ )
814
+ print("completed the ``stack_photometry`` method")
815
+
816
+ return alldata
817
+
818
+ """
819
+ The following code was taken and modified for the purposes of this package from
820
+ https://github.com/HC-Hwang/wise_light_curves/blob/master/wise_light_curves.py
821
+
822
+ Original Authors:
823
+ - Matthew Hill
824
+ - Hsiang-Chih Hwang
825
+
826
+ Update Author:
827
+ - Noah Franz
828
+ """
829
+
830
+ @staticmethod
831
+ def _get_by_position(ra, dec, radius=2.5):
832
+ allwise_cat = "allwise_p3as_mep"
833
+ neowise_cat = "neowiser_p1bs_psd"
834
+ query_url = "http://irsa.ipac.caltech.edu/cgi-bin/Gator/nph-query"
835
+ payload = {
836
+ "catalog": allwise_cat,
837
+ "spatial": "cone",
838
+ "objstr": " ".join([str(ra), str(dec)]),
839
+ "radius": str(radius),
840
+ "radunits": "arcsec",
841
+ "outfmt": "1",
842
+ }
843
+ r = requests.get(query_url, params=payload)
844
+ allwise = ascii.read(r.text)
845
+ payload = {
846
+ "catalog": neowise_cat,
847
+ "spatial": "cone",
848
+ "objstr": " ".join([str(ra), str(dec)]),
849
+ "radius": str(radius),
850
+ "radunits": "arcsec",
851
+ "outfmt": "1",
852
+ "selcols": "ra,dec,sigra,sigdec,sigradec,glon,glat,elon,elat,w1mpro,w1sigmpro,w1snr,w1rchi2,w2mpro,w2sigmpro,w2snr,w2rchi2,rchi2,nb,na,w1sat,w2sat,satnum,cc_flags,det_bit,ph_qual,sso_flg,qual_frame,qi_fact,saa_sep,moon_masked,w1frtr,w2frtr,mjd,allwise_cntr,r_allwise,pa_allwise,n_allwise,w1mpro_allwise,w1sigmpro_allwise,w2mpro_allwise,w2sigmpro_allwise,w3mpro_allwise,w3sigmpro_allwise,w4mpro_allwise,w4sigmpro_allwise", # noqa: E501
853
+ }
854
+ r = requests.get(query_url, params=payload)
855
+
856
+ neowise = ascii.read(r.text, guess=False, format="ipac")
857
+
858
+ return allwise, neowise
859
+
860
+ @staticmethod
861
+ def _download_single_data(
862
+ name, ra, dec, root_path="ipac/", radius=2.5, overwrite=False
863
+ ):
864
+ # ra, dec: in degree
865
+ # name, ra, dec = row['Name'], row['RAJ2000'], row['DEJ2000']
866
+ # name = 'J' + ra + dec
867
+ if root_path[-1] != "/":
868
+ root_path += "/"
869
+ if (
870
+ not overwrite
871
+ and os.path.isfile(root_path + name + "_allwise.ipac")
872
+ and os.path.isfile(root_path + name + "_neowise.ipac")
873
+ ):
874
+ pass
875
+ else:
876
+ allwise, neowise = DataFinder._get_by_position(ra, dec, radius=radius)
877
+ allwise.write(
878
+ root_path + name + "_allwise.ipac", format="ascii.ipac", overwrite=True
879
+ )
880
+ neowise.write(
881
+ root_path + name + "_neowise.ipac", format="ascii.ipac", overwrite=True
882
+ )
883
+
884
+ @staticmethod
885
+ def _get_data_arrays(table, t, mag, magerr):
886
+ """Get the time series from a potentially masked astropy table"""
887
+ if table.masked:
888
+ full_mask = table[t].mask | table[mag].mask | table[magerr].mask
889
+ t = table[t].data
890
+ mag = table[mag].data
891
+ magerr = table[magerr].data
892
+
893
+ t.mask = full_mask
894
+ mag.mask = full_mask
895
+ magerr.mask = full_mask
896
+
897
+ return t.compressed(), mag.compressed(), magerr.compressed()
898
+
899
+ else:
900
+ return table[t].data, table[mag].data, table[magerr].data
901
+
902
+ @staticmethod
903
+ def _make_full_lightcurve(allwise, neowise, band):
904
+ """band = 'w1', 'w2', 'w3', or 'w4'"""
905
+ """Get a combined AllWISE and NEOWISE lightcurve from their Astropy tables"""
906
+
907
+ if band not in ["w1", "w2", "w3", "w4"]:
908
+ raise ValueError("band can only be w1, w2, w3, or w4")
909
+
910
+ use_neowise = band in {"w1", "w2"}
911
+ use_allwise = allwise is not None
912
+
913
+ if use_neowise and use_allwise:
914
+ t, m, e = DataFinder._get_data_arrays(
915
+ allwise, "mjd", band + "mpro_ep", band + "sigmpro_ep"
916
+ )
917
+ t_n, m_n, e_n = DataFinder._get_data_arrays(
918
+ neowise, "mjd", band + "mpro", band + "sigmpro"
919
+ )
920
+ t, m, e = (
921
+ np.concatenate((t, t_n)),
922
+ np.concatenate((m, m_n)),
923
+ np.concatenate((e, e_n)),
924
+ )
925
+
926
+ elif use_neowise and not use_allwise:
927
+ t, m, e = DataFinder._get_data_arrays(
928
+ neowise, "mjd", band + "mpro", band + "sigmpro"
929
+ )
930
+
931
+ elif not use_neowise and use_allwise:
932
+ t, m, e = DataFinder._get_data_arrays(
933
+ allwise, "mjd", band + "mpro_ep", band + "sigmpro_ep"
934
+ )
935
+
936
+ else:
937
+ raise Exception("No good allwise or neowise data!")
938
+
939
+ t_index = t.argsort()
940
+ t, m, e = map(lambda e: e[t_index], [t, m, e])
941
+
942
+ return t, m, e
943
+
944
+ @staticmethod
945
+ def _make_full_lightcurve_multibands(allwise, neowise, bands=["w1", "w2"]):
946
+ t, m, e = DataFinder._make_full_lightcurve(allwise, neowise, bands[0])
947
+ filts = [bands[0] for i in range(len(t))]
948
+ for band in bands[1:]:
949
+ try:
950
+ t_tmp, m_tmp, e_tmp = DataFinder._make_full_lightcurve(
951
+ allwise, neowise, band
952
+ )
953
+ except Exception:
954
+ continue
955
+ t = np.concatenate((t, t_tmp))
956
+ m = np.concatenate((m, m_tmp))
957
+ e = np.concatenate((e, e_tmp))
958
+ filts += [band for i in range(len(t_tmp))]
959
+ return t, m, e, np.array(filts)
960
+
961
+ @staticmethod
962
+ def _cntr_to_source_id(cntr):
963
+ cntr = str(cntr)
964
+
965
+ # fill leanding 0s
966
+ if len(cntr) < 19:
967
+ num_leading_zeros = 19 - len(cntr)
968
+ cntr = "0" * num_leading_zeros + cntr
969
+
970
+ pm = "p"
971
+ if cntr[4] == "0":
972
+ pm = "m"
973
+
974
+ t = chr(96 + int(cntr[8:10]))
975
+
976
+ return "%s%s%s_%cc%s-%s" % (
977
+ cntr[0:4],
978
+ pm,
979
+ cntr[5:8],
980
+ t,
981
+ cntr[11:13],
982
+ cntr[13:19],
983
+ )
984
+
985
+ @staticmethod
986
+ def _only_good_data(allwise, neowise, verbose=False):
987
+ """
988
+ Select good-quality data. The criteria include:
989
+ - matching the all-wise ID
990
+
991
+ To be done:
992
+ - deal with multiple cntr
993
+
994
+ This filtering is described here:
995
+ https://wise2.ipac.caltech.edu/docs/release/neowise/expsup/sec2_3.html
996
+ """
997
+
998
+ neowise_prefilter_n = len(neowise)
999
+ neowise = neowise[
1000
+ (neowise["qual_frame"] > 0.0)
1001
+ * (neowise["qi_fact"] > 0.9)
1002
+ * (neowise["saa_sep"] > 0)
1003
+ * (neowise["moon_masked"] == "00")
1004
+ ]
1005
+ neowise_postfilter_n = len(neowise)
1006
+ if verbose:
1007
+ print(
1008
+ f"Filtered out {neowise_prefilter_n-neowise_postfilter_n} neowise \
1009
+ points, leaving {neowise_postfilter_n}"
1010
+ )
1011
+
1012
+ cntr_list = []
1013
+ for data in neowise:
1014
+ if data["allwise_cntr"] not in cntr_list and data["allwise_cntr"] > 10.0:
1015
+ cntr_list.append(data["allwise_cntr"])
1016
+
1017
+ if len(cntr_list) >= 2:
1018
+ print("multiple cntr:")
1019
+ print(cntr_list)
1020
+ return None, neowise
1021
+
1022
+ if len(cntr_list) == 0:
1023
+ # import pdb; pdb.set_trace()
1024
+ # raise Exception('No center!')
1025
+ return None, neowise
1026
+
1027
+ cntr = cntr_list[0]
1028
+
1029
+ source_id = DataFinder._cntr_to_source_id(cntr)
1030
+
1031
+ allwise_prefilter_n = len(allwise)
1032
+ allwise = allwise[
1033
+ (allwise["source_id_mf"] == source_id)
1034
+ * (allwise["saa_sep"] > 0.0)
1035
+ * (allwise["moon_masked"] == "0000")
1036
+ * (allwise["qi_fact"] > 0.9)
1037
+ ]
1038
+ allwise_postfilter_n = len(neowise)
1039
+ if verbose:
1040
+ print(
1041
+ f"Filtered out {allwise_prefilter_n-allwise_postfilter_n} allwise \
1042
+ points, leaving {allwise_postfilter_n}"
1043
+ )
1044
+
1045
+ return allwise, neowise