astro-otter 0.0.1__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,15 @@
1
+ otter/__init__.py,sha256=llO9sEPh8xa_dH2W0nlp6eaINvOFmxdThUMfnRLvTlg,339
2
+ otter/_version.py,sha256=IF3Dl1lgaRSZdnbDGaVgT-Wp8FNX0rjz37C1iwLyXlU,76
3
+ otter/exceptions.py,sha256=jaNg0fw5WBWlIRLG0aE5xKJvpIg8JAEDqQ7orwkStq4,494
4
+ otter/util.py,sha256=rnzyNWwjrBLsZIbKi33o1Ly-HbTeAMaUT5E8N313RiY,13600
5
+ otter/io/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
6
+ otter/io/otter.py,sha256=S1DaOFg3DtPPyNzrvOg7_QG8fps_6m-w-usINJ6X2gk,17038
7
+ otter/io/transient.py,sha256=vMyaYjlc2SKn9gWII8wrpJRe3NyZViXGMiZ9Pv_voNo,31165
8
+ otter/plotter/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ otter/plotter/otter_plotter.py,sha256=WbnzNUR_pE3d2tJcH3K2pxJvl-r6OUUOZ0kWb35FlQE,2083
10
+ otter/plotter/plotter.py,sha256=QeAQXgeJ9O429Khin9dCT2tXvcjxUzc_HLU74LbRw6g,2861
11
+ astro_otter-0.0.1.dist-info/LICENSE,sha256=s9IPE8A3CAMEaZpDhj4eaorpmfLYGB0mIGphq301PUY,1067
12
+ astro_otter-0.0.1.dist-info/METADATA,sha256=KM1FpQrYPArikJMNzmNCYshN6PzJGrYCp4UCTkM3TOw,31847
13
+ astro_otter-0.0.1.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
14
+ astro_otter-0.0.1.dist-info/top_level.txt,sha256=Wth72sCwBRUk3KZGknSKvLQDMFuJk6qiaAavMDOdG5k,6
15
+ astro_otter-0.0.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: bdist_wheel (0.43.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ otter
otter/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ # get the version
2
+ from ._version import __version__
3
+
4
+
5
+ # explicitly set the package variable to ensure relative import work
6
+ __package__ = "otter"
7
+
8
+ # import important stuff
9
+ from .io.otter import Otter
10
+ from .io.transient import Transient
11
+ from .plotter.otter_plotter import OtterPlotter
12
+ from .plotter.plotter import plot_light_curve, plot_sed
otter/_version.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Just define the package version in one place
3
+ """
4
+
5
+ __version__ = "0.0.1"
otter/exceptions.py ADDED
@@ -0,0 +1,26 @@
1
+ """
2
+ Custom exceptions for otter
3
+ """
4
+
5
+
6
+ class FailedQueryError(ValueError):
7
+ def __str__(self):
8
+ txt = "You're query/search did not return any results! "
9
+ txt += "Try again with different parameters!"
10
+ return txt
11
+
12
+
13
+ class IOError(ValueError):
14
+ pass
15
+
16
+
17
+ class OtterLimitationError(Exception):
18
+ def __init__(self, msg):
19
+ self.msg = "Current Limitation Found: " + msg
20
+
21
+ def __str__(self):
22
+ return self.msg
23
+
24
+
25
+ class TransientMergeError(Exception):
26
+ pass
otter/io/__init__.py ADDED
File without changes
otter/io/otter.py ADDED
@@ -0,0 +1,474 @@
1
+ """
2
+ This is the primary class for user interaction with the catalog
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import glob
8
+ from warnings import warn
9
+
10
+ import pandas as pd
11
+
12
+ from astropy.coordinates import SkyCoord, search_around_sky
13
+ from astropy.table import Table
14
+ from astropy import units as u
15
+
16
+ from .transient import Transient
17
+ from ..exceptions import FailedQueryError, OtterLimitationError
18
+
19
+ import warnings
20
+
21
+ warnings.simplefilter("once", RuntimeWarning)
22
+ warnings.simplefilter("once", UserWarning)
23
+ warnings.simplefilter("once", u.UnitsWarning)
24
+
25
+
26
+ class Otter(object):
27
+ """
28
+ This is the primary class for users to access the otter backend database
29
+
30
+ Args:
31
+ username [str]: Your connection username to the database, default is the user
32
+ login which only has read permission.
33
+ password [str]: Your password corresponding to your username.
34
+ db [str]: The database name to connect to. This is default to 'otter' which is
35
+ the only database so far.
36
+ collection [str]: The collection to read data from. Right now the only
37
+ collection is 'tdes'.
38
+ debug [bool]: debug mode, set to true to limit reading from database.
39
+ """
40
+
41
+ def __init__(self, datadir: str = None, debug: bool = False) -> None:
42
+ # save inputs
43
+ if datadir is None:
44
+ self.CWD = os.path.dirname(os.path.abspath("__FILE__"))
45
+ self.DATADIR = os.path.join(self.CWD, ".otter")
46
+ else:
47
+ self.CWD = os.path.dirname(datadir)
48
+ self.DATADIR = datadir
49
+
50
+ self.debug = debug
51
+
52
+ # make sure the data directory exists
53
+ if not os.path.exists(self.DATADIR):
54
+ try:
55
+ os.makedirs(self.DATADIR)
56
+ except FileExistsError:
57
+ warn(
58
+ "Directory was created between the if statement and trying "
59
+ + "to create the directory!"
60
+ )
61
+ pass
62
+
63
+ def get_meta(self, **kwargs) -> Table:
64
+ """
65
+ Get the metadata of the objects matching the arguments
66
+
67
+ Args:
68
+ **kwargs : Arguments to pass to Otter.query()
69
+ Return:
70
+ The metadata for the transients that match the arguments. Will be an astropy
71
+ Table by default, if raw=True will be a dictionary.
72
+ """
73
+ metakeys = [
74
+ "name",
75
+ "coordinate",
76
+ "date_reference",
77
+ "distance",
78
+ "classification",
79
+ ]
80
+
81
+ return [t[metakeys] for t in self.query(**kwargs)]
82
+
83
+ def cone_search(
84
+ self, coords: SkyCoord, radius: float = 5, raw: bool = False
85
+ ) -> Table:
86
+ """
87
+ Performs a cone search of the catalog over the given coords and radius.
88
+
89
+ Args:
90
+ coords [SkyCoord]: An astropy SkyCoord object with coordinates to match to
91
+ radius [float]: The radius of the cone in arcseconds, default is 0.05"
92
+ raw [bool]: If False (the default) return an astropy table of the metadata
93
+ for matching objects. Otherwise, return the raw json dicts
94
+
95
+ Return:
96
+ The metadata for the transients in coords+radius. Will return an astropy
97
+ Table if raw is False, otherwise a dict.
98
+ """
99
+
100
+ transients = self.query(coords=coords, radius=radius, raw=raw)
101
+
102
+ return transients
103
+
104
+ def get_phot(
105
+ self,
106
+ flux_unit="mag(AB)",
107
+ date_unit="MJD",
108
+ return_type="astropy",
109
+ obs_type=None,
110
+ keep_raw=False,
111
+ wave_unit="nm",
112
+ freq_unit="GHz",
113
+ **kwargs,
114
+ ) -> Table:
115
+ """
116
+ Get the photometry of the objects matching the arguments. This will do the
117
+ unit conversion for you!
118
+
119
+ Args:
120
+ flux_units [astropy.unit.Unit]: Either a valid string to convert
121
+ or an astropy.unit.Unit
122
+ date_units [astropy.unit.Unit]: Either a valid string to convert to a date
123
+ or an astropy.unit.Unit
124
+ return_type [str]: Either 'astropy' or 'pandas'. If astropy, returns an
125
+ astropy Table. If pandas, returns a pandas DataFrame.
126
+ Default is 'astropy'.
127
+ obs_type [str]: Either 'radio', 'uvoir', or 'xray'. Will only return that
128
+ type of photometry if not None. Default is None and will
129
+ return any type of photometry.
130
+ keep_raw [bool]: If True, keep the raw flux/date/freq/wave associated with
131
+ the dataset. Else, just keep the converted data. Default
132
+ is False.
133
+ **kwargs : Arguments to pass to Otter.query(). Can be:
134
+ names [list[str]]: A list of names to get the metadata for
135
+ coords [SkyCoord]: An astropy SkyCoord object with coordinates
136
+ to match to
137
+ radius [float]: The radius in arcseconds for a cone search,
138
+ default is 0.05"
139
+ minZ [float]: The minimum redshift to search for
140
+ maxZ [float]: The maximum redshift to search for
141
+ refs [list[str]]: A list of ads bibcodes to match to. Will only
142
+ return metadata for transients that have this
143
+ as a reference.
144
+ hasSpec [bool]: if True, only return events that have spectra.
145
+
146
+ Return:
147
+ The photometry for the requested transients that match the arguments.
148
+ Will be an astropy Table sorted by transient default name.
149
+ """
150
+ queryres = self.query(hasphot=True, **kwargs)
151
+
152
+ dicts = []
153
+ for transient in queryres:
154
+ # clean the photometry
155
+ default_name = transient["name/default_name"]
156
+ phot = transient.clean_photometry(
157
+ flux_unit=flux_unit,
158
+ date_unit=date_unit,
159
+ wave_unit=wave_unit,
160
+ freq_unit=freq_unit,
161
+ obs_type=obs_type,
162
+ )
163
+ phot["name"] = [default_name] * len(phot)
164
+
165
+ dicts.append(phot)
166
+
167
+ if len(dicts) == 0:
168
+ raise FailedQueryError()
169
+ fullphot = pd.concat(dicts)
170
+
171
+ # remove some possibly confusing keys
172
+ keys_to_keep = [
173
+ "name",
174
+ "converted_flux",
175
+ "converted_flux_err",
176
+ "converted_date",
177
+ "converted_wave",
178
+ "converted_freq",
179
+ "converted_flux_unit",
180
+ "converted_date_unit",
181
+ "converted_wave_unit",
182
+ "converted_freq_unit",
183
+ "obs_type",
184
+ "upperlimit",
185
+ "reference",
186
+ ]
187
+
188
+ if not keep_raw:
189
+ if "telescope" in fullphot:
190
+ fullphot = fullphot[keys_to_keep + ["telescope"]]
191
+ else:
192
+ fullphot = fullphot[keys_to_keep]
193
+
194
+ if return_type == "astropy":
195
+ return Table.from_pandas(fullphot)
196
+ elif return_type == "pandas":
197
+ return fullphot
198
+ else:
199
+ raise IOError("return_type can only be pandas or astropy")
200
+
201
+ def load_file(self, filename: str) -> dict:
202
+ """
203
+ Loads an otter JSON file
204
+
205
+ Args:
206
+ filename [str]: The path to the file to load
207
+ """
208
+
209
+ # read in files from summary
210
+ with open(filename, "r") as f:
211
+ to_ret = Transient(json.load(f))
212
+
213
+ return to_ret
214
+
215
+ def query(
216
+ self,
217
+ names: list[str] = None,
218
+ coords: SkyCoord = None,
219
+ radius: float = 5,
220
+ minz: float = None,
221
+ maxz: float = None,
222
+ refs: list[str] = None,
223
+ hasphot: bool = False,
224
+ hasspec: bool = False,
225
+ raw: bool = False,
226
+ ) -> dict:
227
+ """
228
+ Searches the summary.csv table and reads relevant JSON files
229
+
230
+ WARNING! This does not do any conversions for you!
231
+ This is how it differs from the `get_meta` method. Users should prefer to use
232
+ `get_meta`, `getPhot`, and `getSpec` independently because it is a better
233
+ workflow and can return the data in an astropy table with everything in the
234
+ same units.
235
+
236
+ Args:
237
+ names [list[str]]: A list of names to get the metadata for
238
+ coords [SkyCoord]: An astropy SkyCoord object with coordinates to match to
239
+ radius [float]: The radius in arcseconds for a cone search, default is 0.05"
240
+ minz [float]: The minimum redshift to search for
241
+ maxz [float]: The maximum redshift to search for
242
+ refs [list[str]]: A list of ads bibcodes to match to. Will only return
243
+ metadata for transients that have this as a reference.
244
+ hasphot [bool]: if True, only returns transients which have photometry.
245
+ hasspec [bool]: if True, only return transients that have spectra.
246
+
247
+ Return:
248
+ Get all of the raw (unconverted!) data for objects that match the criteria.
249
+ """
250
+ if (
251
+ all(arg is None for arg in [names, coords, maxz, minz, refs])
252
+ and not hasphot
253
+ and not hasspec
254
+ ):
255
+ # there's nothing to query!
256
+ # read in the metdata from all json files
257
+ # this could be dangerous later on!!
258
+ allfiles = glob.glob(os.path.join(self.DATADIR, "*.json"))
259
+ jsondata = []
260
+
261
+ # read the data from all the json files and convert to Transients
262
+ for jsonfile in allfiles:
263
+ with open(jsonfile, "r") as j:
264
+ t = Transient(json.load(j))
265
+ jsondata.append(t.get_meta())
266
+
267
+ return jsondata
268
+
269
+ # check if the summary table exists, if it doen't create it
270
+ summary_table = os.path.join(self.DATADIR, "summary.csv")
271
+ if not os.path.exists(summary_table):
272
+ self.generate_summary_table(save=True)
273
+
274
+ # then read and query the summary table
275
+ summary = pd.read_csv(summary_table)
276
+
277
+ # coordinate search first
278
+ if coords is not None:
279
+ if not isinstance(coords, SkyCoord):
280
+ raise ValueError("Input coordinate must be an astropy SkyCoord!")
281
+ summary_coords = SkyCoord(
282
+ summary.ra.tolist(), summary.dec.tolist(), unit=(u.deg, u.deg)
283
+ )
284
+
285
+ try:
286
+ summary_idx, _, _, _ = search_around_sky(
287
+ summary_coords, coords, seplimit=radius * u.arcsec
288
+ )
289
+ except ValueError:
290
+ summary_idx, _, _, _ = search_around_sky(
291
+ summary_coords,
292
+ SkyCoord([coords]),
293
+ seplimit=radius * u.arcsec,
294
+ )
295
+
296
+ summary = summary.iloc[summary_idx]
297
+
298
+ # redshift
299
+ if minz is not None:
300
+ summary = summary[summary.z.astype(float) >= minz]
301
+
302
+ if maxz is not None:
303
+ summary = summary[summary.z.astype(float) <= maxz]
304
+
305
+ # check photometry and spectra
306
+ if hasphot:
307
+ summary = summary[summary.hasPhot == True]
308
+
309
+ if hasspec:
310
+ summary = summary[summary.hasSpec == True]
311
+
312
+ # check names
313
+ if names is not None:
314
+ if isinstance(names, str):
315
+ n = {names}
316
+ else:
317
+ n = set(names)
318
+
319
+ checknames = []
320
+ for alias_row in summary.alias:
321
+ rs = set(eval(alias_row))
322
+ intersection = list(n & rs)
323
+ checknames.append(len(intersection) > 0)
324
+
325
+ summary = summary[checknames]
326
+
327
+ # check references
328
+ if refs is not None:
329
+ checkrefs = []
330
+
331
+ if isinstance(refs, str):
332
+ n = {refs}
333
+ else:
334
+ n = set(refs)
335
+
336
+ for ref_row in summary.refs:
337
+ rs = set(eval(ref_row))
338
+ intersection = list(n & rs)
339
+ checkrefs.append(len(intersection) > 0)
340
+
341
+ summary = summary[checkrefs]
342
+
343
+ outdata = [self.load_file(path) for path in summary.json_path]
344
+
345
+ return outdata
346
+
347
+ def save(self, schema: list[dict], testing=False, **kwargs) -> None:
348
+ """
349
+ Upload all the data in the given list of schemas.
350
+
351
+ Args:
352
+ schema [list[dict]]: A list of json dictionaries
353
+ """
354
+
355
+ if not isinstance(schema, list):
356
+ schema = [schema]
357
+
358
+ for transient in schema:
359
+ print(transient["name/default_name"])
360
+
361
+ # convert the json to a Transient
362
+ if not isinstance(transient, Transient):
363
+ transient = Transient(transient)
364
+
365
+ coord = transient.get_skycoord()
366
+ res = self.cone_search(coords=coord)
367
+
368
+ if len(res) == 0:
369
+ # This is a new object to upload
370
+ print("Adding this as a new object...")
371
+ self._save_document(dict(transient), test_mode=testing)
372
+
373
+ else:
374
+ # We must merge this with existing data
375
+ print("Found this object in the database already, merging the data...")
376
+ if len(res) == 1:
377
+ # we can just add these to merge them!
378
+ combined = res[0] + transient
379
+ self._save_document(combined, test_mode=testing)
380
+ else:
381
+ # for now throw an error
382
+ # this is a limitation we can come back to fix if it is causing
383
+ # problems though!
384
+ raise OtterLimitationError("Some objects in Otter are too close!")
385
+
386
+ # update the summary table appropriately
387
+ self.generate_summary_table(save=True)
388
+
389
+ def _save_document(self, schema, test_mode=False):
390
+ """
391
+ Save a json file in the correct format to the OTTER data directory
392
+ """
393
+ # check if this documents key is in the database already
394
+ # and if so remove it!
395
+ jsonpath = os.path.join(self.DATADIR, "*.json")
396
+ aliases = {item["value"].replace(" ", "-") for item in schema["name"]["alias"]}
397
+ filenames = {
398
+ os.path.basename(fname).split(".")[0] for fname in glob.glob(jsonpath)
399
+ }
400
+ todel = list(aliases & filenames)
401
+
402
+ # now save this data
403
+ # create a new file in self.DATADIR with this
404
+ if len(todel) > 0:
405
+ outfilepath = os.path.join(self.DATADIR, todel[0] + ".json")
406
+ if test_mode:
407
+ print("Renaming the following file for backups: ", outfilepath)
408
+ else:
409
+ os.rename(outfilepath, outfilepath + ".backup")
410
+ else:
411
+ if test_mode:
412
+ print("Don't need to mess with the files at all!")
413
+ fname = schema["name"]["default_name"] + ".json"
414
+ fname = fname.replace(" ", "-") # replace spaces in the filename
415
+ outfilepath = os.path.join(self.DATADIR, fname)
416
+
417
+ # format as a json
418
+ if isinstance(schema, Transient):
419
+ schema = dict(schema)
420
+
421
+ out = json.dumps(schema, indent=4)
422
+ # out = '[' + out
423
+ # out += ']'
424
+
425
+ if not test_mode:
426
+ with open(outfilepath, "w") as f:
427
+ f.write(out)
428
+ else:
429
+ print(f"Would write to {outfilepath}")
430
+ # print(out)
431
+
432
+ def generate_summary_table(self, save=False):
433
+ """
434
+ Generate a summary table for the JSON files in self.DATADIR
435
+
436
+ args:
437
+ save [bool]: if True, save the summary file to "summary.csv"
438
+ in self.DATADIR. Default is False.
439
+ """
440
+ allfiles = glob.glob(os.path.join(self.DATADIR, "*.json"))
441
+
442
+ # read the data from all the json files and convert to Transients
443
+ rows = []
444
+ for jsonfile in allfiles:
445
+ with open(jsonfile, "r") as j:
446
+ t = Transient(json.load(j))
447
+ skycoord = t.get_skycoord()
448
+
449
+ row = {
450
+ "name": t.default_name,
451
+ "alias": [alias["value"] for alias in t["name"]["alias"]],
452
+ "ra": skycoord.ra,
453
+ "dec": skycoord.dec,
454
+ "refs": [ref["name"] for ref in t["reference_alias"]],
455
+ }
456
+
457
+ if "date_reference" in t:
458
+ row["discovery_date"] = t.get_discovery_date()
459
+
460
+ if "distance" in t:
461
+ row["z"] = t.get_redshift()
462
+
463
+ row["hasPhot"] = "photometry" in t
464
+ row["hasSpec"] = "spectra" in t
465
+
466
+ row["json_path"] = os.path.abspath(jsonfile)
467
+
468
+ rows.append(row)
469
+
470
+ alljsons = pd.DataFrame(rows)
471
+ if save:
472
+ alljsons.to_csv(os.path.join(self.DATADIR, "summary.csv"))
473
+
474
+ return alljsons