/
lightcurve.py
2988 lines (2643 loc) · 117 KB
/
lightcurve.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"""Defines LightCurve, KeplerLightCurve, and TessLightCurve."""
import os
import datetime
import logging
import warnings
import collections
import numpy as np
from scipy.signal import savgol_filter
from scipy.interpolate import interp1d
import matplotlib
from matplotlib import pyplot as plt
from copy import deepcopy
from astropy.table import Table, Column, MaskedColumn
from astropy.io import fits
from astropy.time import Time, TimeDelta
from astropy import units as u
from astropy.units import Quantity
from astropy.timeseries import TimeSeries, aggregate_downsample
from astropy.table import vstack
from astropy.utils.decorators import deprecated, deprecated_renamed_argument
from astropy.utils.exceptions import AstropyUserWarning
from . import PACKAGEDIR, MPLSTYLE
from .utils import (
running_mean,
bkjd_to_astropy_time,
btjd_to_astropy_time,
validate_method,
_query_solar_system_objects,
)
from .utils import LightkurveWarning, LightkurveDeprecationWarning
__all__ = ["LightCurve", "KeplerLightCurve", "TessLightCurve", "FoldedLightCurve"]
log = logging.getLogger(__name__)
class QColumn(Column):
"""(Temporary) workaround to provide ``.value`` alias to raw data, so as to match ``Quantity``."""
@property
def value(self):
return self.data
class QMaskedColumn(MaskedColumn):
"""(Temporary) workaround to provide ``.value`` alias to raw data, so as to match ``Quantity``."""
@property
def value(self):
return self.data
class QTimeSeries(TimeSeries):
def _convert_col_for_table(self, col):
"""Ensure resulting column has ``.value`` accessor to raw data, irrespective of type of input.
It won't be needed once https://github.com/astropy/astropy/pull/10962 is in astropy release
and Lightkurve requires the correspond astropy release.
"""
col = super()._convert_col_for_table(col)
if (
isinstance(col, Column)
and getattr(col, "unit", None) is None
and (not hasattr(col, "value"))
):
# the logic is similar to those in the grandparent QTable for Quantity
if isinstance(col, MaskedColumn):
qcol = QMaskedColumn(
data=col.data,
name=col.name,
dtype=col.dtype,
description=col.description,
mask=col.mask,
fill_value=col.fill_value,
format=col.format,
meta=col.meta,
copy=False,
)
else:
qcol = QColumn(
data=col.data,
name=col.name,
dtype=col.dtype,
description=col.description,
format=col.format,
meta=col.meta,
copy=False,
)
qcol.info = col.info
qcol.info.indices = col.info.indices
col = qcol
return col
class LightCurve(QTimeSeries):
"""Subclass of AstroPy `~astropy.table.Table` guaranteed to have *time*, *flux*, and *flux_err* columns.
Compared to the generic `~astropy.timeseries.TimeSeries` class, `LightCurve`
ensures that each object has `time`, `flux`, and `flux_err` columns.
`LightCurve` objects also provide user-friendly attribute access to
columns and meta data.
Parameters
----------
data : numpy ndarray, dict, list, `~astropy.table.Table`, or table-like object, optional
Data to initialize time series. This does not need to contain the times
or fluxes, which can be provided separately, but if it does contain the
times and fluxes they should be in columns called ``'time'``,
``'flux'``, and ``'flux_err'`` to be automatically recognized.
time : `~astropy.time.Time` or iterable
Time values. They can either be given directly as a
`~astropy.time.Time` array or as any iterable that initializes the
`~astropy.time.Time` class.
flux : `~astropy.units.Quantity` or iterable
Flux values for every time point.
flux_err : `~astropy.units.Quantity` or iterable
Uncertainty on each flux data point.
**kwargs : dict
Additional keyword arguments are passed to `~astropy.table.QTable`.
Attributes
----------
meta : `dict`
meta data associated with the lightcurve. The header of the underlying FITS file (if applicable)
is store in this dictionary. By convention, keys in this dictionary are usually in uppercase.
Notes
-----
*Attribute access*: You can access a column or a ``meta`` value directly as an attribute.
>>> lc.flux # shortcut for lc['flux'] # doctest: +SKIP
>>> lc.sector # shortcut for lc.meta['SECTOR'] # doctest: +SKIP
>>> lc.flux = lc.flux * 1.05 # update the values of a column. # doctest: +SKIP
In case the given name is both a column name and a key in ``meta``, the column will be returned.
Note that you *cannot* create a new column using the attribute interface. If you do so,
a new attribute is created instead, and a warning is raised.
If you do create such attributes on purpose, please note that the attributes are not carried
over when the lightcurve object is copied, or a new lightcurve object is derived
based on a copy, e.g., ``normalize()``.
Examples
--------
>>> import lightkurve as lk
>>> lc = lk.LightCurve(time=[1, 2, 3, 4], flux=[0.98, 1.02, 1.03, 0.97])
>>> lc.time
<Time object: scale='tdb' format='jd' value=[1. 2. 3. 4.]>
>>> lc.flux
<Quantity [0.98, 1.02, 1.03, 0.97]>
>>> lc.bin(time_bin_size=2, time_bin_start=0.5).flux
<Quantity [1., 1.]>
"""
# The constructor of the `TimeSeries` base class will enforce the presence
# of these columns:
_required_columns = ["time", "flux", "flux_err"]
# The following keywords were removed in Lightkurve v2.0.
# Their use will trigger a warning.
_deprecated_keywords = (
"targetid",
"label",
"time_format",
"time_scale",
"flux_unit",
)
_deprecated_column_keywords = [
"centroid_col",
"centroid_row",
"cadenceno",
"quality",
]
# If an iterable is passed for ``time``, we will initialize an AstroPy
# ``Time`` object using the following format and scale:
_default_time_format = "jd"
_default_time_scale = "tdb"
# To emulate pandas, we do not support creating new columns or meta data
# fields via attribute assignment, and raise a warning in __setattr__ when
# a new attribute is created. We need to relax this warning during the
# initial construction of the object using `_new_attributes_relax`.
_new_attributes_relax = True
def __init__(self, data=None, *args, time=None, flux=None, flux_err=None, **kwargs):
# Delay checking for required columns until the end
self._required_columns_relax = True
# Lightkurve v1.x supported passing time, flux, and flux_err as
# positional arguments. We support it here for backwards compatibility.
if len(args) in [1, 2]:
warnings.warn(
"passing flux as a positional argument is deprecated"
", please use ``flux=...`` instead.",
LightkurveDeprecationWarning,
)
time = data
flux = args[0]
data = None
if len(args) == 2:
flux_err = args[1]
# For backwards compatibility with Lightkurve v1.x,
# we support passing deprecated keywords via **kwargs.
deprecated_kws = {}
for kw in self._deprecated_keywords:
if kw in kwargs:
deprecated_kws[kw] = kwargs.pop(kw)
deprecated_column_kws = {}
for kw in self._deprecated_column_keywords:
if kw in kwargs:
deprecated_column_kws[kw] = kwargs.pop(kw)
# If `time` is passed as keyword argument, we populate it with integer numbers
if data is None or "time" not in data.keys():
if time is None and flux is not None:
time = np.arange(len(flux))
# We are tolerant of missing time format
if time is not None and not isinstance(time, (Time, TimeDelta)):
# Lightkurve v1.x supported specifying the time_format
# as a constructor kwarg
time = Time(
time,
format=deprecated_kws.get("time_format", self._default_time_format),
scale=deprecated_kws.get("time_scale", self._default_time_scale),
)
# Also be tolerant of missing time format if time is passed via `data`
if data and "time" in data.keys():
if not isinstance(data["time"], (Time, TimeDelta)):
data["time"] = Time(
data["time"],
format=deprecated_kws.get("time_format", self._default_time_format),
scale=deprecated_kws.get("time_scale", self._default_time_scale),
)
# Allow overriding the required columns
self._required_columns = kwargs.pop("_required_columns", self._required_columns)
# Call the SampledTimeSeries constructor.
# Disable required columns for now; we'll check those later.
tmp = self._required_columns
self._required_columns = []
super().__init__(data=data, time=time, **kwargs)
self._required_columns = tmp
# For some operations, an empty time series needs to be created, then
# columns added one by one. We should check that when columns are added
# manually, time is added first and is of the right type.
if data is None and time is None and flux is None and flux_err is None:
self._required_columns_relax = True
return
# Load `time`, `flux`, and `flux_err` from the table as local variable names
time = self.columns["time"] # super().__init__() guarantees this is a column
if "flux" in self.colnames:
if flux is None:
flux = self.columns["flux"]
else:
raise TypeError(
f"'flux' has been given both in the `data` table and as a keyword argument"
)
if "flux_err" in self.colnames:
if flux_err is None:
flux_err = self.columns["flux_err"]
else:
raise TypeError(
f"'flux_err' has been given both in the `data` table and as a keyword argument"
)
# Ensure `flux` and `flux_err` are populated with NaNs if missing
if flux is None and time is not None:
flux = np.empty(len(time))
flux[:] = np.nan
if not isinstance(flux, Quantity):
flux = Quantity(flux, deprecated_kws.get("flux_unit"))
if flux_err is None:
flux_err = np.empty(len(flux))
flux_err[:] = np.nan
if not isinstance(flux_err, Quantity):
flux_err = Quantity(flux_err, flux.unit)
# Backwards compatibility with Lightkurve v1.x
# Ensure attributes are set if passed via deprecated kwargs
for kw in deprecated_kws:
if kw not in self.meta:
self.meta[kw.upper()] = deprecated_kws[kw]
# Ensure all required columns are in the right order
with self._delay_required_column_checks():
for idx, col in enumerate(self._required_columns):
if col in self.colnames:
self.remove_column(col)
self.add_column(locals()[col], index=idx, name=col)
# Ensure columns are set if passed via deprecated kwargs
for kw in deprecated_column_kws:
if kw not in self.meta and kw not in self.columns:
self.add_column(deprecated_column_kws[kw], name=kw)
# Ensure flux and flux_err have the same units
if self["flux"].unit != self["flux"].unit:
raise ValueError("flux and flux_err must have the same units")
self._new_attributes_relax = False
self._required_columns_relax = False
self._check_required_columns()
def __getattr__(self, name, **kwargs):
"""Expose all columns and meta keywords as attributes."""
if name in self.__dict__:
return self.__dict__[name]
elif name in self.__class__.__dict__:
return self.__class__.__dict__[name].__get__(self)
elif name in self.columns:
return self[name]
elif "_meta" in self.__dict__:
if name in self.__dict__["_meta"]:
return self.__dict__["_meta"][name]
elif name.upper() in self.__dict__["_meta"]:
return self.__dict__["_meta"][name.upper()]
raise AttributeError(f"object has no attribute {name}")
def __setattr__(self, name, value, **kwargs):
"""To get copied, attributes have to be stored in the meta dictionary!"""
to_set_as_attr = False
if name in self.__dict__:
to_set_as_attr = True
elif name == "time":
self["time"] = value # astropy will convert value to Time if needed
elif ("columns" in self.__dict__) and (name in self.__dict__["columns"]):
self.replace_column(name, value)
elif "_meta" in self.__dict__:
if name in self.__dict__["_meta"]:
self.__dict__["_meta"][name] = value
elif name.upper() in self.__dict__["_meta"]:
self.__dict__["_meta"][name.upper()] = value
else:
to_set_as_attr = True
else:
to_set_as_attr = True
if to_set_as_attr:
if (
name not in self.__dict__
and not name.startswith("_")
and not self._new_attributes_relax
):
warnings.warn(
(
"Lightkurve doesn't allow columns or meta values to be created via a new attribute name."
"A new attribute is created. It will not be carried over when the object is copied."
" - see https://docs.lightkurve.org/api/lightkurve.lightcurve.LightCurve.html"
),
UserWarning,
stacklevel=2,
)
super().__setattr__(name, value, **kwargs)
def _base_repr_(self, html=False, descr_vals=None, **kwargs):
"""Defines the description shown by `__repr__` and `_html_repr_`."""
if descr_vals is None:
descr_vals = [self.__class__.__name__]
if self.masked:
descr_vals.append("masked=True")
if hasattr(self, "targetid"):
descr_vals.append(f"targetid={self.targetid}")
descr_vals.append("length={}".format(len(self)))
return super()._base_repr_(html=html, descr_vals=descr_vals, **kwargs)
# Define `time`, `flux`, `flux_err` as class attributes to enable IDE
# of these required columns auto-completion.
@property
def time(self) -> Time:
"""Time values stored as an AstroPy `~astropy.time.Time` object."""
return self["time"]
@time.setter
def time(self, time):
self["time"] = time
@property
def flux(self) -> Quantity:
"""Brightness values stored as an AstroPy `~astropy.units.Quantity` object."""
return self["flux"]
@flux.setter
def flux(self, flux):
self["flux"] = flux
@property
def flux_err(self) -> Quantity:
"""Brightness uncertainties stored as an AstroPy `~astropy.units.Quantity` object."""
return self["flux_err"]
@flux_err.setter
def flux_err(self, flux_err):
self["flux_err"] = flux_err
# Define deprecated attributes for compatibility with Lightkurve v1.x:
@property
@deprecated(
"2.0", alternative="time.format", warning_type=LightkurveDeprecationWarning
)
def time_format(self):
return self.time.format
@property
@deprecated(
"2.0", alternative="time.scale", warning_type=LightkurveDeprecationWarning
)
def time_scale(self):
return self.time.scale
@property
@deprecated("2.0", alternative="time", warning_type=LightkurveDeprecationWarning)
def astropy_time(self):
return self.time
@property
@deprecated(
"2.0", alternative="flux.unit", warning_type=LightkurveDeprecationWarning
)
def flux_unit(self):
return self.flux.unit
@property
@deprecated("2.0", alternative="flux", warning_type=LightkurveDeprecationWarning)
def flux_quantity(self):
return self.flux
@property
@deprecated(
"2.0",
alternative="fits.open(lc.filename)",
warning_type=LightkurveDeprecationWarning,
)
def hdu(self):
return fits.open(self.filename)
@property
@deprecated("2.0", warning_type=LightkurveDeprecationWarning)
def SAP_FLUX(self):
"""A copy of the light curve in which `lc.flux = lc.sap_flux`
and `lc.flux_err = lc.sap_flux_err`. It is provided for backwards-
compatibility with Lightkurve v1.x and will be removed soon."""
lc = self.copy()
lc["flux"] = lc["sap_flux"]
lc["flux_err"] = lc["sap_flux_err"]
return lc
@property
@deprecated("2.0", warning_type=LightkurveDeprecationWarning)
def PDCSAP_FLUX(self):
"""A copy of the light curve in which `lc.flux = lc.pdcsap_flux`
and `lc.flux_err = lc.pdcsap_flux_err`. It is provided for backwards-
compatibility with Lightkurve v1.x and will be removed soon."""
lc = self.copy()
lc["flux"] = lc["pdcsap_flux"]
lc["flux_err"] = lc["pdcsap_flux_err"]
return lc
def __add__(self, other):
newlc = self.copy()
if isinstance(other, LightCurve):
if len(self) != len(other):
raise ValueError(
"Cannot add LightCurve objects because "
"they do not have equal length ({} vs {})."
"".format(len(self), len(other))
)
if np.any(self.time != other.time):
warnings.warn(
"Two LightCurve objects with inconsistent time "
"values are being added.",
LightkurveWarning,
)
newlc.flux = self.flux + other.flux
newlc.flux_err = np.hypot(self.flux_err, other.flux_err)
else:
newlc.flux = self.flux + other
return newlc
def __radd__(self, other):
return self.__add__(other)
def __sub__(self, other):
return self.__add__(-1 * other)
def __rsub__(self, other):
return (-1 * self).__add__(other)
def __mul__(self, other):
newlc = self.copy()
if isinstance(other, LightCurve):
if len(self) != len(other):
raise ValueError(
"Cannot multiply LightCurve objects because "
"they do not have equal length ({} vs {})."
"".format(len(self), len(other))
)
if np.any(self.time != other.time):
warnings.warn(
"Two LightCurve objects with inconsistent time "
"values are being multiplied.",
LightkurveWarning,
)
newlc.flux = self.flux * other.flux
# Applying standard uncertainty propagation, cf.
# https://en.wikipedia.org/wiki/Propagation_of_uncertainty#Example_formulae
newlc.flux_err = abs(newlc.flux) * np.hypot(
self.flux_err / self.flux, other.flux_err / other.flux
)
elif isinstance(
other, (u.UnitBase, u.FunctionUnitBase)
): # cf. astropy/issues/6517
newlc.flux = other * self.flux
newlc.flux_err = other * self.flux_err
else:
newlc.flux = other * self.flux
newlc.flux_err = abs(other) * self.flux_err
return newlc
def __rmul__(self, other):
return self.__mul__(other)
def __truediv__(self, other):
return self.__mul__(1.0 / other)
def __rtruediv__(self, other):
newlc = self.copy()
if isinstance(other, LightCurve):
if len(self) != len(other):
raise ValueError(
"Cannot divide LightCurve objects because "
"they do not have equal length ({} vs {})."
"".format(len(self), len(other))
)
if np.any(self.time != other.time):
warnings.warn(
"Two LightCurve objects with inconsistent time "
"values are being divided.",
LightkurveWarning,
)
newlc.flux = other.flux / self.flux
newlc.flux_err = abs(newlc.flux) * np.hypot(
self.flux_err / self.flux, other.flux_err / other.flux
)
else:
newlc.flux = other / self.flux
newlc.flux_err = abs((other * self.flux_err) / (self.flux ** 2))
return newlc
def __div__(self, other):
return self.__truediv__(other)
def __rdiv__(self, other):
return self.__rtruediv__(other)
def show_properties(self):
"""Prints a description of all non-callable attributes.
Prints in order of type (ints, strings, lists, arrays, others).
"""
attrs = {}
deprecated_properties = list(self._deprecated_keywords)
deprecated_properties += [
"flux_quantity",
"SAP_FLUX",
"PDCSAP_FLUX",
"astropy_time",
"hdu",
]
for attr in dir(self):
if not attr.startswith("_") and attr not in deprecated_properties:
try:
res = getattr(self, attr)
except Exception:
continue
if callable(res):
continue
attrs[attr] = {"res": res}
if isinstance(res, int):
attrs[attr]["print"] = "{}".format(res)
attrs[attr]["type"] = "int"
elif isinstance(res, np.ndarray):
attrs[attr]["print"] = "array {}".format(res.shape)
attrs[attr]["type"] = "array"
elif isinstance(res, list):
attrs[attr]["print"] = "list length {}".format(len(res))
attrs[attr]["type"] = "list"
elif isinstance(res, str):
if res == "":
attrs[attr]["print"] = "{}".format("None")
else:
attrs[attr]["print"] = "{}".format(res)
attrs[attr]["type"] = "str"
elif attr == "wcs":
attrs[attr]["print"] = "astropy.wcs.wcs.WCS"
attrs[attr]["type"] = "other"
else:
attrs[attr]["print"] = "{}".format(type(res))
attrs[attr]["type"] = "other"
output = Table(names=["Attribute", "Description"], dtype=[object, object])
idx = 0
types = ["int", "str", "list", "array", "other"]
for typ in types:
for attr, dic in attrs.items():
if dic["type"] == typ:
output.add_row([attr, dic["print"]])
idx += 1
output.pprint(max_lines=-1, max_width=-1)
def append(self, others, inplace=False):
"""Append one or more other `LightCurve` object(s) to this one.
Parameters
----------
others : `LightCurve`, or list of `LightCurve`
Light curve(s) to be appended to the current one.
inplace : bool
If True, change the current `LightCurve` instance in place instead
of creating and returning a new one. Defaults to False.
Returns
-------
new_lc : `LightCurve`
Light curve which has the other light curves appened to it.
"""
if inplace:
raise ValueError(
"the `inplace` parameter is no longer supported "
"as of Lightkurve v2.0"
)
if not hasattr(others, "__iter__"):
others = (others,)
# Need `join_type='inner'` until AstroPy supports masked Quantities
return vstack((self, *others), join_type="inner", metadata_conflicts="silent")
def flatten(
self,
window_length=101,
polyorder=2,
return_trend=False,
break_tolerance=5,
niters=3,
sigma=3,
mask=None,
**kwargs,
):
"""Removes the low frequency trend using scipy's Savitzky-Golay filter.
This method wraps `scipy.signal.savgol_filter`.
Parameters
----------
window_length : int
The length of the filter window (i.e. the number of coefficients).
``window_length`` must be a positive odd integer.
polyorder : int
The order of the polynomial used to fit the samples. ``polyorder``
must be less than window_length.
return_trend : bool
If `True`, the method will return a tuple of two elements
(flattened_lc, trend_lc) where trend_lc is the removed trend.
break_tolerance : int
If there are large gaps in time, flatten will split the flux into
several sub-lightcurves and apply `savgol_filter` to each
individually. A gap is defined as a period in time larger than
`break_tolerance` times the median gap. To disable this feature,
set `break_tolerance` to None.
niters : int
Number of iterations to iteratively sigma clip and flatten. If more than one, will
perform the flatten several times, removing outliers each time.
sigma : int
Number of sigma above which to remove outliers from the flatten
mask : boolean array with length of self.time
Boolean array to mask data with before flattening. Flux values where
mask is True will not be used to flatten the data. An interpolated
result will be provided for these points. Use this mask to remove
data you want to preserve, e.g. transits.
**kwargs : dict
Dictionary of arguments to be passed to `scipy.signal.savgol_filter`.
Returns
-------
flatten_lc : `LightCurve`
New light curve object with long-term trends removed.
If ``return_trend`` is set to ``True``, this method will also return:
trend_lc : `LightCurve`
New light curve object containing the trend that was removed.
"""
if mask is None:
mask = np.ones(len(self.time), dtype=bool)
else:
# Deep copy ensures we don't change the original.
mask = deepcopy(~mask)
# No NaNs
mask &= np.isfinite(self.flux)
# No outliers
mask &= np.nan_to_num(np.abs(self.flux - np.nanmedian(self.flux))) <= (
np.nanstd(self.flux) * sigma
)
for iter in np.arange(0, niters):
if break_tolerance is None:
break_tolerance = np.nan
if polyorder >= window_length:
polyorder = window_length - 1
log.warning(
"polyorder must be smaller than window_length, "
"using polyorder={}.".format(polyorder)
)
# Split the lightcurve into segments by finding large gaps in time
dt = self.time.value[mask][1:] - self.time.value[mask][0:-1]
with warnings.catch_warnings(): # Ignore warnings due to NaNs
warnings.simplefilter("ignore", RuntimeWarning)
cut = np.where(dt > break_tolerance * np.nanmedian(dt))[0] + 1
low = np.append([0], cut)
high = np.append(cut, len(self.time[mask]))
# Then, apply the savgol_filter to each segment separately
trend_signal = Quantity(np.zeros(len(self.time[mask])), unit=self.flux.unit)
for l, h in zip(low, high):
# Reduce `window_length` and `polyorder` for short segments;
# this prevents `savgol_filter` from raising an exception
# If the segment is too short, just take the median
if np.any([window_length > (h - l), (h - l) < break_tolerance]):
trend_signal[l:h] = np.nanmedian(self.flux[mask][l:h])
else:
# Scipy outputs a warning here that is not useful, will be fixed in version 1.2
with warnings.catch_warnings():
warnings.simplefilter("ignore", FutureWarning)
trsig = savgol_filter(
x=self.flux.value[mask][l:h],
window_length=window_length,
polyorder=polyorder,
**kwargs,
)
trend_signal[l:h] = Quantity(trsig, trend_signal.unit)
# Ignore outliers; note we add `1e-14` below to avoid detecting
# outliers which are merely caused by numerical noise.
mask1 = np.nan_to_num(np.abs(self.flux[mask] - trend_signal)) < (
np.nanstd(self.flux[mask] - trend_signal) * sigma
+ Quantity(1e-14, self.flux.unit)
)
f = interp1d(
self.time.value[mask][mask1],
trend_signal[mask1],
fill_value="extrapolate",
)
trend_signal = Quantity(f(self.time.value), self.flux.unit)
mask[mask] &= mask1
flatten_lc = self.copy()
with warnings.catch_warnings():
# ignore invalid division warnings
warnings.simplefilter("ignore", RuntimeWarning)
flatten_lc.flux = flatten_lc.flux / trend_signal.value
flatten_lc.flux_err = flatten_lc.flux_err / trend_signal.value
if return_trend:
trend_lc = self.copy()
trend_lc.flux = trend_signal
return flatten_lc, trend_lc
return flatten_lc
@deprecated_renamed_argument(
"transit_midpoint",
"epoch_time",
"2.0",
warning_type=LightkurveDeprecationWarning,
)
@deprecated_renamed_argument(
"t0", "epoch_time", "2.0", warning_type=LightkurveDeprecationWarning
)
def fold(
self,
period=None,
epoch_time=None,
epoch_phase=0,
wrap_phase=None,
normalize_phase=False,
):
"""Returns a `FoldedLightCurve` object folded on a period and epoch.
This method is identical to AstroPy's `~astropy.timeseries.TimeSeries.fold()`
method, except it returns a `FoldedLightCurve` object which offers
convenient plotting methods.
Parameters
----------
period : float `~astropy.units.Quantity`
The period to use for folding. If a ``float`` is passed we'll
assume it is in units of days.
epoch_time : `~astropy.time.Time`
The time to use as the reference epoch, at which the relative time
offset / phase will be ``epoch_phase``. Defaults to the first time
in the time series.
epoch_phase : float or `~astropy.units.Quantity`
Phase of ``epoch_time``. If ``normalize_phase`` is `True`, this
should be a dimensionless value, while if ``normalize_phase`` is
``False``, this should be a `~astropy.units.Quantity` with time
units. Defaults to 0.
wrap_phase : float or `~astropy.units.Quantity`
The value of the phase above which values are wrapped back by one
period. If ``normalize_phase`` is `True`, this should be a
dimensionless value, while if ``normalize_phase`` is ``False``,
this should be a `~astropy.units.Quantity` with time units.
Defaults to half the period, so that the resulting time series goes
from ``-period / 2`` to ``period / 2`` (if ``normalize_phase`` is
`False`) or -0.5 to 0.5 (if ``normalize_phase`` is `True`).
normalize_phase : bool
If `False` phase is returned as `~astropy.time.TimeDelta`,
otherwise as a dimensionless `~astropy.units.Quantity`.
Returns
-------
folded_lightcurve : `FoldedLightCurve`
The folded light curve object in which the ``time`` column
holds the phase values.
"""
# Lightkurve v1.x assumed that `period` was given in days if no unit
# was specified. We maintain this behavior for backwards-compatibility.
if period is not None and not isinstance(period, Quantity):
period *= u.day
if epoch_time is not None and not isinstance(epoch_time, Time):
epoch_time = Time(
epoch_time, format=self.time.format, scale=self.time.scale
)
if (
epoch_phase is not None
and not isinstance(epoch_phase, Quantity)
and not normalize_phase
):
epoch_phase *= u.day
if wrap_phase is not None and not isinstance(wrap_phase, Quantity):
wrap_phase *= u.day
# Warn if `epoch_time` appears to use the wrong format
if epoch_time is not None and epoch_time.value > 2450000:
if self.time.format == "bkjd":
warnings.warn(
"`epoch_time` appears to be given in JD, "
"however the light curve time uses BKJD "
"(i.e. JD - 2454833).",
LightkurveWarning,
)
elif self.time.format == "btjd":
warnings.warn(
"`epoch_time` appears to be given in JD, "
"however the light curve time uses BTJD "
"(i.e. JD - 2457000).",
LightkurveWarning,
)
ts = super().fold(
period=period,
epoch_time=epoch_time,
epoch_phase=epoch_phase,
wrap_phase=wrap_phase,
normalize_phase=normalize_phase,
)
# The folded time would pass the `TimeSeries` validation check if
# `normalize_phase=True`, so creating a `FoldedLightCurve` object
# requires the following three-step workaround:
# 1. Give the folded light curve a valid time column again
with ts._delay_required_column_checks():
folded_time = ts.time.copy()
ts.remove_column("time")
ts.add_column(self.time, name="time", index=0)
# 2. Create the folded object
lc = FoldedLightCurve(data=ts)
# 3. Restore the folded time
with lc._delay_required_column_checks():
lc.remove_column("time")
lc.add_column(folded_time, name="time", index=0)
# Add extra column and meta data specific to FoldedLightCurve
lc.add_column(
self.time.copy(), name="time_original", index=len(self._required_columns)
)
lc.meta["PERIOD"] = period
lc.meta["EPOCH_TIME"] = epoch_time
lc.meta["EPOCH_PHASE"] = epoch_phase
lc.meta["WRAP_PHASE"] = wrap_phase
lc.meta["NORMALIZE_PHASE"] = normalize_phase
lc.sort("time")
return lc
def normalize(self, unit="unscaled"):
"""Returns a normalized version of the light curve.
The normalized light curve is obtained by dividing the ``flux`` and
``flux_err`` object attributes by the by the median flux.
Optionally, the result will be multiplied by 1e2 (if `unit='percent'`),
1e3 (`unit='ppt'`), or 1e6 (`unit='ppm'`).
Parameters
----------
unit : 'unscaled', 'percent', 'ppt', 'ppm'
The desired relative units of the normalized light curve;
'ppt' means 'parts per thousand', 'ppm' means 'parts per million'.
Examples
--------
>>> import lightkurve as lk
>>> lc = lk.LightCurve(time=[1, 2, 3], flux=[25945.7, 25901.5, 25931.2], flux_err=[6.8, 4.6, 6.2])
>>> normalized_lc = lc.normalize()
>>> normalized_lc.flux
<Quantity [1.00055917, 0.99885466, 1. ]>
>>> normalized_lc.flux_err
<Quantity [0.00026223, 0.00017739, 0.00023909]>
Returns
-------
normalized_lightcurve : `LightCurve`
A new light curve object in which ``flux`` and ``flux_err`` have
been divided by the median flux.
Warns
-----
LightkurveWarning
If the median flux is negative or within half a standard deviation
from zero.
"""
validate_method(unit, ["unscaled", "percent", "ppt", "ppm"])
median_flux = np.nanmedian(self.flux)
std_flux = np.nanstd(self.flux)
# If the median flux is within half a standard deviation from zero, the
# light curve is likely zero-centered and normalization makes no sense.
if (median_flux == 0) or (
np.isfinite(std_flux) and (np.abs(median_flux) < 0.5 * std_flux)
):
warnings.warn(
"The light curve appears to be zero-centered "
"(median={:.2e} +/- {:.2e}); `normalize()` will divide "
"the light curve by a value close to zero, which is "
"probably not what you want."
"".format(median_flux, std_flux),
LightkurveWarning,
)
# If the median flux is negative, normalization will invert the light
# curve and makes no sense.
if median_flux < 0:
warnings.warn(
"The light curve has a negative median flux ({:.2e});"
" `normalize()` will therefore divide by a negative "
"number and invert the light curve, which is probably"
"not what you want".format(median_flux),
LightkurveWarning,
)
# Warn if the light curve was already normalized before
if self.meta.get("NORMALIZED"):
warnings.warn(
"The light curve already appears to be in relative "
"units; `normalize()` will convert the light curve "
"into relative units for a second time, which is "
"probably not what you want.".format(self.flux.unit),
LightkurveWarning,
)
# Create a new light curve instance and normalize its values
lc = self.copy()
lc.flux = lc.flux / median_flux
lc.flux_err = lc.flux_err / median_flux
if not lc.flux.unit:
lc.flux *= u.dimensionless_unscaled
if not lc.flux_err.unit:
lc.flux_err *= u.dimensionless_unscaled
# Set the desired relative (dimensionless) units
if unit == "percent":
lc.flux = lc.flux.to(u.percent)
lc.flux_err = lc.flux_err.to(u.percent)
elif unit in ("ppt", "ppm"):
lc.flux = lc.flux.to(unit)
lc.flux_err = lc.flux_err.to(unit)
lc.meta["NORMALIZED"] = True
return lc
def remove_nans(self, column: str = "flux"):
"""Removes cadences where ``column`` is a NaN.
Parameters
----------
column : str
Column to check for NaNs. Defaults to ``'flux'``.