From fa035c517a21195c65787c141b05f581b83577a8 Mon Sep 17 00:00:00 2001 From: Richard O'Shaughnessy Date: Wed, 30 Sep 2026 09:42:37 -0700 Subject: [PATCH 1/3] lalsimutils: phi12 and chi_p_vec, and vectorized in-plane spin coordinates extract_param gains phi12 (listed in valid_params but not implemented) and chi_p_vec, the vector-sum analogue of chi_p (same A1, A2 weights). The spherical branch of convert_waveform_coordinates now builds chi1_perp, chi2_perp, phi12, SOverM2_perp, DeltaOverM2_perp and chi_p_vec vectorized. Test: vectorized path agrees with extract_param to 1e-9. Co-Authored-By: Claude Opus 5.5 --- .../Code/RIFT/lalsimutils.py | 34 ++++++++++++- .../Code/test/test_ring_coordinates.py | 49 +++++++++++++++++++ 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py diff --git a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py index a4132550e..2edf70d67 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py @@ -344,7 +344,7 @@ def lsu_StringFromPNOrder(order): # Class to hold arguments of ChooseWaveform functions # -valid_params = ['m1', 'm2', 's1x', 's1y', 's1z', 's2x', 's2y', 's2z', 'chi1_perp', 'chi2_perp', 'chi1_perp_bar', 'chi2_perp_bar','chi1_perp_u', 'chi2_perp_u', 's1z_bar', 's2z_bar', 'lambda1', 'lambda2', 'theta','phi', 'phiref', 'psi', 'incl', 'tref', 'dist', 'mc', 'mc_ecc', 'eta', 'delta_mc', 'chi1', 'chi2', 'thetaJN', 'phiJL', 'theta1', 'theta2', 'cos_theta1', 'cos_theta2', 'theta1_Jfix', 'theta2_Jfix', 'psiJ', 'beta', 'cos_beta', 'sin_phiJL', 'cos_phiJL', 'phi12', 'phi1', 'phi2', 'LambdaTilde', 'DeltaLambdaTilde', 'lambda_plus', 'lambda_minus', 'q', 'mtot','xi','chiz_plus', 'chiz_minus', 'chieff_aligned','fmin','fref', "SOverM2_perp", "SOverM2_L", "DeltaOverM2_perp", "DeltaOverM2_L", "shu","ampO", "phaseO",'eccentricity','eccentricity_squared','eccentricity_ln', 'chi_pavg','mu1','mu2','eos_table_index','meanPerAno'] +valid_params = ['m1', 'm2', 's1x', 's1y', 's1z', 's2x', 's2y', 's2z', 'chi1_perp', 'chi2_perp', 'chi1_perp_bar', 'chi2_perp_bar','chi1_perp_u', 'chi2_perp_u', 's1z_bar', 's2z_bar', 'lambda1', 'lambda2', 'theta','phi', 'phiref', 'psi', 'incl', 'tref', 'dist', 'mc', 'mc_ecc', 'eta', 'delta_mc', 'chi1', 'chi2', 'thetaJN', 'phiJL', 'theta1', 'theta2', 'cos_theta1', 'cos_theta2', 'theta1_Jfix', 'theta2_Jfix', 'psiJ', 'beta', 'cos_beta', 'sin_phiJL', 'cos_phiJL', 'phi12', 'phi1', 'phi2', 'LambdaTilde', 'DeltaLambdaTilde', 'lambda_plus', 'lambda_minus', 'q', 'mtot','xi','chiz_plus', 'chiz_minus', 'chieff_aligned','fmin','fref', "SOverM2_perp", "SOverM2_L", "DeltaOverM2_perp", "DeltaOverM2_L", "shu","ampO", "phaseO",'eccentricity','eccentricity_squared','eccentricity_ln', 'chi_pavg','chi_p_vec','mu1','mu2','eos_table_index','meanPerAno'] # 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 @@ -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,16 @@ 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 + return np.mod(np.arctan2(self.s2y, self.s2x) - np.arctan2(self.s1y, self.s1x), 2*np.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 @@ -5368,6 +5379,27 @@ 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): + indx_phi1 = low_level_coord_names.index('phi1') + indx_phi2 = low_level_coord_names.index('phi2') + chi1_perp = x_in[:,indx_chi1]*np.sqrt(1-x_in[:,indx_ct1]**2) + chi2_perp = x_in[:,indx_chi2]*np.sqrt(1-x_in[:,indx_ct2]**2) + v1 = chi1_perp*np.exp(1j*x_in[:,indx_phi1]) + v2 = chi2_perp*np.exp(1j*x_in[:,indx_phi2]) + mtot_vals = m1_vals + m2_vals + q_vals = m2_vals/m1_vals + A1 = 2 + 1.5*q_vals; A2 = 2 + 1.5/q_vals + ring_vals = {'chi1_perp': chi1_perp, 'chi2_perp': chi2_perp, + 'phi12': np.mod(x_in[:,indx_phi2] - x_in[:,indx_phi1], 2*np.pi), + 'SOverM2_perp': np.abs(v1*m1_vals**2 + v2*m2_vals**2)/mtot_vals**2, + 'DeltaOverM2_perp': np.abs(v1*m1_vals - v2*m2_vals)/mtot_vals, + 'chi_p_vec': np.abs(v1 + (A2/A1)*q_vals**2*v2)} + 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) # 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): diff --git a/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py b/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py new file mode 100644 index 000000000..917aff508 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py @@ -0,0 +1,49 @@ +"""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 From 6b1e36ebe40c98574e04244a84937546391f7268 Mon Sep 17 00:00:00 2001 From: Richard O'Shaughnessy Date: Wed, 30 Sep 2026 15:39:02 -0700 Subject: [PATCH 2/3] lalsimutils: ring block accepts object arrays; phi12 defined at zero in-plane spin Review of #203 found that CIP's default sampler (adaptive_cartesian) passes an object array of python floats, and the vectorized ring block's np.sqrt raised TypeError, so CIP exited 1 with these fit coordinates. The block now casts to float. phi12 returns 0 in both paths when either in-plane spin vanishes (the two paths disagreed there), and phi12 joins periodic_params. Tests: phi12 at pi/3 in both paths (catches a sign flip in both), object-array input, zero in-plane spin. test_ring_coordinates.py is added to the cip-startup workflow and .gitlab-ci.yml, which list their tests explicitly. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/cip-startup.yml | 3 +- .gitlab-ci.yml | 2 +- .../Code/RIFT/lalsimutils.py | 28 +++++++++------ .../Code/test/test_ring_coordinates.py | 36 +++++++++++++++++++ 4 files changed, 56 insertions(+), 13 deletions(-) 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 2edf70d67..3612fffcc 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$', @@ -1201,7 +1201,10 @@ def extract_param(self,p): 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 + # 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. return np.mod(np.arctan2(self.s2y, self.s2x) - np.arctan2(self.s1y, self.s1x), 2*np.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. @@ -5382,19 +5385,22 @@ def convert_waveform_coordinates(x_in,coord_names=['mc', 'eta'],low_level_coord_ # 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 = x_in[:,indx_chi1]*np.sqrt(1-x_in[:,indx_ct1]**2) - chi2_perp = x_in[:,indx_chi2]*np.sqrt(1-x_in[:,indx_ct2]**2) - v1 = chi1_perp*np.exp(1j*x_in[:,indx_phi1]) - v2 = chi2_perp*np.exp(1j*x_in[:,indx_phi2]) - mtot_vals = m1_vals + m2_vals - q_vals = m2_vals/m1_vals + 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.mod(x_in[:,indx_phi2] - x_in[:,indx_phi1], 2*np.pi), - 'SOverM2_perp': np.abs(v1*m1_vals**2 + v2*m2_vals**2)/mtot_vals**2, - 'DeltaOverM2_perp': np.abs(v1*m1_vals - v2*m2_vals)/mtot_vals, + '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)} for p in ring_names: if p in coord_names_reduced: diff --git a/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py b/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py index 917aff508..2de4e56c6 100644 --- a/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py +++ b/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py @@ -47,3 +47,39 @@ def test_chi_p_vec_limits(): 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. From 4ceb22c8e507f12c46e6115b9f446e65bc03c009 Mon Sep 17 00:00:00 2001 From: Richard O'Shaughnessy Date: Wed, 30 Sep 2026 15:45:06 -0700 Subject: [PATCH 3/3] lalsimutils: ring block keeps the Kerr rule; chi_p_vec not in valid_params From review of the O4d port (#377), which applies here too: - The vectorized in-plane block can end convert_waveform_coordinates before the per-row fallthrough, whose enforce_kerr rule then never ran. The block now applies the same rule (row set to -inf if chi1 or chi2 > 1). Sets that do not use the in-plane names are bit-identical to the base, with and without enforce_kerr. - chi_p_vec is removed from valid_params: grid readers call assign_param on every listed column, and chi_p_vec is derived only. CIP does not need it there. - phi12 cannot return exactly 2 pi. Co-Authored-By: Claude Opus 5.5 --- .../Code/RIFT/lalsimutils.py | 12 ++++++++++-- .../Code/test/test_ring_coordinates.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py index 3612fffcc..e15054715 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/lalsimutils.py @@ -344,7 +344,7 @@ def lsu_StringFromPNOrder(order): # Class to hold arguments of ChooseWaveform functions # -valid_params = ['m1', 'm2', 's1x', 's1y', 's1z', 's2x', 's2y', 's2z', 'chi1_perp', 'chi2_perp', 'chi1_perp_bar', 'chi2_perp_bar','chi1_perp_u', 'chi2_perp_u', 's1z_bar', 's2z_bar', 'lambda1', 'lambda2', 'theta','phi', 'phiref', 'psi', 'incl', 'tref', 'dist', 'mc', 'mc_ecc', 'eta', 'delta_mc', 'chi1', 'chi2', 'thetaJN', 'phiJL', 'theta1', 'theta2', 'cos_theta1', 'cos_theta2', 'theta1_Jfix', 'theta2_Jfix', 'psiJ', 'beta', 'cos_beta', 'sin_phiJL', 'cos_phiJL', 'phi12', 'phi1', 'phi2', 'LambdaTilde', 'DeltaLambdaTilde', 'lambda_plus', 'lambda_minus', 'q', 'mtot','xi','chiz_plus', 'chiz_minus', 'chieff_aligned','fmin','fref', "SOverM2_perp", "SOverM2_L", "DeltaOverM2_perp", "DeltaOverM2_L", "shu","ampO", "phaseO",'eccentricity','eccentricity_squared','eccentricity_ln', 'chi_pavg','chi_p_vec','mu1','mu2','eos_table_index','meanPerAno'] +valid_params = ['m1', 'm2', 's1x', 's1y', 's1z', 's2x', 's2y', 's2z', 'chi1_perp', 'chi2_perp', 'chi1_perp_bar', 'chi2_perp_bar','chi1_perp_u', 'chi2_perp_u', 's1z_bar', 's2z_bar', 'lambda1', 'lambda2', 'theta','phi', 'phiref', 'psi', 'incl', 'tref', 'dist', 'mc', 'mc_ecc', 'eta', 'delta_mc', 'chi1', 'chi2', 'thetaJN', 'phiJL', 'theta1', 'theta2', 'cos_theta1', 'cos_theta2', 'theta1_Jfix', 'theta2_Jfix', 'psiJ', 'beta', 'cos_beta', 'sin_phiJL', 'cos_phiJL', 'phi12', 'phi1', 'phi2', 'LambdaTilde', 'DeltaLambdaTilde', 'lambda_plus', 'lambda_minus', 'q', 'mtot','xi','chiz_plus', 'chiz_minus', 'chieff_aligned','fmin','fref', "SOverM2_perp", "SOverM2_L", "DeltaOverM2_perp", "DeltaOverM2_L", "shu","ampO", "phaseO",'eccentricity','eccentricity_squared','eccentricity_ln', 'chi_pavg','mu1','mu2','eos_table_index','meanPerAno'] # 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 @@ -1205,7 +1205,8 @@ def extract_param(self,p): # 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. - return np.mod(np.arctan2(self.s2y, self.s2x) - np.arctan2(self.s1y, self.s1x), 2*np.pi) + 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 @@ -5169,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: @@ -5402,10 +5404,14 @@ def convert_waveform_coordinates(x_in,coord_names=['mc', 'eta'],low_level_coord_ '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): @@ -5623,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 index 2de4e56c6..21007eb10 100644 --- a/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py +++ b/MonteCarloMarginalizeCode/Code/test/test_ring_coordinates.py @@ -83,3 +83,18 @@ def test_phi12_zero_inplane_spin(): 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