Skip to content

Commit 4884be2

Browse files
Merge pull request #933 from StingraySoftware/gti_corr
Gti corrected power spectrum for long, gappy observations
2 parents b5c779b + 24dd743 commit 4884be2

File tree

4 files changed

+343
-3
lines changed

4 files changed

+343
-3
lines changed

docs/changes/933.feature.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
GtiCorrPowerspectrum is a new class to calculate long-term power spectra from gappy data

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ all = [
7575
"tinygp",
7676
"jaxns",
7777
"etils",
78-
"tensorflow_probability",
78+
"tfp-nightly",
7979
"typing_extensions",
8080
]
8181
docs = [

stingray/powerspectrum.py

Lines changed: 201 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,18 @@
1+
import copy
12
import warnings
23
from collections.abc import Generator, Iterable
34

45
import numpy as np
56
import scipy
67
import scipy.optimize
78
import scipy.stats
9+
import matplotlib.pyplot as plt
810

911
from stingray.crossspectrum import AveragedCrossspectrum, Crossspectrum, DynamicalCrossspectrum
10-
from stingray.stats import pds_probability, amplitude_upper_limit
12+
from stingray.stats import pds_probability, amplitude_upper_limit, pds_detection_level
1113

1214
from .events import EventList
13-
from .gti import cross_two_gtis, time_intervals_from_gtis
15+
from .gti import cross_two_gtis, time_intervals_from_gtis, create_gti_mask
1416

1517
from .lightcurve import Lightcurve
1618
from .fourier import avg_pds_from_iterable, unnormalize_periodograms
@@ -1183,6 +1185,203 @@ def power_colors(
11831185
)
11841186

11851187

1188+
class GtiCorrPowerspectrum(Powerspectrum):
1189+
main_array_attr = "freq"
1190+
type = "powerspectrum"
1191+
1192+
"""Calculate the power spectrum of gappy light curves.
1193+
1194+
GtiCorrPowerspectrum computes the power spectrum of gappy light curves,
1195+
cleaning up the frequencies that are more affected by gaps.
1196+
Optionally, it fills bad time intervals (BTIs) with the mean count rate from
1197+
good time intervals (GTIs), mitigating window-induced features in the periodogram.
1198+
By analyzing the visibility light curve (synthetic light curve with constant mean
1199+
counts in GTIs), the class identifies strong peaks in the periodogram that correspond to
1200+
the missing data and applies notch filtering to the power spectrum to remove these
1201+
frequencies.
1202+
Additionally, it rescales the power spectrum to account for the number of bins in and out GTIs,
1203+
ensuring accurate white noise and rms estimation.
1204+
1205+
The detailed explanation of the method is given in
1206+
`El Byad et al. 2025 <https://arxiv.org/pdf/2505.16921>`__
1207+
1208+
Parameters
1209+
----------
1210+
*args:
1211+
Any arguments that can be passed to ``Powerspectrum``
1212+
1213+
Other Parameters
1214+
----------------
1215+
fill_lc: boolean, optional, default ``True``
1216+
If True, fill the BTIs of the light curve with the mean value of the counts in the GTIs.
1217+
Recommended.
1218+
1219+
sigma_threshold: float, optional, default ``3``
1220+
The sigma threshold for the detection of features in the power spectrum of the observing
1221+
window.
1222+
1223+
**kwargs:
1224+
Any other keyword arguments that can be passed to ``Powerspectrum``
1225+
1226+
Attributes
1227+
----------
1228+
freq: numpy.ndarray
1229+
The array of mid-bin frequencies that the Fourier transform samples
1230+
1231+
power: numpy.ndarray
1232+
The array of power values
1233+
1234+
power_err: numpy.ndarray
1235+
The uncertainties of ``power``.
1236+
An approximation for each bin given by ``power_err= power/sqrt(m)``.
1237+
Where ``m`` is the number of power averaged in each bin (by frequency
1238+
binning, or averaging more than one spectra). Note that for a single
1239+
realization (``m=1``) the error is equal to the power.
1240+
1241+
df: float
1242+
The frequency resolution
1243+
1244+
m: int
1245+
The number of averaged cross-spectra amplitudes in each bin.
1246+
1247+
n: int
1248+
The number of data points/time bins in one segment of the light
1249+
curves.
1250+
1251+
k: array of int
1252+
The rebinning scheme if the object has been rebinned otherwise is set to 1.
1253+
1254+
nphots: float
1255+
The total number of photons in light curve
1256+
1257+
"""
1258+
1259+
def __init__(self, *args, fill_lc=True, sigma_threshold=3, **kwargs):
1260+
dt = kwargs.pop("dt", None)
1261+
skip_checks = kwargs.pop("skip_checks", False)
1262+
1263+
if len(args) == 0:
1264+
self.lc = None
1265+
elif isinstance(args[0], EventList):
1266+
self.lc = args[0].to_lc(dt)
1267+
else:
1268+
self.lc = args[0]
1269+
if dt is None:
1270+
dt = self.lc.dt
1271+
1272+
if fill_lc:
1273+
self.fill_lc_with_mean()
1274+
1275+
self.sigma_threshold = sigma_threshold
1276+
if not skip_checks:
1277+
self.initial_checks(*args, dt=dt, **kwargs)
1278+
1279+
super().__init__(self.lc, *args[1:], dt=dt, **kwargs, skip_checks=True)
1280+
1281+
self.mjdref = None
1282+
if hasattr(self.lc, "mjdref"):
1283+
self.mjdref = self.lc.mjdref
1284+
1285+
if len(args) == 0:
1286+
self.mask = None
1287+
return
1288+
1289+
if fill_lc:
1290+
lc_mask = create_gti_mask(self.lc.time, self.lc.gti)
1291+
self.power *= lc_mask.size / np.count_nonzero(lc_mask)
1292+
1293+
self.mask = np.ones(self.power.size, dtype=bool)
1294+
1295+
def initial_checks(self, *args, **kwargs):
1296+
if self.lc is not None and not np.allclose(np.diff(self.lc.time), self.lc.dt):
1297+
raise ValueError(
1298+
"The time array in the light curve is not evenly spaced. "
1299+
"This is not supported by GtiCorrPowerspectrum."
1300+
)
1301+
1302+
def fill_lc_with_mean(self):
1303+
if self.lc is None:
1304+
return
1305+
1306+
mask = create_gti_mask(self.lc.time, self.lc.gti)
1307+
self.lc.counts = self.lc.counts.astype(float)
1308+
self.lc.counts[~mask] = np.mean(self.lc.counts[mask])
1309+
1310+
def clean_gti_features(self, plot=False, figname="gti_features"):
1311+
gti = getattr(self, "gti", None)
1312+
if gti is None:
1313+
raise AttributeError("GTI attribute is not set for this object.")
1314+
exposure = np.sum(gti[:, 1] - gti[:, 0])
1315+
ref_ctrate = self.nphots / exposure
1316+
self.exposure = exposure
1317+
1318+
lc = copy.deepcopy(self.lc)
1319+
lc.gti = np.array([[gti[0, 0], gti[-1, 1]]])
1320+
mask = create_gti_mask(lc.time, gti)
1321+
lc.counts = lc.counts.astype(float)
1322+
lc.counts[~mask] = 0
1323+
lc.counts[mask] = ref_ctrate * lc.dt
1324+
1325+
ps_gti = Powerspectrum(lc, norm="leahy")
1326+
1327+
# Correct the PS level to overcome the Nph from filling the lc with mean
1328+
prob = scipy.stats.norm.cdf(-self.sigma_threshold)
1329+
thresh = pds_detection_level(prob)
1330+
1331+
bad = ps_gti.power > thresh
1332+
if plot:
1333+
self._plot_gti_features(ps_gti, thresh, figname)
1334+
self.mask = self.mask & ~bad
1335+
newpow = self.apply_mask(self.mask)
1336+
return newpow
1337+
1338+
def _plot_gti_features(self, ps_gti, thresh, figname):
1339+
"""Plot the features in the power spectrum of the GTI-corrected light curve.
1340+
1341+
This method generates a log-log plot of the power spectrum and saves it as a JPEG file.
1342+
1343+
Parameters
1344+
----------
1345+
ps_gti : Powerspectrum
1346+
The power spectrum of the synthetic light curve having the mean counts inside GTIs
1347+
and 0 outside.
1348+
thresh : float
1349+
The threshold value for the power spectrum, above which features are considered significant.
1350+
figname : str
1351+
The name of the figure file to be saved (without extension).
1352+
"""
1353+
fig = plt.figure(figname)
1354+
plt.loglog(ps_gti.freq, ps_gti.power)
1355+
plt.axhline(thresh)
1356+
plt.xlabel("Frequency (Hz)")
1357+
plt.ylabel(f"Power {ps_gti.norm}")
1358+
plt.savefig(figname + ".jpg")
1359+
plt.close(fig)
1360+
1361+
def rebin_log(self, *args, **kwargs):
1362+
"""Rebin the power spectrum logarithmically and filter out NaN values.
1363+
1364+
This method overrides the parent class's `rebin_log` method by applying a mask
1365+
to remove any bins where the rebinned power is NaN.
1366+
1367+
Parameters
1368+
----------
1369+
*args : tuple
1370+
Positional arguments passed to the parent class's `rebin_log` method.
1371+
**kwargs : dict
1372+
Keyword arguments passed to the parent class's `rebin_log` method.
1373+
1374+
Returns
1375+
-------
1376+
GtiCorrPowerspectrum
1377+
A new power spectrum object with rebinned frequencies and powers,
1378+
with NaN values filtered out.
1379+
"""
1380+
new_ps = Powerspectrum.rebin_log(self, *args, **kwargs)
1381+
mask = ~np.isnan(new_ps.power)
1382+
return new_ps.apply_mask(mask)
1383+
1384+
11861385
def powerspectrum_from_time_array(
11871386
times,
11881387
dt,

stingray/tests/test_powerspectrum.py

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import copy
44
import warnings
55
import importlib
6+
import tempfile
67

78
import pytest
89
import matplotlib.pyplot as plt
@@ -11,6 +12,7 @@
1112
from stingray.events import EventList
1213
from stingray.utils import HAS_NUMBA
1314
from stingray import Powerspectrum, AveragedPowerspectrum, DynamicalPowerspectrum
15+
from stingray.powerspectrum import GtiCorrPowerspectrum
1416
from stingray.powerspectrum import powerspectrum_from_time_array
1517
from astropy.modeling.models import Lorentz1D
1618
from stingray.filters import filter_for_deadtime
@@ -381,6 +383,144 @@ def test_deadtime_corr(self):
381383
assert np.isclose(np.std(pds.power), 2 / np.sqrt(tmax / segment_size), rtol=0.1)
382384

383385

386+
class TestGtiCorrPowerspectrum(object):
387+
@classmethod
388+
def setup_class(cls):
389+
"""Set up a light curve and an event list with GTIs for testing GtiCorrPowerspectrum."""
390+
tstart = 0.0
391+
tend = 100.0
392+
dt = 0.01
393+
394+
time = np.arange(tstart + 0.5 * dt, tend + 0.5 * dt, dt)
395+
396+
mean_count_rate = 1000.0
397+
mean_counts = mean_count_rate * dt
398+
399+
poisson_counts = rng.poisson(mean_counts, size=time.shape[0])
400+
401+
cls.lc = Lightcurve(time, counts=poisson_counts, gti=[[tstart, tend]], dt=dt)
402+
cls.events = EventList(
403+
np.sort(
404+
np.random.uniform(
405+
tstart, tend, np.random.poisson(mean_count_rate * (tend - tstart))
406+
)
407+
),
408+
gti=[[tstart, tend]],
409+
)
410+
411+
@pytest.mark.parametrize("norm", ["leahy", "frac", "abs", "none"])
412+
def test_gti_corr_ps(self, norm):
413+
"""GtiCorrPowerspectrum results match Powerspectrum when GTIs are unimportant."""
414+
gcps = GtiCorrPowerspectrum(self.lc, norm=norm)
415+
ps = Powerspectrum(self.lc, norm=norm)
416+
for attr in [
417+
"freq",
418+
"power",
419+
"power_err",
420+
"unnorm_power",
421+
"unnorm_power_err",
422+
"df",
423+
"m",
424+
"n",
425+
"nphots",
426+
]:
427+
assert np.array_equal(getattr(gcps, attr), getattr(ps, attr))
428+
429+
@pytest.mark.parametrize("norm", ["leahy", "frac", "abs", "none"])
430+
def test_gti_corr_ps_events(self, norm):
431+
"""GtiCorrPowerspectrum results match Powerspectrum for event lists."""
432+
gcps = GtiCorrPowerspectrum(self.events, dt=0.01, norm=norm)
433+
ps = Powerspectrum(self.events, dt=0.01, norm=norm)
434+
for attr in [
435+
"freq",
436+
"power",
437+
"power_err",
438+
"unnorm_power",
439+
"unnorm_power_err",
440+
"df",
441+
"m",
442+
"n",
443+
"nphots",
444+
]:
445+
assert np.array_equal(getattr(gcps, attr), getattr(ps, attr))
446+
447+
@pytest.mark.parametrize("norm", ["leahy", "frac", "abs", "none"])
448+
def test_gti_corr_ps_fill_different(self, norm):
449+
"""Filling BTIs or not changes the power spectrum."""
450+
lc = copy.deepcopy(self.lc)
451+
lc.gti = [[0, 30], [35, 100]] # Two GTIs, one gap
452+
gcps_fill = GtiCorrPowerspectrum(lc, norm=norm, fill_lc=True)
453+
gcps_nofill = GtiCorrPowerspectrum(lc, norm=norm, fill_lc=False)
454+
gcps_fill = gcps_fill.clean_gti_features()
455+
gcps_nofill = gcps_nofill.clean_gti_features()
456+
mean_gcps_fill = np.mean(gcps_fill.power)
457+
mean_gcps_nofill = np.mean(gcps_nofill.power)
458+
assert not np.allclose(mean_gcps_fill, mean_gcps_nofill, rtol=0.01)
459+
460+
@pytest.mark.parametrize("norm", ["leahy", "frac", "abs", "none"])
461+
def test_gti_corr_ps_fill(self, norm):
462+
"""Filling BTIs and correcting the normalization adjusts the power spectrum."""
463+
lc = copy.deepcopy(self.lc)
464+
lc.gti = [[0, 30], [35, 100]] # Two GTIs, one gap
465+
gcps = GtiCorrPowerspectrum(lc, norm=norm, fill_lc=True)
466+
gcps = gcps.clean_gti_features()
467+
ps = Powerspectrum(self.lc, norm=norm)
468+
mean_ps = np.mean(ps.power)
469+
mean_gcps = np.mean(gcps.power)
470+
assert np.isclose(mean_gcps, mean_ps, rtol=0.01)
471+
472+
@pytest.mark.parametrize("norm", ["leahy", "frac", "abs", "none"])
473+
def test_gti_corr_ps_fill_events(self, norm):
474+
"""Filling BTIs and correcting the normalization adjusts the power spectrum."""
475+
lc = copy.deepcopy(self.events)
476+
lc.gti = [[0, 30], [35, 100]] # Two GTIs, one gap
477+
gcps = GtiCorrPowerspectrum(lc, dt=0.01, norm=norm, fill_lc=True)
478+
gcps = gcps.clean_gti_features()
479+
ps = Powerspectrum(self.events, dt=0.01, norm=norm)
480+
mean_ps = np.mean(ps.power)
481+
mean_gcps = np.mean(gcps.power)
482+
assert np.isclose(mean_gcps, mean_ps, rtol=0.01)
483+
484+
@pytest.mark.parametrize("norm", ["leahy", "frac", "abs", "none"])
485+
def test_gti_corr_ps_fill_rebin(self, norm):
486+
"""Rebinning works on GtiCorrPowerspectrum."""
487+
lc = copy.deepcopy(self.lc)
488+
lc.gti = [[0, 30], [35, 100]] # Two GTIs, one gap
489+
gcps = GtiCorrPowerspectrum(lc, norm=norm, fill_lc=True)
490+
gcps = gcps.clean_gti_features()
491+
ps = Powerspectrum(self.lc, norm=norm)
492+
ps = ps.rebin_log(0.01)
493+
gcps = gcps.rebin_log(0.01)
494+
495+
mean_ps = np.mean(ps.power)
496+
mean_gcps = np.mean(gcps.power)
497+
assert np.isclose(mean_gcps, mean_ps, rtol=0.1)
498+
499+
def test_gti_corr_apply_gti_lc_fails(self):
500+
"""Applying GTIs to a light curve with gaps in the time array raises an error."""
501+
lc = copy.deepcopy(self.lc)
502+
lc.gti = [[0, 30], [35, 100]] # Two GTIs, one gap
503+
lc.apply_gtis()
504+
with pytest.raises(ValueError, match="The time array in the light"):
505+
GtiCorrPowerspectrum(lc, norm="leahy", fill_lc=True)
506+
507+
def test_gti_corr_plot(self):
508+
"""Plotting GtiCorrPowerspectrum works."""
509+
lc = copy.deepcopy(self.events)
510+
lc.gti = [[0, 30], [35, 100]] # Two GTIs, one gap
511+
gcps = GtiCorrPowerspectrum(lc, dt=0.01, norm="leahy", fill_lc=True)
512+
with tempfile.NamedTemporaryFile(delete=False) as tmpfile:
513+
figname = tmpfile.name
514+
try:
515+
gcps = gcps.clean_gti_features(plot=True, figname=figname)
516+
jpg_name = figname + ".jpg"
517+
assert os.path.exists(jpg_name)
518+
os.unlink(jpg_name)
519+
finally:
520+
if os.path.exists(figname):
521+
os.unlink(figname)
522+
523+
384524
class TestPowerspectrum(object):
385525
@classmethod
386526
def setup_class(cls):

0 commit comments

Comments
 (0)