Skip to content

Commit 77710bb

Browse files
add rational_fit and nr_precessing tests
1 parent 24bb31f commit 77710bb

5 files changed

Lines changed: 391 additions & 4 deletions

File tree

gw_eccentricity/load_data.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -743,6 +743,78 @@ def load_lvcnr_waveform(**kwargs):
743743
return return_dict
744744

745745

746+
def download_sxs_waveform(sxs_id, lev, data_dir, extrap_order=2):
747+
"""Download SXS catalog waveform files to a local directory.
748+
749+
Downloads the files required by ``load_waveform(origin="SXSCatalog")``:
750+
751+
- ``Strain_N{extrap_order}.h5``
752+
- ``Strain_N{extrap_order}.json``
753+
- ``metadata.json``
754+
- ``Horizons.h5``
755+
756+
Parameters
757+
----------
758+
sxs_id : str
759+
SXS simulation identifier, e.g. ``"SXS:BBH:3726"``.
760+
lev : int
761+
Resolution level, e.g. ``3``.
762+
data_dir : str
763+
Root directory. Files are placed inside
764+
``<data_dir>/<sxs_id_safe>/Lev<lev>/``, where ``sxs_id_safe``
765+
is ``sxs_id`` with ``":"`` replaced by ``"_"``
766+
(e.g. ``"SXS_BBH_3726"``).
767+
extrap_order : int, optional
768+
Waveform extrapolation order. Default is 2, which selects the
769+
``Strain_N2.h5`` / ``Strain_N2.json`` file pair.
770+
771+
Returns
772+
-------
773+
str
774+
Full path to the directory that now contains the downloaded
775+
files. Pass this as ``data_dir`` to
776+
``load_waveform(origin="SXSCatalog", data_dir=...)``.
777+
778+
Notes
779+
-----
780+
Files that already exist on disk are skipped, that is not re-downloaded.
781+
The SXS simulations catalogue is loaded via the ``sxs`` Python package to
782+
retrieve direct download URLs for each file.
783+
"""
784+
from sxs.utilities import download_file
785+
786+
# Build target directory
787+
sxs_id_safe = sxs_id.replace(":", "_")
788+
target_dir = os.path.join(
789+
os.path.expanduser(data_dir), sxs_id_safe, f"Lev{lev}")
790+
os.makedirs(target_dir, exist_ok=True)
791+
792+
# Load the simulations catalogue to resolve file URLs
793+
simulations = sxs.Simulations.load(download=True)
794+
if sxs_id not in simulations:
795+
raise ValueError(
796+
f"'{sxs_id}' not found in the SXS simulations catalogue. "
797+
"Check the SXS ID and that the catalogue is up to date.")
798+
799+
sim_files = simulations[sxs_id].get("files", {})
800+
801+
filenames = [
802+
f"Strain_N{extrap_order}.h5",
803+
f"Strain_N{extrap_order}.json",
804+
"metadata.json",
805+
"Horizons.h5",
806+
]
807+
808+
for filename in filenames:
809+
dest = os.path.join(target_dir, filename)
810+
if os.path.exists(dest):
811+
continue # already present, skip
812+
url = sim_files[f"Lev{lev}:{filename}"]["link"]
813+
download_file(url, dest, progress=False)
814+
815+
return target_dir
816+
817+
746818
def load_sxs_catalogformat(**kwargs):
747819
"""Load modes from sxs waveform files in sxs catalog format.
748820

gw_eccentricity/rational_fit.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,8 @@ def _eval_arnoldi_basis(x, H, degree, v0_norm):
104104
# Build higher degree basis functions using the stored Hessenberg
105105
# matrix H
106106
for k in range(degree):
107-
v = V[:, k] * x - V[:, :k+1] @ H[:k+1, k]
107+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
108+
v = V[:, k] * x - V[:, :k+1] @ H[:k+1, k]
108109
if H[k + 1, k] < 1e-14:
109110
break
110111
V[:, k + 1] = v / H[k + 1, k]
@@ -405,8 +406,9 @@ def predict(self, x_new):
405406

406407
# Compute the numerator and denominator at x_new using the
407408
# fitted coefficients
408-
p = P @ self.a
409-
q = Q @ self.b
409+
with np.errstate(divide='ignore', over='ignore', invalid='ignore'):
410+
p = P @ self.a
411+
q = Q @ self.b
410412

411413
# Return the rational function values at x_new
412414
r = p / q

pytest.ini

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
[pytest]
1+
[pytest]
2+
filterwarnings =
3+
ignore::DeprecationWarning:pyseobnr
4+
ignore::DeprecationWarning:scipy
5+
ignore::DeprecationWarning:qnm

test/test_nr_precessing.py

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
"""Tests for precessing=True with frame="inertial" and frame="coprecessing"."""
2+
import os
3+
import json
4+
import numpy as np
5+
import pytest
6+
from gw_eccentricity import measure_eccentricity
7+
from gw_eccentricity.load_data import load_waveform, get_coprecessing_data_dict
8+
9+
# ---------------------------------------------------------------------------
10+
# SXS test cases
11+
# SXS:BBH:2859 — nearly quasi-circular, precessing [active]
12+
# SXS:BBH:xxxx — high-eccentricity, precessing [placeholder; all
13+
# eccentric tests skipped until a suitable simulation is
14+
# available in the public SXS catalog]
15+
# ---------------------------------------------------------------------------
16+
17+
DATA_DIR = "/tmp/gwecc_test_data"
18+
SXS_CASES = {
19+
"eccentric": ("SXS:BBH:xxxx", 0),
20+
"quasicircular": ("SXS:BBH:2859", 4),
21+
}
22+
ALL_ELL2_MODES = [(2, -2), (2, -1), (2, 0), (2, 1), (2, 2)]
23+
24+
25+
def _data_dir(sxs_id, lev):
26+
sxs_id_safe = sxs_id.replace(":", "_")
27+
return os.path.join(DATA_DIR, sxs_id_safe, f"Lev{lev}")
28+
29+
30+
def _load(sxs_id, lev):
31+
"""Load all ell=2 modes from a downloaded SXS waveform."""
32+
return load_waveform(
33+
origin="SXSCatalog",
34+
data_dir=_data_dir(sxs_id, lev),
35+
mode_array=ALL_ELL2_MODES,
36+
num_orbits_to_remove_as_junk=4)
37+
38+
39+
def _ref_ecc(sxs_id, lev):
40+
"""Read reference eccentricity from the downloaded metadata.json."""
41+
path = os.path.join(_data_dir(sxs_id, lev), "metadata.json")
42+
with open(path) as f:
43+
return float(json.load(f)["reference_eccentricity"])
44+
45+
46+
# ---------------------------------------------------------------------------
47+
# Fixtures — check data is present, skip if not
48+
# ---------------------------------------------------------------------------
49+
50+
def _make_fixture(key):
51+
"""Return a module-scoped fixture that loads one SXS case by key."""
52+
@pytest.fixture(scope="module")
53+
def _fixture():
54+
sxs_id, lev = SXS_CASES[key]
55+
data_dir = _data_dir(sxs_id, lev)
56+
if not os.path.isfile(os.path.join(data_dir, "Strain_N2.h5")):
57+
pytest.skip(
58+
f"SXS data not found at {data_dir} — run download_sxs_waveform first")
59+
return _load(sxs_id, lev), _ref_ecc(sxs_id, lev)
60+
return _fixture
61+
62+
63+
eccentric_dataDict = _make_fixture("eccentric")
64+
quasicircular_dataDict = _make_fixture("quasicircular")
65+
66+
67+
@pytest.fixture(scope="module", params=list(SXS_CASES.keys()))
68+
def sxs_dataDict(request):
69+
"""Module-scoped dataDict for each SXS case; skips if data not downloaded."""
70+
key = request.param
71+
sxs_id, lev = SXS_CASES[key]
72+
data_dir = _data_dir(sxs_id, lev)
73+
if not os.path.isfile(os.path.join(data_dir, "Strain_N2.h5")):
74+
pytest.skip(f"SXS data not found at {data_dir} — run download_sxs_waveform first")
75+
return key, _load(sxs_id, lev), _ref_ecc(sxs_id, lev)
76+
77+
78+
# ---------------------------------------------------------------------------
79+
# Test: frame="inertial" triggers internal coprecessing transform
80+
# ---------------------------------------------------------------------------
81+
82+
def test_inertial_frame_gives_finite_eccentricity(sxs_dataDict):
83+
"""measure_eccentricity with precessing=True, frame='inertial' returns finite result."""
84+
key, dataDict, _ = sxs_dataDict
85+
t = dataDict["t"]
86+
t_mid = t[len(t) // 2]
87+
88+
result = measure_eccentricity(
89+
tref_in=t_mid,
90+
method="AmplitudeFits",
91+
dataDict=dataDict,
92+
precessing=True,
93+
frame="inertial")
94+
assert np.isfinite(result["eccentricity"]).all(), (
95+
f"Non-finite eccentricity for {key} with frame='inertial'")
96+
97+
98+
# ---------------------------------------------------------------------------
99+
# Test: frame="coprecessing" (pre-rotated modes) matches frame="inertial"
100+
# ---------------------------------------------------------------------------
101+
102+
def test_coprecessing_frame_matches_inertial(sxs_dataDict):
103+
"""frame='coprecessing' and frame='inertial' agree to within numerical precision."""
104+
key, dataDict, _ = sxs_dataDict
105+
t = dataDict["t"]
106+
t_mid = t[len(t) // 2]
107+
108+
result_inertial = measure_eccentricity(
109+
tref_in=t_mid,
110+
method="AmplitudeFits",
111+
dataDict=dataDict,
112+
precessing=True,
113+
frame="inertial")
114+
115+
coprec_dataDict = get_coprecessing_data_dict(dataDict)
116+
result_coprec = measure_eccentricity(
117+
tref_in=t_mid,
118+
method="AmplitudeFits",
119+
dataDict=coprec_dataDict,
120+
precessing=True,
121+
frame="coprecessing")
122+
123+
np.testing.assert_allclose(
124+
result_coprec["eccentricity"],
125+
result_inertial["eccentricity"],
126+
rtol=1e-10,
127+
err_msg=f"coprecessing vs inertial mismatch for {key}")
128+
129+
130+
# ---------------------------------------------------------------------------
131+
# Test: spin_filter is exercised for quasicircular precessing data.
132+
# Eccentricity evolution should be monotonically decreasing and the
133+
# initial value should be within a factor of 2 of the metadata reference.
134+
#
135+
# NOTE: spin_filter is not triggered for high-eccentricity waveforms
136+
# because the eccentricity signal dominates spin-induced oscillations.
137+
# ---------------------------------------------------------------------------
138+
139+
def test_spin_filter_gives_monotonic_eccentricity(quasicircular_dataDict):
140+
"""For quasicircular precessing NR data (SXS:BBH:2859), verify that:
141+
142+
1. The eccentricity evolution is monotonically decreasing (spin_filter path
143+
exercised — spin-induced oscillations are removed before measurement).
144+
2. The initial eccentricity is within a factor of 2 of the SXS metadata
145+
reference value.
146+
"""
147+
dataDict, ecc_ref = quasicircular_dataDict
148+
t = dataDict["t"]
149+
150+
result = measure_eccentricity(
151+
tref_in=t,
152+
method="AmplitudeFits",
153+
dataDict=dataDict,
154+
precessing=True,
155+
frame="inertial")
156+
157+
ecc = result["eccentricity"]
158+
assert np.isfinite(ecc).all(), "Non-finite eccentricity for quasicircular case"
159+
160+
# use the built-in monotonicity check which populates decc_dt_for_checks
161+
obj = result["gwecc_object"]
162+
obj.check_monotonicity_and_convexity()
163+
assert np.all(obj.decc_dt_for_checks <= 0), (
164+
"Eccentricity is not monotonically decreasing for quasicircular case")
165+
166+
# initial eccentricity vs SXS metadata reference (within factor of 2)
167+
ecc_initial = float(ecc[0])
168+
assert 0.5 * ecc_ref <= ecc_initial <= 2.0 * ecc_ref, (
169+
f"Initial eccentricity {ecc_initial:.6f} not within factor of 2 "
170+
f"of metadata reference {ecc_ref:.6f}")
171+
172+
173+
@pytest.mark.skip(
174+
reason="No suitable high-eccentricity precessing SXS simulation is currently "
175+
"available in the public catalog. TODO: re-enable and tighten to rtol=0.05 "
176+
"once such a simulation is added.")
177+
def test_eccentric_precessing_eccentricity_value(eccentric_dataDict):
178+
"""For high-eccentricity precessing NR data, verify that the initial
179+
eccentricity is within 5% of the SXS metadata reference value.
180+
181+
spin_filter is not triggered for high-eccentricity waveforms; only the
182+
eccentricity value is checked here.
183+
"""
184+
dataDict, ecc_ref = eccentric_dataDict
185+
t = dataDict["t"]
186+
187+
result = measure_eccentricity(
188+
tref_in=t,
189+
method="AmplitudeFits",
190+
dataDict=dataDict,
191+
precessing=True,
192+
frame="inertial")
193+
194+
ecc = result["eccentricity"]
195+
assert np.isfinite(ecc).all(), "Non-finite eccentricity for eccentric case"
196+
197+
ecc_initial = float(ecc[0])
198+
np.testing.assert_allclose(
199+
ecc_initial, ecc_ref, rtol=0.05,
200+
err_msg=f"Initial eccentricity {ecc_initial:.4f} differs from "
201+
f"metadata reference {ecc_ref:.4f} by more than 5%")

0 commit comments

Comments
 (0)