diff --git a/.github/workflows/cip-startup.yml b/.github/workflows/cip-startup.yml index c90f19f0a..8bd754878 100644 --- a/.github/workflows/cip-startup.yml +++ b/.github/workflows/cip-startup.yml @@ -37,4 +37,5 @@ jobs: run: | python -m pytest -q \ MonteCarloMarginalizeCode/Code/test/test_cip_startup.py \ - MonteCarloMarginalizeCode/Code/test/test_lalsim_eos_compat.py + MonteCarloMarginalizeCode/Code/test/test_lalsim_eos_compat.py \ + MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bdad7c4e9..a6e7e9bd0 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -55,7 +55,7 @@ unit_tests: script: - python -m pytest -q MonteCarloMarginalizeCode/Code/test/test_time_marginalization_perrow_offset.py - python -m pytest -q MonteCarloMarginalizeCode/Code/test/test_sky_rotations.py - - python -m pytest -q MonteCarloMarginalizeCode/Code/test/test_cip_startup.py MonteCarloMarginalizeCode/Code/test/test_lalsim_eos_compat.py + - python -m pytest -q MonteCarloMarginalizeCode/Code/test/test_cip_startup.py MonteCarloMarginalizeCode/Code/test/test_lalsim_eos_compat.py MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py test_run: stage: system tests diff --git a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py index a4132550e..e15054715 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py @@ -348,7 +348,7 @@ def lsu_StringFromPNOrder(order): # so far, used for puffball, to prevent insanity (infinite growth) and/or death to downselect # - note we also provide for extrinsic: RA (phi), phiref, psi, just in case we need it in the future -periodic_params = {'phi1':2*np.pi, 'phi2':2*np.pi, 'phiref':2*np.pi, 'psi':np.pi, 'meanPerAno':2*np.pi, 'phi':2*np.pi, 'phiJL':2*np.pi, 'psiJ':2*np.pi} +periodic_params = {'phi1':2*np.pi, 'phi2':2*np.pi, 'phi12':2*np.pi, 'phiref':2*np.pi, 'psi':np.pi, 'meanPerAno':2*np.pi, 'phi':2*np.pi, 'phiJL':2*np.pi, 'psiJ':2*np.pi} tex_dictionary = { "mtot": r'$M$', @@ -371,6 +371,7 @@ def lsu_StringFromPNOrder(order): "DeltaOverM2_perp" : r"$\Delta_\perp$", "DeltaOverM2_L" : r"$\Delta_{||}$", "SOverM2_perp" : r"$S_\perp$", + "chi_p_vec" : r"$\chi_{p,{\rm vec}}$", "SOverM2_L" : r"$S_{||}$", "eta": r"$\eta$", "chi_eff": r"$\chi_{eff}$", @@ -1199,6 +1200,20 @@ def extract_param(self,p): S2p = (m2**2 * chi2)[:2] Sp = np.max([np.linalg.norm( A1*S1p), np.linalg.norm(A2*S2p)]) return Sp/(A1*m1**2) # divide by term for *larger* BH + if p == 'phi12': + # azimuth of spin 2's in-plane component relative to spin 1's, in [0, 2 pi), L frame. + # Undefined if either in-plane component vanishes; 0 is returned then (same as the vectorized path). + if np.hypot(self.s1x, self.s1y) == 0 or np.hypot(self.s2x, self.s2y) == 0: + return 0. + val = np.mod(np.arctan2(self.s2y, self.s2x) - np.arctan2(self.s1y, self.s1x), 2*np.pi) + return 0. if val >= 2*np.pi else val # np.mod of a tiny negative number rounds to 2 pi + if p == 'chi_p_vec': + # vector-sum (ring) analogue of chi_p: |A1 S1perp + A2 S2perp| / (A1 m1^2), same A1, A2 as chi_p. + # chi_p keeps the larger of the two terms; this keeps their vector sum, so it depends on phi12 + # (in-plane spins that cancel give a small value). L frame. + q = self.m2/self.m1 + A1 = (2+ 3.*q/2); A2 = (2+3./(2*q)) + return np.abs( (self.s1x + 1j*self.s1y) + (A2/A1)*q**2*(self.s2x + 1j*self.s2y) ) if p == 'chi_pavg': if (abs(self.s1x) < 1e-4 and abs(self.s1y) < 1e-4 and abs(self.s2x) < 1e-4 and abs(self.s2y) < 1e-4): chipavg = 0.0 @@ -5155,6 +5170,7 @@ def convert_waveform_coordinates(x_in,coord_names=['mc', 'eta'],low_level_coord_ - source_redshift: if nonzero, convert m1 -> m1 (1+z)=m_z, as fit is done in the detector frame. We are **assuming source-frame sampling** """ x_out = np.zeros( (len(x_in), len(coord_names) ) ) + kerr_violation_ring = None # set by the vectorized in-plane block, which can end the conversion early # Check for trivial identity transformations and do those by direct copy, then remove those from the list of output coord names coord_names_reduced = coord_names.copy() for p in low_level_coord_names: @@ -5368,6 +5384,34 @@ def convert_waveform_coordinates(x_in,coord_names=['mc', 'eta'],low_level_coord_ x_out[:,indx_pout_s2y] = x_in[:,indx_chi2]*sintheta2*sinphi2 coord_names_reduced.remove('s2x') coord_names_reduced.remove('s2y') + # in-plane magnitudes, relative azimuth, and ring coordinates (L frame), vectorized + ring_names = ['chi1_perp', 'chi2_perp', 'phi12', 'SOverM2_perp', 'DeltaOverM2_perp', 'chi_p_vec'] + if any(p in coord_names_reduced for p in ring_names): + # CIP's default sampler passes an object array of python floats; ufuncs need a float array + xf = np.asarray(x_in, dtype=float) + m1f = np.asarray(m1_vals, dtype=float); m2f = np.asarray(m2_vals, dtype=float) + indx_phi1 = low_level_coord_names.index('phi1') + indx_phi2 = low_level_coord_names.index('phi2') + chi1_perp = xf[:,indx_chi1]*np.sqrt(1-xf[:,indx_ct1]**2) + chi2_perp = xf[:,indx_chi2]*np.sqrt(1-xf[:,indx_ct2]**2) + v1 = chi1_perp*np.exp(1j*xf[:,indx_phi1]) + v2 = chi2_perp*np.exp(1j*xf[:,indx_phi2]) + mtot_vals = m1f + m2f + q_vals = m2f/m1f + A1 = 2 + 1.5*q_vals; A2 = 2 + 1.5/q_vals + ring_vals = {'chi1_perp': chi1_perp, 'chi2_perp': chi2_perp, + 'phi12': np.where((chi1_perp > 0) & (chi2_perp > 0), np.mod(xf[:,indx_phi2] - xf[:,indx_phi1], 2*np.pi), 0.), + 'SOverM2_perp': np.abs(v1*m1f**2 + v2*m2f**2)/mtot_vals**2, + 'DeltaOverM2_perp': np.abs(v1*m1f - v2*m2f)/mtot_vals, + 'chi_p_vec': np.abs(v1 + (A2/A1)*q_vals**2*v2)} + ring_vals['phi12'] = np.where(ring_vals['phi12'] >= 2*np.pi, 0., ring_vals['phi12']) + for p in ring_names: + if p in coord_names_reduced: + x_out[:,coord_names.index(p)] = ring_vals[p] + coord_names_reduced.remove(p) + if enforce_kerr: + # same rule as the per-row fallthrough below, which this block can bypass + kerr_violation_ring = (xf[:,indx_chi1] > 1) | (xf[:,indx_chi2] > 1) # Spin pseudo-cylindrical coordinate names, standard framing if ('s1z_bar' in low_level_coord_names) and ('phi1' in low_level_coord_names) and ('s2z_bar' in low_level_coord_names) and ('phi2' in low_level_coord_names) and ('mc' in low_level_coord_names) and ('eta' in low_level_coord_names or 'delta_mc' in low_level_coord_names): @@ -5585,6 +5629,8 @@ def convert_waveform_coordinates(x_in,coord_names=['mc', 'eta'],low_level_coord_ x_out[indx_name] *= (1+source_redshift) # return if we don't need to do any more conversions (e.g., if we only have --parameter specification) + if kerr_violation_ring is not None: + x_out[kerr_violation_ring] = -np.inf if len(coord_names_reduced)<1: return x_out diff --git a/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py b/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py new file mode 100644 index 000000000..21007eb10 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py @@ -0,0 +1,100 @@ +"""phi12 and the ring coordinates (chi1_perp, chi2_perp, SOverM2_perp, DeltaOverM2_perp, chi_p_vec): +the vectorized spherical path of convert_waveform_coordinates must agree with extract_param.""" +import numpy as np +import lal +import RIFT.lalsimutils as lsu + +RING = ['chi1_perp', 'chi2_perp', 'phi12', 'SOverM2_perp', 'DeltaOverM2_perp', 'chi_p_vec'] +LOW = ['mc', 'delta_mc', 'chi1', 'cos_theta1', 'phi1', 'chi2', 'cos_theta2', 'phi2'] + + +def _draws(n=300, seed=4): + rng = np.random.default_rng(seed) + x = np.column_stack([rng.uniform(5, 30, n), rng.uniform(0.01, 0.8, n), rng.uniform(0, 0.99, n), + rng.uniform(-1, 1, n), rng.uniform(0, 2 * np.pi, n), rng.uniform(0, 0.99, n), + rng.uniform(-1, 1, n), rng.uniform(0, 2 * np.pi, n)]) + return x + + +def test_vectorized_matches_extract_param(): + x = _draws() + y = lsu.convert_waveform_coordinates(x, coord_names=RING, low_level_coord_names=LOW) + for i, row in enumerate(x): + mc, dmc, c1, ct1, p1, c2, ct2, p2 = row + eta = 0.25 * (1 - dmc ** 2) + m1, m2 = lsu.m1m2(mc, eta) + P = lsu.ChooseWaveformParams() + P.m1, P.m2 = m1 * lal.MSUN_SI, m2 * lal.MSUN_SI + s1 = c1 * np.sqrt(1 - ct1 ** 2); s2 = c2 * np.sqrt(1 - ct2 ** 2) + P.s1x, P.s1y, P.s1z = s1 * np.cos(p1), s1 * np.sin(p1), c1 * ct1 + P.s2x, P.s2y, P.s2z = s2 * np.cos(p2), s2 * np.sin(p2), c2 * ct2 + for j, name in enumerate(RING): + ref = P.extract_param(name) + d = abs(y[i, j] - ref) + if name == 'phi12': + d = min(d, 2 * np.pi - d) + assert d < 1e-9, (name, y[i, j], ref) + + +def test_chi_p_vec_limits(): + # one spin: chi_p_vec equals chi1_perp; opposite in-plane spins of the weighted size cancel + P = lsu.ChooseWaveformParams() + P.m1, P.m2 = 10 * lal.MSUN_SI, 5 * lal.MSUN_SI + P.s1x, P.s1y, P.s2x, P.s2y = 0.4, 0.0, 0.0, 0.0 + assert abs(P.extract_param('chi_p_vec') - 0.4) < 1e-12 + q = 0.5; A1 = 2 + 1.5 * q; A2 = 2 + 1.5 / q + P.s2x = -0.4 / ((A2 / A1) * q ** 2) + if abs(P.s2x) < 1: + assert P.extract_param('chi_p_vec') < 1e-12 + assert abs(P.extract_param('phi12') - np.pi) < 1e-12 + + +def _one(mc, dmc, c1, ct1, p1, c2, ct2, p2): + return np.array([[mc, dmc, c1, ct1, p1, c2, ct2, p2]]) + + +def test_phi12_sign_both_paths(): + # spin 2 60 degrees ahead of spin 1: phi12 = pi/3, not 5 pi/3 (a sign flip in both paths fails here) + x = _one(10., 0.3, 0.5, 0., 0.2, 0.5, 0., 0.2 + np.pi / 3) + y = lsu.convert_waveform_coordinates(x, coord_names=['phi12'], low_level_coord_names=LOW) + assert abs(y[0, 0] - np.pi / 3) < 1e-12 + P = lsu.ChooseWaveformParams() + P.m1, P.m2 = 10 * lal.MSUN_SI, 5 * lal.MSUN_SI + P.s1x, P.s1y = 0.5 * np.cos(0.2), 0.5 * np.sin(0.2) + P.s2x, P.s2y = 0.5 * np.cos(0.2 + np.pi / 3), 0.5 * np.sin(0.2 + np.pi / 3) + assert abs(P.extract_param('phi12') - np.pi / 3) < 1e-12 + + +def test_object_array_input(): + # CIP's default sampler (adaptive_cartesian) passes an object array of python floats + x = _draws(20).astype(object) + y = lsu.convert_waveform_coordinates(x, coord_names=RING, low_level_coord_names=LOW) + y_ref = lsu.convert_waveform_coordinates(x.astype(float), coord_names=RING, low_level_coord_names=LOW) + assert np.allclose(np.asarray(y, dtype=float), y_ref, atol=1e-12) + + +def test_phi12_zero_inplane_spin(): + # phi12 is undefined without an in-plane component; both paths return 0 + for x in (_one(10., 0.3, 0.0, 0.5, 1.5, 0.5, 0.2, 2.5), _one(10., 0.3, 0.5, 0.2, 1.5, 0.5, -1.0, 2.5)): + y = lsu.convert_waveform_coordinates(x, coord_names=['phi12'], low_level_coord_names=LOW) + assert y[0, 0] == 0. + P = lsu.ChooseWaveformParams() + P.m1, P.m2 = 10 * lal.MSUN_SI, 5 * lal.MSUN_SI + P.s1x = P.s1y = 0. + P.s2x, P.s2y = 0.3, 0.1 + assert P.extract_param('phi12') == 0. + + +def test_enforce_kerr_in_ring_block(): + # the vectorized block can end the conversion early; it applies the fallthrough's Kerr rule itself + x = np.vstack([_one(10., 0.3, 1.2, 0.2, 1.5, 0.5, 0.2, 2.5), _one(10., 0.3, 0.5, 0.2, 1.5, 0.5, 0.2, 2.5)]) + y = lsu.convert_waveform_coordinates(x, coord_names=['mc', 'delta_mc', 'chi1_perp', 'chi_p_vec'], + low_level_coord_names=LOW, enforce_kerr=True) + assert np.all(y[0] == -np.inf) and np.all(np.isfinite(y[1])) + y = lsu.convert_waveform_coordinates(x, coord_names=['chi1_perp'], low_level_coord_names=LOW, enforce_kerr=False) + assert np.all(np.isfinite(y)) + + +def test_chi_p_vec_not_assignable(): + # grid readers call assign_param on every column named in valid_params; chi_p_vec is derived only + assert 'chi_p_vec' not in lsu.valid_params