astro-otter 0.0.2__py3-none-any.whl → 0.2.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.

Potentially problematic release.


This version of astro-otter might be problematic. Click here for more details.

@@ -0,0 +1,1037 @@
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
+ sparcl_ids = cone_search_res.sparcl_id.tolist()
606
+ res = client.retrieve(uuid_list=sparcl_ids, include=include)
607
+ all_spec = pd.concat([pd.DataFrame([record]) for record in res.records])
608
+ return Table.from_pandas(all_spec)
609
+
610
+ ###################################################################################
611
+ ######### PRIVATE HELPER METHODS FOR THE QUERYING #################################
612
+ ###################################################################################
613
+ @staticmethod
614
+ def _atlas_stack(filecontent, clipping_sigma, log=logger):
615
+ """
616
+ Function adapted from David Young's :func:`plotter.plot_single_result`
617
+ https://github.com/thespacedoctor/plot-results-from-atlas-force-photometry-service/blob/main/plot_atlas_fp.py
618
+
619
+ And again adapted from https://github.com/SAGUARO-MMA/kne-cand-vetting/blob/master/kne_cand_vetting/survey_phot.py
620
+ """
621
+ epochs = DataFinder._atlas_read_and_sigma_clip_data(
622
+ filecontent, log=log, clipping_sigma=clipping_sigma
623
+ )
624
+
625
+ # c = cyan, o = arange
626
+ magnitudes = {
627
+ "c": {"mjds": [], "mags": [], "magErrs": [], "lim5sig": []},
628
+ "o": {"mjds": [], "mags": [], "magErrs": [], "lim5sig": []},
629
+ "I": {"mjds": [], "mags": [], "magErrs": [], "lim5sig": []},
630
+ }
631
+
632
+ # SPLIT BY FILTER
633
+ for epoch in epochs:
634
+ if epoch["F"] in ["c", "o", "I"]:
635
+ magnitudes[epoch["F"]]["mjds"].append(epoch["MJD"])
636
+ magnitudes[epoch["F"]]["mags"].append(epoch["uJy"])
637
+ magnitudes[epoch["F"]]["magErrs"].append(epoch["duJy"])
638
+ magnitudes[epoch["F"]]["lim5sig"].append(epoch["mag5sig"])
639
+
640
+ # STACK PHOTOMETRY IF REQUIRED
641
+ stacked_magnitudes = DataFinder._stack_photometry(magnitudes, binningdays=1)
642
+
643
+ return stacked_magnitudes
644
+
645
+ @staticmethod
646
+ def _atlas_read_and_sigma_clip_data(filecontent, log, clipping_sigma=2.2):
647
+ """
648
+ Function adapted from David Young's :func:`plotter.read_and_sigma_clip_data`
649
+ https://github.com/thespacedoctor/plot-results-from-atlas-force-photometry-service/blob/main/plot_atlas_fp.py
650
+
651
+ And again adapted from
652
+ https://github.com/SAGUARO-MMA/kne-cand-vetting/blob/master/kne_cand_vetting/survey_phot.py
653
+
654
+ *clean up rouge data from the files by performing some basic clipping*
655
+ **Key Arguments:**
656
+ - `fpFile` -- path to single force photometry file
657
+ - `clippingSigma` -- the level at which to clip flux data
658
+ **Return:**
659
+ - `epochs` -- sigma clipped and cleaned epoch data
660
+ """
661
+
662
+ # CLEAN UP FILE FOR EASIER READING
663
+ fpdata = (
664
+ filecontent.replace("###", "")
665
+ .replace(" ", ",")
666
+ .replace(",,", ",")
667
+ .replace(",,", ",")
668
+ .replace(",,", ",")
669
+ .replace(",,", ",")
670
+ .splitlines()
671
+ )
672
+
673
+ # PARSE DATA WITH SOME FIXED CLIPPING
674
+ oepochs = []
675
+ cepochs = []
676
+ csvreader = csv.DictReader(
677
+ fpdata, dialect="excel", delimiter=",", quotechar='"'
678
+ )
679
+
680
+ for row in csvreader:
681
+ for k, v in row.items():
682
+ try:
683
+ row[k] = float(v)
684
+ except Exception:
685
+ pass
686
+ # REMOVE VERY HIGH ERROR DATA POINTS, POOR CHI SQUARED, OR POOR EPOCHS
687
+ if row["duJy"] > 4000 or row["chi/N"] > 100 or row["mag5sig"] < 17.0:
688
+ continue
689
+ if row["F"] == "c":
690
+ cepochs.append(row)
691
+ if row["F"] == "o":
692
+ oepochs.append(row)
693
+
694
+ # SORT BY MJD
695
+ cepochs = sorted(cepochs, key=itemgetter("MJD"), reverse=False)
696
+ oepochs = sorted(oepochs, key=itemgetter("MJD"), reverse=False)
697
+
698
+ # SIGMA-CLIP THE DATA WITH A ROLLING WINDOW
699
+ cdataflux = []
700
+ cdataflux[:] = [row["uJy"] for row in cepochs]
701
+ odataflux = []
702
+ odataflux[:] = [row["uJy"] for row in oepochs]
703
+
704
+ masklist = []
705
+ for flux in [cdataflux, odataflux]:
706
+ fullmask = rolling_window_sigma_clip(
707
+ log=log, array=flux, clippingSigma=clipping_sigma, windowSize=11
708
+ )
709
+ masklist.append(fullmask)
710
+
711
+ try:
712
+ cepochs = [e for e, m in zip(cepochs, masklist[0]) if m == False]
713
+ except Exception:
714
+ cepochs = []
715
+
716
+ try:
717
+ oepochs = [e for e, m in zip(oepochs, masklist[1]) if m == False]
718
+ except Exception:
719
+ oepochs = []
720
+
721
+ logger.info("Completed the ``read_and_sigma_clip_data`` function")
722
+ # Returns ordered dictionary of all parameters
723
+ return cepochs + oepochs
724
+
725
+ @staticmethod
726
+ def _stack_photometry(magnitudes, binningdays=1.0):
727
+ """
728
+ Function adapted from David Young's :func:`plotter.stack_photometry`
729
+ https://github.com/thespacedoctor/plot-results-from-atlas-force-photometry-service/blob/main/plot_atlas_fp.py
730
+
731
+ And again adapted from
732
+ https://github.com/SAGUARO-MMA/kne-cand-vetting/blob/master/kne_cand_vetting/survey_phot.py
733
+
734
+ *stack the photometry for the given temporal range*
735
+ **Key Arguments:**
736
+ - `magnitudes` -- dictionary of photometry divided into filter sets
737
+ - `binningDays` -- the binning to use (in days)
738
+ **Return:**
739
+ - `summedMagnitudes` -- the stacked photometry
740
+ """
741
+
742
+ # IF WE WANT TO 'STACK' THE PHOTOMETRY
743
+ summed_magnitudes = {
744
+ "c": {"mjds": [], "mags": [], "magErrs": [], "n": [], "lim5sig": []},
745
+ "o": {"mjds": [], "mags": [], "magErrs": [], "n": [], "lim5sig": []},
746
+ "I": {"mjds": [], "mags": [], "magErrs": [], "n": [], "lim5sig": []},
747
+ }
748
+
749
+ # MAGNITUDES/FLUXES ARE DIVIDED IN UNIQUE FILTER SETS - SO ITERATE OVER
750
+ # FILTERS
751
+ alldata = []
752
+ for fil, data in list(magnitudes.items()):
753
+ # WE'RE GOING TO CREATE FURTHER SUBSETS FOR EACH UNQIUE MJD
754
+ # (FLOORED TO AN INTEGER)
755
+ # MAG VARIABLE == FLUX (JUST TO CONFUSE YOU)
756
+ distinctmjds = {}
757
+ for mjd, flx, err, lim in zip(
758
+ data["mjds"], data["mags"], data["magErrs"], data["lim5sig"]
759
+ ):
760
+ # DICT KEY IS THE UNIQUE INTEGER MJD
761
+ key = str(int(math.floor(mjd / float(binningdays))))
762
+ # FIRST DATA POINT OF THE NIGHTS? CREATE NEW DATA SET
763
+ if key not in distinctmjds:
764
+ distinctmjds[key] = {
765
+ "mjds": [mjd],
766
+ "mags": [flx],
767
+ "magErrs": [err],
768
+ "lim5sig": [lim],
769
+ }
770
+ # OR NOT THE FIRST? APPEND TO ALREADY CREATED LIST
771
+ else:
772
+ distinctmjds[key]["mjds"].append(mjd)
773
+ distinctmjds[key]["mags"].append(flx)
774
+ distinctmjds[key]["magErrs"].append(err)
775
+ distinctmjds[key]["lim5sig"].append(lim)
776
+
777
+ # ALL DATA NOW IN MJD SUBSETS. SO FOR EACH SUBSET (I.E. INDIVIDUAL
778
+ # NIGHTS) ...
779
+ for k, v in list(distinctmjds.items()):
780
+ # GIVE ME THE MEAN MJD
781
+ meanmjd = sum(v["mjds"]) / len(v["mjds"])
782
+ summed_magnitudes[fil]["mjds"].append(meanmjd)
783
+ # GIVE ME THE MEAN FLUX
784
+ meanflux = sum(v["mags"]) / len(v["mags"])
785
+ summed_magnitudes[fil]["mags"].append(meanflux)
786
+ # GIVE ME THE COMBINED ERROR
787
+ sum_of_squares = sum(x**2 for x in v["magErrs"])
788
+ comberror = math.sqrt(sum_of_squares) / len(v["magErrs"])
789
+ summed_magnitudes[fil]["magErrs"].append(comberror)
790
+ # 5-sigma limits
791
+ comb5siglimit = 23.9 - 2.5 * math.log10(5.0 * comberror)
792
+ summed_magnitudes[fil]["lim5sig"].append(comb5siglimit)
793
+ # GIVE ME NUMBER OF DATA POINTS COMBINED
794
+ n = len(v["mjds"])
795
+ summed_magnitudes[fil]["n"].append(n)
796
+ alldata.append(
797
+ {
798
+ "mjd": meanmjd,
799
+ "uJy": meanflux,
800
+ "duJy": comberror,
801
+ "F": fil,
802
+ "n": n,
803
+ "mag5sig": comb5siglimit,
804
+ }
805
+ )
806
+ print("completed the ``stack_photometry`` method")
807
+
808
+ return alldata
809
+
810
+ """
811
+ The following code was taken and modified for the purposes of this package from
812
+ https://github.com/HC-Hwang/wise_light_curves/blob/master/wise_light_curves.py
813
+
814
+ Original Authors:
815
+ - Matthew Hill
816
+ - Hsiang-Chih Hwang
817
+
818
+ Update Author:
819
+ - Noah Franz
820
+ """
821
+
822
+ @staticmethod
823
+ def _get_by_position(ra, dec, radius=2.5):
824
+ allwise_cat = "allwise_p3as_mep"
825
+ neowise_cat = "neowiser_p1bs_psd"
826
+ query_url = "http://irsa.ipac.caltech.edu/cgi-bin/Gator/nph-query"
827
+ payload = {
828
+ "catalog": allwise_cat,
829
+ "spatial": "cone",
830
+ "objstr": " ".join([str(ra), str(dec)]),
831
+ "radius": str(radius),
832
+ "radunits": "arcsec",
833
+ "outfmt": "1",
834
+ }
835
+ r = requests.get(query_url, params=payload)
836
+ allwise = ascii.read(r.text)
837
+ payload = {
838
+ "catalog": neowise_cat,
839
+ "spatial": "cone",
840
+ "objstr": " ".join([str(ra), str(dec)]),
841
+ "radius": str(radius),
842
+ "radunits": "arcsec",
843
+ "outfmt": "1",
844
+ "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
845
+ }
846
+ r = requests.get(query_url, params=payload)
847
+
848
+ neowise = ascii.read(r.text, guess=False, format="ipac")
849
+
850
+ return allwise, neowise
851
+
852
+ @staticmethod
853
+ def _download_single_data(
854
+ name, ra, dec, root_path="ipac/", radius=2.5, overwrite=False
855
+ ):
856
+ # ra, dec: in degree
857
+ # name, ra, dec = row['Name'], row['RAJ2000'], row['DEJ2000']
858
+ # name = 'J' + ra + dec
859
+ if root_path[-1] != "/":
860
+ root_path += "/"
861
+ if (
862
+ not overwrite
863
+ and os.path.isfile(root_path + name + "_allwise.ipac")
864
+ and os.path.isfile(root_path + name + "_neowise.ipac")
865
+ ):
866
+ pass
867
+ else:
868
+ allwise, neowise = DataFinder._get_by_position(ra, dec, radius=radius)
869
+ allwise.write(
870
+ root_path + name + "_allwise.ipac", format="ascii.ipac", overwrite=True
871
+ )
872
+ neowise.write(
873
+ root_path + name + "_neowise.ipac", format="ascii.ipac", overwrite=True
874
+ )
875
+
876
+ @staticmethod
877
+ def _get_data_arrays(table, t, mag, magerr):
878
+ """Get the time series from a potentially masked astropy table"""
879
+ if table.masked:
880
+ full_mask = table[t].mask | table[mag].mask | table[magerr].mask
881
+ t = table[t].data
882
+ mag = table[mag].data
883
+ magerr = table[magerr].data
884
+
885
+ t.mask = full_mask
886
+ mag.mask = full_mask
887
+ magerr.mask = full_mask
888
+
889
+ return t.compressed(), mag.compressed(), magerr.compressed()
890
+
891
+ else:
892
+ return table[t].data, table[mag].data, table[magerr].data
893
+
894
+ @staticmethod
895
+ def _make_full_lightcurve(allwise, neowise, band):
896
+ """band = 'w1', 'w2', 'w3', or 'w4'"""
897
+ """Get a combined AllWISE and NEOWISE lightcurve from their Astropy tables"""
898
+
899
+ if band not in ["w1", "w2", "w3", "w4"]:
900
+ raise ValueError("band can only be w1, w2, w3, or w4")
901
+
902
+ use_neowise = band in {"w1", "w2"}
903
+ use_allwise = allwise is not None
904
+
905
+ if use_neowise and use_allwise:
906
+ t, m, e = DataFinder._get_data_arrays(
907
+ allwise, "mjd", band + "mpro_ep", band + "sigmpro_ep"
908
+ )
909
+ t_n, m_n, e_n = DataFinder._get_data_arrays(
910
+ neowise, "mjd", band + "mpro", band + "sigmpro"
911
+ )
912
+ t, m, e = (
913
+ np.concatenate((t, t_n)),
914
+ np.concatenate((m, m_n)),
915
+ np.concatenate((e, e_n)),
916
+ )
917
+
918
+ elif use_neowise and not use_allwise:
919
+ t, m, e = DataFinder._get_data_arrays(
920
+ neowise, "mjd", band + "mpro", band + "sigmpro"
921
+ )
922
+
923
+ elif not use_neowise and use_allwise:
924
+ t, m, e = DataFinder._get_data_arrays(
925
+ allwise, "mjd", band + "mpro_ep", band + "sigmpro_ep"
926
+ )
927
+
928
+ else:
929
+ raise Exception("No good allwise or neowise data!")
930
+
931
+ t_index = t.argsort()
932
+ t, m, e = map(lambda e: e[t_index], [t, m, e])
933
+
934
+ return t, m, e
935
+
936
+ @staticmethod
937
+ def _make_full_lightcurve_multibands(allwise, neowise, bands=["w1", "w2"]):
938
+ t, m, e = DataFinder._make_full_lightcurve(allwise, neowise, bands[0])
939
+ filts = [bands[0] for i in range(len(t))]
940
+ for band in bands[1:]:
941
+ try:
942
+ t_tmp, m_tmp, e_tmp = DataFinder._make_full_lightcurve(
943
+ allwise, neowise, band
944
+ )
945
+ except Exception:
946
+ continue
947
+ t = np.concatenate((t, t_tmp))
948
+ m = np.concatenate((m, m_tmp))
949
+ e = np.concatenate((e, e_tmp))
950
+ filts += [band for i in range(len(t_tmp))]
951
+ return t, m, e, np.array(filts)
952
+
953
+ @staticmethod
954
+ def _cntr_to_source_id(cntr):
955
+ cntr = str(cntr)
956
+
957
+ # fill leanding 0s
958
+ if len(cntr) < 19:
959
+ num_leading_zeros = 19 - len(cntr)
960
+ cntr = "0" * num_leading_zeros + cntr
961
+
962
+ pm = "p"
963
+ if cntr[4] == "0":
964
+ pm = "m"
965
+
966
+ t = chr(96 + int(cntr[8:10]))
967
+
968
+ return "%s%s%s_%cc%s-%s" % (
969
+ cntr[0:4],
970
+ pm,
971
+ cntr[5:8],
972
+ t,
973
+ cntr[11:13],
974
+ cntr[13:19],
975
+ )
976
+
977
+ @staticmethod
978
+ def _only_good_data(allwise, neowise, verbose=False):
979
+ """
980
+ Select good-quality data. The criteria include:
981
+ - matching the all-wise ID
982
+
983
+ To be done:
984
+ - deal with multiple cntr
985
+
986
+ This filtering is described here:
987
+ https://wise2.ipac.caltech.edu/docs/release/neowise/expsup/sec2_3.html
988
+ """
989
+
990
+ neowise_prefilter_n = len(neowise)
991
+ neowise = neowise[
992
+ (neowise["qual_frame"] > 0.0)
993
+ * (neowise["qi_fact"] > 0.9)
994
+ * (neowise["saa_sep"] > 0)
995
+ * (neowise["moon_masked"] == "00")
996
+ ]
997
+ neowise_postfilter_n = len(neowise)
998
+ if verbose:
999
+ print(
1000
+ f"Filtered out {neowise_prefilter_n-neowise_postfilter_n} neowise \
1001
+ points, leaving {neowise_postfilter_n}"
1002
+ )
1003
+
1004
+ cntr_list = []
1005
+ for data in neowise:
1006
+ if data["allwise_cntr"] not in cntr_list and data["allwise_cntr"] > 10.0:
1007
+ cntr_list.append(data["allwise_cntr"])
1008
+
1009
+ if len(cntr_list) >= 2:
1010
+ print("multiple cntr:")
1011
+ print(cntr_list)
1012
+ return None, neowise
1013
+
1014
+ if len(cntr_list) == 0:
1015
+ # import pdb; pdb.set_trace()
1016
+ # raise Exception('No center!')
1017
+ return None, neowise
1018
+
1019
+ cntr = cntr_list[0]
1020
+
1021
+ source_id = DataFinder._cntr_to_source_id(cntr)
1022
+
1023
+ allwise_prefilter_n = len(allwise)
1024
+ allwise = allwise[
1025
+ (allwise["source_id_mf"] == source_id)
1026
+ * (allwise["saa_sep"] > 0.0)
1027
+ * (allwise["moon_masked"] == "0000")
1028
+ * (allwise["qi_fact"] > 0.9)
1029
+ ]
1030
+ allwise_postfilter_n = len(neowise)
1031
+ if verbose:
1032
+ print(
1033
+ f"Filtered out {allwise_prefilter_n-allwise_postfilter_n} allwise \
1034
+ points, leaving {allwise_postfilter_n}"
1035
+ )
1036
+
1037
+ return allwise, neowise