diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b724642bd..8aceae3b2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,43 @@ jobs: - name: Run import check run: python .travis/test-all-mod.py + dependency-compat-check: + needs: install + runs-on: ubuntu-latest + # Focused upstream-library smoke test for scipy/h5py/numba/matplotlib plus + # RIFT's portable precision dtype. This complements the broader import and + # test-run jobs with a compact version matrix and small functional probes. + strategy: + fail-fast: false + matrix: + include: + - lane: legacy + python-version: '3.9' + numpy-pin: 'numpy==1.24.4' + - lane: modern + python-version: '3.12' + numpy-pin: 'numpy>=2.0,<3.0' + name: dependency-compat-check (${{ matrix.lane }} py${{ matrix.python-version }}) + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + cache-dependency-path: requirements.txt + - name: Enable symlink + run: sudo ln -sf $(which python3) /usr/bin/python + - name: Install dependencies + run: | + python -m pip install --upgrade pip --break-system-packages + python -m pip install -r requirements.txt --break-system-packages + # Pin numpy AFTER requirements.txt so it overrides the unpinned + # 'numpy' line in requirements.txt without changing the file. + python -m pip install '${{ matrix.numpy-pin }}' --break-system-packages + python -m pip install --editable . --break-system-packages + - name: Run dependency compatibility check + run: python .travis/test-dependency-compat.py + sim-manager-check: needs: install runs-on: ubuntu-latest diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 646ae725b..ea964449b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -92,6 +92,11 @@ import_check: - python -m pytest -q MonteCarloMarginalizeCode/Code/test/test_lisa_operational_synthetic.py - python -m pytest -q MonteCarloMarginalizeCode/Code/test/test_lisa_helper_contract.py +dependency_compat_check: + stage: system tests + script: + - python .travis/test-dependency-compat.py + sim_manager_check: stage: unit tests script: diff --git a/.travis/test-dependency-compat.py b/.travis/test-dependency-compat.py new file mode 100644 index 000000000..2bdf9b318 --- /dev/null +++ b/.travis/test-dependency-compat.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Smoke-test upstream scientific-library compatibility for RIFT CI. + +This intentionally stays small: the full RIFT import sweep catches broad +breakage, while this file makes the dependency surface in issue #17 explicit +and gives CI logs a compact version matrix for scipy/h5py/numba/matplotlib. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +import h5py +import matplotlib + +matplotlib.use("Agg") +from matplotlib import pyplot as plt # noqa: E402 + +import numba +import numpy as np +import scipy +from scipy import integrate, optimize, stats + +from RIFT.precision import RIFT_FLOAT_HIGH_PRECISION, RIFT_FLOAT_NAME, RiftFloat + + +@numba.njit(cache=False) +def _numba_quadratic(x): + return x * x + 2.0 * x + 1.0 + + +def _check_scipy() -> None: + integral, err = integrate.quad(lambda x: x * x, 0.0, 1.0) + if not np.isclose(integral, 1.0 / 3.0, atol=1e-12): + raise AssertionError(f"scipy.integrate returned {integral} +/- {err}") + + root = optimize.brentq(lambda x: x * x - 2.0, 0.0, 2.0) + if not np.isclose(root, np.sqrt(2.0), atol=1e-12): + raise AssertionError(f"scipy.optimize returned root {root}") + + cdf = stats.norm.cdf(0.0) + if not np.isclose(cdf, 0.5, atol=1e-15): + raise AssertionError(f"scipy.stats returned normal cdf {cdf}") + + +def _check_h5py() -> None: + values = np.arange(6, dtype=np.float64).reshape(2, 3) + with tempfile.TemporaryDirectory() as tmpdir: + h5_path = Path(tmpdir) / "compat.h5" + with h5py.File(h5_path, "w") as h5_file: + h5_file.create_dataset("values", data=values) + h5_file.attrs["library"] = "h5py" + with h5py.File(h5_path, "r") as h5_file: + roundtrip = h5_file["values"][()] + marker = h5_file.attrs["library"] + if marker != "h5py" or not np.array_equal(values, roundtrip): + raise AssertionError("h5py dataset/attribute round trip failed") + + +def _check_numba() -> None: + sample = np.asarray([0.0, 1.0, 2.0]) + expected = np.asarray([1.0, 4.0, 9.0]) + actual = _numba_quadratic(sample) + if not np.allclose(actual, expected): + raise AssertionError(f"numba compiled function returned {actual}") + + +def _check_matplotlib() -> None: + with tempfile.TemporaryDirectory() as tmpdir: + png_path = Path(tmpdir) / "compat.png" + fig, ax = plt.subplots(figsize=(2, 2)) + ax.plot([0, 1], [0, 1]) + ax.set_xlabel("x") + ax.set_ylabel("y") + fig.tight_layout() + fig.savefig(png_path) + plt.close(fig) + if png_path.stat().st_size <= 0: + raise AssertionError("matplotlib produced an empty PNG") + + +def _check_rift_precision() -> None: + dtype = np.dtype(RiftFloat) + if dtype.itemsize < np.dtype(np.float64).itemsize: + raise AssertionError(f"RiftFloat unexpectedly narrower than float64: {dtype}") + if RIFT_FLOAT_HIGH_PRECISION != (dtype.itemsize > np.dtype(np.float64).itemsize): + raise AssertionError("RIFT_FLOAT_HIGH_PRECISION does not match RiftFloat width") + + +def main() -> None: + matrix = { + "numpy": np.__version__, + "scipy": scipy.__version__, + "h5py": h5py.__version__, + "numba": numba.__version__, + "matplotlib": matplotlib.__version__, + "rift_float": RIFT_FLOAT_NAME, + "rift_float_high_precision": RIFT_FLOAT_HIGH_PRECISION, + } + print(json.dumps(matrix, indent=2, sort_keys=True)) + + _check_rift_precision() + _check_scipy() + _check_h5py() + _check_numba() + _check_matplotlib() + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/ILE_data_handling_probe.py b/MonteCarloMarginalizeCode/Code/ILE_data_handling_probe.py index 1eba5a657..b0487949c 100755 --- a/MonteCarloMarginalizeCode/Code/ILE_data_handling_probe.py +++ b/MonteCarloMarginalizeCode/Code/ILE_data_handling_probe.py @@ -210,7 +210,8 @@ def get_unpinned_params(opts, params): NR_template_group=None NR_template_param=None if opts.nr_group and opts.nr_param: - import NRWaveformCatalogManager3 as nrwf + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 NR_template_group = opts.nr_group if nrwf.internal_ParametersAreExpressions[NR_template_group]: NR_template_param = eval(opts.nr_param) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py index eb502050f..c4c48c9a7 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/adaptive.py @@ -170,11 +170,23 @@ def cal_mc_error_from_components(comp, cal_log_weights=None, sample_log_weights= logw = np.zeros(n_cal) if cal_log_weights is None else np.asarray(cal_log_weights, dtype=float) lc = comp + logw[None, :] # log( w_c L_jc ) lnL_marg = logsumexp(lc, axis=1) # per-sample log sum_c w_c L_jc (norm cancels) - log_r = lc - lnL_marg[:, None] # responsibilities r_jc, sum_c r_jc = 1 + # Guard total underflow: a sample whose likelihood is -inf across ALL cal draws has + # lnL_marg = -inf, so lc - lnL_marg = -inf - -inf = NaN, which would poison log_a (and + # hence a_c / sigma / neff, and the adaptive-doubling stop test) for the whole batch. + # Such samples carry zero posterior weight, so force their responsibilities to -inf + # (contribute nothing) instead of NaN. A pathological extrinsic point (e.g. a sky + # position with ~zero antenna response) can produce this on a real run. + finite = np.isfinite(lnL_marg) + log_r = np.full_like(lc, -np.inf) # responsibilities r_jc, sum_c r_jc = 1 + if np.any(finite): + log_r[finite] = lc[finite] - lnL_marg[finite, None] if sample_log_weights is None: - slw = lnL_marg # prior-drawn batch -> weight by marginal L + slw = np.where(finite, lnL_marg, -np.inf) # prior-drawn batch -> weight by marginal L else: slw = np.asarray(sample_log_weights, dtype=float) + slw = np.where(np.isfinite(slw), slw, -np.inf) + if not np.any(np.isfinite(slw)): # fully-degenerate batch: nothing to weight + return float('inf'), 1.0, np.full(n_cal, 1.0 / n_cal) slw = slw - logsumexp(slw) # sum_j W_j = 1 log_a = logsumexp(slw[:, None] + log_r, axis=0) # a_c = sum_j W_j r_jc a_c = np.exp(log_a - logsumexp(log_a)) # exact renormalization diff --git a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/breadcrumbs.py b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/breadcrumbs.py index 07f1223bb..829af9d65 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/breadcrumbs.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/breadcrumbs.py @@ -54,14 +54,14 @@ def save(path, cal=None, extrinsic=None, kind="gaussian", meta=None): cal_prior_sigma=np.asarray(cal["prior_sigma"], dtype=float), cal_node_log_f=np.asarray(cal["node_log_f"], dtype=float), cal_n_nodes_amp=np.int64(cal["n_nodes_amp"]), - cal_dets=np.array(list(cal["dets"]), dtype=object), + cal_dets=np.array([str(x) for x in cal["dets"]]), # string dtype (NOT object): no ) if extrinsic is not None: groups = extrinsic["groups"] d["ext_kind"] = str(extrinsic.get("kind", "gmm")) d["ext_n_groups"] = np.int64(len(groups)) for i, g in enumerate(groups): - d["ext_g%d_params" % i] = np.array(list(g["params"]), dtype=object) + d["ext_g%d_params" % i] = np.array([str(x) for x in g["params"]]) # string, not object d["ext_g%d_means" % i] = np.asarray(g["means"], dtype=float) d["ext_g%d_covs" % i] = np.asarray(g["covariances"], dtype=float) d["ext_g%d_weights" % i] = np.asarray(g["weights"], dtype=float) @@ -113,6 +113,12 @@ def load(path): assert g["kind"] == "gaussian" and g["cal"]["dets"] == ["H1", "L1", "V1"] assert np.allclose(g["cal"]["proposal_mean"], cal["proposal_mean"]) assert g["meta"]["iteration"] == 2 + # PORTABILITY: the file must contain NO pickled objects, so a breadcrumb written by + # one numpy (e.g. the container's 2.x) loads under any other (e.g. the host's 1.x). + # np.load(allow_pickle=False) raises if any array is object-dtype -- guards the dets/ + # params regression where dtype=object silently pickled and broke cross-version load. + with np.load(p, allow_pickle=False) as _z: + assert _z["cal_dets"].dtype.kind in ("U", "S"), _z["cal_dets"].dtype # extrinsic (GMM) round-trip ext = dict(kind="gmm", groups=[ @@ -133,4 +139,7 @@ def load(path): assert np.allclose(g2["extrinsic"]["groups"][0]["means"], ext["groups"][0]["means"]) assert np.allclose(g2["extrinsic"]["groups"][1]["covariances"], ext["groups"][1]["covariances"]) assert g2["cal"] is not None # cal + extrinsic coexist in one breadcrumb - print("PASS: breadcrumb save/load round-trips (cal Gaussian + extrinsic GMM, schema v%d)." % SCHEMA_VERSION) + with np.load(p2, allow_pickle=False) as _z2: # portability: no pickled object arrays + assert _z2["ext_g0_params"].dtype.kind in ("U", "S"), _z2["ext_g0_params"].dtype + print("PASS: breadcrumb save/load round-trips (cal Gaussian + extrinsic GMM, schema v%d), " + "pickle-free (portable across numpy versions)." % SCHEMA_VERSION) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/test_cal_mc_error.py b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/test_cal_mc_error.py index 23461ecb8..80bc25d62 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/test_cal_mc_error.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/test_cal_mc_error.py @@ -94,10 +94,31 @@ def test_extrinsic_batch_weighting(): print("test_extrinsic_batch_weighting: OK") +def test_total_underflow_guard(): + # A sample with -inf lnL across ALL cal draws (e.g. a zero-response extrinsic point) + # must not poison the batch with NaN; it carries zero weight, so the answer must equal + # the finite-samples-only answer. + rng = np.random.default_rng(17) + n_cal = 200 + good = rng.normal(0.0, 0.8, size=(8, n_cal)) + s_good, neff_good, a_good = cal_mc_error_from_components(good) + comp = np.vstack([good, np.full((1, n_cal), -np.inf)]) # append a dead sample + s, neff, a = cal_mc_error_from_components(comp) + assert np.isfinite(s) and np.isfinite(neff), (s, neff) + assert abs(a.sum() - 1.0) < 1e-12 and np.all(np.isfinite(a)) + assert abs(s - s_good) < 1e-9 and abs(neff - neff_good) < 1e-7, (s, s_good, neff, neff_good) + # fully-degenerate batch (every sample dead) returns a finite sentinel, not NaN + dead = np.full((3, n_cal), -np.inf) + s0, neff0, a0 = cal_mc_error_from_components(dead) + assert np.isinf(s0) and neff0 == 1.0 and abs(a0.sum() - 1.0) < 1e-12 + print("test_total_underflow_guard: OK") + + if __name__ == "__main__": test_lognormal_closed_form() test_brute_force_scatter() test_neff_dominated() test_importance_weights_consistency() test_extrinsic_batch_weighting() + test_total_underflow_guard() print("ALL OK") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/calmarg/test_seed_fallback.py b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/test_seed_fallback.py new file mode 100644 index 000000000..6d4d8de08 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/calmarg/test_seed_fallback.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +""" +Regression tests for the calmarg PILOT "graceful degradation" contract. + +Background +---------- +In the in-loop-calmarg PILOT pipeline the wide ILE jobs (and the last-iteration +EXTRINSIC ILE jobs) are SEEDED from the previous iteration's consolidated cal +proposal, referenced as cal_consolidated_$(macroiterationprev).npz and (on OSG +file transfer) listed in condor transfer_input_files. A calpilot only produces +that seed for iterations it<=--calmarg-pilot-max-it on-cadence, so a wide/extrinsic +iteration whose seed was never produced used to HARD-HOLD condor on a missing +transfer source (HoldReasonCode 13) and deadlock the whole DAG. + +The fix pre-seeds, for EVERY referenced iteration index, a placeholder copy of the +always-present cal_consolidated_-1.npz iteration-0 seed -- which is a VALID prior +breadcrumb (proposal == prior). A real calpilot overwrites its placeholder at run +time, so behavior is unchanged whenever the learned seed IS produced; a missing seed +now degrades to the PRIOR instead of dead-holding. This module locks the two +properties that make that safe: + + 1. A prior breadcrumb (the placeholder content) round-trips and, when seeded from, + yields UNWEIGHTED prior cal draws (importance log-weights == 0). I.e. "fall back + to the placeholder" is exactly equivalent to "draw from the broad prior". + 2. The absent/empty-seed guard the ILE binary uses -- + `os.path.exists(p) and os.path.getsize(p) > 0` -- correctly classifies a missing + path and a 0-byte placeholder as "not present yet" (-> prior fallback), and + breadcrumbs.load() raises on an empty file (so the ILE except-fallback fires). + +Run: python3 -m RIFT.calmarg.test_seed_fallback +""" +import os +import tempfile + +import numpy as np + +import RIFT.calmarg.breadcrumbs as breadcrumbs +import RIFT.calmarg.generate_realizations as genr + + +def _prior_breadcrumb_cal(n_amp=5, dets=("H1", "L1"), fmin=20.0, fmax=500.0, + prior_sigma_val=0.1): + """A 'placeholder' cal breadcrumb: proposal == prior (mean, diagonal cov). + + This is the semantic content of cal_consolidated_-1.npz and of the copies the + pipeline pre-seeds into iterations a calpilot never produced. + """ + dim = 2 * n_amp * len(dets) + prior_mean = np.zeros(dim) + prior_sigma = np.full(dim, prior_sigma_val) + return dict( + proposal_mean=prior_mean.copy(), + proposal_cov=np.diag(prior_sigma ** 2), # proposal == prior + prior_mean=prior_mean, + prior_sigma=prior_sigma, + node_log_f=np.linspace(np.log10(fmin), np.log10(fmax), n_amp), + n_nodes_amp=n_amp, + dets=list(dets), + ) + + +def test_prior_placeholder_seeds_as_prior(): + """Seeding from a prior placeholder == unweighted prior draws (log_w == 0).""" + n_amp = 5 + dets = ("H1", "L1") + fmin, fmax = 20.0, 500.0 + cal = _prior_breadcrumb_cal(n_amp=n_amp, dets=dets, fmin=fmin, fmax=fmax) + + p = os.path.join(tempfile.mkdtemp(), "cal_consolidated_-1.npz") + breadcrumbs.save(p, cal=cal, meta=dict(placeholder=True, iteration=-1)) + bc = breadcrumbs.load(p) + + _dat, cal_log_weights, nodes = genr.seed_realizations_from_breadcrumb( + bc, T_segment=4.0, dT=1.0 / 1024, fmin=fmin, fmax=fmax, + n_spline_points=n_amp, n_realizations=256, + rng=np.random.default_rng(1234)) + + assert nodes.shape == (256, 2 * n_amp * len(dets)), nodes.shape + # proposal == prior => log(prior/proposal) == 0 for every realization. + assert np.allclose(cal_log_weights, 0.0, atol=1e-8), \ + "prior-placeholder importance weights should be exactly 0; max|w|=%g" \ + % np.max(np.abs(cal_log_weights)) + print("test_prior_placeholder_seeds_as_prior: OK " + "(max|log_w|=%.2e over %d realizations)" + % (np.max(np.abs(cal_log_weights)), len(cal_log_weights))) + + +def _seed_present(path): + """Exact guard used by the ILE binary to decide 'seed present vs fall back'.""" + return os.path.exists(path) and os.path.getsize(path) > 0 + + +def test_absent_and_empty_seed_guard(): + """A missing path and a 0-byte placeholder both classify as 'not present'.""" + d = tempfile.mkdtemp() + + missing = os.path.join(d, "cal_consolidated_3.npz") # never produced + assert not _seed_present(missing), "missing seed must classify as absent" + + empty = os.path.join(d, "cal_consolidated_.npz") # 0-byte placeholder + open(empty, "a").close() + assert not _seed_present(empty), "0-byte seed must classify as absent" + # ILE loads inside try/except; an empty .npz must raise so the except-fallback fires. + raised = False + try: + breadcrumbs.load(empty) + except Exception: + raised = True + assert raised, "breadcrumbs.load() must raise on an empty (0-byte) placeholder" + + real = os.path.join(d, "cal_consolidated_0.npz") # a genuine seed + breadcrumbs.save(real, cal=_prior_breadcrumb_cal(), meta=dict(iteration=0)) + assert _seed_present(real), "a genuine breadcrumb must classify as present" + breadcrumbs.load(real) # must load without raising + print("test_absent_and_empty_seed_guard: OK " + "(missing -> absent, 0-byte -> absent+raises, real -> present)") + + +if __name__ == "__main__": + test_prior_placeholder_seeds_as_prior() + test_absent_and_empty_seed_guard() + print("ALL OK: calmarg pilot seed absent/placeholder -> graceful prior fallback " + "(no transfer hard-hold).") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py index 3e3579813..ec1a9da73 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerAdaptiveVolume.py @@ -15,6 +15,18 @@ from scipy import integrate, interpolate, special import itertools import functools +import inspect + + +@functools.lru_cache(maxsize=None) +def _prior_pdf_accepts_xpy(fn): + """True if a prior_pdf callable takes an `xpy` kwarg. Many of the mcsamplerGPU prior + helpers default xpy=cupy, so evaluating them on the host CPU copy (as prior_prod does) + would feed a numpy array to cupy and raise; we pass xpy=numpy to those that accept it.""" + try: + return 'xpy' in inspect.signature(fn).parameters + except (TypeError, ValueError): + return False import os @@ -290,8 +302,13 @@ def prior_prod(self, x): x_cpu = identity_convert(x) indx = 0 for param in self.params_ordered: - p_out *= identity_convert_togpu(self.prior_pdf[param](x_cpu[:,indx])) - indx +=1 + fn = self.prior_pdf[param] + xc = x_cpu[:, indx] + # Force host evaluation: several mcsamplerGPU prior helpers default xpy=cupy, which + # would raise on the numpy host copy when cupy is importable (e.g. GPU container runs). + val = fn(xc, xpy=numpy) if _prior_pdf_accepts_xpy(fn) else fn(xc) + p_out *= identity_convert_togpu(val) + indx += 1 return p_out diff --git a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py index 9070e52f0..20687d233 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/integrators/mcsamplerGPU.py @@ -34,7 +34,11 @@ identity_convert_togpu = cupy.asarray junk_to_check_installed = cupy.array(5) # this will fail if GPU not installed correctly cupy_ok = True - cupy_pi = cupy.array(np.pi) + # Keep pi a plain Python float (NOT a device scalar): the prior helpers below divide/add it to + # arrays that may be host (numpy) -- e.g. AV's prior_prod evaluates them on a CPU copy -- and + # `numpy_array / cupy_scalar` re-dispatches to cupy and raises "Unsupported type numpy.ndarray". + # A float works for both backends (cupy_array / float stays on device). + cupy_pi = np.pi from RIFT.interpolators.interp_gpu import interp diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md new file mode 100644 index 000000000..fc6821058 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/SLOWROT_HANDOFF.md @@ -0,0 +1,207 @@ +# Slow-rotation RIFT likelihood — handoff / breadcrumb + +Branch: `rift_slowrot` (off `rift_O4d_fix_rvs_clear_fairdraw_batch`). Design notes + LaTeX +paper live in a **separate local repo** `~/rift-slow-rotation` (no git remote — local only; +21-page PDF via `latexmk -pdf notes/main.tex`). + +## PATH B DELAY PHYSICS — VALIDATED (2026-07 update) +Path B's propagation-delay drift is validated against an INDEPENDENT folded-template ground +truth (`test_slowrot_pathB_bruteforce.py`): data built as Re[F(t) Sigma(t-tau(t))] from the +SAME harmonic F(t),tau(t) model + the SAME modes the likelihood uses (no SimDetectorStrain +convention floor), at an inflated sidereal rate so the delay drift is large. At the REAL +90-min-BNS rate (= x340 inflation on a 16s test): p_max=0 deficit 3.43 -> p_max=1 0.23 -> +p_max=2 0.207 -> p_max=3 0.207: CONVERGES, bound-respected, NO blow-up. So Path B recovers +the delay drift and is production-ready for the target signals with p_max<=2. +KNOWN LIMIT: the p>=3 catastrophic cancellation (huge high-f U terms x tiny delta_tau^p +coefficients) only bites at x1000+ inflation (>2.6x faster than any physical signal): x1000 +gives p=2 deficit 5.9 but p=3 blows to 1e5. So the band-limit fix (low-pass the p>=1 +derivative templates) is a robustness nicety, NOT a blocker for real signals. +Separately validated vs LAL's SimDetectorStrainREAL8TimeSeries (`test_slowrot_pathB_groundtruth.py`): +baseline/PathA/PathB all agree with Jolien's full delay map to ~0.07 at fmax=256 (the ~26 +deficit at fmax=1024 was SimDetectorStrain's high-f TD delay-INTERPOLATION, not a bug -- +confirmed: it vanishes when fmax is lowered). NoLoop uses nearest-neighbor time sampling +(factored_likelihood.py ~L1691), so absolute peak comparisons at high SNR have a resolution +floor (~0.1-0.2); the time-interpolated NoLoop is in oshaughn/rift_O4d and +origin/rift_O4d_junior_calmarg_in_loop if a cleaner absolute comparison is wanted. + +## TWO PARALLEL THRUSTS (2026-07) +Path A + Path B are implemented, validated, and wired into the ILE. Next work is two tracks: +1. **Rotation PE value demo (near-term).** VERIFY-ANYWHERE quick-look DONE + (`~/RIFT_roboto_paper/analyses/slowrot_demo/`, `make demo` / `demo_local.py`): rotation-vs- + static lnL gain grows with Omega*T (null 0.004 -> 64s 0.19 at fixed SNR~30); figure + `outputs/gain_vs_duration.png`. REMAINING: the full DAG injection-recovery PE (posterior + bias + single-network sky map) on the cluster -> `paper/` figure cited from `sec:slowrot` + (structured Makefile targets inject/baseline/rotationA/rotationB/compare). +2. **Frequency-dependent (finite-size) response = Path D (3G regime where 'long' lives).** + RESPONSE FUNCTION IMPLEMENTED + VALIDATED: `slowrot_freqresponse.py` + + `test_slowrot_freqresponse.py`. F_k(f;RA,DEC,psi) from arXiv:2412.01693 single-arm sinc + transfer; f->0 == ComputeDetAMResponse to 6.7e-16; exact FSR null at c/2L=3747 Hz (40 km); + in-band SHAPE distortion 0.24%@1kHz/0.62%@2kHz (LIGO) vs 11.8%/42% (CE) -> negligible for + LIGO, first-order only for 3G. Key: the complex ratio ~1 for CE is dominated by a benign + common e^{-i2pi fL/c} delay (degenerate with tc), NOT sky shape. LAL has NO closed-form FD + response. ROUTE DECISION (notes/sec_freqdep.tex): CE -> route (b) sky-harmonic expansion + (keeps sky extrinsic; fold the common delay into geocenter time; few angular orders); + precessing+HM -> route (a) pinned-sky TD fold. + LIKELIHOOD INTEGRATION DONE + VALIDATED (route b): `factored_likelihood_freqresponse.py` + (+ `test_slowrot_freqresponse_likelihood.py`). Fold the common e^{-i2pi fL/c} delay into the + arrival time; power-series the residual transfer -> sky-independent W_p(f) folded into the FD + modes (reuse ComputeModeIP*) x analytic b_p(sky) -> SKY EXTRINSIC. MAINTAINED-STYLE NoLoop + entry point DiscreteFactoredLogLikelihoodFreqResponseNoLoop (NOT SingleDetectorLogLikelihood). + Validated (CE 40km, 16s): V1 finite-size(L->0) == maintained NoLoop baseline to 3.3e-9; V2 on + a finite-size injection the long-wavelength NoLoop deficit 2.71 -> finite-size 0.558 (converged + Qmax=2), residual ~= peak-resolution floor; V3 Cauchy-Schwarz respected. Scalar companion + agrees with the NoLoop to 0.156 (interp-vs-nearest floor). + REMAINING: ILE wiring (a --freqresponse flag mirroring --rotation-slow); full precessing+HM + (route a pinned-sky); a value demo (the finite-size effect only bites for CE/3G). + AUDIT NOTE (2026-07): all slowrot likelihoods route through the maintained NoLoop; there are + NO SingleDetectorLogLikelihood calls; the ILE wires only NoLoop paths. The scalar + FactoredLogLikelihood*/SingleDetectorLogLikelihood are used only as secondary references and + carry a peak-resolution floor at high SNR -- prefer NoLoop for any absolute comparison. + +## What this is +Generalizes RIFT's marginalized likelihood to account for the **time dependence of the +ground-based detector response over the signal** (Earth rotation), while reusing the +precompute-and-marginalize architecture. Two effects, both implemented (Path A + Path B): +- **Path A** — antenna amplitude drift `F(t)`: exact 5 sidereal harmonics, sky extrinsic. +- **Path B** — propagation-delay drift `tau(t)`: time-domain derivative expansion; adds + delay-derivative order `p` on top of Path A. Matters only for LONG signals. +- **Deferred (Path D)** — frequency-dependent (finite-size) response. Out of scope. + +## Files (all under RIFT/likelihood/ unless noted) +- `slowrot_response.py` — closed-form antenna harmonics `A_n` and delay harmonics `B_n` + (scalar + `*_vector`), derived from `vectorized_lal_tools` conventions. Validated vs LAL + to machine precision (`test_slowrot_response.py`). +- `factored_likelihood_with_rotation.py` — the core: + - `PrecomputeLikelihoodTermsWithRotation(...)` — FD-native precompute: builds each mode + once, applies derivative `(FT_SIGN*2pi i f)^p` (FT_SIGN=-1) and sidereal modulation + `exp(i n Omega t)` (sub-bin freq shift via LAL-FFT phase). Returns bank keyed by + elementary template `a=(p,n)`: `Q^a(t)`, `U^{(a,a')}`, `V^{(a,a')}`. + - `rotation_coefficients` / `rotation_coefficients_vector` — the analytic scalars + `C_{(p,ntilde)} = (1/p!) sum_{n+m=ntilde} A_tilde_n [(-D)^{*p}]_m` (Path A: `{(0,n): A_tilde_n}`). + - `FactoredLogLikelihoodWithRotation(...)` — scalar lnL (per-sample); term1 = `Re[sum_lm + conj(Ylm) sum_a conj(C_a) Q^a(t_det)]`, term2 with `U^{(a,a')}` (coef `conj(C_a)C_a'`) + and `V^{(a,a')}` (coef `C_{(p,-nu)} C_a'`). + - `pack_rotation_arrays` + `DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation` + — the MAINTAINED vectorized (NoLoop) path, mirroring + `factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopOrig`. +- `factored_likelihood.py` — one small FIX: non-numba `lalylm` fallback -> `np.vectorize` + (it broke `ComputeYlmsArrayVector` for the whole vectorized path in non-numba/non-GPU envs). +- `bin/integrate_likelihood_extrinsic_batchmode` — wiring: `--rotation-slow`, + `--rotation-n-harmonics`, `--rotation-p-max`. Guarded (requires `--vectorized`, CPU, no + dist/phase marg). Builds+packs the rotation bank after the baseline pack; the CPU + vectorized `likelihood_function` calls the rotation NoLoop. + +## How to run (RIFT venv; worktree first on PYTHONPATH) + source ~/RIFT_develUWM/bin/activate + export PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code + python RIFT/likelihood/test_slowrot_response.py # A_n,B_n vs LAL + python RIFT/likelihood/test_slowrot_fd_ops.py # FD derivative/modulation + python RIFT/likelihood/test_slowrot_likelihood_v1.py # scalar Path A vs baseline + brute force + python RIFT/likelihood/test_slowrot_noloop.py # vectorized Path A vs baseline NoLoop + python RIFT/likelihood/test_slowrot_noloop_bruteforce.py # vectorized Path A vs brute force + python RIFT/likelihood/test_slowrot_pathB.py # Path B reduction + bound + python RIFT/likelihood/test_slowrot_headtohead.py # matched-sample rotation vs baseline (cubic) + python RIFT/likelihood/test_slowrot_freqresponse.py # [Path D] finite-size response vs LAL + python RIFT/likelihood/test_slowrot_freqresponse_likelihood.py # [Path D] likelihood: V1/V3 + V4 positive control + +VALUE DEMOS (verify-anywhere, no condor/GPU) -- consolidated in the RIFT tree: + cd demo/rift/slowrot && make demo # rotation (Path A/B) + finite-size (Path D) + # demo_rotation.py : gain lnL_rot-lnL_static grows with Omega*T (null control at short T) + # demo_finite_size.py : gain lnL_finite-lnL_LWL grows with arm length fL/c (null at LIGO 4km, + # +39.6 nats at CE 40km); writes outputs/*.{txt,png}. See its README.md. + +End-to-end ILE head-to-head (ILE-GPU-Paper demo data), baseline vs rotation vs finite-size: + D=~/RIFT_develUWM/src/research-projects-RIT/.travis/ILE-GPU-Paper/demos + integrate_likelihood_extrinsic_batchmode --vectorized [std opts] ... # baseline + integrate_likelihood_extrinsic_batchmode --vectorized --rotation-slow ... # Path A + integrate_likelihood_extrinsic_batchmode --vectorized --rotation-slow --rotation-p-max 1 ... # Path B + integrate_likelihood_extrinsic_batchmode --vectorized --freqresponse \ + --freqresponse-arm-length 40000 --freqresponse-qmax 6 ... # Path D (finite-size) + # --interpolate-time selects cubic sub-bin time interpolation for all of the above (default nearest). + +## Validation status (all PASSING) +- Response harmonics vs LAL: ~1e-16. FD ops vs LAL round trips: ~1e-13. +- Path A scalar: V1a (Omega=0 vs baseline) 2.7e-12; V1b (real vs brute force) 2.6e-9. +- Path A vectorized: vs baseline NoLoop 3.6e-12; vs brute force 3.2e-10; V0 (precompute + recovery on real data) exact. +- Path B: scalar reduce-to-baseline 9e-13; respects 0.5; vectorized reduce 6.4e-12. +- Path D (finite-size, --freqresponse): response Sum_p b_p W_p == antenna_response_fd to 6e-11 + on both +/-f; likelihood L->0 reduces to baseline NoLoop 3e-9; Cauchy-Schwarz respected; + V4 positive control asserts finite-size beats LWL by +38.9 nats (15+13 Msun, fmax=2000, CE 40km). +- Cubic time-interp (from calmarg_in_loop, --interpolate-time): both slow-response NoLoops now + support time_interp='nearest'|'cubic'. Cubic exposed+fixed a sub-bin GPS-cancellation bug in + the time reference; head-to-head regression floor 1.6e-3 -> 4.5e-13, test_slowrot_noloop 3.6e-12. +- ILE runs clean (no inf/nan) for baseline / Path A / Path B / Path D; rotation ~ baseline for the + short demo signal (rotation effect ~0.008 in lnL, negligible as physics requires). + +## CRITICAL lesson (a bug that hid for a while) +The U/V template modulation MUST be referenced to the template's INTRINSIC time (epoch ~0), +not the absolute event time t_ev~1e9 -- else a ~1e4 rad spurious phase randomizes U/V and +inflates lnL ABOVE 0.5. This bug once made a short-BBH rotation shift look like ~660 in +lnL; corrected it is ~0.008. My independent "brute force" reference shared the same +convention, so `vec == brute-force` PASSED while both were wrong. **Always cross-check +against the Cauchy-Schwarz bound 0.5, not only against a reference that can share +conventions.** + +## PATH B STATUS (findings 2026-07-04, the systematic pass in progress) +- Matched-seed head-to-head DONE (test_slowrot_headtohead.py): rot(f_sid=0)==baseline 9e-13; + evidence shift ln Z_rot - ln Z_base = -1.1e-3 (MC-noise-free) for the short signal. +- Path B DELAY PHYSICS still only partially validated. In an INFLATED-Omega regime (f_sidereal + x3000, single-det 30+25 BBH) where the delay drift matters, scalar Path B gives + lnL(p_max=0..3) = [1794.39, 1781.31, 1781.63, 928.30]: p=0->1->2 captures a real ~13-in-lnL + delay effect and appears to converge (~1781.6) and respects 0.5=1938 -- BUT p_max=3 + BLOWS UP (increment 853). +- ROOT CAUSE of the p>=3 blow-up (likely): the FD derivative weight (2 pi i f)^p amplifies high + frequencies; in the model norm the integrand ~ (2 pi f)^{2p} |h(f)|^2 / S grows like f^{11/3} + for a chirp (|h|^2 ~ f^{-7/3}), so high-order terms are dominated by the f_max edge, not the + physical low-frequency delay drift. FIX for the systematic pass: BAND-LIMIT the delay- + derivative (p>=1) terms to low frequency (the delay drift physically matters in the long early + inspiral, i.e. low f, not at merger). Options: apply a low-pass / taper before the (2 pi i f)^p + weight, or use a reduced f_max for p>=1 templates, or work with dimensionless (f/f_ref) weights. +- Consequence: current Path B is trustworthy only through p_max<=2 and only after the band-limit + fix is validated. Do NOT ship Path B (p>=1) until this is fixed AND checked against an + independent time-varying-delay brute force (below). + +## OPEN / NEXT (the one remaining systematic pass — do it all together) +1. **Path B rigorous validation** (TWO parts, do together): + (a) FIX the p>=3 high-frequency derivative blow-up by band-limiting the delay-derivative + terms to low frequency (see PATH B STATUS above); re-check convergence is monotone. + (b) Validate vs an INDEPENDENT ground truth that uses LAL's OWN full delay-time map -- + lalsim.SimDetectorStrainREAL8TimeSeries (Jolien's code). KEY FACTS FOUND 2026-07-04: + - RIFT's data ALREADY uses it: non_herm_hoff -> hoft -> SimInspiralTD + + SimDetectorStrainREAL8TimeSeries (lalsimutils.py ~line 3020). So a LONG injection + already carries the real Earth-rotation response; baseline (static) will under-recover + it and Path A/B should recover it. + - GROUND TRUTH for the rotation likelihood must apply SimDetectorStrain to the SAME + RIFT modes the likelihood uses: hk = lsu.hoft_from_hlm(hlmsT, P_extr) (radec path, + applies SimDetectorStrain). Then convert to 2-sided FD exactly like non_herm_hoff + (copy REAL8 -> COMPLEX16, COMPLEX16TimeFreqFFT) and lnL = ComplexIP(d,hk).real - + 0.5*ComplexIP(hk,hk).real. + - DO NOT use non_herm_hoff(P_extr) as the template ground truth: it regenerates via + SimInspiralTD, whose modes differ from RIFT's hlmoff modes -- with IMRPhenomD the + mode-based baseline recovers only ~SNR 35 of the data's ~SNR 82 at the injection (a + waveform-convention mismatch, NOT rotation). Using hoft_from_hlm keeps the modes + identical so ONLY the rotation differs. + - Two gotchas to fix: (i) hoft_from_hlm output length = mode length (e.g. 16451) != + padded data length (16384) -> ResizeREAL8TimeSeries + align epoch (replicate + non_herm_hoff padding); (ii) the mode-based likelihood at a FIXED tref is OFF-PEAK + (epoch bookkeeping) -> compare at the time-MAXIMIZED / marginalized value, not at + extr.tref. + - Use a LONG signal (real sidereal rate) so delay drift matters, OR the inflated-Omega + path (already shows p=1,2 capture the delay, p>=3 needs the band-limit fix). + A convergence test alone is NOT enough: Path B could converge to a WRONG value if the C + coefficients are off (the delay analogue of the reference-time bug). Cross-check 0.5. +2. **Matched-seed quantitative ILE head-to-head** (fixed RNG seed) -> exact, regression-grade + baseline-vs-rotation comparison (currently only MC-noise-level agreement). +3. Cauchy-Schwarz bound checks everywhere; sweep for other shared-convention bugs. +4. Possible follow-ups: GPU (xpy) path for the rotation NoLoop; distance/phase/cal marg + support; Path B `t_star` reference-epoch choice; `rotation_coefficients` closed-form + `A_n`/`B_n` appendix (notes sec app_response has the A_n,B_n). + +## Also note +- A separate background session was fixing the py2-ism in + `factored_likelihood.PackLikelihoodDataStructuresAsArrays` (`rholm_intpArray = range(nKeys)` + fails in py3 when the interpolant arg is truthy). Our tests pass `None` for that arg to + dodge it. +- Subagents were unreliable for this work (deferred, reverted fixes, over-claimed). The + validated results above were done/checked directly. diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py index 44e32c115..7f466fb30 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood.py @@ -84,14 +84,20 @@ def profile(fn): has_GWS=False if not( 'RIFT_LOWLATENCY' in os.environ): - # Dont support external packages in low latency - try: - import NRWaveformCatalogManager3 as nrwf - useNR =True - if log_loud: - print(" factored_likelihood.py : NRWaveformCatalogManager3 available ") - except ImportError: - useNR=False + # Dont support external packages in low latency. + # Resolution order (see RIFT/physics/_nrwf_loader.py): + # 1. nrcatalog.compat_nrwf (cleanup_2026 NR catalog package) + # 2. NRWaveformCatalogManager3 (legacy) + # 3. None + # Override with $RIFT_NRWF_BACKEND={auto,new,legacy,none}. + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, useNR = _rift_get_nrwf() + if useNR and log_loud: + backend = "nrcatalog" if getattr(nrwf, "is_using_legacy", None) else "NRWaveformCatalogManager3" + legacy_note = "" + if getattr(nrwf, "is_using_legacy", None) is not None: + legacy_note = " (legacy wrapped)" if nrwf.is_using_legacy() else " (no legacy)" + print(" factored_likelihood.py : NR backend = " + backend + legacy_note) try: @@ -1396,7 +1402,7 @@ def PackLikelihoodDataStructuresAsArrays(pairKeys, rholms_intpDictionaryForDetec rholmArray[indx1][:] = rholmsDictionaryForDetector[pair1].data.data # Copy the array of time values. ### Step 3: Create rholm_intp array-ized structure - rholm_intpArray = range(nKeys) # create a flexible python array of the desired size, to hold function pointers + rholm_intpArray = [None] * nKeys # create a flexible python array of the desired size, to hold function pointers if rholms_intpDictionaryForDetector: for pair1 in pairKeys: indx1 = lookupKeysToNumber[pair1] @@ -2402,9 +2408,10 @@ def lalT(det, RA, DEC,tref): # note tref is a SCALAR numba_on = False if log_loud: print(" Numba off ") - # Very inefficient - def lalylm(th,ph,s,l,m): - return lal.SpinWeightedSphericalHarmonic(th,ph,s,l,m) + # Very inefficient. np.vectorize replicates the numba @vectorize elementwise + # behavior (the plain scalar wrapper below breaks ComputeYlmsArrayVector, which + # passes numpy ARRAYS for th,ph,s,l,m -> LAL raises 'argument 1 of type REAL8'). + lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) def lalF(det, RA, DEC,psi,tref): if isinstance(RA, float): return ComplexAntennaFactor(det, RA, DEC, psi,tref) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py new file mode 100644 index 000000000..b6d3667bf --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_freqresponse.py @@ -0,0 +1,442 @@ +""" +factored_likelihood_freqresponse : finite-size (frequency-dependent detector response) +generalization of the RIFT precompute + scalar likelihood. "Thrust 2 / route (b)" +(sky-harmonic expansion) of the slow-rotation program -- KEEPS the sky extrinsic. + +Physics (see slowrot_freqresponse.py for the response derivation) +---------------------------------------------------------------- +Beyond the long-wavelength limit the detector strain in the frequency domain is a +per-frequency antenna pattern acting on the two polarizations, + + h_k(f) = F_+(f;sky) h_+(f) + F_x(f;sky) h_x(f) + = (1/2)[ F(f) Sigma(f) + Fbar(f) Sigma*(f) ] , + F = F_+ + i F_x, Fbar = F_+ - i F_x = conj(F(-f)), Sigma = sum_lm Y_lm h_lm. + +The finite-size response factors into a SKY-INDEPENDENT frequency basis W_p(f) times +an analytic SKY/pol scalar b_p (slowrot_freqresponse.finite_size_response_weights / +finite_size_beta): + + F(f;sky) = sum_p b_p(sky) W_p(f) , Fbar(f;sky) = sum_p conj(b_p) W_p(f) , + + p=0 : W_0 = 1, b_0 = F0 (exact lal ComputeDetAMResponse) + p=1+q : W_{1+q} = e^{-i2pi f T} c_q(f) - [q==0], b_{1+q} = beta_q (arm expansion) + +with T = L/c the common (direction-independent) light-crossing delay folded into W_p as +a linear FD phase, and c_q(f) the sky-independent power-series basis of the single-arm +transfer. Every W_p is Hermitian, so -- UNLIKE the sidereal-rotation case -- the V cross +term needs NO harmonic reflection. + +This module is the DIRECT analogue of factored_likelihood_with_rotation: it folds each +W_p(f) into the FD modes ONCE (h_lm(f) -> W_p(f) h_lm(f)) and reuses the EXISTING +ComputeModeIPTimeSeries / ComputeModeCrossTermIP to build + + Q^p_lm(t) = < W_p h_lm | d >, U^{p,p'} = < W_p h_lm | W_p' h_l'm' >, + V^{p,p'} = < conj(W_p h_lm) | W_p' h_l'm' > . + +MAINTAINED PATH: the production entry point is the vectorized (NoLoop) +DiscreteFactoredLogLikelihoodFreqResponseNoLoop -- the direct finite-size analogue of +factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop (the maintained +likelihood used by integrate_likelihood_extrinsic_batchmode). It is VALIDATED against that +NoLoop baseline: at L->0 it reduces to it (to ~1e-9), NOT to the older per-detector scalar +SingleDetectorLogLikelihood. FactoredLogLikelihoodFreqResponse (scalar) is a per-sample +validation companion only (mirrors factored_likelihood_with_rotation.FactoredLogLikelihoodWithRotation). + +The term1/term2 use the same factored form as the NoLoop path (all RIFT factored likelihoods +share this algebra; W_0=1 and b_0 = ComputeDetAMResponse at L->0): + term1 = Re[ (distRef/dist) sum_lm conj(Ylm) sum_p conj(b_p) Q^p_lm(t_det) ] + term2 = -(1/4)(distRef/dist)^2 Re[ sum_{p,p'} ( conj(b_p) b_p' U^{p,p'} conj(Y1)Y2 + + b_p b_p' V^{p,p'} Y1 Y2 ) ] + +Heavy RIFT imports are lazy so the light FD helpers stay importable with numpy alone. +""" +from __future__ import print_function, division + +import numpy as np + +from . import slowrot_freqresponse as sfr + + +# --------------------------------------------------------------------------- +# Low-level FD primitive: fold a per-bin weight into a COMPLEX16FrequencySeries. +# --------------------------------------------------------------------------- +def evaluate_fvals_from_length(npts, deltaF): + """Signed frequency array for a RIFT two-sided COMPLEX16FrequencySeries. + + Identical packing to factored_likelihood_with_rotation.evaluate_fvals_from_length / + RIFT.lalsimutils.evaluate_fvals: f[k] = deltaF*(npts/2 - k), descending from +fNyq. + """ + k = np.arange(npts) + return deltaF * (npts / 2.0 - k) + + +def _copy_freqseries(hf): + import lal + out = lal.CreateCOMPLEX16FrequencySeries( + hf.name, hf.epoch, hf.f0, hf.deltaF, hf.sampleUnits, hf.data.length) + out.data.data[:] = hf.data.data[:] + return out + + +def fd_apply_weight(hf, weight): + """COMPLEX16FrequencySeries -> new series with spectrum multiplied by weight[k].""" + out = _copy_freqseries(hf) + out.data.data[:] = hf.data.data * weight + return out + + +# --------------------------------------------------------------------------- +# The precompute (sky-independent; per detector uses only its arm length L). +# --------------------------------------------------------------------------- +def PrecomputeLikelihoodTermsFreqResponse( + event_time_geo, t_window, P, data_dict, psd_dict, Lmax, fMax, + Qmax=4, L_arm=None, + analyticPSD_Q=False, inv_spec_trunc_Q=False, T_spec=0., + verbose=True, quiet=False, skip_interpolation=False, **hlm_kwargs): + """Finite-size analogue of PrecomputeLikelihoodTerms(WithRotation). + + Builds each FD mode once and forms the generalized overlaps for every response + basis element p = 0..Qmax+1: + + rholms_intp_fr[det][p][(l,m)] : interpolant of Q^p_lm(t) = + crossTerms_fr[det][(p,p')] : { ((l,m),(l',m')) : } + crossTermsV_fr[det][(p,p')] : { ((l,m),(l',m')) : } + + Parameters mirror PrecomputeLikelihoodTerms; finite-size specific: + Qmax : highest power of the arm projection retained (basis size Qmax+2). + L_arm : arm-length override [m] (float -> all detectors, or dict det->L). + None -> each detector's native LAL arm length. + + The response coefficients b_p(det,RA,DEC,psi,tref) that contract these overlaps are + NOT computed here -- see FactoredLogLikelihoodFreqResponse. Returns the intrinsic- + only, sky-independent overlap bank plus meta (carries per-detector L, T, Qmax). + """ + import lal + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + assert data_dict.keys() == psd_dict.keys() + detectors = list(data_dict.keys()) + + def _L_of(det): + if isinstance(L_arm, dict): + return L_arm.get(det, None) + return L_arm + + P.dist = FL.distMpcRef * 1e6 * lsu.lsu_PC + P.deltaF = data_dict[detectors[0]].deltaF + + # --- build base FD modes ONCE --- + hlms, hlms_conj = FL.internal_hlm_generator(P, Lmax, verbose=verbose, quiet=quiet, + **hlm_kwargs) + modes = list(hlms.keys()) + npts = hlms[modes[0]].data.length + deltaF = hlms[modes[0]].deltaF + fvals = evaluate_fvals_from_length(npts, deltaF) + p_list = list(range(Qmax + 2)) + + rholms_fr = {} + rholms_intp_fr = {} + crossTerms_fr = {} + crossTermsV_fr = {} + Ldict = {} + Tdict = {} + + for det in detectors: + psd = psd_dict[det] + data = data_dict[det] + + # per-detector geometry (arm length only; sky enters later via b_p) + _, _, _, L = sfr.detector_geometry(det, L_arm=_L_of(det)) + Ldict[det] = float(L) + Tdict[det] = float(L / sfr.C_SI) + # sky-independent response basis weights W_p(f) on the signed fvals + geom_dummy = dict(L=float(L), T=float(L / sfr.C_SI)) + W = sfr.finite_size_response_weights(fvals, geom_dummy, Qmax) # (Qmax+2, npts) + + # weighted mode families eta^p = W_p h_lm , etac^p = W_p conj(h_lm) + eta = {p: {lm: fd_apply_weight(hlms[lm], W[p]) for lm in modes} for p in p_list} + etac = {p: {lm: fd_apply_weight(hlms_conj[lm], W[p]) for lm in modes} for p in p_list} + + t_det = FL.ComputeArrivalTimeAtDetector(det, P.phi, P.theta, event_time_geo) + rho_epoch = data.epoch - hlms[modes[0]].epoch + t_shift = float(float(t_det) - float(t_window) - float(rho_epoch)) + N_shift = int(t_shift / P.deltaT + 0.5) + N_window = int(2 * t_window / P.deltaT) + t = np.arange(N_window) * P.deltaT + float(rho_epoch + N_shift * P.deltaT) + + rholms_fr[det] = {} + rholms_intp_fr[det] = {} + for p in p_list: + rho = FL.ComputeModeIPTimeSeries( + eta[p], data, psd, P.fmin, fMax, 1. / 2. / P.deltaT, + N_shift, N_window, analyticPSD_Q, inv_spec_trunc_Q, T_spec) + rholms_fr[det][p] = rho + if not skip_interpolation: + rholms_intp_fr[det][p] = FL.InterpolateRholms(rho, t, verbose=verbose) + else: + rholms_intp_fr[det][p] = None + + crossTerms_fr[det] = {} + crossTermsV_fr[det] = {} + for p in p_list: + for pp in p_list: + crossTerms_fr[det][(p, pp)] = FL.ComputeModeCrossTermIP( + eta[p], eta[pp], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, verbose=False, + same_waveform_Q=False) + crossTermsV_fr[det][(p, pp)] = FL.ComputeModeCrossTermIP( + etac[p], eta[pp], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, prefix="V", verbose=False, + same_waveform_Q=False) + + meta = dict(Qmax=Qmax, p_list=p_list, modes=modes, + event_time_geo=float(event_time_geo), + L=Ldict, T=Tdict, L_arm=L_arm) + return rholms_intp_fr, crossTerms_fr, crossTermsV_fr, rholms_fr, meta + + +# --------------------------------------------------------------------------- +# Response coefficients b_p(det, RA, DEC, psi, tref) : b_0 = F0, b_{1+q} = beta_q. +# --------------------------------------------------------------------------- +def response_coefficients(det, RA, DEC, psi, tref, Qmax, L_arm=None): + """{p: b_p} response coefficients for the finite-size basis at (RA,DEC,psi,tref). + + b_0 = F0 = lal.ComputeDetAMResponse (exact LWL baseline); + b_{1+q} = beta_q(sky) = (1/2)[zx^2 a_x^q - zy^2 a_y^q]. + Uses gmst = GreenwichMeanSiderealTime(tref) exactly like ComplexAntennaFactor. + """ + import lal + gmst = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) + geom = sfr.finite_size_geometry(det, RA, DEC, psi, gmst=gmst, L_arm=L_arm) + beta = sfr.finite_size_beta(geom, Qmax) + b = {0: geom['F0']} + for q in range(Qmax + 1): + b[1 + q] = complex(beta[q]) + return b + + +def FactoredLogLikelihoodFreqResponse(extr_params, rholms_intp_fr, crossTerms_fr, + crossTermsV_fr, meta, Lmax): + """Finite-size analogue of factored_likelihood.FactoredLogLikelihood. + + Contracts the response-basis precompute bank with b_p(det,RA,DEC,psi,tref) and the + Ylm. Reduces EXACTLY to the baseline FactoredLogLikelihood as L -> 0. + """ + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + Qmax = meta['Qmax'] + p_list = list(meta['p_list']) + L_arm = meta.get('L_arm', None) + + RA = extr_params.phi + DEC = extr_params.theta + tref = extr_params.tref + phiref = extr_params.phiref + incl = extr_params.incl + psi = extr_params.psi + dist = extr_params.dist + + detectors = list(rholms_intp_fr.keys()) + p0 = p_list[0] + modes = list(rholms_intp_fr[detectors[0]][p0].keys()) + Ylms = FL.ComputeYlms(Lmax, incl, -phiref, selected_modes=modes) + + distMpc = dist / (lsu.lsu_PC * 1e6) + invDistMpc = FL.distMpcRef / distMpc + + def _L_of(det): + if isinstance(L_arm, dict): + return L_arm.get(det, None) + return L_arm + + lnL = 0. + for det in detectors: + b = response_coefficients(det, RA, DEC, psi, tref, Qmax, L_arm=_L_of(det)) + t_det = FL.ComputeArrivalTimeAtDetector(det, RA, DEC, tref) + CT = crossTerms_fr[det] + CTV = crossTermsV_fr[det] + + Q = {p: {m: rholms_intp_fr[det][p][m](float(t_det)) + for m in modes} for p in p_list} + + term1 = 0. + for m in modes: + s = 0. + for p in p_list: + s += np.conj(b[p]) * Q[p][m] + term1 += np.conj(Ylms[m]) * s + term1 = np.real(term1) * invDistMpc + + term2 = 0. + for m1 in modes: + for m2 in modes: + u = 0. + v = 0. + for p in p_list: + for pp in p_list: + u += np.conj(b[p]) * b[pp] * CT[(p, pp)][(m1, m2)] + v += b[p] * b[pp] * CTV[(p, pp)][(m1, m2)] + term2 += u * np.conj(Ylms[m1]) * Ylms[m2] + v * Ylms[m1] * Ylms[m2] + term2 = -np.real(term2) / 4. / (distMpc / FL.distMpcRef) ** 2 + + lnL += term1 + term2 + + return lnL + + +# --------------------------------------------------------------------------- +# Dense-array packing + vectorized time-marginalized lnL (for peak / validation). +# --------------------------------------------------------------------------- +def pack_freqresponse_arrays(meta, rholms_fr, crossTerms_fr, crossTermsV_fr): + """Pack the basis precompute bank into dense arrays keyed per detector by p. + + Returns (lookupNKDict, rho_by_p, U_by_pp, V_by_pp, epochDict). + """ + p_list = list(meta['p_list']) + lookupNKDict = {}; rho_by_p = {}; U_by_pp = {}; V_by_pp = {}; epochDict = {} + for det in rholms_fr: + p0 = p_list[0] + modes = list(rholms_fr[det][p0].keys()) + n_lms = len(modes) + idx = {m: i for i, m in enumerate(modes)} + lookupNKDict[det] = np.array([[m[0], m[1]] for m in modes], dtype=int) + npts = rholms_fr[det][p0][modes[0]].data.length + epochDict[det] = float(rholms_fr[det][p0][modes[0]].epoch) + rho_by_p[det] = {} + for p in p_list: + arr = np.zeros((n_lms, npts), dtype=np.complex128) + for m in modes: + arr[idx[m]] = rholms_fr[det][p][m].data.data + rho_by_p[det][p] = arr + U_by_pp[det] = {}; V_by_pp[det] = {} + for p in p_list: + for pp in p_list: + Um = np.zeros((n_lms, n_lms), dtype=np.complex128) + Vm = np.zeros((n_lms, n_lms), dtype=np.complex128) + cU = crossTerms_fr[det][(p, pp)] + cV = crossTermsV_fr[det][(p, pp)] + for m1 in modes: + for m2 in modes: + Um[idx[m1], idx[m2]] = cU[(m1, m2)] + Vm[idx[m1], idx[m2]] = cV[(m1, m2)] + U_by_pp[det][(p, pp)] = Um + V_by_pp[det][(p, pp)] = Vm + return lookupNKDict, rho_by_p, U_by_pp, V_by_pp, epochDict + + +def DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, P_vec, meta, lookupNKDict, rho_by_p, U_by_pp, V_by_pp, epochDict, + Lmax=2, array_output=False, time_interp='nearest', xpy=np): + """Vectorized finite-size lnL over a time window (single extrinsic point per call + is supported; P_vec fields RA,DEC,incl,phiref,psi,dist may be length-1 arrays). + + GPU: pass xpy=cupy with rho_by_p/U_by_pp/V_by_pp already on device (the ILE converts them + when --gpu). term1 reuses the baseline fused Q_inner_product kernel PER basis weight p + (A = conj(Ylm)) -> no (n_ext,npts,n_lms) temporary, same GPU memory footprint as the baseline; + term2 is small |p_list|^2 einsums over n_lms^2. Exactly mirrors the rotation NoLoop GPU port. + + array_output=True returns lnL_t of shape (npts_ex, npts); else time-marginalized. + """ + import lal + from . import factored_likelihood as FL + on_gpu = not (xpy is np) + if on_gpu: + from . import Q_inner_product + + def _h(v): # host (numpy) copy -- response_coefficients / Ylm / delay helpers are numpy/LAL. + # Under --gpu the ILE hands P_vec fields as cupy arrays; np.atleast_1d(cupy) would raise. + return np.atleast_1d(v.get() if hasattr(v, 'get') else np.asarray(v)) + + p_list = list(meta['p_list']) + Qmax = meta['Qmax'] + L_arm = meta.get('L_arm', None) + RA = _h(P_vec.phi); DEC = _h(P_vec.theta) + incl = _h(P_vec.incl); phiref = _h(P_vec.phiref) + psi = _h(P_vec.psi) + npts = len(tvals); npts_ex = len(RA) + distMpc = _h(P_vec.dist) / (lal.PC_SI * 1e6) + inv_dist = xpy.asarray(FL.distMpcRef / distMpc) # (npts_ex,) on device under --gpu + lnL_t = xpy.zeros((npts_ex, npts), dtype=np.float64) + + def _L_of(det): + if isinstance(L_arm, dict): + return L_arm.get(det, None) + return L_arm + + for det in rho_by_p: + n_lms = len(lookupNKDict[det]) + Ylms = FL.ComputeYlmsArrayVector(lookupNKDict[det], incl, -phiref).T # (npts_ex,n_lms) + # per-sample response coefficients b_p (npts_ex,) + bvec = {} + for i in range(npts_ex): + bi = response_coefficients(det, float(RA[i]), float(DEC[i]), float(psi[i]), + P_vec.tref, Qmax, L_arm=_L_of(det)) + for p in p_list: + bvec.setdefault(p, np.zeros(npts_ex, dtype=complex)) + bvec[p][i] = bi[p] + + t_ref = epochDict[det] + # Precision-preserving time reference (see rotation NoLoop): keep (tref - epoch) and the + # geometric delay separate so the sub-bin fraction is exact under cubic interpolation. + detector_location = np.asarray(FL.lalsim.DetectorPrefixToLALDetector(det).location) + gmst_tref = float(lal.GreenwichMeanSiderealTime(P_vec.tref)) + # RA/DEC are host numpy arrays; TimeDelayFromEarthCenter defaults xpy=cupy whenever cupy + # is importable, so force the host path (else it feeds host arrays to cupy.cos and raises + # -- this likelihood is CPU-only but runs inside the GPU cvmfs container). + t_det = float(P_vec.tref - float(t_ref)) + FL.TimeDelayFromEarthCenter( + detector_location, RA, DEC, gmst_tref, xpy=np) + sample_first = (t_det + float(tvals[0])) / P_vec.deltaT # float(): tvals may be a cupy array on GPU + if time_interp == 'nearest': + ifirst = (np.round(sample_first) + 0.5).astype(int) + else: + ifirst = np.floor(sample_first).astype(int) + frac_first = (sample_first - np.floor(sample_first)).astype(np.float64) + ilast = ifirst + npts + + # Device-side arrays for the heavy contraction (identity on CPU; host->device on GPU). + Ylms_d = xpy.asarray(Ylms); conjY_d = xpy.conj(Ylms_d) + b_d = {p: xpy.asarray(bvec[p]) for p in p_list} + + term1 = xpy.zeros((npts_ex, npts), dtype=np.complex128) + if on_gpu: + # term1 = Re[ sum_p conj(b_p) sum_lm conj(Ylm) Q^p_lm(t) ]: reuse the baseline fused + # kernel per basis weight p (A = conj(Ylm)), no (n_ex,npts,n_lms) temporary. + ifirst_i32 = xpy.asarray(ifirst).astype(np.int32) + frac_d = None if time_interp == 'nearest' else xpy.asarray(frac_first) + for p in p_list: + Q = xpy.ascontiguousarray(rho_by_p[det][p].T) # (n_time, n_lms), device + if time_interp == 'nearest': + res = Q_inner_product.Q_inner_product_cupy(Q, conjY_d, ifirst_i32, npts) + else: + res = Q_inner_product.Q_inner_product_cubic_cupy(Q, conjY_d, ifirst_i32, frac_d, npts) + term1 += xpy.conj(b_d[p])[:, None] * res + else: + for p in p_list: + det_rho = rho_by_p[det][p] + if time_interp == 'nearest': + Qa = np.empty((npts_ex, npts, n_lms), dtype=np.complex128) + for i in range(npts_ex): + Qa[i] = det_rho[..., ifirst[i]:ilast[i]].T + else: + Qa = FL._cubic_Q_window_numpy(det_rho.T, ifirst, frac_first, npts) + term1 += np.conj(bvec[p])[:, None] * np.einsum('xi,xti->xt', np.conj(Ylms), Qa) + term1 = term1.real * inv_dist[:, None] + + term2 = xpy.zeros(npts_ex, dtype=np.complex128) + for p in p_list: + for pp in p_list: + term2 += xpy.conj(b_d[p]) * b_d[pp] * xpy.einsum( + 'xi,xj,ij->x', conjY_d, Ylms_d, xpy.asarray(U_by_pp[det][(p, pp)])) + term2 += b_d[p] * b_d[pp] * xpy.einsum( + 'xi,xj,ij->x', Ylms_d, Ylms_d, xpy.asarray(V_by_pp[det][(p, pp)])) + term2 = (-0.25 * term2.real) * inv_dist ** 2 + + lnL_t += term1 + term2[:, None] + + if array_output: + return lnL_t + lnLmax = xpy.max(lnL_t, axis=-1, keepdims=True) + simps = FL.optimized_gpu_tools.simps if on_gpu else FL.my_simps + L = simps(xpy.exp(lnL_t - lnLmax), dx=P_vec.deltaT, axis=-1) + return lnLmax[..., 0] + xpy.log(L) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py new file mode 100644 index 000000000..04e9d6f43 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/factored_likelihood_with_rotation.py @@ -0,0 +1,655 @@ +""" +factored_likelihood_with_rotation : slow-rotation generalization of the RIFT precompute. + +This is a *separate realization* of factored_likelihood.PrecomputeLikelihoodTerms that +accounts for the time dependence of the ground-based detector response over long signals +(Earth rotation). It does NOT regenerate a family of time-domain templates. Instead it +builds each frequency-domain mode ONCE and realizes the two response modulations as cheap +frequency-domain operations on that single template: + + * delay-derivative order p (Path B): time derivative h^(p)(t) <-> (s 2 pi i f)^p H(f) + -- an exact per-bin weight in the FD. (s = FT_SIGN, fixed to match RIFT's FFT + convention; see evaluate_fvals, which itself warns the omega-sign is reversed.) + + * sidereal harmonic n (Paths A,B): modulation exp(i n Omega t) <-> frequency + shift H(f - n f_sid). Since f_sid = Omega/2pi ~ 1.16e-5 Hz is sub-bin, the shift is + realized exactly as a time-domain linear phase (one FFT round trip per template), not + an integer bin roll. + +The response harmonics themselves (the analytic scalars that multiply these precomputed +overlaps in the extrinsic layer) come from RIFT.likelihood.slowrot_response: + + F_k(t) = sum_{n=-2}^{2} A_n e^{i n g}, tau_k(t) = sum_{n=-1}^{1} B_n e^{i n g}, + g = GMST(t) - RA, A_n, B_n depend only on (detector, dec, psi/dec). + +See the design notes (rift-slow-rotation: sec_formalism 'master expansion', sec_amplitude +Path A, sec_delay Path B, app_response) for the derivation and the meaning of the indices. + +Index conventions in the returned structures +-------------------------------------------- +An "elementary modulated template" is labelled a = (p, n): + chi_a(t) = exp(i n Omega t) * d^p/dt^p h_lm(t - tau_0). +The physical data-term time series carries a post-phase (derived in the notes): + Q^a_lm(t) = exp(i n Omega t) * < chi_a(.-t) | d > [applied here] +while the cross terms are arrival-time independent: + U^{a,a'} = < chi_a | chi_a' >, V^{a,a'} = < chi_a^* | chi_a' >. + +Path A (default) uses only p = 0 (amplitude drift; exact 5-harmonic). Path B adds p >= 1. + +Heavy RIFT imports (factored_likelihood, lalsimutils) are done lazily inside the precompute +so the light FD primitives below are importable with numpy alone (used by the unit tests). +""" +from __future__ import print_function, division + +import numpy as np + +# Sidereal angular rate [rad/s] and frequency [Hz] +OMEGA_EARTH = 7.292115e-5 +F_SIDEREAL = OMEGA_EARTH / (2.0 * np.pi) + +# Sign in the time-derivative weight (s 2 pi i f)^p, matched to RIFT's evaluate_fvals FFT +# convention. VALIDATED empirically against a LAL FFT round trip in +# test_slowrot_fd_ops.py (which will fail loudly if this is wrong). +FT_SIGN = -1.0 + + +# --------------------------------------------------------------------------- +# Low-level FD primitives (numpy only; operate on a complex spectrum + its fvals). +# --------------------------------------------------------------------------- +def evaluate_fvals_from_length(npts, deltaF): + """Signed frequency array for a RIFT two-sided COMPLEX16FrequencySeries. + + Replicates RIFT.lalsimutils.evaluate_fvals (kept local so these primitives need no + heavyweight import). Note RIFT's expression ``npts/2 - k if k<=npts/2 else -k+npts/2`` + is the same in both branches, i.e. simply f[k] = deltaF*(npts/2 - k), descending from + +fNyq at k=0. + """ + k = np.arange(npts) + return deltaF * (npts / 2.0 - k) + + +def time_derivative_weight(fvals, p): + """(FT_SIGN * 2 pi i f)^p : exact FD weight for the p-th time derivative.""" + if p == 0: + return np.ones_like(fvals, dtype=complex) + return (FT_SIGN * 2.0j * np.pi * fvals) ** p + + +def apply_time_derivative_array(spectrum, fvals, p): + """Return the spectrum of d^p/dt^p h given the spectrum of h (both two-sided).""" + return spectrum * time_derivative_weight(fvals, p) + + +def apply_sidereal_modulation_array(spectrum, coef, deltaF, f_sidereal=F_SIDEREAL): + """Return the spectrum of exp(i coef Omega t) h(t) given the spectrum of h(t). + + exp(i coef Omega t) multiplication in time <-> frequency shift by coef*f_sidereal. + This is sub-bin (f_sidereal << deltaF for realistic segments), so it is applied exactly + as a time-domain linear phase via a DFT round trip that respects RIFT's fvals packing. + + WARNING: this reference implementation forms an explicit (npts x npts) DFT matrix -- it + is O(npts^2) and intended ONLY for unit tests at small npts. The production path uses + the LAL-FFT round trip in _lal_freq_modulate() below (O(npts log npts)). + """ + if coef == 0: + return spectrum.copy() + npts = len(spectrum) + dt = 1.0 / (npts * deltaF) + t = np.arange(npts) * dt + fvals = evaluate_fvals_from_length(npts, deltaF) + # Round-trip-consistent DFT pair for the packing f[k]=deltaF*(npts/2-k): + # h(t_j) = (1/npts) sum_k H_k exp(+2 pi i f_k t_j), H_k = sum_j h(t_j) exp(-2 pi i f_k t_j) + # LAL's convention (encoded by evaluate_fvals) reverses the omega sign: a tone + # exp(+2 pi i f0 t) lands at fvals = -f0. The consistent DFT pair is therefore + # h(t_j) = (1/npts) sum_k H_k exp(-2 pi i f_k t_j), H_k = sum_j h(t_j) exp(+2 pi i f_k t_j). + phase_inv = np.exp(-2.0j * np.pi * np.outer(t, fvals)) # (npts, npts): H -> h(t) + h_td = phase_inv.dot(spectrum) / npts + h_td *= np.exp(1.0j * coef * 2.0 * np.pi * f_sidereal * t) # exp(i coef Omega t) + phase_fwd = np.exp(2.0j * np.pi * np.outer(fvals, t)) # h(t) -> H + return phase_fwd.dot(h_td) + + +# --------------------------------------------------------------------------- +# LAL-series wrappers (used inside the precompute; import lal lazily). +# --------------------------------------------------------------------------- +def _copy_freqseries(hf): + import lal + out = lal.CreateCOMPLEX16FrequencySeries( + hf.name, hf.epoch, hf.f0, hf.deltaF, hf.sampleUnits, hf.data.length) + out.data.data[:] = hf.data.data[:] + return out + + +def fd_apply_time_derivative(hf, p): + """COMPLEX16FrequencySeries -> new series for the p-th time derivative.""" + if p == 0: + return _copy_freqseries(hf) + out = _copy_freqseries(hf) + fvals = evaluate_fvals_from_length(hf.data.length, hf.deltaF) + out.data.data[:] = apply_time_derivative_array(hf.data.data, fvals, p) + return out + + +def _lal_freq_modulate(hf, coef, f_sidereal=F_SIDEREAL, t_ref=0.0): + """Modulate by exp(i coef Omega (t_abs - t_ref)) via a LAL-FFT round trip (O(N log N)). + + Reverse-FFT the spectrum to the time domain, multiply by the sidereal linear phase + referenced to ABSOLUTE sample time t_abs = float(hf.epoch) + j*deltaT (minus t_ref), + forward-FFT back. Uses the same COMPLEX16 transforms RIFT uses for its overlaps. + + The reference t_ref is physical, not cosmetic: the true antenna phase is + exp(i n (GMST(t')-RA)) = exp(i n (GMST(t_ev)-RA)) * exp(i n Omega (t'-t_ev)), so the + precompute must carry exactly exp(i n Omega (t' - t_ev)) at absolute data time t', + with the constant GMST(t_ev) piece carried analytically by A_n (slowrot_response). + Hence callers pass t_ref = event_time_geo. + """ + import lal + if coef == 0: + return _copy_freqseries(hf) + npts = hf.data.length + deltaT = 1.0 / (npts * hf.deltaF) + ts = lal.CreateCOMPLEX16TimeSeries("m", hf.epoch, 0., deltaT, + lal.DimensionlessUnit, npts) + revplan = lal.CreateReverseCOMPLEX16FFTPlan(npts, 0) + fwdplan = lal.CreateForwardCOMPLEX16FFTPlan(npts, 0) + lal.COMPLEX16FreqTimeFFT(ts, hf, revplan) + t_abs = float(hf.epoch) + np.arange(npts) * deltaT + ts.data.data[:] = ts.data.data * np.exp( + 1.0j * coef * 2.0 * np.pi * f_sidereal * (t_abs - t_ref)) + out = _copy_freqseries(hf) + lal.COMPLEX16TimeFreqFFT(out, ts, fwdplan) + return out + + +def fd_apply_sidereal_modulation(hf, n, f_sidereal=F_SIDEREAL, t_ref=0.0): + """COMPLEX16FrequencySeries -> new series for exp(i n Omega (t_abs - t_ref)) h(t).""" + return _lal_freq_modulate(hf, n, f_sidereal, t_ref) + + +def build_elementary_template(hf, p, n, f_sidereal=F_SIDEREAL): + """chi_{(p,n)} spectrum from the base mode spectrum hf: derivative then modulation.""" + return fd_apply_sidereal_modulation(fd_apply_time_derivative(hf, p), n, f_sidereal) + + +def _elementary_index_set(harmonics, p_max): + """List of a=(p,n). Superset grid; unused (p,n) get zero response coefficients.""" + return [(p, n) for p in range(p_max + 1) for n in harmonics] + + +# --------------------------------------------------------------------------- +# The precompute. +# --------------------------------------------------------------------------- +def PrecomputeLikelihoodTermsWithRotation( + event_time_geo, t_window, P, data_dict, psd_dict, Lmax, fMax, + harmonics=(-2, -1, 0, 1, 2), p_max=0, f_sidereal=F_SIDEREAL, + analyticPSD_Q=False, inv_spec_trunc_Q=False, T_spec=0., + verbose=True, quiet=False, internal_fast_precompute=True, + skip_interpolation=False, **hlm_kwargs): + """Slow-rotation analogue of factored_likelihood.PrecomputeLikelihoodTerms. + + Builds each FD mode once (via factored_likelihood.internal_hlm_generator) and forms the + generalized overlaps for every elementary modulated template a=(p,n): + + rholms_intp_rot[det][a][(l,m)] : interpolant of Q^a_lm(t) = e^{inOmega t} + crossTerms_rot[det][(a,a')] : { ((l,m),(l',m')) : } + crossTermsV_rot[det][(a,a')] : { ((l,m),(l',m')) : } + + Parameters mirror PrecomputeLikelihoodTerms; rotation-specific: + harmonics : sidereal harmonic indices n to carry (antenna needs |n|<=2). + p_max : max delay-derivative order (0 = Path A amplitude-only; >=1 = Path B). + f_sidereal: sidereal frequency [Hz]. + + Response coefficients A_n(det,dec,psi), B_n(det,dec) that contract these overlaps are + NOT computed here -- see slowrot_response and the (forthcoming) rotation-aware + FactoredLogLikelihood assembly. This routine produces only the intrinsic-only, + sky-independent overlap bank. + + NOTE: full-stack numerical validation (V1 of the notes' validation matrix: agreement + with a brute-force time-varying-response likelihood) requires the data/PSD/waveform + environment and is done separately; the FD primitives used here are unit-tested in + test_slowrot_fd_ops.py. + """ + # Lazy heavy imports (need the full RIFT stack / lal). + import lal + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + assert data_dict.keys() == psd_dict.keys() + detectors = list(data_dict.keys()) + t_ev = float(event_time_geo) + # The exp(i n Omega t) modulation for the data term Q is applied to the DATA (shift by + # -n f_sidereal, referenced to t_ev), which is mode-independent: one shift per (det,n), + # and -- since the modulation lives on the fixed absolute data-time axis -- needs NO + # arrival-time-dependent post-phase. U,V use modulated templates (same t_ev reference). + + # Reference distance handling identical to the base precompute. + P.dist = FL.distMpcRef * 1e6 * lsu.lsu_PC + P.deltaF = data_dict[detectors[0]].deltaF + + # --- build base FD modes ONCE --- + hlms, hlms_conj = FL.internal_hlm_generator(P, Lmax, verbose=verbose, quiet=quiet, + **hlm_kwargs) + a_list = _elementary_index_set(harmonics, p_max) + p_values = sorted(set(p for (p, n) in a_list)) + + # --- derivative-weighted template families (per p), built once, reused for all n,det --- + # (the derivative is a trivial FD weight; no waveform regeneration) + hlms_p = {p: {lm: fd_apply_time_derivative(hlms[lm], p) for lm in hlms} for p in p_values} + hlms_conj_p = {p: {lm: fd_apply_time_derivative(hlms_conj[lm], p) for lm in hlms_conj} + for p in p_values} + + # For U,V we need the modulated templates chi_a (modulation cannot be pushed onto data + # in a overlap). Build them once per a (cheap FD op). + # + # CRITICAL reference-time note: the template time series carry the INTRINSIC epoch + # (hlms.epoch ~ -T_dur, i.e. near 0), NOT the absolute event time t_ev ~ 1e9. When the + # template is placed at the event, sample j sits at absolute time t' = t_ev + (hlms.epoch + # + j*dt), so the physical modulation exp(i n Omega (t' - t_ev)) = exp(i n Omega + # (hlms.epoch + j*dt)) -- i.e. reference the template modulation to 0, NOT to t_ev. + # (Referencing to t_ev with the tiny template epoch would apply exp(i n Omega * ~-1e9), + # a ~1e4 rad spurious phase that randomizes U,V and inflates lnL beyond 0.5.) + # The data-term route above keeps t_ev because the DATA epoch really is ~ t_ev. + chi = {a: {lm: fd_apply_sidereal_modulation(hlms_p[a[0]][lm], a[1], f_sidereal, 0.0) + for lm in hlms} for a in a_list} + chi_conj = {a: {lm: fd_apply_sidereal_modulation(hlms_conj_p[a[0]][lm], a[1], f_sidereal, 0.0) + for lm in hlms_conj} for a in a_list} + + rholms_rot = {} + rholms_intp_rot = {} + crossTerms_rot = {} + crossTermsV_rot = {} + + for det in detectors: + psd = psd_dict[det] + data = data_dict[det] + t_det = FL.ComputeArrivalTimeAtDetector(det, P.phi, P.theta, event_time_geo) + rho_epoch = data.epoch - hlms[list(hlms.keys())[0]].epoch + t_shift = float(float(t_det) - float(t_window) - float(rho_epoch)) + N_shift = int(t_shift / P.deltaT + 0.5) + N_window = int(2 * t_window / P.deltaT) + t = np.arange(N_window) * P.deltaT + float(rho_epoch + N_shift * P.deltaT) + + # ---- data-term overlaps Q^a_lm(t) ---- + # exp(i n Omega t) on the template is equivalent to shifting the data spectrum by + # -n f_sidereal (mode-independent). Realize it by modulating the DATA time series + # by exp(-i n Omega (t_abs - t_ev)) (round trip). Because the modulation lives on + # the absolute data-time axis, the resulting overlap is directly + # Q^a_lm(t) = int e^{-i n Omega (t'-t_ev)} [d^p h_lm]^*(t'-t) d(t') dt' + # with NO arrival-time-dependent post-phase. + rholms_rot[det] = {} + rholms_intp_rot[det] = {} + data_by_n = {} + for n in set(nn for (_, nn) in a_list): + data_by_n[n] = data if n == 0 else _lal_freq_modulate(data, -n, f_sidereal, t_ev) + + for a in a_list: + p, n = a + rho = FL.ComputeModeIPTimeSeries( + hlms_p[p], data_by_n[n], psd, P.fmin, fMax, 1. / 2. / P.deltaT, + N_shift, N_window, analyticPSD_Q, inv_spec_trunc_Q, T_spec) + rholms_rot[det][a] = rho + if not skip_interpolation: + rholms_intp_rot[det][a] = FL.InterpolateRholms(rho, t, verbose=verbose) + else: + rholms_intp_rot[det][a] = None + + # ---- cross terms U,V for each ordered pair (a,a') ---- + crossTerms_rot[det] = {} + crossTermsV_rot[det] = {} + for a in a_list: + for ap in a_list: + crossTerms_rot[det][(a, ap)] = FL.ComputeModeCrossTermIP( + chi[a], chi[ap], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, verbose=False, + same_waveform_Q=False) + crossTermsV_rot[det][(a, ap)] = FL.ComputeModeCrossTermIP( + chi_conj[a], chi[ap], psd, P.fmin, fMax, 1. / 2. / P.deltaT, P.deltaF, + analyticPSD_Q, inv_spec_trunc_Q, T_spec, prefix="V", verbose=False, + same_waveform_Q=False) + + meta = dict(harmonics=tuple(harmonics), p_max=p_max, f_sidereal=f_sidereal, + a_list=a_list, event_time_geo=float(event_time_geo), + omega_earth=OMEGA_EARTH, modes=list(hlms.keys())) + return rholms_intp_rot, crossTerms_rot, crossTermsV_rot, rholms_rot, meta + + +# --------------------------------------------------------------------------- +# Rotation-aware log-likelihood assembly (Path A: amplitude drift, p_max=0). +# --------------------------------------------------------------------------- +def antenna_harmonics_tilde(det, RA, DEC, psi, tref): + """Return {n: A_tilde_n} where F_k(t) = sum_n A_tilde_n exp(i n Omega (t - tref)). + + A_tilde_n = A_n(response, dec, psi) * exp(i n (GMST(tref) - RA)), with A_n the + RA/time-independent antenna harmonics from slowrot_response. So A_tilde_n is the + coefficient of the precompute's modulation exp(i n Omega (t - tref)); sum_n A_tilde_n + = F_k(tref) reproduces lal.ComputeDetAMResponse exactly. + """ + import lal + import lalsimulation as lalsim + from . import slowrot_response as srr + lald = lalsim.DetectorPrefixToLALDetector(det) + A = srr.antenna_harmonics(lald.response, DEC, psi) + g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) - RA + return {n: A[n] * np.exp(1.0j * n * g_ev) for n in A} + + +def _convolve_harmonics(a, b): + """Convolve two harmonic sequences (dicts {m: coef}) -> dict {m: coef}.""" + out = {} + for m1, c1 in a.items(): + for m2, c2 in b.items(): + out[m1 + m2] = out.get(m1 + m2, 0j) + c1 * c2 + return out + + +def rotation_coefficients(det, RA, DEC, psi, tref, p_max): + """Coefficients {(p, ntilde): C} of the elementary modulated templates + chi_{(p,n)} = exp(i n Omega (t-tref)) h^{(p)}(t-tau0) in the folded response+delay + template (Path B). For p_max=0 this is {(0,n): A_tilde_n} (Path A). + + C_{(p, ntilde)} = (1/p!) sum_{n+m=ntilde} A_tilde_n [(-D)^{*p}]_m + with A_tilde_n the antenna harmonics and D_m the delay-DRIFT harmonics: + delta_tau(t) = tau(t) - tau(tref) = sum_m D_m exp(i m Omega (t-tref)), + D_0 = -(B_tilde_1 + B_tilde_-1), D_{+-1} = B_tilde_{+-1} (B_tilde = delay harmonics). + At Omega->0 delta_tau=0 -> D=0 -> C_{(p,n)}=0 for p>=1, so Path B -> Path A. + """ + import math + import lal + import lalsimulation as lalsim + from . import slowrot_response as srr + lald = lalsim.DetectorPrefixToLALDetector(det) + g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) - RA + A = srr.antenna_harmonics(lald.response, DEC, psi) + Atil = {n: A[n] * np.exp(1.0j * n * g_ev) for n in A} + if p_max == 0: + return {(0, n): Atil[n] for n in Atil} + Bd = srr.delay_harmonics(lald.location, DEC) # {m: B_m}, m in -1,0,1 + Btil = {m: Bd[m] * np.exp(1.0j * m * g_ev) for m in Bd} + tau0 = np.real(sum(Btil.values())) # tau(tref) + D = {m: Btil[m] for m in Btil} + D[0] = D[0] - tau0 # = -(Btil_1 + Btil_-1) + negD = {m: -D[m] for m in D} + C = {} + E = {0: 1.0 + 0j} # (-D)^{*0} + for p in range(p_max + 1): + if p > 0: + E = _convolve_harmonics(E, negD) # (-D)^{*p} + inv = 1.0 / math.factorial(p) + for n, an in Atil.items(): + for m, em in E.items(): + key = (p, n + m) + C[key] = C.get(key, 0j) + inv * an * em + return C + + +def FactoredLogLikelihoodWithRotation(extr_params, rholms_intp_rot, crossTerms_rot, + crossTermsV_rot, meta, Lmax): + """Slow-rotation analogue of factored_likelihood.FactoredLogLikelihood (Path A). + + Contracts the harmonic-resolved precompute bank with the antenna harmonics + A_tilde_n(det, RA, DEC, psi, tref). Reduces EXACTLY to the baseline + FactoredLogLikelihood when f_sidereal -> 0 (all modulations become identity and + sum_n A_tilde_n -> F_k(tref)). + + Currently implements p_max=0 (amplitude drift only); the delay-derivative (Path B) + contraction with B_n is a TODO. + """ + import lal + from . import factored_likelihood as FL + from .. import lalsimutils as lsu + + p_max = meta['p_max'] + a_list = list(meta['a_list']) + harmonics = list(meta['harmonics']) + hset = set(harmonics) + for n in harmonics: + assert -n in hset, "harmonic set must be symmetric (need -n for the V term): %s" % harmonics + + RA = extr_params.phi + DEC = extr_params.theta + tref = extr_params.tref + phiref = extr_params.phiref + incl = extr_params.incl + psi = extr_params.psi + dist = extr_params.dist + + detectors = list(rholms_intp_rot.keys()) + a0 = a_list[0] + modes = list(rholms_intp_rot[detectors[0]][a0].keys()) + Ylms = FL.ComputeYlms(Lmax, incl, -phiref, selected_modes=modes) + + distMpc = dist / (lsu.lsu_PC * 1e6) + invDistMpc = FL.distMpcRef / distMpc + + lnL = 0. + for det in detectors: + C = rotation_coefficients(det, RA, DEC, psi, tref, p_max) # {(p,n): C_a} + t_det = FL.ComputeArrivalTimeAtDetector(det, RA, DEC, tref) + CT = crossTerms_rot[det] + CTV = crossTermsV_rot[det] + + # Q^a_lm(t_det) for each elementary template a=(p,n) + Q = {a: {m: rholms_intp_rot[det][a][m](float(t_det)) + for m in modes} for a in a_list} + + # term1 = Re[ sum_lm conj(Ylm) sum_a conj(C_a) Q^a_lm(t_det) ] + term1 = 0. + for m in modes: + s = 0. + for a in a_list: + s += np.conj(C.get(a, 0j)) * Q[a][m] + term1 += np.conj(Ylms[m]) * s + term1 = np.real(term1) * invDistMpc + + # term2 = -1/4 Re[ sum_{p1,p2} ( U-part conj(Y1)Y2 + V-part Y1 Y2 ) ] + # U-part = sum_{a,a'} conj(C_a) C_a' U^{(a,a')}[p1,p2] + # V-part = sum_{a=(p,nu),a'} C_{(p,-nu)} C_a' V^{(a,a')}[p1,p2] + term2 = 0. + for p1 in modes: + for p2 in modes: + u = 0. + v = 0. + for a in a_list: + aR = (a[0], -a[1]) + for ap in a_list: + u += np.conj(C.get(a, 0j)) * C.get(ap, 0j) * CT[(a, ap)][(p1, p2)] + v += C.get(aR, 0j) * C.get(ap, 0j) * CTV[(a, ap)][(p1, p2)] + term2 += u * np.conj(Ylms[p1]) * Ylms[p2] + v * Ylms[p1] * Ylms[p2] + term2 = -np.real(term2) / 4. / (distMpc / FL.distMpcRef) ** 2 + + lnL += term1 + term2 + + return lnL + + +# --------------------------------------------------------------------------- +# Vectorized (NoLoop) rotation likelihood -- the maintained batchmode path. +# --------------------------------------------------------------------------- +def rotation_coefficients_vector(det, RA, DEC, psi, tref, p_max): + """Vectorized rotation_coefficients: RA, DEC, psi are arrays (npts_ex,); returns + {(p, n): complex ndarray (npts_ex,)}. Same algebra as rotation_coefficients.""" + import math + import lal + import lalsimulation as lalsim + from . import slowrot_response as srr + lald = lalsim.DetectorPrefixToLALDetector(det) + RA = np.asarray(RA); DEC = np.asarray(DEC); psi = np.asarray(psi) + g_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(tref)))) - RA + A = srr.antenna_harmonics_vector(lald.response, DEC, psi) + Atil = {n: A[n] * np.exp(1.0j * n * g_ev) for n in A} + if p_max == 0: + return {(0, n): Atil[n] for n in Atil} + Bd = srr.delay_harmonics_vector(lald.location, DEC) + Btil = {m: Bd[m] * np.exp(1.0j * m * g_ev) for m in Bd} + tau0 = np.real(sum(Btil.values())) + D = {m: Btil[m] for m in Btil} + D[0] = D[0] - tau0 + negD = {m: -D[m] for m in D} + C = {} + E = {0: np.ones_like(g_ev, dtype=complex)} + for p in range(p_max + 1): + if p > 0: + E = _convolve_harmonics(E, negD) + inv = 1.0 / math.factorial(p) + for n, an in Atil.items(): + for m, em in E.items(): + key = (p, n + m) + C[key] = C.get(key, 0j) + inv * an * em + return C + + +def pack_rotation_arrays(meta, rholms_rot, crossTerms_rot, crossTermsV_rot): + """Pack the elementary-template precompute bank into dense arrays for the NoLoop path. + + Returns (lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDict), keyed per detector by + elementary template a=(p,n) (Path A: a=(0,n); Path B: also p>=1). + """ + a_list = list(meta['a_list']) + lookupNKDict = {}; rho_by_a = {}; U_by_aa = {}; V_by_aa = {}; epochDict = {} + for det in rholms_rot: + a0 = a_list[0] + modes = list(rholms_rot[det][a0].keys()) + n_lms = len(modes) + idx = {m: i for i, m in enumerate(modes)} + lookupNKDict[det] = np.array([[m[0], m[1]] for m in modes], dtype=int) + npts = rholms_rot[det][a0][modes[0]].data.length + epochDict[det] = float(rholms_rot[det][a0][modes[0]].epoch) + rho_by_a[det] = {} + for a in a_list: + arr = np.zeros((n_lms, npts), dtype=np.complex128) + for m in modes: + arr[idx[m]] = rholms_rot[det][a][m].data.data + rho_by_a[det][a] = arr + U_by_aa[det] = {}; V_by_aa[det] = {} + for a in a_list: + for ap in a_list: + Um = np.zeros((n_lms, n_lms), dtype=np.complex128) + Vm = np.zeros((n_lms, n_lms), dtype=np.complex128) + cU = crossTerms_rot[det][(a, ap)] + cV = crossTermsV_rot[det][(a, ap)] + for m1 in modes: + for m2 in modes: + Um[idx[m1], idx[m2]] = cU[(m1, m2)] + Vm[idx[m1], idx[m2]] = cV[(m1, m2)] + U_by_aa[det][(a, ap)] = Um + V_by_aa[det][(a, ap)] = Vm + return lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDict + + +def DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta, lookupNKDict, rho_by_a, U_by_aa, V_by_aa, epochDict, + Lmax=2, array_output=False, time_interp='nearest', xpy=np): + """Vectorized rotation-aware lnL (Path A). + + GPU: pass xpy=cupy with rho_by_a/U_by_aa/V_by_aa already on device (the ILE converts them + when --gpu). term1 reuses the baseline fused Q_inner_product kernel PER elementary + template a (no (n_ext,npts,n_lms) temporary -> same memory footprint as the baseline, + looped over |a_list|); term2 is small |a_list|^2 einsums over n_lms^2. Requires n_cal=1 + (no glitch/calibration marginalization). + + Mirrors factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopOrig with + sums over sidereal harmonics and the per-sample antenna harmonics + A_tilde_n = A_n(det,dec,psi) exp(i n (GMST(tref)-RA)). Extrinsic params in P_vec are + ARRAYS of length npts_ex (RA, DEC, incl, phiref, psi, dist); tref, deltaT scalar. + + array_output=True returns lnL_t of shape (npts_ex, npts) (before time marginalization); + array_output=False returns the time-marginalized lnL of shape (npts_ex,). + """ + import lal + from . import factored_likelihood as FL + on_gpu = not (xpy is np) + if on_gpu: + from . import Q_inner_product + + def _h(v): # host (numpy) copy -- the antenna-harmonic / Ylm / delay helpers are numpy/LAL + return v.get() if hasattr(v, 'get') else np.asarray(v) + + a_list = list(meta['a_list']) + p_max = meta['p_max'] + RA = _h(P_vec.phi); DEC = _h(P_vec.theta) + incl = _h(P_vec.incl); phiref = _h(P_vec.phiref); psi = _h(P_vec.psi) + npts = len(tvals); npts_ex = len(RA) + distMpc = _h(P_vec.dist) / (lal.PC_SI * 1e6) + inv_dist = xpy.asarray(FL.distMpcRef / distMpc) # (npts_ex,) on device + lnL_t = xpy.zeros((npts_ex, npts), dtype=np.float64) + + for det in rho_by_a: + n_lms = len(lookupNKDict[det]) + Ylms = FL.ComputeYlmsArrayVector(lookupNKDict[det], incl, -phiref).T # (npts_ex, n_lms) + C = rotation_coefficients_vector(det, RA, DEC, psi, P_vec.tref, p_max) # {(p,n): (npts_ex,)} + zeroC = np.zeros(npts_ex, dtype=complex) + + def Cg(a): + return C[a] if a in C else zeroC + t_ref = epochDict[det] + # Match the maintained baseline NoLoop's precision-preserving time reference: keep the + # tiny (tref - epoch) offset and the small geometric delay separate, rather than + # subtracting two ~1e9 s absolute arrival times. The absolute-difference form loses + # ~1e-3 of a sample bin, which is harmless for nearest-neighbour snapping but shows up + # directly in the sub-bin fraction used by cubic interpolation. + detector_location = np.asarray(FL.lalsim.DetectorPrefixToLALDetector(det).location) + gmst_tref = float(lal.GreenwichMeanSiderealTime(P_vec.tref)) + # RA/DEC are host (numpy) copies via _h(); force the delay onto the host too. + # TimeDelayFromEarthCenter defaults xpy=cupy whenever cupy is importable, which would + # feed host arrays to cupy.cos and raise -- invisible in a no-cupy sandbox, fatal on a GPU. + t_det = float(P_vec.tref - float(t_ref)) + FL.TimeDelayFromEarthCenter( + detector_location, RA, DEC, gmst_tref, xpy=np) + sample_first = (t_det + float(tvals[0])) / P_vec.deltaT # float(): tvals may be a cupy array on GPU + if time_interp == 'nearest': + ifirst = (np.round(sample_first) + 0.5).astype(int) + else: + ifirst = np.floor(sample_first).astype(int) + frac_first = (sample_first - np.floor(sample_first)).astype(np.float64) + ilast = ifirst + npts + + # Device-side arrays for the heavy contraction (identity on CPU; host->device on GPU). + Ylms_d = xpy.asarray(Ylms); conjY_d = xpy.conj(Ylms_d) + zero_d = xpy.zeros(npts_ex, dtype=complex) + C_d = {k: xpy.asarray(v) for k, v in C.items()} + Cg_d = lambda a: C_d[a] if a in C_d else zero_d + + term1 = xpy.zeros((npts_ex, npts), dtype=np.complex128) + if on_gpu: + # term1 = Re[ sum_a conj(C_a) sum_lm conj(Ylm) Q^a_lm(t) ]: reuse the baseline fused + # kernel per elementary template a (A = conj(Ylm)), no (n_ex,npts,n_lms) temporary. + ifirst_i32 = xpy.asarray(ifirst).astype(np.int32) + frac_d = None if time_interp == 'nearest' else xpy.asarray(frac_first) + for a in a_list: + Q = xpy.ascontiguousarray(rho_by_a[det][a].T) # (n_time, n_lms), device + if time_interp == 'nearest': + res = Q_inner_product.Q_inner_product_cupy(Q, conjY_d, ifirst_i32, npts) + else: + res = Q_inner_product.Q_inner_product_cubic_cupy(Q, conjY_d, ifirst_i32, frac_d, npts) + term1 += xpy.conj(Cg_d(a))[:, None] * res + else: + for a in a_list: + det_rho = rho_by_a[det][a] + if time_interp == 'nearest': + Qa = np.empty((npts_ex, npts, n_lms), dtype=np.complex128) + for i in range(npts_ex): + Qa[i] = det_rho[..., ifirst[i]:ilast[i]].T + else: + # cubic sub-sample interpolation (calmarg time_interp='cubic'): + # _cubic_Q_window_numpy expects Q_block shape (n_time, n_lm). + Qa = FL._cubic_Q_window_numpy(det_rho.T, ifirst, frac_first, npts) + term1 += np.conj(Cg(a))[:, None] * np.einsum('xi,xti->xt', np.conj(Ylms), Qa) + term1 = term1.real * inv_dist[:, None] + + term2 = xpy.zeros(npts_ex, dtype=np.complex128) + for a in a_list: + aR = (a[0], -a[1]) + for ap in a_list: + term2 += xpy.conj(Cg_d(a)) * Cg_d(ap) * xpy.einsum( + 'xi,xj,ij->x', conjY_d, Ylms_d, xpy.asarray(U_by_aa[det][(a, ap)])) + term2 += Cg_d(aR) * Cg_d(ap) * xpy.einsum( + 'xi,xj,ij->x', Ylms_d, Ylms_d, xpy.asarray(V_by_aa[det][(a, ap)])) + term2 = (-0.25 * term2.real) * inv_dist ** 2 + + lnL_t += term1 + term2[:, None] + + if array_output: + return lnL_t + lnLmax = xpy.max(lnL_t, axis=-1, keepdims=True) + simps = FL.optimized_gpu_tools.simps if on_gpu else FL.my_simps + L = simps(xpy.exp(lnL_t - lnLmax), dx=P_vec.deltaT, axis=-1) + return lnLmax[..., 0] + xpy.log(L) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py new file mode 100644 index 000000000..a9950d275 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_freqresponse.py @@ -0,0 +1,442 @@ +""" +slowrot_freqresponse : closed-form FREQUENCY-DEPENDENT (finite-size) detector antenna +response of a ground-based Michelson interferometer, beyond the long-wavelength +approximation. "Path D" of the slow-rotation generalization of the RIFT likelihood. + +Motivation +---------- +The usual antenna response F_+(RA,DEC,psi), F_x(RA,DEC,psi) returned by +lal.ComputeDetAMResponse is the LONG-WAVELENGTH LIMIT: it assumes the GW strain is +uniform across the detector while a photon traverses an arm. Beyond that limit the +finite light-travel time T = L/c across an arm imprints genuine in-band frequency +structure on the response, controlled by the free spectral range + + f_FSR = c / (2 L) ( 37.5 kHz for 4-km LIGO ; 3.75 kHz for a 40-km CE arm ). + +The response becomes F_A -> F_A(f; RA, DEC, psi). This matters for 3G detectors +(Cosmic Explorer, 40 km) whose long signals reach a non-negligible fraction of f_FSR +in band; it is completely negligible for the 4-km LIGO instruments. + +Formula and convention (arXiv:2412.01693, "Beyond the Long Wavelength Approximation", +Eqs. 4-6; equivalent to Rakhmanov-Romano-Whelan 2008, arXiv:0808.3805, and +Essick-Vitale-Evans 2017, PRD 96 084004 / arXiv:1708.06843) +------------------------------------------------------------------------------------- +Single-arm round-trip transfer function, for arm unit vector a-hat and source direction +n-hat (direction TO the source, Sathyaprakash-Schutz convention -- the SAME n-hat used +by lal.ComputeDetAMResponse / TimeDelayFromEarthCenter), with T = L/c the one-way +photon time of flight and T_pm^a = T (1 +/- a-hat . n-hat): + + D~(a-hat, n-hat, f) = (e^{-i 2 pi f T} / 2) + * [ e^{+i pi f T_-^a} sinc(pi f T_+^a) + + e^{-i pi f T_+^a} sinc(pi f T_-^a) ] (Eq. 6) + +with sinc(z) = sin(z)/z, so D~ -> 1 as f -> 0. The frequency-dependent antenna +patterns are then (Eqs. 4-5) + + F_A(f, n-hat) = (1/2) [ D~(x-hat, n-hat, f) (eps^A : x-hat x-hat) + - D~(y-hat, n-hat, f) (eps^A : y-hat y-hat) ] , A in {+, x} + +where eps^+ , eps^x are the GW polarization tensors set by (RA, DEC, psi) and +x-hat, y-hat are the two arm unit vectors. At f -> 0 both D~ -> 1 and this collapses to + F_A(0) = eps^A : (1/2)(x-hat x-hat - y-hat y-hat) = eps^A : D_response + = lal.ComputeDetAMResponse , +since the detector response tensor is exactly D_response = (1/2)(x-hat x-hat - y-hat y-hat). + +Implementation note (exactness of the f -> 0 limit) +--------------------------------------------------- +LAL stores the detector response tensor `d.response` in single precision (REAL4). +Reconstructing (1/2)(x x - y y) from the geodetic arm geometry therefore agrees with +`d.response` only to ~1e-7. To reproduce lal.ComputeDetAMResponse to MACHINE PRECISION +at f = 0 we write the response as an EXACT long-wavelength baseline plus a finite-size +correction that vanishes identically at f = 0: + + F_A(f) = F_A^LWL + (1/2)[ (D~_x - 1)(eps^A : x x) - (D~_y - 1)(eps^A : y y) ] + +The baseline F_A^LWL is computed from the SAME `d.response` and triad algebra as +lal.ComputeDetAMResponse (see slowrot_response.py), so at f = 0 (where D~_x = D~_y = 1 +EXACTLY, sinc(0)=1) the correction is exactly zero and F_A(0) == ComputeDetAMResponse. +The tiny (~1e-7) geodetic imprecision only enters the O((f/f_FSR)^2) correction, i.e. +utterly negligibly. + +Pure numpy + lal; importable without the heavy RIFT stack (mirrors slowrot_response). + +Conventions (identical to slowrot_response.py / vectorized_lal_tools.py): + g = GMST - RA (Greenwich hour angle) + nhat = (cos_dec cos g, -cos_dec sin g, sin_dec) (direction to source) + X = (-cp sg - sp cg sd, -cp cg + sp sg sd, sp cd) (polarization triad) + Y = ( sp sg - cp cg sd, sp cg + cp sg sd, cp cd) + F_+ = X.D.X - Y.D.Y , F_x = X.D.Y + Y.D.X + (cp,sp)=(cos,sin)psi ; (cd,sd)=(cos,sin)dec ; (cg,sg)=(cos,sin)g +""" +from __future__ import print_function, division + +import numpy as np + +try: + import lalsimulation as _lalsim + _HAVE_LAL = True +except Exception: # pragma: no cover + _HAVE_LAL = False + +C_SI = 299792458.0 # m/s, matches vectorized_lal_tools / slowrot_response +DEFAULT_L_LIGO = 3994.5 # m, nominal LIGO arm length (override for CE etc.) + + +# --------------------------------------------------------------------------- +# Detector geometry: arm unit vectors and arm length from a LAL detector. +# --------------------------------------------------------------------------- +def _cartesian_arm(cosAlt, sinAlt, cosAz, sinAz, cosLat, sinLat, cosLon, sinLon): + """Earth-fixed Cartesian unit vector of a detector arm. + + Exact port of LAL's getCartesianComponents (LALDetectors.c): azimuth measured + from local North toward East, altitude above the local horizontal. + """ + uNorth = cosAlt * cosAz + uEast = cosAlt * sinAz + uRho = -sinLat * uNorth + cosLat * sinAlt + return np.array([ + cosLon * uRho - sinLon * uEast, + sinLon * uRho + cosLon * uEast, + cosLat * uNorth + sinLat * sinAlt, + ]) + + +def detector_geometry(det, L_arm=None): + """Return (response, x_arm, y_arm, L) for a detector prefix ('H1','L1','V1',...). + + Parameters + ---------- + det : str detector prefix understood by lalsimulation + L_arm : float or None arm length [m] override (e.g. 40000. for a 40-km CE arm); + default = 2 * xArmMidpoint from the LAL detector. + + Returns + ------- + response : (3,3) float ndarray Earth-fixed detector response tensor D (from LAL) + x_arm, y_arm : (3,) float ndarray Earth-fixed arm unit vectors + L : float arm length [m] used for the finite-size transfer + """ + if not _HAVE_LAL: # pragma: no cover + raise RuntimeError("lalsimulation is required for detector_geometry") + d = _lalsim.DetectorPrefixToLALDetector(det) + fr = d.frDetector + lat, lon = fr.vertexLatitudeRadians, fr.vertexLongitudeRadians + cL, sL, cO, sO = np.cos(lat), np.sin(lat), np.cos(lon), np.sin(lon) + x_arm = _cartesian_arm(np.cos(fr.xArmAltitudeRadians), np.sin(fr.xArmAltitudeRadians), + np.cos(fr.xArmAzimuthRadians), np.sin(fr.xArmAzimuthRadians), + cL, sL, cO, sO) + y_arm = _cartesian_arm(np.cos(fr.yArmAltitudeRadians), np.sin(fr.yArmAltitudeRadians), + np.cos(fr.yArmAzimuthRadians), np.sin(fr.yArmAzimuthRadians), + cL, sL, cO, sO) + L = float(L_arm) if L_arm is not None else 2.0 * float(fr.xArmMidpoint) + return np.asarray(d.response, dtype=float), x_arm, y_arm, L + + +# --------------------------------------------------------------------------- +# Kinematic pieces (triad + source direction), matching ComputeDetAMResponse. +# --------------------------------------------------------------------------- +def _triad(dec, psi, g): + """Polarization triad X, Y and source direction nhat at hour angle g = GMST - RA. + + dec, psi, g may be scalars or broadcastable arrays; returned vectors carry the + 3-component along the last axis. + """ + cd, sd = np.cos(dec), np.sin(dec) + cp, sp = np.cos(psi), np.sin(psi) + cg, sg = np.cos(g), np.sin(g) + + X = np.stack([-cp * sg - sp * cg * sd, + -cp * cg + sp * sg * sd, + sp * cd * np.ones_like(sg)], axis=-1) + Y = np.stack([ sp * sg - cp * cg * sd, + sp * cg + cp * sg * sd, + cp * cd * np.ones_like(sg)], axis=-1) + nhat = np.stack([cd * cg, -cd * sg, sd * np.ones_like(sg)], axis=-1) + return X, Y, nhat + + +# --------------------------------------------------------------------------- +# Single-arm finite-size transfer function (Eq. 6 of arXiv:2412.01693). +# --------------------------------------------------------------------------- +def single_arm_transfer(a_dot_n, f, L): + """Round-trip single-arm transfer function D~(a-hat, n-hat, f). + + Parameters + ---------- + a_dot_n : float or ndarray a-hat . n-hat (arm unit vector dotted with source dir) + f : float or ndarray frequency [Hz] + L : float arm length [m] + + Returns + ------- + complex ndarray broadcast over (a_dot_n, f). D~ -> 1 as f -> 0. + + D~ = (e^{-i 2 pi f T}/2)[ e^{+i pi f T_-} sinc(pi f T_+) + e^{-i pi f T_+} sinc(pi f T_-) ] + with T = L/c, T_pm = T(1 +/- a_dot_n), sinc(z)=sin(z)/z. + (np.sinc(u) = sin(pi u)/(pi u), so sinc(pi f T_pm) = np.sinc(f T_pm).) + """ + a = np.asarray(a_dot_n, dtype=float) + f = np.asarray(f, dtype=float) + T = L / C_SI + Tp = T * (1.0 + a) # T_+ + Tm = T * (1.0 - a) # T_- + pref = np.exp(-1j * 2.0 * np.pi * f * T) / 2.0 + term_p = np.exp(1j * np.pi * f * Tm) * np.sinc(f * Tp) + term_m = np.exp(-1j * np.pi * f * Tp) * np.sinc(f * Tm) + return pref * (term_p + term_m) + + +# --------------------------------------------------------------------------- +# Frequency-dependent antenna response. +# --------------------------------------------------------------------------- +def _lwl_response(response, X, Y): + """Long-wavelength F_+, F_x from triad and response tensor (== ComputeDetAMResponse). + + F_+ = X.D.X - Y.D.Y , F_x = X.D.Y + Y.D.X . Vectorized over leading axes of X,Y. + """ + D = response + XDX = np.einsum('...i,ij,...j->...', X, D, X) + YDY = np.einsum('...i,ij,...j->...', Y, D, Y) + XDY = np.einsum('...i,ij,...j->...', X, D, Y) + YDX = np.einsum('...i,ij,...j->...', Y, D, X) + return XDX - YDY, XDY + YDX + + +def antenna_response_fd(det, ra, dec, psi, f, gmst=0.0, L_arm=None): + """Frequency-dependent antenna response F_+(f), F_x(f) for a ground-based detector. + + Beyond the long-wavelength limit: includes the finite light-travel-time transfer + across the arms. Reduces EXACTLY to lal.ComputeDetAMResponse as f -> 0. + + Parameters + ---------- + det : str detector prefix ('H1','L1','V1','K1',...) + ra, dec, psi : float right ascension, declination, polarization angle [rad] + f : float or (Nf,) ndarray frequency [Hz] (scalar or 1-D array) + gmst : float Greenwich mean sidereal time [rad] (default 0). Enters + only through g = gmst - ra, exactly as ComputeDetAMResponse. + L_arm : float or None arm-length override [m] (e.g. 40000. for 40-km CE); + default = LAL detector arm length (~4 km for LIGO). + + Returns + ------- + (Fp, Fc) : complex ndarray, same shape as f. At f=0, imag part -> 0 and the real + parts equal lal.ComputeDetAMResponse(response, ra, dec, psi, gmst). + """ + response, x_arm, y_arm, L = detector_geometry(det, L_arm=L_arm) + return antenna_response_fd_geom(response, x_arm, y_arm, L, ra, dec, psi, f, gmst=gmst) + + +def antenna_response_fd_geom(response, x_arm, y_arm, L, ra, dec, psi, f, gmst=0.0): + """Same as antenna_response_fd but with detector geometry supplied explicitly. + + Useful for exotic geometries (custom L, custom arms). See antenna_response_fd. + """ + g = gmst - ra + X, Y, nhat = _triad(dec, psi, g) # shape (3,) + + # Exact long-wavelength baseline (matches ComputeDetAMResponse to machine precision). + Fp_lwl, Fc_lwl = _lwl_response(response, X, Y) + + # Arm-basis polarization projections eps^A : (a a). + Xx, Yx = float(X @ x_arm), float(Y @ x_arm) + Xy, Yy = float(X @ y_arm), float(Y @ y_arm) + Ppx = Xx * Xx - Yx * Yx # eps^+ : x x + Ppy = Xy * Xy - Yy * Yy # eps^+ : y y + Pcx = 2.0 * Xx * Yx # eps^x : x x + Pcy = 2.0 * Xy * Yy # eps^x : y y + + # Finite-size transfer per arm (vanishes-minus-one -> 0 at f=0). + ax = float(x_arm @ nhat) + ay = float(y_arm @ nhat) + Dx = single_arm_transfer(ax, f, L) + Dy = single_arm_transfer(ay, f, L) + + Fp = Fp_lwl + 0.5 * ((Dx - 1.0) * Ppx - (Dy - 1.0) * Ppy) + Fc = Fc_lwl + 0.5 * ((Dx - 1.0) * Pcx - (Dy - 1.0) * Pcy) + return Fp, Fc + + +def free_spectral_range(L): + """Free spectral range f_FSR = c / (2 L) [Hz].""" + return C_SI / (2.0 * L) + + +# =========================================================================== +# Step 1 : sky-harmonic / power-series expansion of the finite-size response. +# =========================================================================== +# The finite-size antenna response is (pure Eqs. 4-6, no LWL split) +# +# F_A(f;sky) = (1/2)[ D~(x,n,f) (eps^A:xx) - D~(y,n,f) (eps^A:yy) ] . +# +# Factor out the common one-way light-crossing delay T = L/c that both arms +# share (DIRECTION-INDEPENDENT, degenerate with coalescence time): +# +# D~(a,n,f) = e^{-i 2 pi f T} g(f; a) , a = a-hat . n-hat , +# g(f; a) = (1/2)[ e^{+i u(1-a)} sinc(u(1+a)) + e^{-i u(1+a)} sinc(u(1-a)) ] , +# u = pi f T , sinc(x) = sin(x)/x , g(f;0) = sinc(2u) , g -> 1 as f->0. +# +# Taylor-expand the residual g in the arm projection a (the small in-band +# parameter is eps*a with eps = f L/c = f T): +# +# g(f; a) = sum_{q>=0} c_q(f) a^q . (c_q sky-INDEPENDENT) +# +# The arm projections a_x = x-hat.n, a_y = y-hat.n and the arm-basis polarization +# contractions combine into a single COMPLEX analytic sky/pol scalar per power q, +# +# beta_q(sky) = (1/2)[ zx^2 a_x^q - zy^2 a_y^q ] , +# zx = X.x-hat + i Y.x-hat , zy = X.y-hat + i Y.y-hat (X,Y polarization triad) +# +# so that G_A(f;sky) := F_A e^{+i2 pi f T} gives the complex response +# +# G(f) = G_+ + i G_x = sum_q c_q(f) beta_q(sky) , (the analogue of A_n) +# beta_0 = (1/2)(zx^2 - zy^2) = F_+^LWL + i F_x^LWL (== ComputeDetAMResponse, up +# to the ~1e-7 REAL4 geodety). +# +# The full response, EXACTLY reproducing antenna_response_fd, is then +# +# F(f) = F0_lal + e^{-i2 pi f T} G(f) - beta_0 (Fp+iFc form) +# Fbar(f) = conj(F0_lal) + e^{-i2 pi f T} Gbar(f) - conj(beta_0), Gbar=sum_q c_q conj(beta_q) +# F_+ = (F+Fbar)/2 , F_x = (F-Fbar)/(2i) +# +# with F0_lal the EXACT lal.ComputeDetAMResponse baseline (so f->0 is machine +# precise even though beta_0 from the arm triads carries the ~1e-7 geodetic error; +# the arm-based pieces enter only the finite-size correction, which vanishes at f=0). +# --------------------------------------------------------------------------- +from math import comb as _comb, factorial as _factorial + + +def _sinc_shift_apoly(u, s, Qmax, jmax=64): + """a-power coefficients d_m(u), m=0..Qmax, of sinc(u(1 + s a)) (s = +/-1). + + Entire-series form (stable at u=0): sinc(x) = sum_j (-1)^j x^{2j}/(2j+1)!, + x = u(1+s a) => coeff of a^m is s^m sum_{j>=ceil(m/2)} (-1)^j u^{2j}/(2j+1)! C(2j,m). + u may be a numpy array (frequencies); returns shape (Qmax+1,) + u.shape. + """ + u = np.asarray(u, dtype=float) + out = np.zeros((Qmax + 1,) + u.shape, dtype=float) + u2 = u * u + # precompute u^{2j} + upow = np.ones_like(u) # u^{0} + for j in range(0, jmax + 1): + coef = ((-1) ** j) / float(_factorial(2 * j + 1)) + for m in range(0, min(Qmax, 2 * j) + 1): + out[m] += coef * _comb(2 * j, m) * upow + upow = upow * u2 + for m in range(Qmax + 1): + out[m] *= float(s) ** m + return out.astype(complex) + + +def _poly_mul(A, B, Qmax): + """Truncated product (to order Qmax) of two a-power stacks A[k],B[k] over freq axis.""" + out = np.zeros_like(A[:Qmax + 1]) + for i in range(min(len(A), Qmax + 1)): + for j in range(min(len(B), Qmax + 1 - i)): + out[i + j] += A[i] * B[j] + return out + + +def finite_size_c_coeffs(f, L, Qmax): + """Sky-independent frequency basis c_q(f), q = 0..Qmax, of g(f;a)=sum_q c_q(f) a^q. + + Parameters + ---------- + f : ndarray frequency [Hz] (signed values allowed; c_q(-f)=conj(c_q(f))) + L : float arm length [m] + Qmax : int highest power of the arm projection a retained + + Returns + ------- + c : complex ndarray, shape (Qmax+1,) + f.shape. c_0(f)=sinc(2 pi f T), + c_{q>=1} carry the finite-size shape distortion; all -> delta_{q0} as f->0. + """ + f = np.asarray(f, dtype=float) + T = L / C_SI + u = np.pi * f * T + # exp(-i u a): coeff of a^k is (-i u)^k / k! + exp_neg = np.zeros((Qmax + 1,) + u.shape, dtype=complex) + term = np.ones_like(u, dtype=complex) + for k in range(Qmax + 1): + exp_neg[k] = term / float(_factorial(k)) + term = term * (-1j * u) + sinc_p = _sinc_shift_apoly(u, +1.0, Qmax) # sinc(u(1+a)) + sinc_m = _sinc_shift_apoly(u, -1.0, Qmax) # sinc(u(1-a)) + eiu = np.exp(1j * u) + term1 = eiu * _poly_mul(exp_neg, sinc_p, Qmax) # e^{iu} e^{-iua} sinc(u(1+a)) + term2 = np.conj(eiu) * _poly_mul(exp_neg, sinc_m, Qmax) # e^{-iu} e^{-iua} sinc(u(1-a)) + return 0.5 * (term1 + term2) + + +def finite_size_geometry(det, ra, dec, psi, gmst=0.0, L_arm=None): + """Geometric scalars for the finite-size expansion at (det, ra, dec, psi, gmst). + + Returns dict with: + T = L/c common one-way delay [s] + L arm length [m] + ax, ay arm projections x-hat.n, y-hat.n + zx, zy = X.a-hat + i Y.a-hat (complex pol/arm scalars) + F0 = Fp_lwl + i Fc_lwl (EXACT lal baseline response, machine precise f=0) + """ + response, x_arm, y_arm, L = detector_geometry(det, L_arm=L_arm) + g = gmst - ra + X, Y, nhat = _triad(dec, psi, g) + Fp_lwl, Fc_lwl = _lwl_response(response, X, Y) + Xx, Yx = float(X @ x_arm), float(Y @ x_arm) + Xy, Yy = float(X @ y_arm), float(Y @ y_arm) + zx = Xx + 1j * Yx + zy = Xy + 1j * Yy + ax = float(x_arm @ nhat) + ay = float(y_arm @ nhat) + return dict(T=L / C_SI, L=L, ax=ax, ay=ay, zx=zx, zy=zy, + F0=complex(Fp_lwl) + 1j * complex(Fc_lwl)) + + +def finite_size_beta(geom, Qmax): + """Analytic sky/pol coefficients beta_q = (1/2)[zx^2 a_x^q - zy^2 a_y^q], q=0..Qmax.""" + zx2, zy2, ax, ay = geom['zx'] ** 2, geom['zy'] ** 2, geom['ax'], geom['ay'] + return np.array([0.5 * (zx2 * ax ** q - zy2 * ay ** q) for q in range(Qmax + 1)], + dtype=complex) + + +def F_fd_expanded(det, ra, dec, psi, f, Qmax, gmst=0.0, L_arm=None): + """Order-Qmax reconstruction of the finite-size response F_+(f), F_x(f). + + Converges to antenna_response_fd(det,ra,dec,psi,f,...) as Qmax -> infinity. + Uses the EXACT lal baseline F0 for the constant part and the power-series + finite-size correction e^{-i2 pi f T}(sum_q c_q beta_q) - beta_0. + """ + f = np.asarray(f, dtype=float) + geom = finite_size_geometry(det, ra, dec, psi, gmst=gmst, L_arm=L_arm) + beta = finite_size_beta(geom, Qmax) + c = finite_size_c_coeffs(f, geom['L'], Qmax) # (Qmax+1,)+f.shape + G = np.tensordot(beta, c, axes=(0, 0)) # sum_q beta_q c_q(f) + Gbar = np.tensordot(np.conj(beta), c, axes=(0, 0)) + phase = np.exp(-1j * 2.0 * np.pi * f * geom['T']) + F = geom['F0'] + phase * G - beta[0] + Fbar = np.conj(geom['F0']) + phase * Gbar - np.conj(beta[0]) + Fp = 0.5 * (F + Fbar) + Fc = (F - Fbar) / (2.0j) + return Fp, Fc + + +def finite_size_response_weights(fvals, geom, Qmax): + """Per-basis frequency weights W_p(f) folded into the FD modes for the likelihood. + + Response basis (p = 0..Qmax+1) reproducing F(f) = sum_p b_p W_p(f): + p=0 ("baseline") : W_0(f) = 1 b_0 = F0 (exact lal) + p=1+q : W_{1+q}(f) = e^{-i2pi f T} c_q(f) - [q==0] + b_{1+q} = beta_q (arm) + Each W_p is Hermitian (W_p(-f)=conj(W_p(f))) so the V cross term needs NO + harmonic reflection. The common delay e^{-i2 pi f T} (= a T=L/c arrival-time + shift of the finite-size correction relative to the LWL baseline) is carried + inside the correction weights. Returns (weights (Npbasis, Nf) complex, coeff-builder). + """ + fvals = np.asarray(fvals, dtype=float) + c = finite_size_c_coeffs(fvals, geom['L'], Qmax) + phase = np.exp(-1j * 2.0 * np.pi * fvals * geom['T']) + W = np.empty((Qmax + 2, fvals.shape[0]), dtype=complex) + W[0] = 1.0 + for q in range(Qmax + 1): + W[1 + q] = phase * c[q] - (1.0 if q == 0 else 0.0) + return W diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_response.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_response.py new file mode 100644 index 000000000..66220571d --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/slowrot_response.py @@ -0,0 +1,271 @@ +""" +slowrot_response : closed-form sidereal-harmonic decomposition of the ground-based +detector response, for the "slow-rotation" generalization of the RIFT likelihood. + +Over a long signal the Earth rotates, so the antenna response and the geometric +propagation delay drift. Both are exactly band-limited in harmonics of the Greenwich +hour angle g(t) = GMST(t) - RA: + + F(t) = F_+(t) + i F_x(t) = sum_{n=-2}^{+2} A_n exp(i n g(t)) (5 harmonics) + tau(t) = -(1/c) r_det . nhat(t) = sum_{n=-1}^{+1} B_n exp(i n g(t)) (3 harmonics) + +The key structural facts (see notes/ sec_amplitude, sec_delay, and the response appendix): + + * The complex antenna harmonics A_n depend ONLY on the detector response tensor, the + source declination, and the polarization angle -- NOT on right ascension or time. + RA and time enter only through the phase exp(i n g(t)) = exp(i n (GMST(t)-RA)). + Since GMST(t) ~ GMST(t_ref) + Omega (t - t_ref), this is exactly the sidereal-harmonic + modulation exp(i n Omega t) of the design notes, with sky/time in analytic scalars. + + * The delay harmonics B_n depend only on the detector location and the declination. + +This module derives A_n, B_n from the SAME algebra used by +RIFT.likelihood.vectorized_lal_tools.ComputeDetAMResponse / TimeDelayFromEarthCenter, so +the reconstruction + + F(t) = sum_n A_n exp(i n g) matches lal.ComputeDetAMResponse + tau(t) = sum_n B_n exp(i n g) matches lal.TimeDelayFromEarthCenter + +to machine precision for any g. This is an exact algebraic identity in g, not an +approximation; the ONLY approximation in the slow-rotation program is the near-linearity +of GMST(t) (handled elsewhere) and the truncation of the delay-derivative expansion +(Path B), neither of which enters here. + +Conventions mirror vectorized_lal_tools.py exactly: + g = GMST - RA (Greenwich hour angle) + nhat = (cos_dec cos g, -cos_dec sin g, sin_dec) + X, Y polarization triads as in ComputeDetAMResponse + F_+ = X.D.X - Y.D.Y, F_x = X.D.Y + Y.D.X (D = detector response tensor, symmetric) +""" +from __future__ import print_function, division + +import numpy as np + +C_SI = 299792458.0 # m/s, matches vectorized_lal_tools + + +# --------------------------------------------------------------------------- +# Constant polarization-triad basis vectors (independent of hour angle g). +# +# Writing X = Xc cos g + Xs sin g + X0 (and likewise Y), read off directly from +# vectorized_lal_tools.ComputeDetAMResponse: +# X[0] = -cos_psi sin_g - sin_psi cos_g sin_dec +# X[1] = -cos_psi cos_g + sin_psi sin_g sin_dec +# X[2] = sin_psi cos_dec +# Y[0] = sin_psi sin_g - cos_psi cos_g sin_dec +# Y[1] = sin_psi cos_g + cos_psi sin_g sin_dec +# Y[2] = cos_psi cos_dec +# --------------------------------------------------------------------------- +def _triad_components(dec, psi): + """Return the six constant 3-vectors (Xc, Xs, X0, Yc, Ys, Y0).""" + cd, sd = np.cos(dec), np.sin(dec) + cp, sp = np.cos(psi), np.sin(psi) + + Xc = np.array([-sp * sd, -cp, 0.0]) # coeff of cos g in X + Xs = np.array([-cp, sp * sd, 0.0]) # coeff of sin g in X + X0 = np.array([ 0.0, 0.0, sp * cd]) + + Yc = np.array([-cp * sd, sp, 0.0]) # coeff of cos g in Y + Ys = np.array([ sp, cp * sd, 0.0]) # coeff of sin g in Y + Y0 = np.array([ 0.0, 0.0, cp * cd]) + return Xc, Xs, X0, Yc, Ys, Y0 + + +def antenna_harmonics(response_matrix, dec, psi): + """Complex antenna-pattern harmonics A_n, n = -2..2. + + F(t) = F_+(t) + i F_x(t) = sum_{n=-2}^{2} A_n exp(i n g), g = GMST(t) - RA. + + Parameters + ---------- + response_matrix : (3,3) array detector response tensor (Earth-fixed), symmetric + dec, psi : float declination and polarization angle [rad] + + Returns + ------- + A : dict {n: complex} for n in (-2,-1,0,1,2). Independent of RA and time. + """ + D = np.asarray(response_matrix, dtype=float) + Xc, Xs, X0, Yc, Ys, Y0 = _triad_components(dec, psi) + + def B(u, v): + return float(u @ D @ v) # bilinear form; D symmetric => B(u,v)=B(v,u) + + # --- F_+ = X.D.X - Y.D.Y, expanded in cos/sin of (n g) --- + # Using cos^2 = 1/2 + 1/2 cos2g, sin^2 = 1/2 - 1/2 cos2g, cos sin = 1/2 sin2g. + Pp0 = (0.5 * (B(Xc, Xc) + B(Xs, Xs)) + B(X0, X0) + - (0.5 * (B(Yc, Yc) + B(Ys, Ys)) + B(Y0, Y0))) + Pp1 = 2.0 * (B(Xc, X0) - B(Yc, Y0)) # cos g + Qp1 = 2.0 * (B(Xs, X0) - B(Ys, Y0)) # sin g + Pp2 = 0.5 * ((B(Xc, Xc) - B(Xs, Xs)) - (B(Yc, Yc) - B(Ys, Ys))) # cos 2g + Qp2 = B(Xc, Xs) - B(Yc, Ys) # sin 2g + + # --- F_x = X.D.Y + Y.D.X = 2 X.D.Y (D symmetric) --- + Pc0 = B(Xc, Yc) + B(Xs, Ys) + 2.0 * B(X0, Y0) + Pc1 = 2.0 * (B(Xc, Y0) + B(X0, Yc)) # cos g + Qc1 = 2.0 * (B(Xs, Y0) + B(X0, Ys)) # sin g + Pc2 = B(Xc, Yc) - B(Xs, Ys) # cos 2g + Qc2 = B(Xc, Ys) + B(Xs, Yc) # sin 2g + + # Complex combine: F = F_+ + i F_x. For harmonic n>=1, the real signal piece + # P_n cos n g + Q_n sin n g -> A_n exp(i n g) + A_{-n} exp(-i n g) + # with A_{+n} = (P_n - i Q_n)/2, A_{-n} = (P_n + i Q_n)/2, and here P_n, Q_n are + # THEMSELVES complex (P_n = Pp_n + i Pc_n, Q_n = Qp_n + i Qc_n). + P0 = Pp0 + 1j * Pc0 + P1 = Pp1 + 1j * Pc1 + Q1 = Qp1 + 1j * Qc1 + P2 = Pp2 + 1j * Pc2 + Q2 = Qp2 + 1j * Qc2 + + A = { + 0: P0, + 1: 0.5 * (P1 - 1j * Q1), + -1: 0.5 * (P1 + 1j * Q1), + 2: 0.5 * (P2 - 1j * Q2), + -2: 0.5 * (P2 + 1j * Q2), + } + return A + + +def antenna_harmonics_vector(response_matrix, dec, psi): + """Vectorized antenna_harmonics: dec, psi arrays (broadcast shape S). + + Returns {n: complex ndarray shape S} for n in -2..2. Mirrors antenna_harmonics + exactly (same algebra), but dec/psi may be numpy arrays (or plain Python floats/ + 0-d arrays) of a common broadcastable shape S. + """ + D = np.asarray(response_matrix, dtype=float) + dec = np.asarray(dec, dtype=float) + psi = np.asarray(psi, dtype=float) + cd, sd = np.cos(dec), np.sin(dec) + cp, sp = np.cos(psi), np.sin(psi) + + # np.broadcast(dec, psi) misbehaves/raises for plain Python floats or 0-d arrays + # in some numpy versions (it wants array-like objects, and its .shape attribute + # is not the intuitive broadcast shape for scalar inputs). np.broadcast_shapes + # operates purely on shapes and works uniformly for scalars (shape ()), 0-d + # arrays, and full arrays. + out_shape = np.broadcast_shapes(np.shape(dec), np.shape(psi)) + zero = np.zeros(out_shape) + + def vec(a, b, c): + return np.stack(np.broadcast_arrays(a, b, c), axis=-1) + + Xc = vec(-sp * sd, -cp, zero) + Xs = vec(-cp, sp * sd, zero) + X0 = vec(zero, zero, sp * cd) + + Yc = vec(-cp * sd, sp, zero) + Ys = vec( sp, cp * sd, zero) + Y0 = vec(zero, zero, cp * cd) + + def B(u, v): + return np.einsum('...i,ij,...j->...', u, D, v) + + Pp0 = 0.5 * (B(Xc, Xc) + B(Xs, Xs)) + B(X0, X0) - (0.5 * (B(Yc, Yc) + B(Ys, Ys)) + B(Y0, Y0)) + Pp1 = 2.0 * (B(Xc, X0) - B(Yc, Y0)) + Qp1 = 2.0 * (B(Xs, X0) - B(Ys, Y0)) + Pp2 = 0.5 * ((B(Xc, Xc) - B(Xs, Xs)) - (B(Yc, Yc) - B(Ys, Ys))) + Qp2 = B(Xc, Xs) - B(Yc, Ys) + + Pc0 = B(Xc, Yc) + B(Xs, Ys) + 2.0 * B(X0, Y0) + Pc1 = 2.0 * (B(Xc, Y0) + B(X0, Yc)) + Qc1 = 2.0 * (B(Xs, Y0) + B(X0, Ys)) + Pc2 = B(Xc, Yc) - B(Xs, Ys) + Qc2 = B(Xc, Ys) + B(Xs, Yc) + + P0 = Pp0 + 1j * Pc0 + P1 = Pp1 + 1j * Pc1 + Q1 = Qp1 + 1j * Qc1 + P2 = Pp2 + 1j * Pc2 + Q2 = Qp2 + 1j * Qc2 + + # B(u,v) already contracts down to shape out_shape (einsum drops the trailing + # vector index), so no additional broadcasting of P0/P1/... is actually needed; + # multiply by ones defensively/explicitly to guarantee the advertised return shape. + ones = np.ones(out_shape) + return { + 0: P0 * ones, + 1: 0.5 * (P1 - 1j * Q1) * ones, + -1: 0.5 * (P1 + 1j * Q1) * ones, + 2: 0.5 * (P2 - 1j * Q2) * ones, + -2: 0.5 * (P2 + 1j * Q2) * ones, + } + + +def delay_harmonics(location_xyz, dec): + """Geometric-delay harmonics B_n, n = -1..1 [seconds]. + + tau(t) = -(1/c) r_det . nhat(t) = sum_{n=-1}^{1} B_n exp(i n g), g = GMST(t) - RA, + with nhat = (cos_dec cos g, -cos_dec sin g, sin_dec) as in + vectorized_lal_tools.TimeDelayFromEarthCenter. + + Parameters + ---------- + location_xyz : (3,) array detector position relative to Earth center [m], Earth-fixed + dec : float declination [rad] + + Returns + ------- + B : dict {n: complex} for n in (-1,0,1). B_0 real; B_{-1} = conj(B_1). + Independent of RA and time. + """ + r = np.asarray(location_xyz, dtype=float) + cd, sd = np.cos(dec), np.sin(dec) + + # tau = -(1/c)[ r_x cos_dec cos g - r_y cos_dec sin g + r_z sin_dec ] + # = T0 + T1c cos g + T1s sin g + T0 = -(r[2] * sd) / C_SI + T1c = -(cd * r[0]) / C_SI + T1s = +(cd * r[1]) / C_SI + + B = { + 0: complex(T0), + 1: 0.5 * (T1c - 1j * T1s), + -1: 0.5 * (T1c + 1j * T1s), + } + return B + + +def delay_harmonics_vector(location_xyz, dec): + """Vectorized delay_harmonics: dec is an array (shape S). Returns {n: complex ndarray + shape S} for n=-1,0,1. Same algebra as delay_harmonics.""" + r = np.asarray(location_xyz, dtype=float) + dec = np.asarray(dec, dtype=float) + cd, sd = np.cos(dec), np.sin(dec) + T0 = -(r[2] * sd) / C_SI + T1c = -(cd * r[0]) / C_SI + T1s = (cd * r[1]) / C_SI + return {0: T0 + 0j, 1: 0.5 * (T1c - 1j * T1s), -1: 0.5 * (T1c + 1j * T1s)} + + +# --------------------------------------------------------------------------- +# Reconstruction helpers (evaluate the harmonic series at given hour angle g). +# --------------------------------------------------------------------------- +def greenwich_hour_angle(gmst, ra): + """g = GMST - RA.""" + return gmst - ra + + +def antenna_from_harmonics(A, g): + """F_+ + i F_x = sum_n A_n exp(i n g). g may be scalar or array.""" + g = np.asarray(g, dtype=float) + F = np.zeros(g.shape, dtype=complex) if g.shape else 0j + for n, An in A.items(): + F = F + An * np.exp(1j * n * g) + return F + + +def delay_from_harmonics(B, g): + """tau(t) = sum_n B_n exp(i n g) (imaginary part cancels to ~0). g scalar or array.""" + g = np.asarray(g, dtype=float) + tau = np.zeros(g.shape, dtype=complex) if g.shape else 0j + for n, Bn in B.items(): + tau = tau + Bn * np.exp(1j * n * g) + return np.real(tau) + + +def antenna_response(A, gmst, ra): + """Convenience: (F_plus, F_cross) from harmonics at (gmst, ra).""" + F = antenna_from_harmonics(A, greenwich_hour_angle(gmst, ra)) + return np.real(F), np.imag(F) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py new file mode 100644 index 000000000..7c42a1890 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_fd_ops.py @@ -0,0 +1,144 @@ +""" +Validate the frequency-domain primitives of factored_likelihood_with_rotation against +LAL FFT round trips (numpy + lal only; no glue / full RIFT stack needed). + +Checks: + 1. LAL forward/reverse COMPLEX16 FFT round-trips to identity (normalization sanity). + 2. Which signed frequency LAL assigns to a tone, vs evaluate_fvals_from_length -> fixes + the sign FT_SIGN in the time-derivative weight. + 3. fd_apply_time_derivative reproduces d^p/dt^p exactly for a multi-tone signal. + 4. _lal_freq_modulate reproduces exp(i coef Omega t) multiplication exactly. + 5. the O(N^2) reference apply_sidereal_modulation_array agrees with the LAL round trip. + +Run: python test_slowrot_fd_ops.py (also usable under pytest) +""" +from __future__ import print_function, division + +import os +import importlib.util + +import numpy as np +import lal + +_here = os.path.dirname(os.path.abspath(__file__)) +_spec = importlib.util.spec_from_file_location( + "flwr", os.path.join(_here, "factored_likelihood_with_rotation.py")) +flwr = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(flwr) + +N = 256 +DELTA_T = 1.0 / 512.0 +DELTA_F = 1.0 / (N * DELTA_T) +_T = np.arange(N) * DELTA_T + + +def _make_timeseries(cvec): + ts = lal.CreateCOMPLEX16TimeSeries("h", lal.LIGOTimeGPS(0.), 0., DELTA_T, + lal.DimensionlessUnit, N) + ts.data.data[:] = cvec.astype(complex) + return ts + + +def _forward(ts): + """DataFourier-style forward transform.""" + fwd = lal.CreateForwardCOMPLEX16FFTPlan(N, 0) + hf = lal.CreateCOMPLEX16FrequencySeries("hf", ts.epoch, 0., DELTA_F, + lal.HertzUnit, N) + lal.COMPLEX16TimeFreqFFT(hf, ts, fwd) + return hf + + +def _reverse(hf): + rev = lal.CreateReverseCOMPLEX16FFTPlan(N, 0) + ts = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., DELTA_T, + lal.DimensionlessUnit, N) + lal.COMPLEX16FreqTimeFFT(ts, hf, rev) + return ts + + +def _multitone(): + """h(t) = sum_j c_j exp(2 pi i f_j t), f_j on distinct grid bins.""" + bins = [3, 7, -5, 12, -11] + coeffs = [1.0, 0.5 - 0.3j, -0.8j, 0.4, 0.2 + 0.1j] + h = np.zeros(N, dtype=complex) + for b, c in zip(bins, coeffs): + h += c * np.exp(2.0j * np.pi * (b * DELTA_F) * _T) + return h, bins, coeffs + + +def test_roundtrip_identity(): + h, _, _ = _multitone() + back = _reverse(_forward(_make_timeseries(h))).data.data + assert np.max(np.abs(back - h)) < 1e-10, "LAL round trip not identity" + print("roundtrip: max err = %.2e" % np.max(np.abs(back - h))) + + +def test_tone_frequency_assignment_and_FT_SIGN(): + """Determine FT_SIGN so that (FT_SIGN 2 pi i f)^1 gives the true derivative.""" + f0_bin = 7 + h = np.exp(2.0j * np.pi * (f0_bin * DELTA_F) * _T) + hf = _forward(_make_timeseries(h)) + H = hf.data.data + fvals = flwr.evaluate_fvals_from_length(N, DELTA_F) + kpeak = int(np.argmax(np.abs(H))) + f_assigned = fvals[kpeak] + # true single-tone first derivative is (2 pi i f0) h; the reconstructed weight is + # (FT_SIGN 2 pi i f_assigned). Equate -> FT_SIGN = f0 / f_assigned. + f0 = f0_bin * DELTA_F + needed_sign = np.sign(f0 / f_assigned) + print("tone at bin %d: f0=%.4f, evaluate_fvals assigns %.4f -> FT_SIGN should be %+d" + % (f0_bin, f0, f_assigned, int(needed_sign))) + assert abs(abs(f_assigned) - abs(f0)) < 1e-9, "tone landed at wrong |f|" + assert flwr.FT_SIGN == needed_sign, ( + "module FT_SIGN=%g but LAL convention requires %g" % (flwr.FT_SIGN, needed_sign)) + + +def test_time_derivative_exact(): + h, bins, coeffs = _multitone() + for p in (1, 2, 3): + # analytic derivative + dh = np.zeros(N, dtype=complex) + for b, c in zip(bins, coeffs): + w = (2.0j * np.pi * b * DELTA_F) + dh += c * (w ** p) * np.exp(2.0j * np.pi * (b * DELTA_F) * _T) + hf = _forward(_make_timeseries(h)) + hf_d = flwr.fd_apply_time_derivative(hf, p) + num = _reverse(hf_d).data.data + err = np.max(np.abs(num - dh)) / np.max(np.abs(dh)) + print("derivative p=%d: rel err = %.2e" % (p, err)) + assert err < 1e-9, "derivative order %d inexact: %g" % (p, err) + + +def test_sidereal_modulation_exact(): + h, _, _ = _multitone() + f_sid = 0.05 * DELTA_F # exaggerated so coef*f_sid is an appreciable sub-bin shift + for coef in (1, 2, -1, -2): + expected = np.exp(2.0j * np.pi * coef * f_sid * _T) * h + hf = _forward(_make_timeseries(h)) + hf_mod = flwr._lal_freq_modulate(hf, coef, f_sidereal=f_sid) + num = _reverse(hf_mod).data.data + err = np.max(np.abs(num - expected)) / np.max(np.abs(expected)) + print("modulation coef=%+d: rel err = %.2e" % (coef, err)) + assert err < 1e-9, "modulation coef %d inexact: %g" % (coef, err) + + +def test_reference_matrix_matches_lal_modulation(): + """The O(N^2) reference and the LAL round trip must agree.""" + h, _, _ = _multitone() + f_sid = 0.05 * DELTA_F + hf = _forward(_make_timeseries(h)) + for coef in (1, -2): + via_lal = flwr._lal_freq_modulate(hf, coef, f_sidereal=f_sid).data.data + via_mat = flwr.apply_sidereal_modulation_array(hf.data.data, coef, DELTA_F, f_sid) + err = np.max(np.abs(via_lal - via_mat)) / np.max(np.abs(via_lal)) + print("reference-vs-LAL coef=%+d: rel err = %.2e" % (coef, err)) + assert err < 1e-9, "reference matrix disagrees with LAL: %g" % err + + +if __name__ == "__main__": + test_roundtrip_identity() + test_tone_frequency_assignment_and_FT_SIGN() + test_time_derivative_exact() + test_sidereal_modulation_exact() + test_reference_matrix_matches_lal_modulation() + print("ALL FD-PRIMITIVE CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py new file mode 100644 index 000000000..5d295859e --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse.py @@ -0,0 +1,200 @@ +""" +Validate slowrot_freqresponse (Path D, frequency-dependent finite-size antenna response). + +Checks: + (A) LONG-WAVELENGTH LIMIT: antenna_response_fd(..., f=0) == lal.ComputeDetAMResponse to + machine precision, over many random (ra,dec,psi) and H1/L1/V1/K1. KEY CHECK. + (B) FREE-SPECTRAL-RANGE STRUCTURE: the single-arm transfer's first null sits at the + expected frequency c / (L (1 + a.n)); |F(f)| departs from |F(0)| on the f_FSR scale. + (C) IN-BAND MAGNITUDE: fractional response change |F(f)/F(0) - 1| at 1 kHz and 2 kHz for + (i) 4-km LIGO and (ii) a 40-km CE arm -- quantifies whether the effect matters in band. + +Run directly: + python test_slowrot_freqresponse.py +or under pytest (the test_* functions assert). +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +try: + import RIFT.likelihood.slowrot_freqresponse as fr +except Exception: + import importlib.util, os + _here = os.path.dirname(os.path.abspath(__file__)) + _spec = importlib.util.spec_from_file_location( + "slowrot_freqresponse", os.path.join(_here, "slowrot_freqresponse.py")) + fr = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(fr) + +DETECTORS = ["H1", "L1", "V1", "K1"] +_TOL = 1e-11 # machine-precision agreement vs ComputeDetAMResponse + +_RNG = np.random.RandomState(20260707) +_CASES = [] +for _det in DETECTORS: + for _ in range(8): + _ra = _RNG.uniform(0, 2 * np.pi) + _dec = np.arcsin(_RNG.uniform(-1, 1)) + _psi = _RNG.uniform(0, np.pi) + _gmst = _RNG.uniform(0, 2 * np.pi) + _CASES.append((_det, _ra, _dec, _psi, _gmst)) + + +# ---- (A) long-wavelength limit vs LAL ------------------------------------------------ +def test_long_wavelength_limit_matches_lal(): + worst = 0.0 + for det, ra, dec, psi, gmst in _CASES: + lald = lalsim.DetectorPrefixToLALDetector(det) + Fp_ref, Fc_ref = lal.ComputeDetAMResponse(lald.response, ra, dec, psi, gmst) + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst) + e = max(abs(Fp - Fp_ref), abs(Fc - Fc_ref)) + worst = max(worst, e) + assert e < _TOL, "FD(f=0) mismatch %g for %s ra=%.3f dec=%.3f psi=%.3f" % ( + e, det, ra, dec, psi) + print("(A) long-wavelength limit: worst |F_fd(0) - ComputeDetAMResponse| = %.3e" % worst) + return worst + + +def test_zero_frequency_is_real(): + """At f=0 the response must be purely real (imag part = 0 to machine precision).""" + worst = 0.0 + for det, ra, dec, psi, gmst in _CASES: + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst) + worst = max(worst, abs(Fp.imag), abs(Fc.imag)) + assert worst < 1e-14, "imag(F(0)) = %g" % worst + print("(A') Im F(0): worst = %.3e" % worst) + + +# ---- (B) free-spectral-range / sinc-null structure ----------------------------------- +def _first_local_min(a, L, fmax_frac=3.5, N=700000): + """Frequency and depth of the first local minimum of |D~| in (0, fmax_frac*f_FSR].""" + fFSR = fr.free_spectral_range(L) + fg = np.linspace(1.0, fmax_frac * fFSR, N) + mag = np.abs(fr.single_arm_transfer(a, fg, L)) + # first interior local minimum + lo = (mag[1:-1] < mag[:-2]) & (mag[1:-1] < mag[2:]) + idx = np.nonzero(lo)[0] + i = idx[0] + 1 if len(idx) else int(np.argmin(mag)) + return fg[i], mag[i], fFSR + + +def test_single_arm_null_at_fsr_for_transverse(): + """a.n = 0 (source transverse to arm): D~ = e^{-i2pi fT} sinc(2fT), EXACT null at f_FSR.""" + L = 40000.0 + T = L / fr.C_SI + fFSR = fr.free_spectral_range(L) + fg = np.linspace(1.0, 4.0 * fFSR, 200001) + D = fr.single_arm_transfer(0.0, fg, L) + ref = np.exp(-1j * 2.0 * np.pi * fg * T) * np.sinc(2.0 * fg * T) + ident = np.max(np.abs(D - ref)) + assert ident < 1e-13, "a=0 closed form mismatch %g" % ident + # exact first null at f_FSR + val_at_fsr = abs(fr.single_arm_transfer(0.0, fFSR, L)) + assert val_at_fsr < 1e-12, "|D~(a=0, f_FSR)| = %g (should vanish)" % val_at_fsr + print("(B) a.n=0: D~=e^{-i2pi fT} sinc(2fT) to %.2e; EXACT first null at f_FSR=c/2L=%.5g Hz" + % (ident, fFSR)) + + +def test_single_arm_dip_structure(): + """General a.n: |D~| dips on the f_FSR scale (exact nulls only for a.n=0).""" + L = 40000.0 + for a in [-0.7, -0.3, 0.0, 0.4, 0.8]: + fmin, depth, fFSR = _first_local_min(a, L) + print("(B') a.n=%+.2f: first |D~| dip at %.4g Hz = %.3f f_FSR (|D~|min=%.2e)" + % (a, fmin, fmin / fFSR, depth)) + assert 0.3 * fFSR < fmin < 3.5 * fFSR, "dip off the f_FSR scale for a=%g" % a + + +def test_fsr_scale_departure(): + """|F(f)| departs from |F(0)| by O(1) once f ~ f_FSR (use a 40-km CE arm).""" + det, ra, dec, psi, gmst = "H1", 1.2, 0.5, 0.7, 2.0 + L = 40000.0 + fFSR = fr.free_spectral_range(L) + Fp0, Fc0 = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst, L_arm=L) + F0 = abs(Fp0 + 1j * Fc0) + for frac in [0.01, 0.1, 0.5, 1.0]: + f = frac * fFSR + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, f, gmst=gmst, L_arm=L) + rel = abs(abs(Fp + 1j * Fc) - F0) / F0 + print("(B') f/f_FSR=%.2f (f=%.4g Hz): ||F(f)|-|F(0)||/|F(0)| = %.3e" % (frac, f, rel)) + + +# ---- (C) in-band magnitude: LIGO vs CE ---------------------------------------------- +def _fractional_change(det, L, freqs, n_sky=4000): + """Median-over-sky of complex |F(f)/F(0)-1| AND amplitude-only ||F(f)|-|F(0)||/|F(0)|, + excluding sky positions near antenna-pattern nulls (|F(0)|<0.3) where the ratio blows + up for reasons unrelated to the finite-size effect. + + Returns (complex_med, amp_med) arrays over freqs. The complex ratio is DOMINATED by + the overall light-crossing phase e^{-i2 pi f L/c} (a benign direction-independent + delay of L/c, degenerate with coalescence time); the amplitude-only change is the + physically meaningful measure of antenna-pattern SHAPE distortion. + """ + rng = np.random.RandomState(1234) + comp = [[] for _ in freqs] + amp = [[] for _ in freqs] + for _ in range(n_sky): + ra = rng.uniform(0, 2 * np.pi) + dec = np.arcsin(rng.uniform(-1, 1)) + psi = rng.uniform(0, np.pi) + gmst = rng.uniform(0, 2 * np.pi) + Fp0, Fc0 = fr.antenna_response_fd(det, ra, dec, psi, 0.0, gmst=gmst, L_arm=L) + F0 = complex(Fp0) + 1j * complex(Fc0) + if abs(F0) < 0.3: + continue + for i, f in enumerate(freqs): + Fp, Fc = fr.antenna_response_fd(det, ra, dec, psi, f, gmst=gmst, L_arm=L) + Ff = complex(Fp) + 1j * complex(Fc) + comp[i].append(abs(Ff / F0 - 1.0)) + amp[i].append(abs(abs(Ff) - abs(F0)) / abs(F0)) + return (np.array([np.median(c) for c in comp]), + np.array([np.median(a) for a in amp])) + + +def test_in_band_magnitude_ligo_vs_ce(): + freqs = [1000.0, 2000.0] + ligo_c, ligo_a = _fractional_change("H1", 3994.5, freqs) + ce_c, ce_a = _fractional_change("H1", 40000.0, freqs) + print("(C) IN-BAND fractional response change (median over sky, away from nulls):") + print(" complex |F(f)/F(0)-1| (incl. benign overall e^{-i2pi f L/c} delay phase):") + for i, f in enumerate(freqs): + print(" f=%5.0f Hz : LIGO(4km) = %.3e CE(40km) = %.3e" % (f, ligo_c[i], ce_c[i])) + print(" amplitude-only ||F(f)|-|F(0)||/|F(0)| (pattern-SHAPE distortion, physical):") + for i, f in enumerate(freqs): + print(" f=%5.0f Hz : LIGO(4km) = %.3e CE(40km) = %.3e (CE/LIGO ~%.0fx)" + % (f, ligo_a[i], ce_a[i], ce_a[i] / max(ligo_a[i], 1e-30))) + # LIGO amplitude distortion is sub-percent (tiny); CE is tens of percent (>> LIGO). + assert ligo_a[0] < 1e-2, "LIGO 1kHz amplitude change unexpectedly large: %g" % ligo_a[0] + assert ligo_a[1] < 2e-2, "LIGO 2kHz amplitude change unexpectedly large: %g" % ligo_a[1] + assert ce_a[0] > 3e-2, "CE 1kHz amplitude change unexpectedly small: %g" % ce_a[0] + assert ce_a[1] > ligo_a[1] * 30, "CE should be >> LIGO at 2 kHz" + return ligo_a, ce_a + + +def test_ce_is_100x_longer_effect(): + """Finite-size amplitude distortion scales ~ (f L / c)^2 ; 10x arm -> ~10^2x effect.""" + f = 2000.0 + _, ligo_a = _fractional_change("H1", 3994.5, [f]) + _, ce_a = _fractional_change("H1", 40000.0, [f]) + ratio = ce_a[0] / max(ligo_a[0], 1e-30) + print("(C') CE/LIGO amplitude-distortion ratio at 2 kHz = %.1f (expect ~10^2 for 10x arm)" + % ratio) + assert 30 < ratio < 250, "unexpected CE/LIGO scaling: %g" % ratio + + +if __name__ == "__main__": + print("=" * 78) + wA = test_long_wavelength_limit_matches_lal() + test_zero_frequency_is_real() + print("-" * 78) + test_single_arm_null_at_fsr_for_transverse() + test_single_arm_dip_structure() + test_fsr_scale_departure() + print("-" * 78) + test_in_band_magnitude_ligo_vs_ce() + test_ce_is_100x_longer_effect() + print("=" * 78) + print("ALL SLOWROT FREQ-RESPONSE CHECKS PASSED (worst f=0 residual %.3e)" % wA) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_gpu.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_gpu.py new file mode 100644 index 000000000..22e9dcf11 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_gpu.py @@ -0,0 +1,93 @@ +""" +test_slowrot_freqresponse_gpu : GPU vs CPU consistency for the finite-size (frequency-dependent +response, Path D) NoLoop likelihood. + +Runs DiscreteFactoredLogLikelihoodFreqResponseNoLoop with xpy=numpy and with xpy=cupy on the SAME +packed data (moving the Q banks + U/V to device, exactly as the ILE does under --gpu) and asserts +they agree to ~1e-8. SKIPPED if cupy / a GPU is unavailable. + +The GPU term1 reuses the baseline fused Q_inner_product kernel per basis weight p (A = conj(Ylm)), +no (n_ex,npts,n_lms) temporary -- the same fused-kernel pattern the rotation GPU port uses. Run on +a GPU node: + python RIFT/likelihood/test_slowrot_freqresponse_gpu.py +""" +from __future__ import print_function, division +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_freqresponse as flfr + +if not getattr(fl, "numba_on", True): + fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) + +try: + import cupy + _ = cupy.array(1.0) + 1.0 # force a real device op + HAVE_GPU = True +except Exception as e: + HAVE_GPU = False + _WHY = str(e) + +fSample = 4096.0; fmin = 30.0; fmax = 1700.0; event_time = 1e9 +t_window = 0.1; Lmax = 2; deltaT = 1. / fSample; deltaF = 1. / 4. +L_CE = 40000.0; Qmax = 4 + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, detector='H1', + dist=200e6 * lal.PC_SI, deltaT=deltaT, tref=event_time, deltaF=deltaF) +data_dict = {det: lsu.non_herm_hoff(_p) for det, _p in + ((d, (lambda P, dd: (setattr(P, 'detector', dd), P)[1])(Psig.manual_copy(), d)) + for d in ("H1", "L1", "V1"))} +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +def _P_vec(K=200, seed=71): + rng = np.random.RandomState(seed) + Pv = Psig.manual_copy() + Pv.phi = rng.uniform(0, 2 * np.pi, K) + Pv.theta = np.arcsin(rng.uniform(-1, 1, K)) + Pv.psi = rng.uniform(0, np.pi, K) + Pv.incl = np.arccos(rng.uniform(-1, 1, K)) + Pv.phiref = rng.uniform(0, 2 * np.pi, K) + Pv.dist = (rng.uniform(100, 800, K) * 1e6 * lsu.lsu_PC) + Pv.tref = float(event_time); Pv.deltaT = deltaT + return Pv + + +def _to_gpu(rho_by_p, U_by_pp, V_by_pp): + r = {d: {p: cupy.asarray(rho_by_p[d][p]) for p in rho_by_p[d]} for d in rho_by_p} + u = {d: {p: cupy.asarray(U_by_pp[d][p]) for p in U_by_pp[d]} for d in U_by_pp} + v = {d: {p: cupy.asarray(V_by_pp[d][p]) for p in V_by_pp[d]} for d in V_by_pp} + return r, u, v + + +def test_gpu_matches_cpu(): + if not HAVE_GPU: + print("(GPU) SKIPPED: cupy/GPU unavailable (%s)" % _WHY); return + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + Qmax=Qmax, L_arm=L_CE, analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=True) + meta = bk[4] + lk, rbp, ubp, vbp, ep = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + Pv = _P_vec() + tvals = np.arange(int(2 * 0.03 / deltaT)) * deltaT - 0.03 + for interp in ('nearest', 'cubic'): + lnL_cpu = flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, meta, lk, rbp, ubp, vbp, ep, Lmax=Lmax, time_interp=interp, xpy=np) + rG, uG, vG = _to_gpu(rbp, ubp, vbp) + lnL_gpu = flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + cupy.asarray(tvals), Pv, meta, lk, rG, uG, vG, ep, Lmax=Lmax, time_interp=interp, xpy=cupy) + lnL_gpu = cupy.asnumpy(lnL_gpu) + d = np.max(np.abs(np.asarray(lnL_cpu) - lnL_gpu)) + print("(GPU) freqresponse NoLoop xpy=cupy vs xpy=np, interp=%-7s : max|diff| = %.3e" % (interp, d)) + assert d < 1e-8, "GPU freqresponse disagrees with CPU (%s): %g" % (interp, d) + + +if __name__ == "__main__": + test_gpu_matches_cpu() + print("SLOWROT FREQRESPONSE GPU CHECK DONE" if HAVE_GPU else "SLOWROT FREQRESPONSE GPU CHECK SKIPPED (no GPU)") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_likelihood.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_likelihood.py new file mode 100644 index 000000000..d83001403 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_freqresponse_likelihood.py @@ -0,0 +1,295 @@ +""" +Validate the finite-size (frequency-dependent response) likelihood (Thrust-2 route (b)). + +Builds an EXACT finite-size detector-strain injection from the SAME modes +(internal_hlm_generator -> IFFT), h_k(f) = F_+(f;sky) h_+(f) + F_x(f;sky) h_x(f) with the +VALIDATED antenna_response_fd, using a 40-km CE arm. Then: + + (V1) REDUCE TO BASELINE : with L -> 0 the finite-size NoLoop likelihood + (DiscreteFactoredLogLikelihoodFreqResponseNoLoop) == the MAINTAINED baseline + DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop (NOT the older scalar + SingleDetectorLogLikelihood/FactoredLogLikelihood). + (V2) GROUND TRUTH : the finite-size NoLoop likelihood reconstructs the finite-size + injection -- deficit vs 0.5 converges as Qmax grows. NOTE this default config + (1.6+1.4 BNS, fmax=1024) is a WEAK demonstration: the model-distinguishing part of + the response (beyond the common e^{-i2pi f L/c} light-crossing delay, which the + baseline absorbs by time-marginalization) is only ~0.1% in-band, so finite-size ~= + baseline here and no gain is expected. The meaningful demonstration is run_strong (V4). + (V3) CAUCHY-SCHWARZ : every lnL <= 0.5 (network). A violation => a wrong term. + (V4) POSITIVE CONTROL : run_strong -- in an in-band-effect config (15+13 Msun, fmax=2000, + loud CE) the finite-size likelihood beats the long-wavelength baseline by a large, + ASSERTED margin. This is what validates the finite-L assembly (V1 only tests L->0). + +All comparisons use the maintained NoLoop path, time-MAXIMIZED (NoLoop point eval is off-peak), +with cubic sub-bin time interpolation. Measured: V1 |diff| ~3e-9; V2 (BNS/1024, weak) baseline +deficit 7.76 -> finite-size 7.80 (no gain, as expected); V3 bound respected; V4 (15+13/2000) +baseline deficit 55.8 -> finite-size 16.9, GAIN +38.9 nats (Qmax=6; residual is series +truncation at fL/c~0.27). +""" +from __future__ import print_function, division +import sys +import numpy as np +import lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.slowrot_freqresponse as sfr +import RIFT.likelihood.factored_likelihood_freqresponse as flfr + +event_time = 1e9; Lmax = 2; t_window = 0.1; det = 'H1' +psd = lalsim.SimNoisePSDaLIGOZeroDetHighPower +apx = lalsim.GetApproximantFromString("IMRPhenomD") +L_CE = 40000.0 # 40-km Cosmic Explorer arm (geometry override) +SCALE = 40. # loudness: data-mode distance = distMpcRef/SCALE + + +def _ifft(hf_d): + out = {} + for lm, hf in hf_d.items(): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); out[lm] = ht + return out + + +def _fwd_fd(re_series, epoch, dt, N): + ht = lal.CreateCOMPLEX16TimeSeries("h", epoch, 0., dt, lal.DimensionlessUnit, N) + ht.data.data[:] = re_series[:N] + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., 1. / dt / N, lsu.lsu_HertzUnit, N) + lal.COMPLEX16TimeFreqFFT(hf, ht, lal.CreateForwardCOMPLEX16FFTPlan(N, 0)); return hf + + +def _rev_td(hf): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); return ht + + +from scipy.interpolate import InterpolatedUnivariateSpline + + +def _peak(lt): + lt = np.asarray(lt, float); x = np.arange(len(lt)) + sp = InterpolatedUnivariateSpline(x, lt, k=4) + xs = np.linspace(0, len(lt) - 1, len(lt) * 32) + return float(np.max(sp(xs))) + + +def run(Qlist=(0, 2, 4, 6, 8)): + fmin, fmax, deltaT, seglen = 30., 1024., 1. / 2048., 16. + deltaF = 1. / seglen; fNyq = 1. / 2. / deltaT; N = int(round(seglen / deltaT)) + RA, DEC, PSI, INCL, PHIREF = 1.2, 0.3, 0.5, 0.4, 0.0 + DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / SCALE + + Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=1.6 * lal.MSUN_SI, m2=1.4 * lal.MSUN_SI, detector=det, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=event_time, deltaF=deltaF) + Psig.approx = apx + Pm = Psig.manual_copy(); Pm.dist = DLOUD + + # --- base FD modes ONCE; build detector strain from the SAME modes --- + hlms_fd, _ = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) + hlmsT = _ifft(hlms_fd) + lm0 = list(hlmsT.keys())[0] + nn = hlmsT[lm0].data.length; dt = hlmsT[lm0].deltaT; e0 = float(hlmsT[lm0].epoch) + + # complex Sigma(t) = sum_lm Y_lm h_lm(t) on intrinsic axis (epoch e0) + Sig = np.zeros(nn, complex) + for lm in hlmsT: + Sig += hlmsT[lm].data.data * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, lm[0], lm[1]) + + # geometric arrival delay for this sky (SAME function the likelihood uses) + dt_geo = float(fl.ComputeArrivalTimeAtDetector(det, RA, DEC, event_time)) - event_time + data_epoch = lal.LIGOTimeGPS(e0 + event_time) + + # Sigma delayed by the geometric delay: e^{-i2pi f dt_geo} in FD (packed fvals) + fvals = flfr.evaluate_fvals_from_length(nn, deltaF) + Sig_fd = _fwd_fd(Sig, data_epoch, dt, nn).data.data # FD of complex Sigma(t) + Sig_del_fd = Sig_fd * np.exp(-1j * 2.0 * np.pi * fvals * dt_geo) + Sig_del = _rev_td(_wrap_fd(Sig_del_fd, data_epoch, deltaF)).data.data # complex TD, delayed + hplus_t = np.real(Sig_del); hcross_t = -np.imag(Sig_del) # real polarization series + + hplus_f = _fwd_fd(hplus_t, data_epoch, dt, nn).data.data + hcross_f = _fwd_fd(hcross_t, data_epoch, dt, nn).data.data + gmst_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) + + def make_data(Larm, finite=True): + if finite: + Fp, Fc = sfr.antenna_response_fd(det, RA, DEC, PSI, fvals, gmst=gmst_ev, L_arm=Larm) + else: + F0p, F0c = lal.ComputeDetAMResponse( + lalsim.DetectorPrefixToLALDetector(det).response, RA, DEC, PSI, gmst_ev) + Fp = np.full(nn, F0p, complex); Fc = np.full(nn, F0c, complex) + hk_f = Fp * hplus_f + Fc * hcross_f + return _wrap_fd(hk_f, data_epoch, deltaF) + + data = make_data(L_CE, finite=True) + data_dict = {det: data}; psd_dict = {det: psd} + IPc = lsu.ComplexIP(fmin, fmax, fNyq, data.deltaF, psd, True, False, 0.) + HALF_DD = 0.5 * IPc.ip(data, data).real + print("finite-size CE injection (L=%.0f km): seglen=%.0fs 0.5 = %.5f" + % (L_CE / 1e3, seglen, HALF_DD)) + + # --- extrinsic evaluation setup --- + Pv = Psig.manual_copy() + for k, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, k, np.ones(1) * v) + Pv.tref = event_time; Pv.deltaT = deltaT + Nw = int(0.03 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + # --- baseline standard RIFT likelihood (long-wavelength) --- + ri, ct, ctV, rho, snr, rest = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lkB = {}; rAB = {}; cuB = {}; cvB = {}; epB = {} + for d in data_dict: + a, b, c, U, V, rA, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[d].keys()), None, rho[d], ct[d], ctV[d]) + lkB[d] = a; rAB[d] = rA; cuB[d] = U; cvB[d] = V; epB[d] = e + lnL_base = _peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lkB, rAB, cuB, cvB, epB, Lmax=Lmax, xpy=np, return_lnLt=True,time_interp='cubic')[0]) + print(" baseline (long-wavelength) lnL = %.5f deficit = %.5f [<= 0.5 ? %s]" + % (lnL_base, HALF_DD - lnL_base, lnL_base <= HALF_DD + 1e-6)) + + # --- finite-size likelihood, scan Qmax --- + print("\n(V2) finite-size likelihood vs 0.5 as Qmax grows:") + worst_excess = -1e99 + for Qmax in Qlist: + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + Qmax=Qmax, L_arm=L_CE, analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=True) + lk, rbp, ubp, vbp, ep = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + lnLt = flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lk, rbp, ubp, vbp, ep, Lmax=Lmax, array_output=True,time_interp='cubic')[0] + lnL = _peak(lnLt) + excess = lnL - HALF_DD + worst_excess = max(worst_excess, excess) + print(" Qmax=%2d (basis %d): lnL=%.5f deficit=%.5f excess vs 0.5=%+.2e %s" + % (Qmax, Qmax + 2, lnL, HALF_DD - lnL, excess, + "" if excess <= 1e-6 else " <-- BOUND VIOLATION")) + + # --- (V1) reduce to baseline with L -> 0 --- + print("\n(V1) reduce-to-baseline: finite-size(L->0) vs standard RIFT (same data):") + # rebuild data with the LONG-WAVELENGTH (constant F) response so both agree + data_lwl = make_data(L_CE, finite=False) + dd_lwl = {det: data_lwl} + ri2, ct2, ctV2, rho2, snr2, rest2 = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, dd_lwl, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lkB2 = {}; rAB2 = {}; cuB2 = {}; cvB2 = {}; epB2 = {} + for d in dd_lwl: + a, b, c, U, V, rA, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho2[d].keys()), None, rho2[d], ct2[d], ctV2[d]) + lkB2[d] = a; rAB2[d] = rA; cuB2[d] = U; cvB2[d] = V; epB2[d] = e + lnL_base2 = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lkB2, rAB2, cuB2, cvB2, epB2, Lmax=Lmax, xpy=np, return_lnLt=True,time_interp='cubic')[0] + for Qmax in [0, 4]: + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + event_time, t_window, Psig, dd_lwl, psd_dict, Lmax, fmax, + Qmax=Qmax, L_arm=1e-6, analyticPSD_Q=True, verbose=False, quiet=True, + skip_interpolation=True) + lk, rbp, ubp, vbp, ep = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + lnLt = flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lk, rbp, ubp, vbp, ep, Lmax=Lmax, array_output=True,time_interp='cubic')[0] + worst = np.max(np.abs(lnLt - lnL_base2)) + print(" Qmax=%d L->0: max|lnL_fr - lnL_baseline| over window = %.3e" % (Qmax, worst)) + + print("\n(V3) worst excess over 0.5 across all finite-size evals = %+.3e (%s)" + % (worst_excess, "BOUND RESPECTED" if worst_excess <= 1e-6 else "VIOLATED")) + return HALF_DD, lnL_base, worst_excess + + +def _wrap_fd(arr, epoch, deltaF): + n = len(arr) + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., deltaF, lsu.lsu_HertzUnit, n) + hf.data.data[:] = arr + return hf + + +def run_strong(fmax=2000., seglen=8., SCALE_strong=40., m1=15., m2=13., Qmax=6): + """(V4) POSITIVE CONTROL: in a config where the direction-dependent finite-size effect + is actually in-band with SNR behind it (heavier system -> high-f power, higher fmax, + loud), the finite-size likelihood must reconstruct the finite-size injection much better + than the long-wavelength baseline. + + This is the meaningful demonstration of the finite-size likelihood. The default `run()` + at BNS/fmax=1024 is a WEAK config: there the model-distinguishing effect (beyond the + common e^{-i2pi f L/c} light-crossing delay, which the baseline absorbs by + time-marginalization) is ~0.1% in-band -- below the peak-resolution floor -- so + finite-size ~= baseline there and NO gain is expected (or asserted). + + Measured (H1, 40-km CE arm, 15+13 Msun, fmax=2000, loud): baseline deficit ~55.8, + finite-size deficit ~16.9 -> gain ~+38.9 nats (Qmax=6; residual is series truncation at + fL/c~0.27, shrinks with Qmax). + """ + event_time = 1e9; t_window = 0.1; det = 'H1'; L_arm = L_CE + deltaT = 1. / (2. * fmax); fmin = 30. + deltaF = 1. / seglen; fNyq = 1. / 2. / deltaT + RA, DEC, PSI, INCL, PHIREF = 1.2, 0.3, 0.5, 0.4, 0.0 + DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / SCALE_strong + + Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=m1 * lal.MSUN_SI, m2=m2 * lal.MSUN_SI, detector=det, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=event_time, deltaF=deltaF) + Psig.approx = apx + Pm = Psig.manual_copy(); Pm.dist = DLOUD + + hlms_fd, _ = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) + hlmsT = _ifft(hlms_fd); lm0 = list(hlmsT.keys())[0] + nn = hlmsT[lm0].data.length; dt = hlmsT[lm0].deltaT; e0 = float(hlmsT[lm0].epoch) + Sig = np.zeros(nn, complex) + for lm in hlmsT: + Sig += hlmsT[lm].data.data * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, lm[0], lm[1]) + dt_geo = float(fl.ComputeArrivalTimeAtDetector(det, RA, DEC, event_time)) - event_time + data_epoch = lal.LIGOTimeGPS(e0 + event_time) + fvals = flfr.evaluate_fvals_from_length(nn, deltaF) + Sig_fd = _fwd_fd(Sig, data_epoch, dt, nn).data.data + Sig_del = _rev_td(_wrap_fd(Sig_fd * np.exp(-1j * 2 * np.pi * fvals * dt_geo), data_epoch, deltaF)).data.data + hpf = _fwd_fd(np.real(Sig_del), data_epoch, dt, nn).data.data + hcf = _fwd_fd(-np.imag(Sig_del), data_epoch, dt, nn).data.data + gmst = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) + Fp, Fc = sfr.antenna_response_fd(det, RA, DEC, PSI, fvals, gmst=gmst, L_arm=L_arm) + data = _wrap_fd(Fp * hpf + Fc * hcf, data_epoch, deltaF) + dd = {det: data}; pdd = {det: psd} + IPc = lsu.ComplexIP(fmin, fmax, fNyq, data.deltaF, psd, True, False, 0.) + HALF = 0.5 * IPc.ip(data, data).real + + Pv = Psig.manual_copy() + for k, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, k, np.ones(1) * v) + Pv.tref = event_time; Pv.deltaT = deltaT + Nw = int(0.03 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + ri, ct, ctV, rho, snr, rest = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, dd, pdd, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for d in dd: + a, b, c, U, V, r, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[d].keys()), None, rho[d], ct[d], ctV[d]) + lk[d] = a; rA[d] = r; cu[d] = U; cv[d] = V; ep[d] = e + base = _peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lk, rA, cu, cv, ep, Lmax=Lmax, xpy=np, return_lnLt=True, time_interp='cubic')[0]) + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + event_time, t_window, Psig, dd, pdd, Lmax, fmax, Qmax=Qmax, L_arm=L_arm, + analyticPSD_Q=True, verbose=False, quiet=True, skip_interpolation=True) + lkf, rbp, ubp, vbp, epf = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + fin = _peak(flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lkf, rbp, ubp, vbp, epf, Lmax=Lmax, array_output=True, time_interp='cubic')[0]) + gain = (HALF - base) - (HALF - fin) + print("\n(V4) POSITIVE CONTROL %g+%g Msun, fmax=%g, CE %gkm, loud:" % (m1, m2, fmax, L_arm / 1e3)) + print(" 0.5=%.1f baseline_deficit=%.3f finite_deficit=%.3f GAIN=%+.3f nats" + % (HALF, HALF - base, HALF - fin, gain)) + assert base <= HALF + 1e-6 and fin <= HALF + 1e-6, "Cauchy-Schwarz bound violated" + assert gain > 10.0, "finite-size failed to beat baseline where the effect is in-band: gain=%g" % gain + return HALF, base, fin, gain + + +if __name__ == "__main__": + run() + run_strong() + print("\nALL SLOWROT FREQRESPONSE LIKELIHOOD CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_gpu.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_gpu.py new file mode 100644 index 000000000..af0cd645b --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_gpu.py @@ -0,0 +1,90 @@ +""" +test_slowrot_gpu : GPU vs CPU consistency for the rotation-aware NoLoop likelihood. + +Runs DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation with xpy=numpy and with +xpy=cupy on the SAME packed data (moving the Q banks + U/V to device, exactly as the ILE does +under --gpu) and asserts they agree to ~1e-8. SKIPPED if cupy / a GPU is unavailable. + +The GPU term1 reuses the baseline fused Q_inner_product kernel per elementary template (no +(n_ex,npts,n_lms) temporary), so this also exercises that path. Run on a GPU node: + python RIFT/likelihood/test_slowrot_gpu.py +""" +from __future__ import print_function, division +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +if not getattr(fl, "numba_on", True): + fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) + +try: + import cupy + _ = cupy.array(1.0) + 1.0 # force a real device op + HAVE_GPU = True +except Exception as e: + HAVE_GPU = False + _WHY = str(e) + +fSample = 4096.0; fmin = 30.0; fmax = 1700.0; event_time = 1e9 +t_window = 0.1; Lmax = 2; deltaT = 1. / fSample; deltaF = 1. / 4. +HARM = (-2, -1, 0, 1, 2) + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, detector='H1', + dist=200e6 * lal.PC_SI, deltaT=deltaT, tref=event_time, deltaF=deltaF) +data_dict = {det: lsu.non_herm_hoff(_p) for det, _p in + ((d, (lambda P, dd: (setattr(P, 'detector', dd), P)[1])(Psig.manual_copy(), d)) + for d in ("H1", "L1", "V1"))} +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +def _P_vec(K=200, seed=71): + rng = np.random.RandomState(seed) + Pv = Psig.manual_copy() + Pv.phi = rng.uniform(0, 2 * np.pi, K) + Pv.theta = np.arcsin(rng.uniform(-1, 1, K)) + Pv.psi = rng.uniform(0, np.pi, K) + Pv.incl = np.arccos(rng.uniform(-1, 1, K)) + Pv.phiref = rng.uniform(0, 2 * np.pi, K) + Pv.dist = (rng.uniform(100, 800, K) * 1e6 * lsu.lsu_PC) + Pv.tref = float(event_time); Pv.deltaT = deltaT + return Pv + + +def _to_gpu(rho_by_a, U_by_aa, V_by_aa): + r = {d: {a: cupy.asarray(rho_by_a[d][a]) for a in rho_by_a[d]} for d in rho_by_a} + u = {d: {p: cupy.asarray(U_by_aa[d][p]) for p in U_by_aa[d]} for d in U_by_aa} + v = {d: {p: cupy.asarray(V_by_aa[d][p]) for p in V_by_aa[d]} for d in V_by_aa} + return r, u, v + + +def test_gpu_matches_cpu(): + if not HAVE_GPU: + print("(GPU) SKIPPED: cupy/GPU unavailable (%s)" % _WHY); return + ri, ct, ctV, rho, meta = flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=True) + lk, rbn, ubn, vbn, ep = flwr.pack_rotation_arrays(meta, rho, ct, ctV) + Pv = _P_vec() + tvals = np.arange(int(2 * 0.03 / deltaT)) * deltaT - 0.03 + for interp in ('nearest', 'cubic'): + lnL_cpu = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, Pv, meta, lk, rbn, ubn, vbn, ep, Lmax=Lmax, time_interp=interp, xpy=np) + rG, uG, vG = _to_gpu(rbn, ubn, vbn) + lnL_gpu = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + cupy.asarray(tvals), Pv, meta, lk, rG, uG, vG, ep, Lmax=Lmax, time_interp=interp, xpy=cupy) + lnL_gpu = cupy.asnumpy(lnL_gpu) + d = np.max(np.abs(np.asarray(lnL_cpu) - lnL_gpu)) + print("(GPU) rotation NoLoop xpy=cupy vs xpy=np, interp=%-7s : max|diff| = %.3e" % (interp, d)) + assert d < 1e-8, "GPU rotation disagrees with CPU (%s): %g" % (interp, d) + + +if __name__ == "__main__": + test_gpu_matches_cpu() + print("SLOWROT GPU CHECK DONE" if HAVE_GPU else "SLOWROT GPU CHECK SKIPPED (no GPU)") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_headtohead.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_headtohead.py new file mode 100644 index 000000000..f2930aca4 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_headtohead.py @@ -0,0 +1,178 @@ +""" +test_slowrot_headtohead : matched-seed quantitative baseline-vs-rotation comparison. + +Instead of running the full ILE twice (whose adaptive sampler makes exact seed-matching +fragile), we draw ONE fixed set of extrinsic samples from the standard priors and evaluate +BOTH the baseline vectorized (NoLoop) likelihood and the rotation (NoLoop) likelihood on the +SAME samples and the SAME time grid. Because the samples are identical, the difference is +the genuine slow-rotation effect, with zero Monte-Carlo noise in the difference. + +Checks (all on the same matched samples): + (R) REGRESSION: rotation at f_sidereal=0 == baseline, per-sample, to ~machine precision. + (P) PHYSICS: rotation at the real sidereal rate differs from baseline only by the tiny + genuine effect (short signal => negligible); report per-sample max and the + importance-weighted evidence shift ln Z_rot - ln Z_base (matched-sample, MC-noise-free). + (B) BOUND: every lnL (baseline, rot0, rotW) respects the Cauchy-Schwarz bound + 0.5 * sum_det . + +Run under the RIFT venv with this worktree first on PYTHONPATH. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1e9 +t_window = 0.1 +Lmax = 2 +deltaT = 1. / fSample +deltaF = 1. / 4. +HARM = (-2, -1, 0, 1, 2) +DMIN, DMAX = 10.0, 1000.0 # Mpc, for the d^2 distance prior + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=deltaT, + tref=event_time, deltaF=deltaF) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lsu.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +def _network_half_dd(): + tot = 0.0 + for det in data_dict: + IP = lsu.ComplexIP(fmin, fmax, 1. / 2. / deltaT, deltaF, psd_dict[det], True, False, 0.) + tot += IP.ip(data_dict[det], data_dict[det]).real + return 0.5 * tot + + +def _draw_extrinsic(K, seed=20260704): + rng = np.random.RandomState(seed) + RA = rng.uniform(0, 2 * np.pi, K) + DEC = np.arcsin(rng.uniform(-1, 1, K)) + PSI = rng.uniform(0, np.pi, K) + INCL = np.arccos(rng.uniform(-1, 1, K)) + PHIREF = rng.uniform(0, 2 * np.pi, K) + # d^2 prior in [DMIN, DMAX] + u = rng.uniform(0, 1, K) + DIST = (DMIN ** 3 + u * (DMAX ** 3 - DMIN ** 3)) ** (1. / 3.) + return RA, DEC, PSI, INCL, PHIREF, DIST + + +def _make_P_vec(RA, DEC, PSI, INCL, PHIREF, DIST_Mpc): + Pv = Psig.manual_copy() + Pv.phi = RA.astype(float) + Pv.theta = DEC.astype(float) + Pv.psi = PSI.astype(float) + Pv.incl = INCL.astype(float) + Pv.phiref = PHIREF.astype(float) + Pv.dist = (DIST_Mpc * 1e6 * lsu.lsu_PC).astype(float) + Pv.tref = float(event_time) + Pv.deltaT = deltaT + return Pv + + +def _pack_baseline(): + ri, ct, ctV, rho, snr, rest = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for det in data_dict: + a, b, c, U, V, rArr, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[det].keys()), None, rho[det], ct[det], ctV[det]) + lk[det] = a; rA[det] = rArr; cu[det] = U; cv[det] = V; ep[det] = e + return lk, rA, cu, cv, ep + + +def _pack_rotation(f_sidereal): + ri, ct, ctV, rho, meta = flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=f_sidereal, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=True) + lk, rbn, ubn, vbn, ep = flwr.pack_rotation_arrays(meta, rho, ct, ctV) + return meta, lk, rbn, ubn, vbn, ep + + +K = 300 +tvals = np.arange(int(2 * 0.03 / deltaT)) * deltaT - 0.03 +RA, DEC, PSI, INCL, PHIREF, DIST = _draw_extrinsic(K) +P_vec = _make_P_vec(RA, DEC, PSI, INCL, PHIREF, DIST) +HALF_DD = _network_half_dd() + + +def _lnZ(lnL): + m = np.max(lnL) + return m + np.log(np.mean(np.exp(lnL - m))) + + +# precompute once +_lkB, _rAB, _cuB, _cvB, _epB = _pack_baseline() +_metaR0, _lkR0, _rbn0, _ubn0, _vbn0, _epR0 = _pack_rotation(0.0) +_metaRW, _lkRW, _rbnW, _ubnW, _vbnW, _epRW = _pack_rotation(flwr.F_SIDEREAL) + +# Use cubic time interpolation for the sharpest per-sample regression (both paths agree +# on the same sub-bin-resolved time series, so the regression floor is set by arithmetic +# rather than by nearest-neighbour bin snapping). +TIME_INTERP = 'cubic' +lnL_base = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, P_vec, _lkB, _rAB, _cuB, _cvB, _epB, Lmax=Lmax, xpy=np, time_interp=TIME_INTERP) +lnL_rot0 = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, _metaR0, _lkR0, _rbn0, _ubn0, _vbn0, _epR0, Lmax=Lmax, time_interp=TIME_INTERP) +lnL_rotW = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, _metaRW, _lkRW, _rbnW, _ubnW, _vbnW, _epRW, Lmax=Lmax, time_interp=TIME_INTERP) + + +# NOTE: the baseline DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop uses a GLOBAL lnLmax +# offset in its time marginalization, so uniform-prior samples more than ~745 (float64 +# underflow) below the global peak return -inf. Our rotation NoLoop uses a PER-SAMPLE +# offset (always finite). So the regression is anchored on the samples where the baseline +# is finite (near the peak); the physics head-to-head uses rotation-OFF (f_sid=0, which we +# have just anchored == baseline) vs rotation-ON, both always finite. +_base = np.asarray(lnL_base); _r0 = np.asarray(lnL_rot0); _rW = np.asarray(lnL_rotW) +_finite = np.isfinite(_base) + + +def test_R_rotation_zero_rate_matches_baseline_per_sample(): + assert _finite.sum() > 0, "baseline finite nowhere -- cannot anchor" + d = np.max(np.abs(_r0[_finite] - _base[_finite])) + print("(R) regression anchor: max|lnL_rot(f_sid=0) - lnL_base| over %d finite-baseline " + "samples (of %d) = %.3e" % (int(_finite.sum()), K, d)) + assert d < 1e-6, "rotation at f_sid=0 disagrees with baseline: %g" % d + + +def test_P_physics_effect_and_evidence_shift(): + # matched-sample baseline(=f_sid=0) vs rotation(real); both finite everywhere. + d = np.max(np.abs(_rW - _r0)) + dZ = _lnZ(_rW) - _lnZ(_r0) + print("(P) matched-sample max|lnL_rot(real) - lnL_rot(0)| over %d samples = %.3e" % (K, d)) + print("(P) evidence shift ln Z_rot - ln Z_base (matched, MC-noise-free) = %+.6e" % dZ) + print("(P) [short 30+25 BBH => tiny; ln Z(f_sid=0)=%.4f ln Z(real)=%.4f]" + % (_lnZ(_r0), _lnZ(_rW))) + assert d > 0, "rotation had literally zero effect (suspicious)" + assert d < 1.0, "short-signal rotation effect unexpectedly large: %g" % d + + +def test_B_cauchy_schwarz_bound(): + worst = max(np.max(_r0), np.max(_rW)) + print("(B) max lnL (rot0/rotW) = %.4f 0.5_network = %.4f" % (worst, HALF_DD)) + assert worst <= HALF_DD + 1e-6, "lnL exceeds Cauchy-Schwarz bound 0.5: %g > %g" % (worst, HALF_DD) + + +if __name__ == "__main__": + test_R_rotation_zero_rate_matches_baseline_per_sample() + test_P_physics_effect_and_evidence_shift() + test_B_cauchy_schwarz_bound() + print("ALL SLOWROT HEAD-TO-HEAD CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py new file mode 100644 index 000000000..abb2adb0f --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_likelihood_v1.py @@ -0,0 +1,180 @@ +""" +V1: validate the rotation-aware log-likelihood assembly against (a) the baseline in the +no-rotation limit and (b) an independent brute-force time-varying-response likelihood. + +Run under the RIFT venv with this worktree first on PYTHONPATH: + source ~/RIFT_develUWM/bin/activate + PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code \ + python .../RIFT/likelihood/test_slowrot_likelihood_v1.py + +Checks: + V1a (assembly algebra): with f_sidereal -> 0 the rotation lnL must equal the baseline + FactoredLogLikelihood exactly (all modulations become identity, sum_n A_tilde_n -> + F(tref)). This validates the whole harmonic contraction incl. the V-term's A_{-nu}. + V1b (rotation physics): with the real sidereal rate, the rotation lnL must equal a + brute-force Path-R likelihood that applies the FULL time-varying antenna pattern + F_k(t) (sampled from lal.ComputeDetAMResponse, independent of the A_n harmonic + decomposition) directly to the data (term1) and to the modes (term2). This validates + that Q^{(n)} is paired with the correct conj(A_tilde_n), i.e. the physics. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1000000000.0 +t_window = 0.1 +Lmax = 2 + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=1. / fSample, + tref=event_time, deltaF=1. / 4.) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lsu.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + +extr = lsu.ChooseWaveformParams( + radec=True, phi=Psig.phi, theta=Psig.theta, psi=0.5, incl=0.7, phiref=0.9, + tref=event_time, dist=300e6 * lal.PC_SI) + +HARM = (-2, -1, 0, 1, 2) + + +def _precompute_rot(f_sidereal): + return flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=f_sidereal, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + + +def test_V1a_reduces_to_baseline(): + rint, ct, ctV, rho, meta = _precompute_rot(f_sidereal=0.0) + lnL_rot = flwr.FactoredLogLikelihoodWithRotation(extr, rint, ct, ctV, meta, Lmax) + rint_b, ct_b, ctV_b, rho_b, _, _ = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lnL_base = fl.FactoredLogLikelihood(extr, rho_b, rint_b, ct_b, ctV_b, Lmax) + print("V1a: lnL_rot=%.10g lnL_base=%.10g |diff|=%.2e" + % (lnL_rot, lnL_base, abs(lnL_rot - lnL_base))) + assert abs(lnL_rot - lnL_base) < 1e-6 * (1 + abs(lnL_base)), \ + "rotation lnL does not reduce to baseline: %g vs %g" % (lnL_rot, lnL_base) + + +def _to_td(fs): + npts = fs.data.length + dt = 1.0 / (npts * fs.deltaF) + ts = lal.CreateCOMPLEX16TimeSeries("x", fs.epoch, 0., dt, lal.DimensionlessUnit, npts) + lal.COMPLEX16FreqTimeFFT(ts, fs, lal.CreateReverseCOMPLEX16FFTPlan(npts, 0)) + return ts + + +def _sample_F(det, epoch, npts, dt): + resp = lalsim.DetectorPrefixToLALDetector(det).response + RA, DEC, psi = extr.phi, extr.theta, extr.psi + gmst_ev = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) + tabs = float(epoch) + np.arange(npts) * dt + nc = 128 + tc = np.linspace(tabs[0], tabs[-1], nc) + Fc = np.empty(nc, dtype=complex) + for i, tt in enumerate(tc): + gmst = gmst_ev + flwr.OMEGA_EARTH * (tt - event_time) + fp, fx = lal.ComputeDetAMResponse(resp, RA, DEC, psi, gmst) + Fc[i] = fp + 1j * fx + return (np.interp(tabs, tc, Fc.real) + 1j * np.interp(tabs, tc, Fc.imag)) + + +def _det_window(det, data, hlms): + t_det = fl.ComputeArrivalTimeAtDetector(det, extr.phi, extr.theta, extr.tref) + rho_epoch = data.epoch - hlms[list(hlms.keys())[0]].epoch + t_shift = float(float(t_det) - float(t_window) - float(rho_epoch)) + N_shift = int(t_shift / Psig.deltaT + 0.5) + N_window = int(2 * t_window / Psig.deltaT) + t = np.arange(N_window) * Psig.deltaT + float(rho_epoch + N_shift * Psig.deltaT) + return t_det, N_shift, N_window, t + + +def _pathR_lnL(modes): + Pm = Psig.manual_copy() + Pm.dist = fl.distMpcRef * 1e6 * lsu.lsu_PC + Pm.deltaF = data_dict['H1'].deltaF + hlms, hlms_conj = fl.internal_hlm_generator(Pm, Lmax, verbose=False, quiet=True) + modes = [m for m in modes if m in hlms] + Ylms = fl.ComputeYlms(Lmax, extr.incl, -extr.phiref, selected_modes=modes) + distMpc = extr.dist / (lsu.lsu_PC * 1e6) + invDistMpc = fl.distMpcRef / distMpc + lnL = 0. + for det in data_dict: + data = data_dict[det] + psd = psd_dict[det] + npts = data.data.length + dt = 1.0 / (npts * data.deltaF) + fNyq = 1. / 2. / Psig.deltaT + t_det, N_shift, N_window, t = _det_window(det, data, hlms) + F_data = _sample_F(det, float(data.epoch), npts, dt) + d_td = _to_td(data) + dtld = lal.CreateCOMPLEX16TimeSeries("dF", data.epoch, 0., dt, + lal.DimensionlessUnit, npts) + dtld.data.data[:] = np.conj(F_data) * d_td.data.data + dtld_f = lsu.DataFourier(dtld) + rho = fl.ComputeModeIPTimeSeries(hlms, dtld_f, psd, Psig.fmin, fmax, fNyq, + N_shift, N_window, True, False, 0.) + rint = fl.InterpolateRholms(rho, t, verbose=False) + term1 = 0. + for m in modes: + term1 += np.conj(Ylms[m]) * rint[m](float(t_det)) + term1 = np.real(term1) * invDistMpc + IP = lsu.ComplexIP(Psig.fmin, fmax, fNyq, data.deltaF, psd, True, False, 0.) + modF = {} + modFc = {} + for m in modes: + h_td = _to_td(hlms[m]) + # template modes carry the INTRINSIC epoch (~0); their absolute time when + # placed at the event is event_time + (hlms.epoch + j*dt), so sample F there. + F_mode = _sample_F(det, event_time + float(hlms[m].epoch), hlms[m].data.length, dt) + prod = lal.CreateCOMPLEX16TimeSeries("Fh", hlms[m].epoch, 0., dt, + lal.DimensionlessUnit, hlms[m].data.length) + prod.data.data[:] = F_mode * h_td.data.data + modF[m] = lsu.DataFourier(prod) + prodc = lal.CreateCOMPLEX16TimeSeries("Fhc", hlms[m].epoch, 0., dt, + lal.DimensionlessUnit, hlms[m].data.length) + prodc.data.data[:] = np.conj(F_mode * h_td.data.data) + modFc[m] = lsu.DataFourier(prodc) + term2 = 0. + for p1 in modes: + for p2 in modes: + U = IP.ip(modF[p1], modF[p2]) + V = IP.ip(modFc[p1], modF[p2]) + term2 += U * np.conj(Ylms[p1]) * Ylms[p2] + V * Ylms[p1] * Ylms[p2] + term2 = -np.real(term2) / 4. / (distMpc / fl.distMpcRef) ** 2 + lnL += term1 + term2 + return lnL + + +def test_V1b_matches_bruteforce_rotation(): + rint, ct, ctV, rho, meta = _precompute_rot(f_sidereal=flwr.F_SIDEREAL) + lnL_rot = flwr.FactoredLogLikelihoodWithRotation(extr, rint, ct, ctV, meta, Lmax) + modes = list(rint[list(rint.keys())[0]][(0, 0)].keys()) + lnL_R = _pathR_lnL(modes) + print("V1b: lnL_rot=%.10g lnL_pathR=%.10g |diff|=%.2e" + % (lnL_rot, lnL_R, abs(lnL_rot - lnL_R))) + assert abs(lnL_rot - lnL_R) < 1e-4 * (1 + abs(lnL_R)), \ + "rotation lnL disagrees with brute force: %g vs %g" % (lnL_rot, lnL_R) + + +if __name__ == "__main__": + test_V1a_reduces_to_baseline() + test_V1b_matches_bruteforce_rotation() + print("ALL SLOWROT V1 LIKELIHOOD CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop.py new file mode 100644 index 000000000..b6d200833 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop.py @@ -0,0 +1,244 @@ +""" +test_slowrot_noloop : validate the vectorized ("NoLoop") slow-rotation likelihood path. + +Run under the RIFT venv with this worktree first on PYTHONPATH: + source ~/RIFT_develUWM/bin/activate + export PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code + python RIFT/likelihood/test_slowrot_noloop.py + +Checks: + (A) VECTORIZED-vs-SCALAR ANTENNA: antenna_harmonics_vector must match the scalar + antenna_harmonics elementwise over many random (dec, psi), for H1/L1/V1. + (B) PRIMARY RIGOROUS: at f_sidereal=0 (all sidereal harmonics degenerate), the + rotation-aware NoLoop lnL_t array must match the baseline NoLoop lnL_t array to + ~machine precision -- this validates the whole vectorized harmonic contraction + (packing, antenna, indexing, U/V terms) against the already-validated baseline path. + (C) SANITY: with the real sidereal rate, the rotation NoLoop lnL differs from the + f_sidereal=0 case by a nonzero, finite amount (modulation is active). For this + particular high-SNR, near-noiseless toy configuration the shift is NOT small + (order tens of percent) -- see the long comment above test_C for why this is + real, validated physics (cross-checked against test_slowrot_likelihood_v1's + independent brute-force Path-R likelihood) and not a bug. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +import RIFT.likelihood.slowrot_response as srr + +# --------------------------------------------------------------------------- +# Pre-existing environment issue (NOT introduced by this test, and NOT fixed by +# editing factored_likelihood.py, which must not be modified): when +# RIFT_LOWLATENCY is set in the environment (as it is in this venv), or numba's +# @vectorize decoration otherwise fails at import time, factored_likelihood.py +# falls back to a plain scalar `def lalylm(th,ph,s,l,m): return +# lal.SpinWeightedSphericalHarmonic(...)`. That scalar function is called by +# fl.ComputeYlmsArrayVector with numpy ARRAY arguments, which +# lal.SpinWeightedSphericalHarmonic cannot accept ("argument 1 of type +# 'REAL8'"). This breaks fl.ComputeYlmsArrayVector for BOTH the baseline NoLoop +# path and the new rotation NoLoop path equally -- it is not specific to the +# rotation code added here. We work around it locally, only for this test +# process, by rebinding fl.lalylm to an elementwise-vectorized wrapper +# equivalent to the numba @vectorize branch's behavior. This does not alter +# factored_likelihood.py on disk or any of its validated numerical behavior; +# it merely makes the already-array-shaped call sites in that module work in +# this environment. +if not getattr(fl, "numba_on", True): + fl.lalylm = np.vectorize(lal.SpinWeightedSphericalHarmonic, otypes=[complex]) + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1e9 +t_window = 0.1 +Lmax = 2 +deltaT = 1. / fSample +deltaF = 1. / 4. + +HARM = (-2, -1, 0, 1, 2) + +Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=deltaT, + tref=event_time, deltaF=deltaF) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lsu.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +# --------------------------------------------------------------------------- +# (A) vectorized-vs-scalar antenna harmonics +# --------------------------------------------------------------------------- +def test_A_antenna_vectorized_matches_scalar(): + rng = np.random.RandomState(20260703) + ndraw = 50 + decs = np.arcsin(rng.uniform(-1, 1, ndraw)) + psis = rng.uniform(0, np.pi, ndraw) + + worst = 0.0 + for det in ("H1", "L1", "V1"): + resp = lalsim.DetectorPrefixToLALDetector(det).response + A_vec = srr.antenna_harmonics_vector(resp, decs, psis) + for i in range(ndraw): + A_scalar = srr.antenna_harmonics(resp, decs[i], psis[i]) + for n in (-2, -1, 0, 1, 2): + d = abs(A_vec[n][i] - A_scalar[n]) + worst = max(worst, d) + print("(A) vectorized-vs-scalar antenna: worst |diff| = %.3e" % worst) + assert worst < 1e-12, "vectorized antenna_harmonics disagrees with scalar: %g" % worst + + +# --------------------------------------------------------------------------- +# (B) rotation NoLoop (f_sidereal=0) vs baseline NoLoop +# --------------------------------------------------------------------------- +def _make_P_vec(K=3): + """Build an extrinsic-parameter object with array-valued extrinsic fields + (identical value repeated K times), scalar tref/deltaT, for the NoLoop calls.""" + P_vec = Psig.manual_copy() + phi = 1.0 + theta = 0.2 + incl = 0.7 + phiref = 0.9 + psi = 0.5 + dist = 300e6 * lal.PC_SI + P_vec.phi = np.ones(K) * phi + P_vec.theta = np.ones(K) * theta + P_vec.incl = np.ones(K) * incl + P_vec.phiref = np.ones(K) * phiref + P_vec.psi = np.ones(K) * psi + P_vec.dist = np.ones(K) * dist + P_vec.tref = event_time + P_vec.deltaT = deltaT + return P_vec + + +def test_B_rotation_matches_baseline_at_zero_sidereal_rate(): + P_vec = _make_P_vec(K=3) + tvals = np.linspace(-0.05, 0.05, 200) + + # ---- baseline precompute + pack + NoLoop (return_lnLt=True gives raw lnL_t) ---- + rholms_intp_b, crossTerms_b, crossTermsV_b, rholms_b, _, _ = fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + + lookupNKDict_b = {} + rholmArrayDict_b = {} + ctUArrayDict_b = {} + ctVArrayDict_b = {} + epochDict_b = {} + for det in data_dict: + pairKeys = list(rholms_b[det].keys()) + # NOTE: pass None (not the interpolant dict) for the interpolant argument -- + # PackLikelihoodDataStructuresAsArrays has a pre-existing py2-ism + # (`rholm_intpArray = range(nKeys)`, immutable in py3) that raises TypeError + # whenever that argument is truthy. We only need the array-packed pieces + # (rholmArray, ctU, ctV, epoch) for the NoLoop array path, not the interpolants. + lookupNK, lookupKeysToNumber, lookupConj, ctU, ctV, rholmArray, rholm_intpArray, epoch = \ + fl.PackLikelihoodDataStructuresAsArrays( + pairKeys, None, rholms_b[det], crossTerms_b[det], crossTermsV_b[det]) + lookupNKDict_b[det] = lookupNK + rholmArrayDict_b[det] = rholmArray + ctUArrayDict_b[det] = ctU + ctVArrayDict_b[det] = ctV + epochDict_b[det] = epoch + + lnL_t_base = fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, P_vec, lookupNKDict_b, rholmArrayDict_b, ctUArrayDict_b, ctVArrayDict_b, + epochDict_b, Lmax=Lmax, xpy=np, return_lnLt=True,time_interp='cubic') + + # ---- rotation precompute (f_sidereal=0) + pack + rotation NoLoop ---- + rholms_intp_r, crossTerms_r, crossTermsV_r, rholms_r, meta = \ + flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=0.0, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + + lookupNKDict_r, rho_by_n, U_by_nn, V_by_nn, epochDict_r = flwr.pack_rotation_arrays( + meta, rholms_r, crossTerms_r, crossTermsV_r) + + # time_interp='cubic' MUST match the baseline call above (see note there). + lnL_t_rot = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta, lookupNKDict_r, rho_by_n, U_by_nn, V_by_nn, epochDict_r, + Lmax=Lmax, array_output=True, time_interp='cubic') + + assert lnL_t_base.shape == lnL_t_rot.shape, \ + "shape mismatch: base %s vs rot %s" % (lnL_t_base.shape, lnL_t_rot.shape) + + diff = np.abs(lnL_t_base - lnL_t_rot) + worst = np.max(diff) + print("(B) baseline-vs-rotation(f_sidereal=0) lnL_t: shape=%s max|diff|=%.3e " + "(max|lnL_t_base|=%.3e)" % (lnL_t_base.shape, worst, np.max(np.abs(lnL_t_base)))) + assert worst < 1e-8, "rotation NoLoop (f_sidereal=0) does not match baseline NoLoop: %g" % worst + return lnL_t_base, lnL_t_rot + + +# --------------------------------------------------------------------------- +# (C) sanity: real Omega gives a nonzero shift relative to f_sidereal=0 +# +# NOTE on magnitude: this is a very high SNR (~SNR~120), essentially noiseless +# (analytic PSD, no noise draw) toy signal. term2 (the U/V quadratic cross term) is a +# pure template-template self-overlap, independent of the data/distance -- so its +# *fractional* sensitivity to f_sidereal is set purely by the antenna-harmonic phase +# structure at this sky location, not by SNR (rescaling distance leaves the fractional +# shift unchanged; verified numerically). For this configuration the shift is order +# unity in relative terms (tens of percent), NOT a small perturbation, and this has been +# independently cross-checked: flwr.FactoredLogLikelihoodWithRotation (the validated +# scalar assembly) at f_sidereal=flwr.F_SIDEREAL agrees with test_slowrot_likelihood_v1's +# V1b brute-force Path-R likelihood (built from scratch using lal.ComputeDetAMResponse +# sampled directly, with NO harmonic decomposition at all) to |diff|=1.58e-09. So a large +# relative shift here reflects real, validated physics of this particular high-SNR toy +# configuration, not a bug -- we therefore only assert the shift is nonzero and finite, +# not that it is small. +# --------------------------------------------------------------------------- +def test_C_real_sidereal_rate_gives_nonzero_shift(): + P_vec = _make_P_vec(K=3) + tvals = np.linspace(-0.05, 0.05, 200) + + # f_sidereal = 0 case (reuse rotation precompute machinery) + rholms_intp_0, crossTerms_0, crossTermsV_0, rholms_0, meta_0 = \ + flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=0.0, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + lookupNKDict_0, rho_by_n_0, U_by_nn_0, V_by_nn_0, epochDict_0 = flwr.pack_rotation_arrays( + meta_0, rholms_0, crossTerms_0, crossTermsV_0) + lnL_0 = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta_0, lookupNKDict_0, rho_by_n_0, U_by_nn_0, V_by_nn_0, epochDict_0, + Lmax=Lmax, array_output=False) + + # real sidereal rate + rholms_intp_w, crossTerms_w, crossTermsV_w, rholms_w, meta_w = \ + flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=HARM, p_max=0, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=False) + lookupNKDict_w, rho_by_n_w, U_by_nn_w, V_by_nn_w, epochDict_w = flwr.pack_rotation_arrays( + meta_w, rholms_w, crossTerms_w, crossTermsV_w) + lnL_w = flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P_vec, meta_w, lookupNKDict_w, rho_by_n_w, U_by_nn_w, V_by_nn_w, epochDict_w, + Lmax=Lmax, array_output=False) + + diff = lnL_w - lnL_0 + rel = np.abs(diff) / (1.0 + np.abs(lnL_0)) + print("(C) lnL(f_sidereal=0) = %s" % np.array2string(lnL_0, precision=10)) + print("(C) lnL(f_sidereal=F_SIDEREAL) = %s" % np.array2string(lnL_w, precision=10)) + print("(C) diff = %s |rel| = %s" % (np.array2string(diff, precision=6), + np.array2string(rel, precision=6))) + assert np.all(np.isfinite(lnL_w)), "non-finite lnL at real sidereal rate: %s" % lnL_w + assert np.all(np.abs(diff) > 0), "expected a nonzero shift from Earth rotation: %s" % diff + + +if __name__ == "__main__": + test_A_antenna_vectorized_matches_scalar() + test_B_rotation_matches_baseline_at_zero_sidereal_rate() + test_C_real_sidereal_rate_gives_nonzero_shift() + print("ALL SLOWROT NOLOOP CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py new file mode 100644 index 000000000..675d4cf44 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_noloop_bruteforce.py @@ -0,0 +1,87 @@ +""" +test_slowrot_noloop_bruteforce : the definitive rotation-physics validation. + +The vectorized rotation NoLoop lnL_t (real sidereal rate) is compared, over the full +time window, to an INDEPENDENT brute-force likelihood that applies the true time-varying +antenna pattern F_k(t) -- sampled directly from lal.ComputeDetAMResponse -- to the data +(term1) and to the modes (term2), reusing RIFT's own overlaps. This confirms both that +the vectorized harmonic contraction is correct AND that the (large, at high SNR) shift the +sidereal rotation induces in the marginalized lnL is genuine physics, not an artifact. + +Run: source ~/RIFT_develUWM/bin/activate; + PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code python +""" +import numpy as np, lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +fmin=30.;fmax=1700.;event_time=1e9;t_window=0.1;Lmax=2;deltaT=1/4096.;deltaF=1/4.;fNyq=1/2./deltaT +HARM=(-2,-1,0,1,2); OM=flwr.OMEGA_EARTH +Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=0.3,phiref=0.0,theta=0.2,phi=1.0,psi=0.4, + m1=30*lal.MSUN_SI,m2=25*lal.MSUN_SI,detector='H1',dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF) +data_dict={} +for det in ("H1","L1","V1"): + P=Psig.manual_copy();P.detector=det;data_dict[det]=lsu.non_herm_hoff(P) +psd_dict={det:lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} +RA,DEC,PSI,INCL,PHIREF,DIST=1.0,0.2,0.5,0.7,0.9,300e6*lal.PC_SI +gmst_ev=float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))) +def to_td(fs): + n=fs.data.length;dt=1./(n*fs.deltaF);ts=lal.CreateCOMPLEX16TimeSeries("x",fs.epoch,0.,dt,lal.DimensionlessUnit,n) + lal.COMPLEX16FreqTimeFFT(ts,fs,lal.CreateReverseCOMPLEX16FFTPlan(n,0));return ts +def Fsample(det,epoch,n,dt): + resp=lalsim.DetectorPrefixToLALDetector(det).response + t=float(epoch)+np.arange(n)*dt + return np.array([ (lambda a,b:a+1j*b)(*lal.ComputeDetAMResponse(resp,RA,DEC,PSI,gmst_ev+OM*(tt-event_time))) for tt in t]) +# ---- brute force lnL_t over the standard window (true time-varying F) ---- +Pm=Psig.manual_copy();Pm.dist=fl.distMpcRef*1e6*lsu.lsu_PC;Pm.deltaF=deltaF +hlms,hlms_conj=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True) +Ylms=fl.ComputeYlms(Lmax,INCL,-PHIREF,selected_modes=list(hlms.keys())) +distMpc=DIST/(lsu.lsu_PC*1e6);invD=fl.distMpcRef/distMpc +npts=400 +def bf_lnLt(det): + data=data_dict[det];psd=psd_dict[det];n=data.data.length;dt=1./(n*data.deltaF) + t_det=fl.ComputeArrivalTimeAtDetector(det,RA,DEC,event_time) + rho_epoch=data.epoch-hlms[list(hlms.keys())[0]].epoch + t_shift=float(float(t_det)-float(t_window)-float(rho_epoch));N_shift=int(t_shift/deltaT+0.5);N_window=int(2*t_window/deltaT) + tgrid=np.arange(N_window)*deltaT+float(rho_epoch+N_shift*deltaT) + Fd=Fsample(det,float(data.epoch),n,dt);dtd=to_td(data) + df=lal.CreateCOMPLEX16TimeSeries("dF",data.epoch,0.,dt,lal.DimensionlessUnit,n);df.data.data[:]=np.conj(Fd)*dtd.data.data + rr=fl.ComputeModeIPTimeSeries(hlms,lsu.DataFourier(df),psd,fmin,fmax,fNyq,N_shift,N_window,True,False,0.) + ri=fl.InterpolateRholms(rr,tgrid,verbose=False) + modes=list(ri.keys()) + # window aligned like NoLoop + ifirst=int(round((float(t_det)-0.02-float(rr[list(rr.keys())[0]].epoch))/deltaT)+0.5) + tsel=np.array([float(rr[list(rr.keys())[0]].epoch)+(ifirst+j)*deltaT for j in range(npts)]) + term1=np.zeros(npts,dtype=complex) + for m in modes: + term1+=np.conj(Ylms[m])*np.array([ri[m](tt) for tt in tsel]) + term1=term1.real*invD + IP=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.) + modF={};modC={} + for m in modes: + htd=to_td(hlms[m]);Fm=Fsample(det,event_time+float(hlms[m].epoch),hlms[m].data.length,dt) + pr=lal.CreateCOMPLEX16TimeSeries("Fh",hlms[m].epoch,0.,dt,lal.DimensionlessUnit,hlms[m].data.length);pr.data.data[:]=Fm*htd.data.data;modF[m]=lsu.DataFourier(pr) + pc=lal.CreateCOMPLEX16TimeSeries("Fc",hlms[m].epoch,0.,dt,lal.DimensionlessUnit,hlms[m].data.length);pc.data.data[:]=np.conj(Fm*htd.data.data);modC[m]=lsu.DataFourier(pc) + t2=0j + for p1 in modes: + for p2 in modes: + t2+=IP.ip(modF[p1],modF[p2])*np.conj(Ylms[p1])*Ylms[p2]+IP.ip(modC[p1],modF[p2])*Ylms[p1]*Ylms[p2] + t2=-t2.real/4./(distMpc/fl.distMpcRef)**2 + return term1+t2 +bf=sum(bf_lnLt(det) for det in data_dict) +m=np.max(bf);bf_marg=m+np.log(np.trapz(np.exp(bf-m),dx=deltaT)) +# ---- vec rotation, real Omega, same window ---- +rint,ct,ctV,rho,meta=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=HARM,p_max=0,f_sidereal=flwr.F_SIDEREAL,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) +lk,rbn,ubn,vbn,ep=flwr.pack_rotation_arrays(meta,rho,ct,ctV) +Pv=Psig.manual_copy() +for k,v in [('phi',RA),('theta',DEC),('incl',INCL),('phiref',PHIREF),('psi',PSI),('dist',DIST)]: setattr(Pv,k,np.ones(1)*v) +Pv.tref=event_time;Pv.deltaT=deltaT +tvals=np.arange(npts)*deltaT-0.02 +vec=flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,meta,lk,rbn,ubn,vbn,ep,Lmax=Lmax,array_output=True)[0] +mv=np.max(vec);vec_marg=mv+np.log(np.trapz(np.exp(vec-mv),dx=deltaT)) +worst = float(np.max(np.abs(vec - bf))) +print("brute-force(real F(t)) : peak=%.4f marg=%.4f" % (np.max(bf), bf_marg)) +print("vec rotation : peak=%.4f marg=%.4f" % (np.max(vec), vec_marg)) +print("max|vec_lnL_t - bruteforce_lnL_t| over window = %.3e" % worst) +assert worst < 1e-6, "vectorized rotation NoLoop disagrees with brute-force time-varying response: %g" % worst +print("PASS: vectorized rotation NoLoop == brute-force time-varying-response likelihood") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB.py new file mode 100644 index 000000000..a60cf89eb --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB.py @@ -0,0 +1,66 @@ +""" +test_slowrot_pathB : Path B (delay-derivative) sanity checks. + + (1) scalar Path B (p_max=1) at f_sidereal=0 reduces EXACTLY to the baseline + FactoredLogLikelihood (the p>=1 coefficient sums vanish at Omega=0). + (2) scalar Path B (p_max=1) at the real sidereal rate respects the Cauchy-Schwarz + bound lnL <= 0.5 at the injection parameters. + (3) vectorized Path B (p_max=1) at f_sidereal=0 reduces to the baseline NoLoop. + +Rigorous validation of the p>=1 delay physics vs a brute force with TIME-VARYING delay on +a LONG signal is deferred to the systematic final-validation pass. +Run: PYTHONPATH=.../Code python RIFT/likelihood/test_slowrot_pathB.py +""" +from __future__ import print_function, division +import numpy as np, lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fmin=30.;fmax=1700.;event_time=1e9;t_window=0.1;Lmax=2;deltaT=1/4096.;deltaF=1/4. +HB=tuple(range(-3,4)) +Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=0.3,phiref=0.0,theta=0.2,phi=1.0,psi=0.4, + m1=30*lal.MSUN_SI,m2=25*lal.MSUN_SI,detector='H1',dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF) +data_dict={} +for d in ["H1","L1","V1"]: + P=Psig.manual_copy();P.detector=d;data_dict[d]=lsu.non_herm_hoff(P) +psd_dict={d:lalsim.SimNoisePSDaLIGOZeroDetHighPower for d in data_dict} + +def test_scalar_reduces_to_baseline(): + extr=lsu.ChooseWaveformParams(radec=True,phi=1.0,theta=0.2,psi=0.5,incl=0.7,phiref=0.9,tref=event_time,dist=300e6*lal.PC_SI) + rb=fl.PrecomputeLikelihoodTerms(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,analyticPSD_Q=True,verbose=False,quiet=True,ignore_threshold=None) + lnLb=fl.FactoredLogLikelihood(extr,rb[3],rb[0],rb[1],rb[2],Lmax) + rr=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=HB,p_max=1,f_sidereal=0.0,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) + lnLB=flwr.FactoredLogLikelihoodWithRotation(extr,rr[0],rr[1],rr[2],rr[4],Lmax) + print("(1) scalar PathB(p1,fsid0)=%.8f baseline=%.8f |d|=%.2e"%(lnLB,lnLb,abs(lnLB-lnLb))) + assert abs(lnLB-lnLb)<1e-6*(1+abs(lnLb)) + +def test_scalar_respects_bound(): + extr=lsu.ChooseWaveformParams(radec=True,phi=1.0,theta=0.2,psi=0.4,incl=0.3,phiref=0.0,tref=event_time,dist=200e6*lal.PC_SI) + rr=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,{'H1':data_dict['H1']},{'H1':psd_dict['H1']},Lmax,fmax,harmonics=HB,p_max=1,f_sidereal=flwr.F_SIDEREAL,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) + lnL=flwr.FactoredLogLikelihoodWithRotation(extr,rr[0],rr[1],rr[2],rr[4],Lmax) + IP=lsu.ComplexIP(fmin,fmax,1/2./deltaT,deltaF,psd_dict['H1'],True,False,0.);dd=IP.ip(data_dict['H1'],data_dict['H1']).real + print("(2) scalar PathB(p1,real,H1) lnL=%.4f 0.5=%.4f"%(lnL,0.5*dd)) + assert lnL<=0.5*dd + +def test_vec_reduces_to_baseline(): + tvals=np.arange(400)*deltaT-0.02 + rb=fl.PrecomputeLikelihoodTerms(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,analyticPSD_Q=True,verbose=False,quiet=True,ignore_threshold=None) + lk={};ra={};cu={};cv={};ep={} + for d in data_dict: + a,b,c,U,V,rA,rI,e=fl.PackLikelihoodDataStructuresAsArrays(list(rb[3][d].keys()),None,rb[3][d],rb[1][d],rb[2][d]);lk[d]=a;ra[d]=rA;cu[d]=U;cv[d]=V;ep[d]=e + Pv=Psig.manual_copy() + for k,v in [('phi',1.0),('theta',0.2),('incl',0.7),('phiref',0.9),('psi',0.5),('dist',300e6*lal.PC_SI)]: setattr(Pv,k,np.ones(1)*v) + Pv.tref=event_time;Pv.deltaT=deltaT + bl=fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals,Pv,lk,ra,cu,cv,ep,Lmax=Lmax,xpy=np)[0] + rr0=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=HB,p_max=1,f_sidereal=0.0,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=False) + lkr,rar,uar,var,epr=flwr.pack_rotation_arrays(rr0[4],rr0[3],rr0[1],rr0[2]) + vv=flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,rr0[4],lkr,rar,uar,var,epr,Lmax=Lmax,array_output=False)[0] + print("(3) vec PathB(p1,fsid0)=%.8f baseline NoLoop=%.8f |d|=%.2e"%(vv,bl,abs(vv-bl))) + assert abs(vv-bl)<1e-7*(1+abs(bl)) + +if __name__=="__main__": + test_scalar_reduces_to_baseline() + test_scalar_respects_bound() + test_vec_reduces_to_baseline() + print("ALL SLOWROT PATH B CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py new file mode 100644 index 000000000..262a552a3 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_bruteforce.py @@ -0,0 +1,53 @@ +from __future__ import print_function, division +import numpy as np, lal, lalsimulation as lalsim +from scipy.interpolate import CubicSpline +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +import RIFT.likelihood.slowrot_response as srr +event_time=1e9; Lmax=2; t_window=0.1; det='H1' +psd=lalsim.SimNoisePSDaLIGOZeroDetHighPower; apx=lalsim.GetApproximantFromString("IMRPhenomD") +import os +OMEGA_INF=flwr.OMEGA_EARTH*float(os.environ.get("INFL","340")); FSID_INF=OMEGA_INF/(2*np.pi) +def _ifft(hf_d): + o={} + for lm,hf in hf_d.items(): + n=hf.data.length; dt=1./(n*hf.deltaF); ht=lal.CreateCOMPLEX16TimeSeries("h",hf.epoch,0.,dt,lal.DimensionlessUnit,n) + lal.COMPLEX16FreqTimeFFT(ht,hf,lal.CreateReverseCOMPLEX16FFTPlan(n,0)); o[lm]=ht + return o +def _to_fd(re,epoch,dt,N): + ht=lal.CreateCOMPLEX16TimeSeries("h",epoch,0.,dt,lal.DimensionlessUnit,N); ht.data.data[:]=re[:N] + hf=lal.CreateCOMPLEX16FrequencySeries("hf",epoch,0.,1./dt/N,lsu.lsu_HertzUnit,N) + lal.COMPLEX16TimeFreqFFT(hf,ht,lal.CreateForwardCOMPLEX16FFTPlan(N,0)); return hf +from scipy.interpolate import InterpolatedUnivariateSpline +def _peak(lt): + lt=np.asarray(lt,float); x=np.arange(len(lt)); sp=InterpolatedUnivariateSpline(x,lt,k=4) + xs=np.linspace(0,len(lt)-1,len(lt)*32); return float(np.max(sp(xs))) +fmin,fmax,deltaT,seglen=25.,512.,1/2048.,16.; deltaF=1./seglen; fNyq=1/2./deltaT; N=int(round(seglen/deltaT)) +RA,DEC,PSI,INCL,PHIREF=1.2,0.3,0.5,0.4,0.0; DLOUD=fl.distMpcRef*1e6*lsu.lsu_PC/30. +Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=INCL,phiref=PHIREF,theta=DEC,phi=RA,psi=PSI, + m1=2.2*lal.MSUN_SI,m2=1.8*lal.MSUN_SI,detector=det,dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF); Psig.approx=apx +Pm=Psig.manual_copy(); Pm.dist=DLOUD +hlms_fd,_=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True); hlmsT=_ifft(hlms_fd) +lm0=list(hlmsT.keys())[0]; nn=hlmsT[lm0].data.length; dt=hlmsT[lm0].deltaT; ep=float(hlmsT[lm0].epoch); tt=ep+np.arange(nn)*dt +Sig=np.zeros(nn,complex) +for lm in hlmsT: Sig+=hlmsT[lm].data.data*lal.SpinWeightedSphericalHarmonic(INCL,-PHIREF,-2,lm[0],lm[1]) +reS=CubicSpline(tt,Sig.real,extrapolate=False); imS=CubicSpline(tt,Sig.imag,extrapolate=False) +lald=lalsim.DetectorPrefixToLALDetector(det); g_ev=lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(event_time))-RA +A=srr.antenna_harmonics(lald.response,DEC,PSI); At={k:A[k]*np.exp(1j*k*g_ev) for k in A} +B=srr.delay_harmonics(lald.location,DEC); Bt={k:B[k]*np.exp(1j*k*g_ev) for k in B} +tau_t=np.real(sum(Bt[k]*np.exp(1j*k*OMEGA_INF*tt) for k in Bt)) +F_t=sum(At[k]*np.exp(1j*k*OMEGA_INF*tt) for k in At) +Sig_d=np.nan_to_num(reS(tt-tau_t)+1j*imS(tt-tau_t)) +data=_to_fd(np.real(F_t*Sig_d),lal.LIGOTimeGPS(float(hlmsT[lm0].epoch)+event_time),dt,N); data_dict={det:data}; psd_dict={det:psd} +IPc=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.); HALF_DD=0.5*IPc.ip(data,data).real +print("inflated seglen=%.0fs 0.5=%.4f"%(seglen,HALF_DD)) +Pv=Psig.manual_copy() +for k,v in [('phi',RA),('theta',DEC),('incl',INCL),('phiref',PHIREF),('psi',PSI),('dist',DLOUD)]: setattr(Pv,k,np.ones(1)*v) +Pv.tref=event_time; Pv.deltaT=deltaT; Nw=int(0.02/deltaT); tvals=np.arange(-Nw,Nw)*deltaT +for pmax in [0,1,2,3]: + nh=2+pmax + bk=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=tuple(range(-nh,nh+1)),p_max=pmax,f_sidereal=FSID_INF,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=True) + lk,rbn,ubn,vbn,epd=flwr.pack_rotation_arrays(bk[4],bk[3],bk[1],bk[2]) + lnL=_peak(flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,bk[4],lk,rbn,ubn,vbn,epd,Lmax=Lmax,array_output=True)[0]) + print(" p_max=%d : lnL=%.5f deficit=%.5f"%(pmax,lnL,HALF_DD-lnL)) diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_groundtruth.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_groundtruth.py new file mode 100644 index 000000000..7e2ad583f --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_pathB_groundtruth.py @@ -0,0 +1,69 @@ +from __future__ import print_function, division +import sys +import numpy as np +import lal, lalsimulation as lalsim +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr +event_time=1e9; Lmax=2; t_window=0.1; det='H1' +psd=lalsim.SimNoisePSDaLIGOZeroDetHighPower +apx=lalsim.GetApproximantFromString("IMRPhenomD") +SCALE=40. # loudness: data-mode distance = distMpcRef/SCALE + +def _ifft(hlms_fd): + out={} + for lm,hf in hlms_fd.items(): + n=hf.data.length; dt=1./(n*hf.deltaF) + ht=lal.CreateCOMPLEX16TimeSeries("h",hf.epoch,0.,dt,lal.DimensionlessUnit,n) + lal.COMPLEX16FreqTimeFFT(ht,hf,lal.CreateReverseCOMPLEX16FFTPlan(n,0)); out[lm]=ht + return out +def _fd(hk,TDlen): + if hk.data.length!=TDlen: hk=lal.ResizeREAL8TimeSeries(hk,0,TDlen) + n=TDlen + htC=lal.CreateCOMPLEX16TimeSeries("h",hk.epoch,hk.f0,hk.deltaT,hk.sampleUnits,n); htC.data.data[:]=hk.data.data + hf=lal.CreateCOMPLEX16FrequencySeries("hf",hk.epoch,hk.f0,1./hk.deltaT/n,lsu.lsu_HertzUnit,n) + lal.COMPLEX16TimeFreqFFT(hf,htC,lal.CreateForwardCOMPLEX16FFTPlan(n,0)); return hf +def _peak(lt,up=16): + lt=np.asarray(lt,dtype=float); N=len(lt) + L=np.fft.rfft(lt); Lp=np.zeros(N*up//2+1,dtype=complex); Lp[:len(L)]=L + return float(np.max(np.fft.irfft(Lp,N*up)*up)) + +def run(mode="short"): + if mode=="long": fmin,fmax,deltaT,seglen,m1,m2=25.,256.,1/2048.,32.,2.2*lal.MSUN_SI,1.8*lal.MSUN_SI + else: fmin,fmax,deltaT,seglen,m1,m2=30.,1700.,1/4096.,4.,30*lal.MSUN_SI,25*lal.MSUN_SI + deltaF=1./seglen; fNyq=1/2./deltaT; TDlen=int(round(seglen/deltaT)) + DLOUD=fl.distMpcRef*1e6*lsu.lsu_PC/SCALE + Psig=lsu.ChooseWaveformParams(fmin=fmin,radec=True,incl=0.4,phiref=0.0,theta=0.3,phi=1.2,psi=0.5, + m1=m1,m2=m2,detector=det,dist=200e6*lal.PC_SI,deltaT=deltaT,tref=event_time,deltaF=deltaF); Psig.approx=apx + Pm=Psig.manual_copy(); Pm.dist=DLOUD + hlms_fd,_=fl.internal_hlm_generator(Pm,Lmax,verbose=False,quiet=True) + hlmsT=_ifft(hlms_fd) + data=_fd(lsu.hoft_from_hlm(hlmsT,Psig.manual_copy()),TDlen) + data_dict={det:data}; psd_dict={det:psd} + IPc=lsu.ComplexIP(fmin,fmax,fNyq,data.deltaF,psd,True,False,0.); HALF_DD=0.5*IPc.ip(data,data).real + print("[%s] seglen=%.0fs data_len=%d 0.5=%.4f"%(mode,seglen,data.data.length,HALF_DD)) + RA,DEC,PSI,INCL,PHIREF,DIST=1.2,0.3,0.5,0.4,0.0,DLOUD + ri,ct,ctV,rho,snr,rest=fl.PrecomputeLikelihoodTerms(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,analyticPSD_Q=True,verbose=False,quiet=True,ignore_threshold=None) + Pe=Psig.manual_copy(); Pe.incl=INCL;Pe.phiref=PHIREF;Pe.psi=PSI;Pe.phi=RA;Pe.theta=DEC;Pe.dist=DIST + hk_gt=_fd(lsu.hoft_from_hlm(hlmsT,Pe),TDlen); lnL_gt=IPc.ip(data,hk_gt).real-0.5*IPc.ip(hk_gt,hk_gt).real + Pv=Psig.manual_copy() + for k,v in [('phi',RA),('theta',DEC),('incl',INCL),('phiref',PHIREF),('psi',PSI),('dist',DIST)]: + setattr(Pv,k,np.ones(1)*v) + Pv.tref=event_time; Pv.deltaT=deltaT + Nw=int(0.02/deltaT); tvals=np.arange(-Nw,Nw)*deltaT + lkB={};rAB={};cuB={};cvB={};epB={} + for d in data_dict: + a,b,c,U,V,rA,rI,e=fl.PackLikelihoodDataStructuresAsArrays(list(rho[d].keys()),None,rho[d],ct[d],ctV[d]) + lkB[d]=a;rAB[d]=rA;cuB[d]=U;cvB[d]=V;epB[d]=e + lnL_base=_peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals,Pv,lkB,rAB,cuB,cvB,epB,Lmax=Lmax,xpy=np,return_lnLt=True)[0]) + def rot(pmax): + nh=2+pmax + bk=flwr.PrecomputeLikelihoodTermsWithRotation(event_time,t_window,Psig,data_dict,psd_dict,Lmax,fmax,harmonics=tuple(range(-nh,nh+1)),p_max=pmax,f_sidereal=flwr.F_SIDEREAL,analyticPSD_Q=True,verbose=False,quiet=True,skip_interpolation=True) + lk,rbn,ubn,vbn,ep=flwr.pack_rotation_arrays(bk[4],bk[3],bk[1],bk[2]) + return _peak(flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation(tvals,Pv,bk[4],lk,rbn,ubn,vbn,ep,Lmax=Lmax,array_output=True)[0]) + lnL_A=rot(0); lnL_B=rot(1) + print(" ground truth (SimDetStrain) lnL = %.5f"%lnL_gt) + print(" baseline NoLoop peak lnL = %.5f deficit=%.5f"%(lnL_base,lnL_gt-lnL_base)) + print(" Path A NoLoop (F(t)) lnL = %.5f deficit=%.5f"%(lnL_A,lnL_gt-lnL_A)) + print(" Path B NoLoop (F(t)+p=1) lnL = %.5f deficit=%.5f"%(lnL_B,lnL_gt-lnL_B)) +if __name__=="__main__": run(sys.argv[1] if len(sys.argv)>1 else "short") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_precompute_integration.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_precompute_integration.py new file mode 100644 index 000000000..62a77c062 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_precompute_integration.py @@ -0,0 +1,125 @@ +""" +Full-stack integration test for factored_likelihood_with_rotation on real waveforms/data. + +Run under the RIFT venv with this worktree first on PYTHONPATH, e.g. + + source ~/RIFT_develUWM/bin/activate + PYTHONPATH=~/RIFT_slowrot/MonteCarloMarginalizeCode/Code \ + python .../RIFT/likelihood/test_slowrot_precompute_integration.py + +Inputs are built exactly like RIFT.calmarg.test_precompute_alignment (a short BBH injection, +noiseless detector data via non_herm_hoff, analytic aLIGO PSD), i.e. the same machinery the +ILE-GPU-Paper CI demo exercises. + +Checks: + V0 (recovery): PrecomputeLikelihoodTermsWithRotation(harmonics=(0,), p_max=0) reproduces + the baseline PrecomputeLikelihoodTerms bit-for-bit -- Q, U, V. This validates all of + the new plumbing (mode generation, p=0 / n=0 identity, cross-term assembly). + Vmag (magnitude sanity): running Path A (harmonics -2..2) end-to-end on real data, the + a=(0,0) overlap still equals the baseline exactly, and the modulated a=(0,+-1) overlaps + differ from it by a small but nonzero amount of the expected order Omega*(signal + duration) -- confirming the sidereal modulation is applied, at the right (tiny) scale. + +Full physical validation (V1: the rotation-aware lnL vs a brute-force time-varying-response +likelihood) needs the lnL assembly that contracts this bank with A_n,B_n, and is the next +step. +""" +from __future__ import print_function, division + +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lalsimutils +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +fSample = 4096.0 +fmin = 30.0 +fmax = 1700.0 +event_time = 1000000000.0 +t_window = 0.1 +Lmax = 2 + +Psig = lalsimutils.ChooseWaveformParams( + fmin=fmin, radec=True, incl=0.3, phiref=0.0, theta=0.2, phi=1.0, psi=0.4, + m1=30 * lal.MSUN_SI, m2=25 * lal.MSUN_SI, + detector='H1', dist=200e6 * lal.PC_SI, deltaT=1. / fSample, + tref=event_time, deltaF=1. / 4.) + +data_dict = {} +for det in ("H1", "L1", "V1"): + P = Psig.manual_copy(); P.detector = det + data_dict[det] = lalsimutils.non_herm_hoff(P) +psd_dict = {det: lalsim.SimNoisePSDaLIGOZeroDetHighPower for det in data_dict} + + +def _maxrel(a, b): + a = np.asarray(a); b = np.asarray(b) + scale = max(np.max(np.abs(a)), np.max(np.abs(b)), 1e-300) + return float(np.max(np.abs(a - b)) / scale) + + +def _run_base(): + # ignore_threshold=None so no mode pruning -> same mode set as the rotation path + return fl.PrecomputeLikelihoodTerms( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, skip_interpolation=True, + ignore_threshold=None) + + +def _run_rot(harmonics, p_max): + return flwr.PrecomputeLikelihoodTermsWithRotation( + event_time, t_window, Psig, data_dict, psd_dict, Lmax, fmax, + harmonics=harmonics, p_max=p_max, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=True) + + +def test_V0_recovers_baseline(): + _, ct_b, ctV_b, rho_b, _, _ = _run_base() + _, ct_r, ctV_r, rho_r, meta = _run_rot(harmonics=(0,), p_max=0) + a0 = (0, 0) + worst_q = worst_u = worst_v = 0.0 + for det in data_dict: + for mode in rho_b[det]: + worst_q = max(worst_q, _maxrel(rho_r[det][a0][mode].data.data, + rho_b[det][mode].data.data)) + for key in ct_b[det]: + worst_u = max(worst_u, abs(ct_r[det][(a0, a0)][key] - ct_b[det][key])) + worst_v = max(worst_v, abs(ctV_r[det][(a0, a0)][key] - ctV_b[det][key])) + print("V0 recovery: max rel|dQ|=%.2e max|dU|=%.2e max|dV|=%.2e" + % (worst_q, worst_u, worst_v)) + assert worst_q < 1e-8, "Q mismatch vs baseline: %g" % worst_q + assert worst_u < 1e-8 * (1 + worst_u), "U mismatch vs baseline: %g" % worst_u + assert worst_v < 1e-8 * (1 + worst_v), "V mismatch vs baseline: %g" % worst_v + + +def test_Vmag_modulation_applied_at_right_scale(): + _, ct_b, _, rho_b, _, _ = _run_base() + _, _, _, rho_r, meta = _run_rot(harmonics=(-2, -1, 0, 1, 2), p_max=0) + # a=(0,0) must still equal baseline exactly even with the extra harmonics present + worst_0 = 0.0 + for det in data_dict: + for mode in rho_b[det]: + worst_0 = max(worst_0, _maxrel(rho_r[det][(0, 0)][mode].data.data, + rho_b[det][mode].data.data)) + print("Vmag a=(0,0) vs baseline: max rel|dQ|=%.2e" % worst_0) + assert worst_0 < 1e-8, "modulated run corrupts the n=0 overlap: %g" % worst_0 + + # modulated harmonics differ from n=0 by ~ Omega * (signal duration), tiny but nonzero. + # Signal duration scale for a 30+25 BBH from 30 Hz is ~ 0.1-1 s -> Omega*T ~ 1e-5..1e-4. + for n in (1, -1, 2, -2): + worst_n = 0.0 + for det in data_dict: + for mode in rho_b[det]: + worst_n = max(worst_n, _maxrel(rho_r[det][(0, n)][mode].data.data, + rho_r[det][(0, 0)][mode].data.data)) + print("Vmag a=(0,%+d) vs a=(0,0): max rel|dQ|=%.2e" % (n, worst_n)) + assert worst_n > 1e-9, "harmonic n=%d has no effect (modulation not applied?)" % n + assert worst_n < 1e-2, "harmonic n=%d far too large (bug in modulation scale?): %g" % (n, worst_n) + + +if __name__ == "__main__": + test_V0_recovers_baseline() + test_Vmag_modulation_applied_at_right_scale() + print("ALL SLOWROT PRECOMPUTE INTEGRATION CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_response.py b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_response.py new file mode 100644 index 000000000..7bdeed3e1 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/likelihood/test_slowrot_response.py @@ -0,0 +1,135 @@ +""" +Validate slowrot_response against LAL over a full sidereal day. + +The harmonic decomposition is an exact algebraic identity in the hour angle g, so the +reconstruction must match lal.ComputeDetAMResponse and lal.TimeDelayFromEarthCenter to +machine precision (up to LAL's own internal rounding). Run directly: + + python test_slowrot_response.py + +or under pytest (the test_* functions assert). +""" +from __future__ import print_function, division + +import numpy as np + +import lal +import lalsimulation as lalsim + +# Load the leaf module directly by path so this test needs only numpy+lal, not the full +# RIFT package stack (whose __init__ pulls in glue/lalsimutils). When the package is +# properly installed, `import RIFT.likelihood.slowrot_response` is equivalent. +try: + import RIFT.likelihood.slowrot_response as sr +except Exception: + import importlib.util + import os + _here = os.path.dirname(os.path.abspath(__file__)) + _spec = importlib.util.spec_from_file_location( + "slowrot_response", os.path.join(_here, "slowrot_response.py")) + sr = importlib.util.module_from_spec(_spec) + _spec.loader.exec_module(sr) + +DETECTORS = ["H1", "L1", "V1", "K1"] +SIDEREAL_DAY = 86164.0905 # s +T0_GPS = 1000000000.0 # arbitrary reference epoch + +_TOL = 1e-11 # radians of F, seconds of tau -- essentially machine precision + + +def _lal_detector(det): + return lalsim.DetectorPrefixToLALDetector(det) + + +def _sample_gmst(n=512): + tgps = T0_GPS + np.linspace(0.0, SIDEREAL_DAY, n, endpoint=False) + gmst = np.array([lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(float(t))) for t in tgps]) + return tgps, gmst + + +def _max_antenna_error(det, ra, dec, psi): + lald = _lal_detector(det) + A = sr.antenna_harmonics(lald.response, dec, psi) + _, gmst = _sample_gmst() + # model + Fp_mod, Fc_mod = sr.antenna_response(A, gmst, ra) + # reference (LAL), sampled at the same GMST values + Fp_ref = np.empty_like(gmst) + Fc_ref = np.empty_like(gmst) + for i, g in enumerate(gmst): + Fp_ref[i], Fc_ref[i] = lal.ComputeDetAMResponse(lald.response, ra, dec, psi, float(g)) + return max(np.max(np.abs(Fp_mod - Fp_ref)), np.max(np.abs(Fc_mod - Fc_ref))) + + +def _max_delay_error(det, ra, dec): + lald = _lal_detector(det) + B = sr.delay_harmonics(lald.location, dec) + tgps, gmst = _sample_gmst() + g = sr.greenwich_hour_angle(gmst, ra) + tau_mod = sr.delay_from_harmonics(B, g) + tau_ref = np.array([ + lal.TimeDelayFromEarthCenter(lald.location, ra, dec, lal.LIGOTimeGPS(float(t))) + for t in tgps + ]) + return np.max(np.abs(tau_mod - tau_ref)) + + +# ---- deterministic parameter sweep ------------------------------------------------- +_RNG = np.random.RandomState(20260703) +_CASES = [] +for _det in DETECTORS: + for _ in range(5): + _ra = _RNG.uniform(0, 2 * np.pi) + _dec = np.arcsin(_RNG.uniform(-1, 1)) + _psi = _RNG.uniform(0, np.pi) + _CASES.append((_det, _ra, _dec, _psi)) + + +def test_antenna_harmonics_match_lal(): + worst = 0.0 + for det, ra, dec, psi in _CASES: + e = _max_antenna_error(det, ra, dec, psi) + worst = max(worst, e) + assert e < _TOL, "antenna mismatch %g for %s ra=%.3f dec=%.3f psi=%.3f" % ( + e, det, ra, dec, psi) + print("antenna: worst |dF| = %.3e" % worst) + + +def test_delay_harmonics_match_lal(): + worst = 0.0 + for det, ra, dec, psi in _CASES: + e = _max_delay_error(det, ra, dec) + worst = max(worst, e) + assert e < _TOL, "delay mismatch %g s for %s ra=%.3f dec=%.3f" % (e, det, ra, dec) + print("delay: worst |dtau| = %.3e s" % worst) + + +def test_only_five_and_three_harmonics(): + """Confirm there is genuinely no content beyond |n|=2 (antenna) / |n|=1 (delay).""" + lald = _lal_detector("H1") + ra, dec, psi = 1.0, 0.4, 0.7 + _, gmst = _sample_gmst(1024) + g = sr.greenwich_hour_angle(gmst, ra) + # antenna: DFT of F(g) sampled uniformly in g should have power only at |n|<=2 + gg = np.linspace(0, 2 * np.pi, 1024, endpoint=False) + F = sr.antenna_from_harmonics(sr.antenna_harmonics(lald.response, dec, psi), gg) + coeffs = np.fft.fft(F) / len(gg) + power = np.abs(coeffs) + keep = np.zeros_like(power, dtype=bool) + for n in (-2, -1, 0, 1, 2): + keep[n % len(gg)] = True + assert np.max(power[~keep]) < 1e-12, "antenna has power beyond |n|=2: %g" % np.max(power[~keep]) + tau = sr.delay_from_harmonics(sr.delay_harmonics(lald.location, dec), gg) + tcoef = np.abs(np.fft.fft(tau) / len(gg)) + keep = np.zeros_like(tcoef, dtype=bool) + for n in (-1, 0, 1): + keep[n % len(gg)] = True + assert np.max(tcoef[~keep]) < 1e-15, "delay has power beyond |n|=1: %g" % np.max(tcoef[~keep]) + print("harmonic content confirmed: antenna |n|<=2, delay |n|<=1") + + +if __name__ == "__main__": + test_antenna_harmonics_match_lal() + test_delay_harmonics_match_lal() + test_only_five_and_three_harmonics() + print("ALL SLOWROT RESPONSE CHECKS PASSED") diff --git a/MonteCarloMarginalizeCode/Code/RIFT/misc/ourparams.py b/MonteCarloMarginalizeCode/Code/RIFT/misc/ourparams.py index 0160310a5..5dd624be9 100644 --- a/MonteCarloMarginalizeCode/Code/RIFT/misc/ourparams.py +++ b/MonteCarloMarginalizeCode/Code/RIFT/misc/ourparams.py @@ -15,10 +15,10 @@ from igwn_ligolw import utils, lsctables, table try: - hasNR=True - import NRWaveformCatalogManager3 as nrwf -except: - hasNR=False + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() +except ImportError: + nrwf = None; hasNR = False print(" - no NR waveforms - ") rosDebugMessagesDictionary = {} # Mutable after import (passed by reference). Not clear if it can be used by caling routines diff --git a/MonteCarloMarginalizeCode/Code/RIFT/physics/_nrwf_loader.py b/MonteCarloMarginalizeCode/Code/RIFT/physics/_nrwf_loader.py new file mode 100644 index 000000000..c108a5cfb --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/RIFT/physics/_nrwf_loader.py @@ -0,0 +1,82 @@ +"""Centralised NR-waveform-catalog import shim. + +Every RIFT call site that used to do :: + + try: + import NRWaveformCatalogManager3 as nrwf + hasNR = True + except ImportError: + nrwf = None + hasNR = False + +now does :: + + from RIFT.physics._nrwf_loader import get_nrwf + nrwf, hasNR = get_nrwf() + +Resolution order +---------------- +1. ``nrcatalog.compat_nrwf`` (cleanup_2026 branch of + NRWaveformCatalogManager_repo). Re-exports every legacy name and + overlays new providers on top. +2. ``NRWaveformCatalogManager3`` (the legacy module by itself). +3. ``None`` (NR unavailable). + +Override with the environment variable ``RIFT_NRWF_BACKEND``: +``auto`` (default), ``new``, ``legacy``, or ``none``. +""" + +from __future__ import annotations + +import logging +import os +from typing import Optional, Tuple + +log = logging.getLogger(__name__) + +_cached: Optional[Tuple[object, bool]] = None + + +def get_nrwf() -> Tuple[Optional[object], bool]: + """Return ``(module, has_nrwf_bool)``. Cached after the first call.""" + global _cached + if _cached is not None: + return _cached + + backend = os.environ.get("RIFT_NRWF_BACKEND", "auto").lower() + + if backend == "none": + _cached = (None, False) + return _cached + + if backend in ("auto", "new"): + try: + import nrcatalog.compat_nrwf as nrwf # type: ignore + log.info("RIFT NR backend: nrcatalog.compat_nrwf (legacy %s)", + "wrapped" if nrwf.is_using_legacy() else "absent") + _cached = (nrwf, True) + return _cached + except ImportError as exc: + if backend == "new": + log.warning("RIFT_NRWF_BACKEND=new but nrcatalog not importable: %s", exc) + _cached = (None, False) + return _cached + # fall through to legacy + + if backend in ("auto", "legacy"): + try: + import NRWaveformCatalogManager3 as nrwf # type: ignore + log.info("RIFT NR backend: NRWaveformCatalogManager3 (legacy)") + _cached = (nrwf, True) + return _cached + except ImportError as exc: + log.info("RIFT NR backend: no NR module available (%s)", exc) + + _cached = (None, False) + return _cached + + +def reset_cache() -> None: + """Forget the cached choice — used in tests.""" + global _cached + _cached = None diff --git a/MonteCarloMarginalizeCode/Code/bin/create_event_parameter_pipeline_BasicIteration b/MonteCarloMarginalizeCode/Code/bin/create_event_parameter_pipeline_BasicIteration index 18edcf544..4fc97ed2e 100755 --- a/MonteCarloMarginalizeCode/Code/bin/create_event_parameter_pipeline_BasicIteration +++ b/MonteCarloMarginalizeCode/Code/bin/create_event_parameter_pipeline_BasicIteration @@ -2287,6 +2287,14 @@ if opts.last_iteration_extrinsic: ile_node.add_macro("macroevent", event*n_group) ile_node.add_macro("macrongroup", n_group) ile_node.add_macro("macroiteration", it) + # Option C cal-pilot / extrinsic-handoff seed path. The wide ILE args carry + # --calibration-proposal-breadcrumb .../cal_consolidated_$(macroiterationprev).npz + # (and the extrinsic-handoff analogue). This last-iteration EXTRINSIC ILE node was + # missing macroiterationprev, so the macro expanded to blank -> the job requested the + # literal cal_consolidated_.npz, which never exists -> transfer hard-hold (OSG) or a + # silently-unseeded run (shared FS). Set it just like the wide ILE node so the + # extrinsic stage seeds from the previous iteration's consolidated proposal. + ile_node.add_macro("macroiterationprev", it-1) if not(parent_fit_node is None): ile_node.add_parent(parent_fit_node) dag.add_node(ile_node) diff --git a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode index 8ce0ee353..774c05700 100755 --- a/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode +++ b/MonteCarloMarginalizeCode/Code/bin/integrate_likelihood_extrinsic_batchmode @@ -254,6 +254,12 @@ optp.add_option("--extrinsic-proposal-adapt",action='store_true',default=False, optp.add_option("--vectorized", action="store_true", help="Perform manipulations of lm and timeseries using numpy arrays, not LAL data structures. (Combine with --gpu to enable GPU use, where available)") optp.add_option("--gpu", action="store_true", help="Perform manipulations of lm and timeseries using numpy arrays, CONVERTING TO GPU when available. You MUST use this option with --vectorized (otherwise it is a no-op). You MUST have a suitable version of cupy installed, your cuda operational, etc") optp.add_option("--force-gpu-only", action="store_true", help="Hard fail if no GPU present (assessed by cupy not loading)") +optp.add_option("--rotation-slow", action="store_true", help="[Path A] Slow-rotation likelihood: account for the sidereal time-dependence of the antenna pattern F(t) over the signal (harmonic modulation). Requires --vectorized; supports --gpu (n_cal=1, no glitch/cal marg). Uses factored_likelihood_with_rotation.") +optp.add_option("--rotation-n-harmonics", type=int, default=2, help="Number of sidereal harmonics for --rotation-slow (antenna pattern needs 2, i.e. n=-2..2).") +optp.add_option("--rotation-p-max", type=int, default=0, help="[Path B] Max delay-derivative order for --rotation-slow (0 = amplitude drift only; >=1 adds propagation-delay drift).") +optp.add_option("--freqresponse", action="store_true", help="[Path D] Finite-size (frequency-dependent) detector-response likelihood: account for the finite light-travel-time transfer across the arms (matters for 3G/CE-ET). Requires --vectorized; supports --gpu (n_cal=1, no glitch/cal marg). Uses factored_likelihood_freqresponse (sky-harmonic route b, sky stays extrinsic).") +optp.add_option("--freqresponse-qmax", type=int, default=4, help="Highest power of the arm projection retained for --freqresponse (basis size Qmax+2). Higher for larger fL/c (heavier systems / higher fmax).") +optp.add_option("--freqresponse-arm-length", default=None, help="Arm-length override [m] for --freqresponse. Either a single float applied to ALL detectors (e.g. 40000 for 40-km CE), or per-detector 'C1=40000,E1=10000,...' (needed for mixed CE+ET networks, since LAL's cached C1 arm is a placeholder). Default: each detector's native LAL arm length.") optp.add_option("--force-xpy", action="store_true", help="Use the xpy code path. Use with --vectorized --gpu to use the fallback CPU-based code path. Useful for debugging.") optp.add_option("-o", "--output-file", help="Save result to this file.") optp.add_option("-O", "--output-format", default='xml', help="[xml|hdf5]") @@ -304,6 +310,10 @@ integration_params.add_option("--d-max", default=10000,type=float,help="Maximum integration_params.add_option("--d-min", default=1,type=float,help="Minimum distance in volume integral. Used to SET THE PRIOR; changing this value changes the numerical answer.") integration_params.add_option("--declination-cosine-sampler",action='store_true',help="If specified, the parameter used for declination is cos(dec), not dec") integration_params.add_option("--inclination-cosine-sampler",action='store_true',help="If specified, the parameter used for inclination is cos(dec), not dec") +integration_params.add_option("--limit-right-ascension",default=None,help="Restrict RA sampling AND prior to 'LO,HI' [rad] (truth-centered zoom box). Narrows the extrinsic prior like --d-min/--d-max do for distance; keep the box large vs the posterior so credible regions are unaffected. Assumes the plain (non-cosine) dec/incl samplers.") +integration_params.add_option("--limit-declination",default=None,help="Restrict declination sampling AND prior to 'LO,HI' [rad].") +integration_params.add_option("--limit-inclination",default=None,help="Restrict inclination sampling AND prior to 'LO,HI' [rad].") +integration_params.add_option("--limit-psi",default=None,help="Restrict polarization psi sampling AND prior to 'LO,HI' [rad].") integration_params.add_option("--internal-rotate-phase", action='store_true',help="If specified, the integration sampler uses phase_p ==phi+psi and phase_m == phi-psi as sampling coordinates, both ranging from 0 to 4 pi. The prior is twice as large.") integration_params.add_option("--internal-sky-network-coordinates",action='store_true',help="If specified, perform integration in sky coordinates aligned with the first two IFOs provided") integration_params.add_option("--internal-sky-network-coordinates-raw",action='store_true',help="If specified, does not attempt to organize IFO network sensibly, uses them AS PROVIDED IN ORDER.") @@ -385,6 +395,30 @@ def _truthy_option(value): opts._noloop_time_interp = "cubic" if _truthy_option(opts.interpolate_time) else "nearest" +if opts.rotation_slow: + # Path A/B slow-rotation: wired into BOTH the CPU-vectorized and GPU (xpy) branches; the + # NoLoop-with-rotation reuses the baseline fused Q_inner_product kernel per elementary + # template on GPU (same memory footprint as the baseline). + if not opts.vectorized: + raise ValueError("--rotation-slow requires --vectorized") + if opts.gpu and getattr(opts, 'calibration_marginalization', False): + raise ValueError("--rotation-slow on GPU does not support calibration/glitch marginalization (n_cal>1)") + if getattr(opts, 'distance_marginalization', False) or getattr(opts, 'phase_marginalization', False): + raise ValueError("--rotation-slow does not yet support distance/phase marginalization") + +if opts.freqresponse: + # Path D finite-size response: wired into BOTH the CPU-vectorized and GPU (xpy) branches; + # the NoLoop reuses the baseline fused Q_inner_product kernel per basis weight p on GPU + # (same memory footprint as the baseline), mirroring --rotation-slow. + if opts.rotation_slow: + raise ValueError("--freqresponse and --rotation-slow both replace the likelihood; use at most one") + if not opts.vectorized: + raise ValueError("--freqresponse requires --vectorized") + if opts.gpu and getattr(opts, 'calibration_marginalization', False): + raise ValueError("--freqresponse on GPU does not support calibration/glitch marginalization (n_cal>1)") + if getattr(opts, 'distance_marginalization', False) or getattr(opts, 'phase_marginalization', False): + raise ValueError("--freqresponse does not yet support distance/phase marginalization") + # cosmo d prior tools for interpolation: not used normally, but set if needed final_scipy_interpolate=None if 'cosmo' in opts.d_prior: @@ -431,6 +465,8 @@ if cupy_success and opts.gpu and opts.sampler_method == 'adaptive_cartesian_gpu' if not(opts.use_gwsignal): os.environ['RIFT_NO_GWSIGNAL'] = 'True' import RIFT.likelihood.factored_likelihood as factored_likelihood +import RIFT.likelihood.factored_likelihood_with_rotation as factored_likelihood_with_rotation +import RIFT.likelihood.factored_likelihood_freqresponse as factored_likelihood_freqresponse if opts.use_gwsignal and not(factored_likelihood.has_GWS): print(" HARD FAILURE: this node could not import gwsignal ! ") @@ -1045,6 +1081,15 @@ param_limits = { "psi": (0, 2*numpy.pi), if opts.internal_rotate_phase: param_limits['psi'] = (0, 4*numpy.pi) param_limits['phi_orb'] = (0, 4*numpy.pi) +# Optional truth-centered "zoom box": narrow the extrinsic sampling AND prior ranges so the +# adaptive sampler can resolve a narrow high-SNR peak it could never find from the full prior. +# Threads through param_limits into every sky/orientation sampler + its pdf/cdf_inv/prior_pdf. +for _optv, _k in [(opts.limit_psi, 'psi'), (opts.limit_right_ascension, 'right_ascension'), + (opts.limit_declination, 'declination'), (opts.limit_inclination, 'inclination')]: + if _optv: + _lo, _hi = [float(_x) for _x in str(_optv).split(',')] + param_limits[_k] = (_lo, _hi) + print(" [limit] restricting {} sampling/prior to [{:.4f}, {:.4f}]".format(_k, _lo, _hi)) # # Parameter integral sampling strategy @@ -1822,6 +1867,67 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t ctVArrayDict[det] = cupy.asarray(ctVArrayDict[det]) epochDict[det] = cupy.asarray(epochDict[det]) + # [Path A] slow-rotation precompute: build the harmonic-indexed bank and pack it. + rotation_slow_data = None + if opts.rotation_slow: + _pmax = int(opts.rotation_p_max) + _nh = max(int(opts.rotation_n_harmonics), 2 + _pmax) # wide enough to cover all C_{(p,ntilde)} + _harm = tuple(range(-_nh, _nh + 1)) + _rint_r, _ct_r, _ctV_r, _rho_r, _meta_r = factored_likelihood_with_rotation.PrecomputeLikelihoodTermsWithRotation( + fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax, + harmonics=_harm, p_max=_pmax, analyticPSD_Q=False, + inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec, + verbose=opts.verbose, quiet=not opts.verbose, skip_interpolation=True) + _lkR, _rhoN, _uNN, _vNN, _epR = factored_likelihood_with_rotation.pack_rotation_arrays( + _meta_r, _rho_r, _ct_r, _ctV_r) + if opts.gpu and (not xpy_default is np): + # move the (per-elementary-template) Q banks + U/V to device; the NoLoop + # reuses the baseline fused kernel per template. epoch/lookup stay on host. + for _det in _rhoN: + for _a in _rhoN[_det]: + _rhoN[_det][_a] = cupy.asarray(_rhoN[_det][_a]) + for _pair in _uNN[_det]: + _uNN[_det][_pair] = cupy.asarray(_uNN[_det][_pair]) + for _pair in _vNN[_det]: + _vNN[_det][_pair] = cupy.asarray(_vNN[_det][_pair]) + rotation_slow_data = dict(meta=_meta_r, lookupNKDict=_lkR, rho_by_n=_rhoN, + U_by_nn=_uNN, V_by_nn=_vNN, epochDict=_epR) + print(" [rotation-slow] precompute complete; p_max", _pmax, "sidereal harmonics", _harm, + "(GPU)" if (opts.gpu and not xpy_default is np) else "(CPU)") + + # [Path D] finite-size (frequency-dependent) response precompute: fold each W_p(f) + # into the modes once and pack the response-basis overlap bank. + freqresponse_data = None + if opts.freqresponse: + _qmax = int(opts.freqresponse_qmax) + _arm = opts.freqresponse_arm_length + if _arm is not None: + if '=' in str(_arm): # per-detector 'C1=40000,E1=10000' + _arm = {kv.split('=')[0]: float(kv.split('=')[1]) for kv in str(_arm).split(',')} + else: + _arm = float(_arm) + _rint_f, _ct_f, _ctV_f, _rho_f, _meta_f = factored_likelihood_freqresponse.PrecomputeLikelihoodTermsFreqResponse( + fiducial_epoch, t_window, P, data_dict, psd_dict, opts.l_max, fmax, + Qmax=_qmax, L_arm=_arm, analyticPSD_Q=False, + inv_spec_trunc_Q=inv_spec_trunc_Q, T_spec=T_spec, + verbose=opts.verbose, quiet=not opts.verbose, skip_interpolation=True) + _lkF, _rhoP, _uPP, _vPP, _epF = factored_likelihood_freqresponse.pack_freqresponse_arrays( + _meta_f, _rho_f, _ct_f, _ctV_f) + if opts.gpu and (not xpy_default is np): + # move the (per-basis-weight) Q banks + U/V to device; the NoLoop reuses the + # baseline fused kernel per weight p. epoch/lookup stay on host (as in rotation). + for _det in _rhoP: + for _p in _rhoP[_det]: + _rhoP[_det][_p] = cupy.asarray(_rhoP[_det][_p]) + for _pair in _uPP[_det]: + _uPP[_det][_pair] = cupy.asarray(_uPP[_det][_pair]) + for _pair in _vPP[_det]: + _vPP[_det][_pair] = cupy.asarray(_vPP[_det][_pair]) + freqresponse_data = dict(meta=_meta_f, lookupNKDict=_lkF, rho_by_p=_rhoP, + U_by_pp=_uPP, V_by_pp=_vPP, epochDict=_epF) + print(" [freqresponse] precompute complete; Qmax", _qmax, "arm-length", opts.freqresponse_arm_length, + "(GPU)" if (opts.gpu and not xpy_default is np) else "(CPU)") + def _cal_error_probe(n_cal_now, n_start=256, n_cap=None, rel_tol=0.1): """Estimate (sigma_lnZ_cal, neff_cal, n_used, dist_mode): the calibration MC error of lnZ and the effective cal draw count, from per-realization @@ -2091,7 +2197,20 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t P.psi= psi_true P.phiref = phi_orb_true - lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVector(tvals, + if opts.rotation_slow: + lnL = factored_likelihood_with_rotation.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P, rotation_slow_data['meta'], rotation_slow_data['lookupNKDict'], + rotation_slow_data['rho_by_n'], rotation_slow_data['U_by_nn'], + rotation_slow_data['V_by_nn'], rotation_slow_data['epochDict'], Lmax=opts.l_max, + time_interp=opts._noloop_time_interp) + elif opts.freqresponse: + lnL = factored_likelihood_freqresponse.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, P, freqresponse_data['meta'], freqresponse_data['lookupNKDict'], + freqresponse_data['rho_by_p'], freqresponse_data['U_by_pp'], + freqresponse_data['V_by_pp'], freqresponse_data['epochDict'], Lmax=opts.l_max, + time_interp=opts._noloop_time_interp) + else: + lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVector(tvals, P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict,Lmax=opts.l_max) # nEvals +=len(right_ascension) if supplemental_ln_likelihood: @@ -2143,7 +2262,20 @@ def analyze_event(P_list, indx_event, data_dict, psd_dict, fmax, opts,inv_spec_t P.phiref = phi_orb_true - lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals, + if opts.rotation_slow: + lnL = factored_likelihood_with_rotation.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, P, rotation_slow_data['meta'], rotation_slow_data['lookupNKDict'], + rotation_slow_data['rho_by_n'], rotation_slow_data['U_by_nn'], + rotation_slow_data['V_by_nn'], rotation_slow_data['epochDict'], Lmax=opts.l_max, + time_interp=opts._noloop_time_interp, xpy=xpy_default) + elif opts.freqresponse: + lnL = factored_likelihood_freqresponse.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, P, freqresponse_data['meta'], freqresponse_data['lookupNKDict'], + freqresponse_data['rho_by_p'], freqresponse_data['U_by_pp'], + freqresponse_data['V_by_pp'], freqresponse_data['epochDict'], Lmax=opts.l_max, + time_interp=opts._noloop_time_interp, xpy=xpy_default) + else: + lnL = factored_likelihood.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop(tvals, P, lookupNKDict, rholmArrayDict, ctUArrayDict, ctVArrayDict,epochDict,Lmax=opts.l_max,xpy=xpy_default,n_cal=n_cal_for_likelihood, cal_method=('fused' if use_fused_calmarg and opts._noloop_time_interp == 'nearest' else 'loop'), cal_log_weights=calibration_log_weights, time_interp=opts._noloop_time_interp) # non-distmarg: default-helper fused kernel (cal_distmarg=None) diff --git a/MonteCarloMarginalizeCode/Code/bin/util_CIPDirSummarizeEvidence.py b/MonteCarloMarginalizeCode/Code/bin/util_CIPDirSummarizeEvidence.py index bf0a17b59..17f29adb8 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_CIPDirSummarizeEvidence.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_CIPDirSummarizeEvidence.py @@ -31,8 +31,8 @@ net_dat.append([lnL, sigma_lnL, n_eff]) net_dat =np.array(net_dat) -lnL = np.average(lnL, weights=1./sigma_lnL**2) -sigma_lnL = np.max([np.sqrt(np.mean(sigma_lnL**2)/len(net_dat)),np.std(lnL)]) # not quite right but ok +lnL = np.average(net_dat[:, 0], weights=1./net_dat[:, 1]**2) +sigma_lnL = np.max([np.sqrt(np.mean(net_dat[:, 1]**2)/len(net_dat)),np.std(net_dat[:, 0])]) # not quite right but ok dat_out = [lnL, sigma_lnL] if opts.stream_output: print(*dat_out) diff --git a/MonteCarloMarginalizeCode/Code/bin/util_CompareWaveformsInDetectors.py b/MonteCarloMarginalizeCode/Code/bin/util_CompareWaveformsInDetectors.py index 28b325d1e..e17b64749 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_CompareWaveformsInDetectors.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_CompareWaveformsInDetectors.py @@ -29,7 +29,9 @@ import lal import sys -import NRWaveformCatalogManager3 as nrwf +from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + +nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 from matplotlib import pyplot as plt diff --git a/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform.py b/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform.py index 49cfbee60..9f1f0e238 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform.py @@ -145,7 +145,8 @@ if "--nr-lookup-group" in opts_list: nr_group=opts_list[opts_list.index("--nr-lookup-group")+1] import RIFT.lalsimutils as lalsimutils - import NRWaveformCatalogManager3 as nrwf + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 nr_group = opts_list[opts_list.index("--nr-lookup-group")+1] print(" Looking up NR parameters from best fit parameters") P_list = lalsimutils.xml_to_ChooseWaveformParams_array(infile) diff --git a/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform_NRFromIndex.py b/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform_NRFromIndex.py index 2dcd40613..773f24bf9 100644 --- a/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform_NRFromIndex.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_GenerateMaxlnLWaveform_NRFromIndex.py @@ -16,8 +16,9 @@ from shutil import copyfile from RIFT.precision import RiftFloat +from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf -import NRWaveformCatalogManager3 as nrwf +nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 import lal import RIFT.lalsimutils as lalsimutils diff --git a/MonteCarloMarginalizeCode/Code/bin/util_IterativeFisher.py b/MonteCarloMarginalizeCode/Code/bin/util_IterativeFisher.py index 3f906df33..0b62f975e 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_IterativeFisher.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_IterativeFisher.py @@ -72,10 +72,10 @@ print(" - No multiprocessing - ") try: - import NRWaveformCatalogManager3 as nrwf - hasNR =True -except: - hasNR=False + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() +except ImportError: + nrwf = None; hasNR = False try: hasEOB=True import EOBTidalExternal as eobwf diff --git a/MonteCarloMarginalizeCode/Code/bin/util_ManualOverlapGrid.py b/MonteCarloMarginalizeCode/Code/bin/util_ManualOverlapGrid.py index e49e4f8f0..8aaac76b5 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_ManualOverlapGrid.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_ManualOverlapGrid.py @@ -67,10 +67,10 @@ print(" - No multiprocessing - ") try: - import NRWaveformCatalogManager3 as nrwf - hasNR =True -except: - hasNR=False + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() +except ImportError: + nrwf = None; hasNR = False try: hasEOB=True import RIFT.physics.EOBTidalExternal as eobwf diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NRBestOfIndex.py b/MonteCarloMarginalizeCode/Code/bin/util_NRBestOfIndex.py index 70b85c564..9eadf4192 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_NRBestOfIndex.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NRBestOfIndex.py @@ -31,10 +31,10 @@ print(" - No multiprocessing - ") try: - import NRWaveformCatalogManager3 as nrwf - hasNR =True -except: - hasNR=False + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() +except ImportError: + nrwf = None; hasNR = False ### ### Load options diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NRDumpDetectorResponse.py b/MonteCarloMarginalizeCode/Code/bin/util_NRDumpDetectorResponse.py index 89d9e3592..5aef8d6be 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_NRDumpDetectorResponse.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NRDumpDetectorResponse.py @@ -16,8 +16,9 @@ import lal import sys -import NRWaveformCatalogManager3 as nrwf +from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf +nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 parser = argparse.ArgumentParser() parser.add_argument("--inj",default=None) parser.add_argument("--event",default=None,type=int) diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NRExtrudeOverlapGrid.py b/MonteCarloMarginalizeCode/Code/bin/util_NRExtrudeOverlapGrid.py index ee753d3da..f1865ecb7 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_NRExtrudeOverlapGrid.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NRExtrudeOverlapGrid.py @@ -39,10 +39,10 @@ print(" - No multiprocessing - ") try: - import NRWaveformCatalogManager3 as nrwf - hasNR =True -except: - hasNR=False + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() +except ImportError: + nrwf = None; hasNR = False try: hasEOB=True diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NRIndexedFminCutToILE.py b/MonteCarloMarginalizeCode/Code/bin/util_NRIndexedFminCutToILE.py index 92da56b57..c40716f52 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_NRIndexedFminCutToILE.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NRIndexedFminCutToILE.py @@ -13,8 +13,8 @@ import argparse import numpy as np import RIFT.lalsimutils as lalsimutils -import NRWaveformCatalogManager3 as nrwf - +from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf +nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 import lal MsunInSec = lal.MSUN_SI*lal.G_SI/lal.C_SI**3 diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NRNearestSimulationTo.py b/MonteCarloMarginalizeCode/Code/bin/util_NRNearestSimulationTo.py index 3ab202c91..029183c4c 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_NRNearestSimulationTo.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NRNearestSimulationTo.py @@ -28,7 +28,11 @@ -import NRWaveformCatalogManager3 as nrwf +from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + + + +nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 import scipy.optimize as optimize parser = argparse.ArgumentParser() diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NRRelabelILE.py b/MonteCarloMarginalizeCode/Code/bin/util_NRRelabelILE.py index 1d6873033..d06a11818 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_NRRelabelILE.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NRRelabelILE.py @@ -25,10 +25,10 @@ print(" - No multiprocessing - ") try: - import NRWaveformCatalogManager3 as nrwf - hasNR =True -except: - hasNR=False + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() +except ImportError: + nrwf = None; hasNR = False try: hasEOB=True import EOBTidalExternal as eobwf diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NRWriteFrame.py b/MonteCarloMarginalizeCode/Code/bin/util_NRWriteFrame.py index bde513210..25f23465c 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_NRWriteFrame.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NRWriteFrame.py @@ -21,9 +21,9 @@ import lalframe import lal -import NRWaveformCatalogManager3 as nrwf - +from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf +nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 parser = argparse.ArgumentParser() parser.add_argument("--group", default="Sequence-GT-Aligned-UnequalMass",help="inspiral XML file containing injection information.") parser.add_argument("--param", default=(0.0, 2.),help="Parameter value") diff --git a/MonteCarloMarginalizeCode/Code/bin/util_NR_GWMemory.py b/MonteCarloMarginalizeCode/Code/bin/util_NR_GWMemory.py index f25678320..57d74e52c 100644 --- a/MonteCarloMarginalizeCode/Code/bin/util_NR_GWMemory.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_NR_GWMemory.py @@ -7,8 +7,9 @@ import numpy as np import RIFT.lalsimutils as lalsimutils -import NRWaveformCatalogManager3 as nrwf +from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf +nrwf, _useNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 ### ### Get hdot ### diff --git a/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py b/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py index 85a656e6b..daa9e9ebd 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_RIFT_pseudo_pipe.py @@ -588,13 +588,6 @@ def run_lisa_known_sky_surface(opts): parser.add_argument("--internal-force-puff-iterations", default=4, type=int, help="Number of iterations to be puffed") opts= parser.parse_args() -# Multi-GPU ILE fan-out: --ile-gpu-fanout funnels through RIFT_ILE_GPU_FANOUT, which -# create_event_parameter_pipeline_BasicIteration (run via os.system, inheriting this -# environment) and dag_utils read at DAG-build time to size request_GPUs/CPUs and bake -# the value into ile_pre.sh. A CLI value wins over any inherited environment value. -if opts.ile_gpu_fanout is not None: - os.environ['RIFT_ILE_GPU_FANOUT'] = str(opts.ile_gpu_fanout) - config_stored=None; config_dict=None ile_condor_commands = None if (opts.use_ini): @@ -619,7 +612,16 @@ def run_lisa_known_sky_surface(opts): if not('RIFT_REQUIRE_GPUS' in os.environ) and 'ile_require_gpus' in rift_items: os.environ['RIFT_REQUIRE_GPUS'] = rift_items['ile_require_gpus'] - + + # Container family (multi-container per-machine image selection): let the ini + # carry the manifest + base exe dir, so a single ini is self-contained. These + # are read from os.environ by create_event_parameter_pipeline_BasicIteration / + # write_ILE_sub_simple; the environment still dominates if already set. + if not('SINGULARITY_RIFT_IMAGE' in os.environ) and 'singularity_rift_image' in rift_items: + os.environ['SINGULARITY_RIFT_IMAGE'] = rift_items['singularity_rift_image'] + if not('SINGULARITY_BASE_EXE_DIR' in os.environ) and 'singularity_base_exe_dir' in rift_items: + os.environ['SINGULARITY_BASE_EXE_DIR'] = rift_items['singularity_base_exe_dir'] + # attempt to lazy-select the command-line that are present in the ini file section for item in rift_items: item_renamed = item.replace('-','_') @@ -643,7 +645,14 @@ def run_lisa_known_sky_surface(opts): for item in rift_items: val = rift_items[item].strip() ile_condor_commands.append([item, val]) - + +# Multi-GPU ILE fan-out: funnel --ile-gpu-fanout (CLI) OR ile-gpu-fanout=... (ini +# [rift-pseudo-pipe]) through RIFT_ILE_GPU_FANOUT. Done AFTER the ini is parsed so an +# ini-only value also lands. create_event_parameter_pipeline_BasicIteration (run via +# os.system, inheriting this environment) and dag_utils read it at DAG-build time to +# size request_GPUs/CPUs and bake the value into ile_pre.sh. +if opts.ile_gpu_fanout is not None: + os.environ['RIFT_ILE_GPU_FANOUT'] = str(opts.ile_gpu_fanout) if opts.lisa_known_sky: run_lisa_known_sky_surface(opts) @@ -1837,6 +1846,30 @@ def run_lisa_known_sky_surface(opts): if opts.calmarg_pilot: cmd += " --calmarg-pilot --calmarg-pilot-cadence {} --calmarg-pilot-max-it {} --calmarg-pilot-top-fraction {} --calmarg-pilot-max-points {} ".format( opts.calmarg_pilot_cadence, opts.calmarg_pilot_max_it, opts.calmarg_pilot_top_fraction, opts.calmarg_pilot_max_points) + if opts.use_osg_file_transfer: + # Graceful degradation for the OSG file-transfer regime. The wide ILE jobs (and the + # last-iteration EXTRINSIC ILE jobs) list cal_consolidated_$(macroiterationprev).npz in + # transfer_input_files; condor HARD-HOLDS (HoldReasonCode 13) if that source file is + # absent on the submit node. A calpilot only produces cal_consolidated_.npz for + # iterations it<=--calmarg-pilot-max-it on-cadence, so any wide/extrinsic iteration + # whose seed was never produced (e.g. --calmarg-pilot-max-it 1 but 5 wide iterations) + # would dead-hold. Pre-seed a VALID prior-breadcrumb placeholder (a copy of the always + # -present cal_consolidated_-1.npz iteration-0 seed) for EVERY iteration index a wide or + # extrinsic job can reference. A real calpilot OVERWRITES its placeholder at runtime via + # transfer_output_files (the DAG seed barrier guarantees ordering), so behavior is + # unchanged whenever the learned seed IS produced; a missing seed now falls back to the + # prior (the placeholder == proposal==prior -> zero-weight prior cal draws) instead of + # dead-holding. skip-if-exists preserves real seeds across a DAG rescue/resume. + _cal_ph_seed = os.getcwd() + "/cal_consolidated_-1.npz" + if os.path.exists(_cal_ph_seed): + # wide it in [it_start, n_iterations-1] references prev=it-1; the extrinsic stage + # (it=n_iterations) references prev=n_iterations-1 -> indices 0 .. n_iterations-1. + for _kit in range(0, int(n_iterations)): + _cal_ph_dst = os.getcwd() + "/cal_consolidated_{}.npz".format(_kit) + if not os.path.exists(_cal_ph_dst): + shutil.copyfile(_cal_ph_seed, _cal_ph_dst) + else: + print(" WARNING: cal_consolidated_-1.npz placeholder absent; cannot pre-seed missing cal proposal breadcrumbs (wide/extrinsic ILE may hard-hold if a calpilot stage is skipped).") if opts.extrinsic_handoff: cmd += " --extrinsic-handoff --extrinsic-handoff-select {} ".format(opts.extrinsic_handoff_select) if opts.assume_eccentric: diff --git a/MonteCarloMarginalizeCode/Code/bin/util_WriteInjectionFile.py b/MonteCarloMarginalizeCode/Code/bin/util_WriteInjectionFile.py index 774623de7..fd4956844 100755 --- a/MonteCarloMarginalizeCode/Code/bin/util_WriteInjectionFile.py +++ b/MonteCarloMarginalizeCode/Code/bin/util_WriteInjectionFile.py @@ -29,9 +29,10 @@ opts= parser.parse_args() hasNR = False +nrwf = None if opts.group: - import NRWaveformCatalogManager3 as nrwf - hasNR=True + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() # prefers nrcatalog.compat_nrwf, falls back to NRWaveformCatalogManager3 P=None diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/.gitignore b/MonteCarloMarginalizeCode/Code/demo/reconstruct/.gitignore new file mode 100644 index 000000000..fe2895898 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/.gitignore @@ -0,0 +1,14 @@ +# run artifacts (regenerated by the demos) -- do not commit +model/rundir_*/ +model/data/ +model/*.gwf +model/H1-psd.xml.gz +model/L1-psd.xml.gz +model/*-psd-ascii.dat +model/coinc.xml +model/seed_inj.xml.gz +model/gw150914_samples.npz +nr/ile_fd_* +nr/rundir*/ +**/__pycache__/ +*.pyc diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/README.md b/MonteCarloMarginalizeCode/Code/demo/reconstruct/README.md new file mode 100644 index 000000000..0c2daff2d --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/README.md @@ -0,0 +1,77 @@ +# demo/reconstruct : whitened strain reconstruction with a confidence band + +Reconstruct the whitened gravitational-wave strain in each detector, with a 90% +credible band, and overlay it on the data -- the "waveform reconstruction" +figure (cf. Fig. 2 of arXiv:2009.05461). Two self-contained walkthroughs: + +* **`nr/`** -- reconstruct a **fixed numerical-relativity simulation** against + an event (example: GW190521, RIT-Five eBBH-1794). +* **`model/`** -- a fully self-contained **waveform-model** example: download + GW150914 from GWOSC, run RIFT end-to-end (IMRPhenomD, aligned spin), and + reconstruct from the resulting posterior. + +Shared tools (used by both): + +| file | role | +|---|---| +| `reconstruct_strain.py` | pool fair-draw samples → generate whitened waveforms at each sample's own (time, phase) → 90% band vs data. Back-ends: `--approx MODEL` or `--group/--nr-param` (NR). | +| `extract_ile_samples.py` | fast RIFT ILE `sim_inspiral` XML → compact `.npz` (keeps per-sample `time` and full spins). | +| `dat_to_compact.py` | `extrinsic_posterior_samples.dat` → compact `.npz` (pipeline output). | +| `make_reconstruct_subfile.sh` | turn a pipeline `ILE.sub` into a reconstruction submit file (condor). | + +**Model waveforms use the mode path, not `hoft`.** For `--approx` models the code +builds `h(t)` from the `hlmoft` modes (`lalsimutils.hlmoft` → `hoft_from_hlm`, with +`fd_standoff_factor=0.9`) — the *same* construction ILE's likelihood uses. This makes +the reconstruction's coalescence-time (and phase) reference match the `geocent_end_time` +ILE reports; reconstructing with `lalsimutils.hoft()` instead leaves an **fref-dependent +~1 ms time bias** (most visible for precessing/spinning systems). Full spins, including +in-plane components, are carried through so precession is reconstructed correctly. NR +mode is unaffected — it uses the raw provided strain in both ILE and reconstruct. + +## The one thing you must get right + +The reconstruction is only tight and physical if every posterior sample carries +its **own coalescence time**, coherent with its phase. The ILE extrinsic run +that produces the samples **must** be invoked with + +``` +--fairdraw-extrinsic-output --resample-time-marginalization +``` + +(keep `--time-marginalization`; do **not** use `--maximize-only`). With these, +RIFT draws a geocenter time per output sample from that sample's own lnL(t), +coherent with `coa_phase`; the exported `geocent_end_time` then varies per row. +`reconstruct_strain.py` places each realization at that (time, phase), so the +band coheres **with no post-hoc alignment**. Without these flags the coalescence +time is marginalized away, the phase is unconstrained, and the band smears to +the full waveform amplitude. + +Whitening is done in the frequency domain against the same PSD the analysis +used (do not rely on `gwpy.whiten()`; the analysis PSD often only reaches +`srate/2`). + +## Running under HTCondor (recommended for real events) + +Most events are analyzed with the standard pipeline +(`util_RIFT_pseudo_pipe.py` / `create_event_parameter_pipeline_BasicIteration`), +which produces the posterior and an `ILE.sub`. To get reconstruction-ready +samples without hand-running anything, copy that submit file and inject the two +required flags, keeping the pipeline's container / GPU / accounting settings: + +``` +./make_reconstruct_subfile.sh /path/to/run/ILE.sub ILE_extr.sub +condor_submit ILE_extr.sub # emits _0_.xml.gz (fair-draw, per-sample time) +./extract_ile_samples.py _0_.xml.gz samples.npz +./reconstruct_strain.py --samples samples.npz --fair-draw --approx IMRPhenomD \ + --psd-file H1=H1-psd.xml.gz --psd-file L1=L1-psd.xml.gz \ + --event-time --out reconstruction.png +``` + +See `nr/README.md` and `model/README.md` for the two complete examples. + +## Requirements + +RIFT (`RIFT.lalsimutils`, the ILE executable), `lalsimulation`, `gwpy`, +`pesummary`, numpy/scipy/matplotlib; for NR mode also `NRWaveformCatalogManager3` +and the NR data files (git-annex). Do not set `RIFT_LOWLATENCY=1` (it breaks NR +git-annex lookup). diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/dat_to_compact.py b/MonteCarloMarginalizeCode/Code/demo/reconstruct/dat_to_compact.py new file mode 100755 index 000000000..6ee6e432e --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/dat_to_compact.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python +""" +dat_to_compact.py -- RIFT posterior .dat -> compact .npz for reconstruct_strain.py. + +Converts a standard RIFT `extrinsic_posterior_samples.dat` +(from convert_output_format_ile2inference, header: + m1 m2 a1x a1y a1z a2x a2y a2z mc eta ra dec time phiorb incl psi distance Npts lnL p ps neff ...) +into the compact .npz that reconstruct_strain.py consumes. These rows are already +fair (equal-weight) posterior draws; use with --fair-draw. + +If the run's final extrinsic stage used --resample-time-marginalization, the +'time' column varies per row (coherent coalescence times) -- exactly what the +reconstruction needs. + +Usage: dat_to_compact.py extrinsic_posterior_samples.dat out_compact.npz +""" +import sys +import numpy as np + + +def convert(src, out): + S = np.genfromtxt(src, names=True, replace_space=None) + n = S.dtype.names + + def col(name, default=0.0): + return S[name].astype(float) if name in n else np.full(len(S), default) + + d = dict( + m1=col("m1"), m2=col("m2"), a1z=col("a1z"), a2z=col("a2z"), + a1x=col("a1x"), a1y=col("a1y"), a2x=col("a2x"), a2y=col("a2y"), + ra=col("ra"), dec=col("dec"), incl=col("incl"), psi=col("psi"), + phiorb=col("phiorb"), distance=col("distance"), time=col("time"), + lnL=col("lnL"), p=col("p", 1.0), ps=col("ps", 1.0), + eccentricity=col("eccentricity", 0.0), + ) + np.savez(out, **d) + print("dat_to_compact: %d rows -> %s (distinct times=%d)" + % (len(S), out, len(np.unique(d["time"]))), flush=True) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + convert(sys.argv[1], sys.argv[2]) diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/extract_ile_samples.py b/MonteCarloMarginalizeCode/Code/demo/reconstruct/extract_ile_samples.py new file mode 100755 index 000000000..ae69ad91b --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/extract_ile_samples.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +""" +extract_ile_samples.py -- fast RIFT ILE sample extractor. + +Reads a RIFT `integrate_likelihood_extrinsic_batchmode` output XML +(the sim_inspiral table written by --save-samples) and dumps a compact +.npz with exactly the columns the reconstruction needs. This reads the +ligolw table directly, so it is ~30 s for a few-million-row file instead +of the ~25 min a full `convert_output_format_ile2inference` takes. + +Column convention (RIFT xmlutils CMAP / ILE export block): + alpha1 = lnL (per-sample log likelihood, at the drawn time if + --resample-time-marginalization was used) + alpha2 = p (prior) + alpha3 = ps (sampling prior) + alpha4 = eccentricity + longitude=RA latitude=dec inclination polarization=psi + coa_phase=phi_orb distance(Mpc) mass1/2(Msun) spin1z/2z + geocent_end_time(+_ns) = coalescence time -> stored here as 'time' + +Usage: + extract_ile_samples.py ILE_output_0_.xml.gz out_compact.npz +""" +import sys, time +import numpy as np +from igwn_ligolw import ligolw, lsctables, utils + + +class _CH(ligolw.LIGOLWContentHandler): + pass +lsctables.use_in(_CH) + + +def extract(src, out): + t0 = time.time() + doc = utils.load_filename(src, contenthandler=_CH) + tb = lsctables.SimInspiralTable.get_table(doc) + n = len(tb) + + def col(attr): + return np.fromiter((getattr(r, attr) for r in tb), float, n) + + d = dict( + m1=col("mass1"), m2=col("mass2"), + a1z=col("spin1z"), a2z=col("spin2z"), + a1x=col("spin1x"), a1y=col("spin1y"), a2x=col("spin2x"), a2y=col("spin2y"), + ra=col("longitude"), dec=col("latitude"), + incl=col("inclination"), psi=col("polarization"), + phiorb=col("coa_phase"), distance=col("distance"), + lnL=col("alpha1"), p=col("alpha2"), ps=col("alpha3"), + eccentricity=col("alpha4"), + # geocent coalescence time (varies per row iff --resample-time-marginalization) + time=np.fromiter((r.geocent_end_time + 1e-9 * r.geocent_end_time_ns + for r in tb), float, n), + ) + np.savez(out, **d) + print("extract_ile_samples: %d rows in %.1fs -> %s (distinct times=%d)" + % (n, time.time() - t0, out, len(np.unique(d["time"]))), flush=True) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + sys.exit(__doc__) + extract(sys.argv[1], sys.argv[2]) diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/make_reconstruct_subfile.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/make_reconstruct_subfile.sh new file mode 100755 index 000000000..1478decbb --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/make_reconstruct_subfile.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# +# make_reconstruct_subfile.sh ILE.sub [ILE_extr.sub] +# +# Turn a STANDARD RIFT pipeline ILE submit file into an "extrinsic reconstruction" +# submit file: one that emits fair-draw posterior samples each carrying its own +# coalescence time, ready for reconstruct_strain.py. +# +# Most end users run RIFT under HTCondor via the standard pipeline +# (util_RIFT_pseudo_pipe.py / create_event_parameter_pipeline_BasicIteration), +# which writes an ILE.sub. Rather than hand-run ILE, copy that submit file and +# inject the two required flags, then submit the copy. This keeps the cluster's +# accounting group, container (+SingularityImage), GPU request, and data/PSD +# arguments exactly as the pipeline configured them. +# +# Required additions (see ../README.md): +# --fairdraw-extrinsic-output --resample-time-marginalization +# and we make sure --time-marginalization is present and --maximize-only is absent. +# +# After running this, submit with: condor_submit ILE_extr.sub +# (or add it as a node in your DAG). Then extract each output *_0_.xml.gz with +# ../extract_ile_samples.py and feed the .npz to ../reconstruct_strain.py. +set -e +SRC=${1:?usage: make_reconstruct_subfile.sh ILE.sub [ILE_extr.sub]} +DST=${2:-ILE_extr.sub} +ADD="--fairdraw-extrinsic-output --resample-time-marginalization" + +cp "$SRC" "$DST" +# The condor 'arguments = ...' line holds the ILE command line. +python3 - "$DST" "$ADD" <<'PY' +import re, sys +path, add = sys.argv[1], sys.argv[2] +s = open(path).read().splitlines(keepends=True) +out = [] +for line in s: + if re.match(r'\s*arguments\s*=', line, re.I): + core = line.rstrip("\n") + # drop --maximize-only if present (it corrupts the per-sample lnL scale) + core = core.replace("--maximize-only", "") + # ensure time marginalization + if "--time-marginalization" not in core: + core += " --time-marginalization" + # add the fair-draw + resample-in-time flags if not already present + for flag in add.split(): + if flag not in core: + core += " " + flag + line = core + "\n" + out.append(line) +open(path, "w").writelines(out) +print("wrote", path) +PY +echo "Created $DST from $SRC." +echo "Submit with: condor_submit $DST (output: _0_.xml.gz)" +echo "Then: ../extract_ile_samples.py _0_.xml.gz samples.npz" +echo " ../reconstruct_strain.py --samples samples.npz --fair-draw ..." diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/GW150914_D.ini b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/GW150914_D.ini new file mode 100644 index 000000000..99b2612e3 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/GW150914_D.ini @@ -0,0 +1,69 @@ +# RIFT ini for a full GW150914 run (IMRPhenomD, aligned spin), CIT / singularity. +# +# GW150914: GPS 1126259462.4, H1 + L1 only (no Virgo in O1), O1 4 kHz GWOSC open +# data. IMPORTANT: the O1 open-data in-frame channel is :LOSC-STRAIN (NOT +# the O4-era GWOSC-4KHZ_R1_STRAIN). Verified by reading the frame channel table. +# +# This ini supplies data geometry (seglen/srate/fmin/fmax, channels, ifos) that +# util_RIFT_pseudo_pipe.py has no CLI flag for. Command-line args on +# util_RIFT_pseudo_pipe.py (--approx, --l-max, --assume-nospin, --force-mc-range, +# the extrinsic + OSG flags, sizes) WIN over this ini, so [rift-pseudo-pipe] is +# kept minimal. Spin is pinned to zero by the CLI flag --assume-nospin. +# +# CIT: [analysis] singularity=True -> the pipeline reads SINGULARITY_RIFT_IMAGE. +# Strain is the LOCAL GWOSC frames + cache (--fake-data-cache); OSG/CVMFS not used +# for data (frames ride along via --use-osg-file-transfer). + +[analysis] +ifos=['H1','L1'] +singularity=True +osg=True + +[paths] + +[input] +max-psd-length=10000 + +[condor] +accounting_group=ligo.dev.o4.cbc.pe.rift +accounting_group_user=richard.oshaughnessy + +[datafind] +url-type=file +# GWOSC O1 public strain (already downloaded locally under data/). Frame tags. +types = {'H1': 'H1_LOSC_4_V1', 'L1': 'L1_LOSC_4_V1'} + +[data] +# O1 open-data channel is :LOSC-STRAIN (prefixed form for the ini). +channels = {'H1': 'H1:LOSC-STRAIN', 'L1': 'L1:LOSC-STRAIN'} + +[lalinference] +# minimum-frequency 20 per IFO; maximum-frequency 1024 (heavy system, srate 4096). +flow = {'H1': 20, 'L1': 20} +fhigh = {'H1': 1024, 'L1': 1024} + +[engine] +fref=20 +amporder = -1 +# GW150914 is heavy (Mc~28): a short 8-s segment at srate 4096 is plenty. +seglen = 8 +srate = 4096 +approx = IMRPhenomD +# Chirp-mass window bracketing GW150914 (Mc~28); matches CLI --force-mc-range [25,35]. +chirpmass-min = 25 +chirpmass-max = 35 +comp-min = 5 +comp-max = 100 +distance-max = 1000 +# Aligned-spin analysis; spins are pinned to zero via CLI --assume-nospin. + +[rift-pseudo-pipe] +# Keep minimal: CLI args win. (Do NOT set sizes / request-disk here -- the ini +# parser would override the command line.) +cip-fit-method="rf" +l-max=2 +internal-distance-max=1000 +fmin-template=20 +event-time=1126259462.4 +n-output-samples=5000 +use-online-psd=False diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/Makefile b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/Makefile new file mode 100644 index 000000000..d4d1f74e7 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/Makefile @@ -0,0 +1,79 @@ +# model : self-contained GW150914 waveform-MODEL strain reconstruction (RIFT). +# +# Download GW150914 from GWOSC (O1, H1+L1), run a full RIFT parameter-estimation +# DAG (IMRPhenomD, aligned spin pinned to zero) to a posterior, then reconstruct +# the whitened strain band from that posterior. See README.md. +# +# Order: +# make data # download O1 frames + build event.cache + detect channel +# make psd # estimate off-source PSDs (gwpy) -> H1/L1-psd.xml.gz +# make coinc # build coinc.xml (event time + IFOs) +# make dag # generate the RIFT DAG (rundir_gw150914_D) +# make submit # apply CIT-local fixes + condor_submit_dag +# make status # condor_q for this run +# # ... the DAG runs on condor over hours ... +# make reconstruct # once extrinsic_posterior_samples.dat exists -> PNG +# +# All config (container, GPS, paths, sizes) lives in config.sh. + +SHELL := /bin/bash +CFG := config.sh + +.PHONY: help +help: + @echo "targets: data psd coinc dag submit status reconstruct all clean" + @echo " data download GWOSC O1 frames + cache + channel detection" + @echo " psd off-source PSD estimation (gwpy) -> H1/L1-psd.xml.gz" + @echo " coinc build coinc.xml (event time + IFOs)" + @echo " dag generate the RIFT DAG (IMRPhenomD, --assume-nospin)" + @echo " submit CIT-local fixes + condor_submit_dag" + @echo " status condor_q for this run" + @echo " reconstruct posterior -> whitened strain band PNG (after the DAG finishes)" + @echo "edit config.sh for container path / env / sizes." + +# 'all' builds and SUBMITS everything up to the condor run (not reconstruct, +# which needs the finished posterior). +.PHONY: all +all: data psd coinc dag submit + +.PHONY: data +data: + source $(CFG); rift_env; \ + $$PYBIN $$MODEL_DIR/fetch_gwosc_data.py --outdir $$DATA_DIR \ + --event-time $$EVENT_TIME --ifos $$IFOS + +.PHONY: psd +psd: + source $(CFG); rift_env; \ + $$PYBIN $$MODEL_DIR/estimate_psd.py --cache $$CACHE \ + --channel H1=H1:$$CHANNEL_BARE --channel L1=L1:$$CHANNEL_BARE \ + --seg-start $$PSD_SEG_START --seg-len $$PSD_SEG_LEN --fftlen $$PSD_FFTLEN \ + --srate $$SRATE --outdir $$MODEL_DIR + +.PHONY: coinc +coinc: + bash make_coinc.sh + +.PHONY: dag +dag: + bash build_dag.sh + +.PHONY: submit +submit: + bash submit.sh + +.PHONY: status +status: + source $(CFG); \ + condor_q -nobatch 2>/dev/null | grep -E "$${USER}|JOB|held" || condor_q 2>/dev/null; \ + echo "--- held (if any) ---"; condor_q -held 2>/dev/null | tail -20 + +.PHONY: reconstruct +reconstruct: + bash reconstruct_gw150914.sh + +.PHONY: clean +clean: + rm -rf rundir_gw150914_D data + rm -f H1-psd.xml.gz L1-psd.xml.gz coinc.xml seed_inj.xml.gz \ + gw150914_samples.npz GW150914_reconstruction.png diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/README.md b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/README.md new file mode 100644 index 000000000..2a6677880 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/README.md @@ -0,0 +1,121 @@ +# model : self-contained GW150914 waveform-MODEL strain reconstruction + +A fully self-contained, end-to-end example of the waveform-reconstruction figure +using a **waveform model** (IMRPhenomD) rather than a fixed NR simulation: + +1. download **GW150914** from GWOSC (O1, H1 + L1 only), +2. estimate an off-source PSD, +3. run a **full RIFT parameter-estimation DAG** (IMRPhenomD, aligned spin pinned + to zero) on HTCondor to a posterior, and +4. **reconstruct** the whitened detector strain, with a 90% credible band, from + that posterior — each posterior sample drawn as an IMRPhenomD waveform at its + own coalescence time. + +Nothing is faked and nothing is imported from another event: the frames, PSD, +coinc, DAG, and posterior are all produced here. + +## Quick start + +```bash +make data # download O1 frames + build event.cache + detect channel +make psd # estimate off-source PSDs (gwpy) -> H1/L1-psd.xml.gz +make coinc # build coinc.xml (event time + IFOs) +make dag # generate the RIFT DAG (rundir_gw150914_D) +make submit # apply CIT-local fixes + condor_submit_dag +make status # condor_q for this run +# ... the DAG runs on condor over hours ... +make reconstruct # once extrinsic_posterior_samples.dat exists -> GW150914_reconstruction.png +``` + +`make all` runs `data psd coinc dag submit`. All configuration (container path, +event GPS, sizes, paths) is in **`config.sh`**. + +## The one thing that makes the reconstruction cohere + +The band is only tight and physical if every posterior sample carries its **own +coalescence time**, coherent with its phase. In the pipeline that is arranged by +building the DAG with + +``` +--add-extrinsic --batch-extrinsic --add-extrinsic-time-resampling \ +--internal-ile-srate-time-resampling 4096 +``` + +**These flags make the pipeline's final `ILE_extr` stage emit, into +`ILE_extr.sub`,** + +``` +--fairdraw-extrinsic-output --resample-time-marginalization +``` + +(kept alongside `--time-marginalization`). With them, RIFT draws a geocenter time +per output sample from that sample's own `lnL(t)`, coherent with `coa_phase`, and +the exported `time` column in `extrinsic_posterior_samples.dat` **varies per row**. +`build_dag.sh` verifies both flags landed in `ILE_extr.sub`. + +**The reconstruction reads `rundir_gw150914_D/extrinsic_posterior_samples.dat`** +(via `../dat_to_compact.py` -> compact `.npz`), then `../reconstruct_strain.py` +places each IMRPhenomD realization at its own `(time, phase)` — so the band +coheres with **no post-hoc alignment**. + +## GW150914 specifics (verified here) + +| item | value | +|---|---| +| GPS event time | `1126259462.4` | +| detectors | H1, L1 (no Virgo in O1) | +| GWOSC data | O1 4 kHz open data, 4096-s block starting `1126256640` | +| in-frame channel | `:LOSC-STRAIN` — **not** `GWOSC-4KHZ_R1_STRAIN` | +| approximant | IMRPhenomD (aligned spin), `--assume-nospin`, `--l-max 2` | +| seglen / srate / fmax | 8 s / 4096 Hz / 1024 Hz | +| chirp-mass window | `[25,35]` (Mc ~ 28) | + +The **channel name is the key adaptation** for O1: the O1 open-data frames +(`H-H1_LOSC_4_V1-...gwf`) carry `H1:LOSC-STRAIN`, the older LOSC convention, not +the O4-era `GWOSC-4KHZ_R1_STRAIN`. `fetch_gwosc_data.py` reads the real channel +from the frame table rather than assuming it. + +The PSD off-source window deliberately starts 1060 s into the block +(`1126257700`): the first ~500 s of the L1 O1 block contains a data-quality gap +(NaNs). + +## What each file does + +| file | role | +|---|---| +| `config.sh` | container path, event GPS, channels, sizes, paths, `rift_env` | +| `fetch_gwosc_data.py` | resolve GWOSC O1 .gwf URLs, curl frames, build `event.cache`, detect channel | +| `estimate_psd.py` | off-source median-Welch PSD (gwpy) -> ascii -> `convert_psd_ascii2xml` xml.gz, sanity-check load | +| `make_coinc.sh` | `util_WriteInjectionFile.py` + `util_SimInspiralToCoinc.py` -> `coinc.xml` | +| `GW150914_D.ini` | data geometry the pipeline has no CLI flag for (seglen/srate/fmin/fmax, channels, ifos) | +| `build_dag.sh` | `util_RIFT_pseudo_pipe.py` -> `rundir_gw150914_D`; stages PSDs; verifies the fair-draw flags | +| `submit.sh` | CIT-local sub fixes (container universe / local .sif transfer / getenv / GPU floor / local pin) + `condor_submit_dag` | +| `reconstruct_gw150914.sh` | posterior -> compact `.npz` -> whitened strain band PNG | +| `Makefile` | `data psd coinc dag submit status reconstruct clean` | + +Shared, read-only tools live one level up: `../reconstruct_strain.py`, +`../dat_to_compact.py`, `../extract_ile_samples.py`, +`../make_reconstruct_subfile.sh`. + +## Environment + +The pipeline generator runs on the submit node. `config.sh`'s `rift_env` +activates an igwn conda env (deps: lal, gwpy, igwn_ligolw, numpy/scipy) and +prepends the RIFT **source** tree (`$RIFT_SRC/MonteCarloMarginalizeCode/Code`, +`bin/`) so the exact RIFT version is used. The condor jobs themselves run inside +the RIFT singularity image `$CONTAINER_SIF` (container universe, local .sif file +transfer for the CIT pool). + +## Finishing the reconstruction once the posterior lands + +```bash +../dat_to_compact.py rundir_gw150914_D/extrinsic_posterior_samples.dat gw150914_samples.npz +../reconstruct_strain.py --samples gw150914_samples.npz --fair-draw --approx IMRPhenomD \ + --psd-file H1=H1-psd.xml.gz --psd-file L1=L1-psd.xml.gz \ + --event-time 1126259462.4 --event-name GW150914 --sim-id IMRPhenomD --srate 4096 \ + --out GW150914_reconstruction.png +``` + +`make reconstruct` runs exactly this. Whitening uses the same PSDs the analysis +used (`H1/L1-psd.xml.gz`), in the frequency domain. +``` diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/build_dag.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/build_dag.sh new file mode 100755 index 000000000..d7854309c --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/build_dag.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash +# build_dag.sh -- generate the RIFT parameter-estimation DAG for GW150914. +# +# IMRPhenomD forces aligned spin; --assume-nospin pins the spins to zero; --l-max 2. +# The extrinsic flags are the whole point of this demo: +# +# --add-extrinsic --batch-extrinsic --add-extrinsic-time-resampling +# --internal-ile-srate-time-resampling 4096 +# +# make the pipeline's FINAL ILE_extr stage emit, into ILE_extr.sub, +# --fairdraw-extrinsic-output --resample-time-marginalization +# so every posterior sample in extrinsic_posterior_samples.dat carries its OWN +# coalescence time (coherent with its phase) -- exactly what reconstruct_strain.py +# needs for a phase-coherent band with NO post-hoc alignment. +# +# PSDs: pseudo_pipe has no --psd-file flag in this version; the helper points each +# analysis IFO at /-psd.xml.gz. So we build the rundir, then COPY the +# estimated per-IFO PSDs in (the file is only read at DAG-RUN time). +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config.sh" +rift_env +cd "$MODEL_DIR" + +test -s "$COINC" || { echo "missing coinc.xml (run: make coinc)"; exit 1; } +test -s "$CACHE" || { echo "missing event.cache (run: make data)"; exit 1; } +test -s "$H1_PSD" && test -s "$L1_PSD" || { echo "missing PSDs (run: make psd)"; exit 1; } +test -s "$INI" || { echo "missing $INI"; exit 1; } + +echo "[dag] util_RIFT_pseudo_pipe.py -> $RUNDIR" +rm -rf "$RUNDIR" 2>/dev/null || true + +util_RIFT_pseudo_pipe.py \ + --use-ini "$INI" --use-coinc "$COINC" --fake-data-cache "$CACHE" \ + --approx "$APPROX" --l-max "$LMAX" --assume-nospin \ + --fmin-template "$FMIN" --force-mc-range "$MC_RANGE" \ + --ile-sampler-method AV --ile-n-eff "$ILE_NEFF" \ + --ile-jobs-per-worker "$JOBS_PER_WORKER" --internal-force-iterations "$NIT" \ + --add-extrinsic --batch-extrinsic --add-extrinsic-time-resampling \ + --internal-ile-srate-time-resampling "$SRATE_TIME" \ + --n-output-samples-last "$NSAMP_LAST" \ + --use-osg --use-osg-cip --use-osg-file-transfer \ + --internal-truncate-files-for-osg-file-transfer \ + --internal-ile-request-disk "$DISK_ILE" \ + --internal-cip-request-disk "$DISK_CIP" \ + --internal-general-request-disk "$DISK_GEN" \ + --use-rundir "$RUNDIR" + +# stage the per-IFO PSDs into the rundir (read at DAG-run time) +for ifo in $IFOS; do + cp -v "$MODEL_DIR/${ifo}-psd.xml.gz" "$RUNDIR/${ifo}-psd.xml.gz" +done + +# ---- verify the build ---------------------------------------------------- +test -s "$RUNDIR/$PP_DAG" || { echo "FAIL: no DAG produced"; exit 1; } +for ifo in $IFOS; do + grep -q -- "${ifo}-psd.xml.gz" "$RUNDIR/ILE.sub" \ + || { echo "FAIL: ILE.sub missing per-IFO PSD ${ifo}-psd.xml.gz"; exit 1; } +done +grep -q -- "--resample-time-marginalization" "$RUNDIR/ILE_extr.sub" \ + || { echo "FAIL: ILE_extr.sub missing --resample-time-marginalization"; exit 1; } +grep -q -- "--fairdraw-extrinsic-output" "$RUNDIR/ILE_extr.sub" \ + || { echo "FAIL: ILE_extr.sub missing --fairdraw-extrinsic-output"; exit 1; } +echo "[dag] OK: DAG built in $RUNDIR" +echo " ILE_extr.sub has --fairdraw-extrinsic-output --resample-time-marginalization" +echo " Next: make submit" diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/config.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/config.sh new file mode 100755 index 000000000..5adbbbef0 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/config.sh @@ -0,0 +1,137 @@ +#!/usr/bin/env bash +# config.sh -- central configuration for the GW150914 waveform-MODEL strain +# reconstruction demo. Sourced by every script and by the Makefile. +# +# Self-contained: downloads GW150914 O1 open data from GWOSC, runs a full RIFT +# parameter-estimation DAG (IMRPhenomD, aligned spin pinned to zero), then +# reconstructs the whitened strain band from the resulting posterior. + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" +export MODEL_DIR="$HERE" + +# --------------------------------------------------------------------------- +# RIFT execution environment (conda deps + RIFT source tree on PATH/PYTHONPATH) +# --------------------------------------------------------------------------- +# The pipeline GENERATOR (util_RIFT_pseudo_pipe.py, convert_psd_ascii2xml, +# util_WriteInjectionFile.py, util_SimInspiralToCoinc.py) runs on THIS submit +# node. We supply its python dependencies (lal, gwpy, igwn_ligolw, cupy-less +# numpy path) from an igwn conda env, and the RIFT package + bin/ from the +# source checkout so we run exactly this RIFT version. +export CONDA_SH="${CONDA_SH:-/cvmfs/software.igwn.org/conda/etc/profile.d/conda.sh}" +export CONDA_ENV="${CONDA_ENV:-test_junior_o4d}" +export RIFT_SRC="${RIFT_SRC:-/home/richard.oshaughnessy/RIFT_ralph}" +export RIFT_CODE="$RIFT_SRC/MonteCarloMarginalizeCode/Code" + +# rift_env(): activate conda + prepend the RIFT source tree. Idempotent. +# conda's activate script trips `set -e`/`set -u` (it touches unbound vars), so we +# disable those options across activation and restore the caller's state after. +rift_env() { + local _opts=$- + set +eu + # shellcheck disable=SC1090 + source "$CONDA_SH" 2>/dev/null || true + conda activate "$CONDA_ENV" 2>/dev/null || true + case "$_opts" in *e*) set -e;; esac + case "$_opts" in *u*) set -u;; esac + export PYTHONPATH="$RIFT_CODE:${PYTHONPATH:-}" + export PATH="$RIFT_CODE/bin:$PATH" + # CIT thread caps (RLIMIT_NPROC on loaded hosts kills numpy import mid-build) + export OMP_NUM_THREADS=4 OPENBLAS_NUM_THREADS=4 MKL_NUM_THREADS=4 NUMEXPR_NUM_THREADS=4 +} +export PYBIN="python" + +# --------------------------------------------------------------------------- +# Container (CIT-local: container universe + local .sif file transfer; +# OSDF .sif delivery fails to open on CIT execute nodes -- see submit.sh) +# --------------------------------------------------------------------------- +export CONTAINER_SIF="${CONTAINER_SIF:-/home/richard.oshaughnessy/rift_cit_build_container_family/built_containers_timeinterp_20260707/rift_o4d-calmarg_in_loop_cc60-90_cuda118_20260707.sif}" +export SINGULARITY_RIFT_IMAGE="$CONTAINER_SIF" +export SINGULARITY_BASE_EXE_DIR="/usr/local/bin/" +# durable CIT DAG-build settings (mirror the manual submit.sh fixes) +export RIFT_GETENV='*' RIFT_GETENV_OSG='*' +export RIFT_CONTAINER_UNIVERSE=1 +export RIFT_REQUIRE_GPUS='(Capability >= 6.0)' +# GPU capability band the cc60-90 cuda11.8 image supports (floor .. ceiling) +export GPU_CAP_FLOOR=6.0 GPU_CAP_CEIL=9.0 + +# CIT-local fix tools (read-only, used by submit.sh) +export FIX_TOOLS="${FIX_TOOLS:-/home/richard.oshaughnessy/LVK/O4_era/investigations/rift_transverse_highSNR_study/tools}" + +# --- LIGO accounting (REQUIRED at DAG-build time) -------------------------- +# The CIT schedd has a submit transform (/etc/condor/config.d/99-transform) that +# derives LigoSearchTag from the job's accounting_group and REJECTS the submit +# ("Invalid value for search tag: None") if it is unset. RIFT bakes +# accounting_group into every sub only when these env vars are set at build time; +# without them the build warns "LIGO accounting information not available". +export LIGO_USER_NAME="${LIGO_USER_NAME:-richard.oshaughnessy}" +export LIGO_ACCOUNTING="${LIGO_ACCOUNTING:-ligo.dev.o4.cbc.pe.rift}" + +# --------------------------------------------------------------------------- +# Event: GW150914 (O1; H1 + L1 only, no Virgo) +# --------------------------------------------------------------------------- +export EVENT_NAME=GW150914 +export EVENT_TIME=1126259462.4 # GPS +export IFOS="H1 L1" + +# --- GWOSC O1 4 kHz open data -------------------------------------------- +# 4096-s open-data block covering GPS 1126259462 (block start 1126256640). +# IMPORTANT: the in-frame channel for O1 open data is :LOSC-STRAIN, NOT +# GWOSC-4KHZ_R1_STRAIN (that is the O4 naming). Verified by reading the frame +# channel table (gwpy iter_channel_names). ILE/pseudo_pipe prepend ':', +# so scripts store it bare ('LOSC-STRAIN'); the ini stores it prefixed. +export FRAME_BLOCK=1126256640 +export FRAME_DUR=4096 +export CHANNEL_BARE="LOSC-STRAIN" # used by ILE-style '--channel-name IFO=...' +export FRAME_TAG_H="H1_LOSC_4_V1" +export FRAME_TAG_L="L1_LOSC_4_V1" + +# --- analysis settings (IMRPhenomD, aligned spin, spins pinned to zero) --- +export APPROX=IMRPhenomD +export LMAX=2 +export FMIN=20 +export FREF=20 +export FMAX=1024 # GW150914 is heavy (Mc~28); srate 4096 +export SRATE=4096 +export SEGLEN=8 # short segment (heavy system) +export MC_RANGE="[25,35]" # force chirp-mass window (Mc~28) +export DIST_MAX=1000 # Mpc + +# --- coinc seed (masses are ONLY a time/IFO seed; grid is proposed fresh) -- +export SEED_M1=36 SEED_M2=29 SEED_S1Z=0 SEED_S2Z=0 +export SEED_DIST=410 SEED_SNR=24 + +# --- pipeline size knobs -------------------------------------------------- +export NIT=6 # iterations +export ILE_NEFF=1000 +export JOBS_PER_WORKER=100 +export NSAMP_LAST=20000 # final fair-draw extrinsic samples +export SRATE_TIME=4096 # --internal-ile-srate-time-resampling +# per-job disk for OSG file transfer (frames + PSDs ride along) +export DISK_ILE=16G DISK_CIP=16G DISK_GEN=16G + +# --- PSD estimation (off-source, gwpy) ------------------------------------ +# ~400 s of clean data ~1360 s BEFORE the event (offset 1060 s into the block). +# NB: the first ~500 s of the L1 O1 block contains a data-quality gap (NaNs), so +# we deliberately start the PSD window past it. Event is at block offset 2822 s. +export PSD_SEG_START=$((FRAME_BLOCK + 1060)) # 1126257700 +export PSD_SEG_LEN=400 +export PSD_FFTLEN=4 # s (Welch/median FFT length) + +# --------------------------------------------------------------------------- +# Paths / filenames +# --------------------------------------------------------------------------- +export DATA_DIR="$MODEL_DIR/data" +export CACHE="$DATA_DIR/event.cache" +export INI="$MODEL_DIR/GW150914_D.ini" +export COINC="$MODEL_DIR/coinc.xml" +export RUNDIR="$MODEL_DIR/rundir_gw150914_D" +export H1_PSD="$MODEL_DIR/H1-psd.xml.gz" +export L1_PSD="$MODEL_DIR/L1-psd.xml.gz" +export PP_DAG="marginalize_intrinsic_parameters_BasicIterationWorkflow.dag" +export POSTERIOR="$RUNDIR/extrinsic_posterior_samples.dat" +export SAMPLES_NPZ="$MODEL_DIR/gw150914_samples.npz" +export OUT_PNG="$MODEL_DIR/GW150914_reconstruction.png" + +# shared reconstruction tools (one level up; read-only) +export RECON_PY="$MODEL_DIR/../reconstruct_strain.py" +export DAT2COMPACT_PY="$MODEL_DIR/../dat_to_compact.py" diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/estimate_psd.py b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/estimate_psd.py new file mode 100755 index 000000000..e0b88f5d3 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/estimate_psd.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""estimate_psd.py -- off-source PSD from the fetched GW150914 strain. + +This is the ONE part of the pipeline not copied from a working run. We read the +downloaded frame with gwpy, crop an off-source stretch (a few hundred s away from +the event, near the start of the block), estimate a median-Welch PSD, write a +2-column (freq, psd) ascii per IFO, then convert it to the ILE-readable xml.gz +with RIFT's helper `convert_psd_ascii2xml` (we do NOT hand-roll the xml: RIFT +assumes the PSD starts at f=0 and the helper zero-pads down to 0). Finally we +sanity-check that the resulting xml loads via RIFT.lalsimutils and starts at f=0. + +Usage: + estimate_psd.py --cache data/event.cache \ + --channel H1=H1:LOSC-STRAIN --channel L1=L1:LOSC-STRAIN \ + --seg-start 1126256704 --seg-len 400 --fftlen 4 --srate 4096 --outdir . +""" +from __future__ import annotations +import argparse, os, shutil, subprocess, sys, tempfile +import numpy as np + + +def read_cache(cache): + urls = {} + with open(cache) as f: + for line in f: + obs, tag, gps, dur, url = line.split() + urls.setdefault(obs, []).append(url.replace("file://localhost", "")) + return urls + + +def estimate(cache, ifo, channel, seg_start, seg_len, fftlen, srate): + from gwpy.timeseries import TimeSeries + frames = read_cache(cache).get(ifo[0], []) + if not frames: + raise SystemExit("no frame for %s in %s" % (ifo, cache)) + ts = TimeSeries.read(frames, channel, start=seg_start, end=seg_start + seg_len) + nan_frac = float(np.mean(~np.isfinite(np.asarray(ts.value)))) + if nan_frac > 0.001: + raise SystemExit( + "%s PSD window [%d,%d) is %.0f%% NaN (data-quality gap); pick a clean " + "off-source window (see PSD_SEG_START in config.sh)" + % (ifo, seg_start, seg_start + seg_len, 100 * nan_frac)) + if ts.sample_rate.value != srate: + ts = ts.resample(srate) + # median-averaged Welch PSD, 4-s FFT, 50% overlap -> robust off-source PSD + psd = ts.psd(fftlength=fftlen, overlap=fftlen / 2.0, method="median") + freq = psd.frequencies.value + val = psd.value + good = np.isfinite(val) & (val > 0) + return freq[good], val[good] + + +def to_xml(freq, psd, ifo, outdir, rift_bin): + helper = shutil.which("convert_psd_ascii2xml") + if not helper and rift_bin: + cand = os.path.join(rift_bin, "convert_psd_ascii2xml") + if os.path.exists(cand): + helper = cand + if not helper: + raise SystemExit("convert_psd_ascii2xml not on PATH (activate RIFT env)") + tmp = tempfile.mkdtemp(prefix="psdconv_") + ascii_path = os.path.join(tmp, "%s-psd-ascii.dat" % ifo) + np.savetxt(ascii_path, np.column_stack([freq, psd])) + r = subprocess.run([sys.executable, helper, + "--fname-psd-ascii", os.path.abspath(ascii_path), + "--ifo", ifo, "--conventional-postfix"], + cwd=tmp, capture_output=True, text=True) + tmp_xml = os.path.join(tmp, "%s-psd.xml.gz" % ifo) + out_xml = os.path.join(outdir, "%s-psd.xml.gz" % ifo) + if r.returncode != 0 or not os.path.exists(tmp_xml): + shutil.rmtree(tmp, ignore_errors=True) + raise SystemExit("convert_psd_ascii2xml FAILED for %s: %s" + % (ifo, (r.stderr or r.stdout).strip()[:400])) + shutil.copyfile(tmp_xml, out_xml) + shutil.rmtree(tmp, ignore_errors=True) + return out_xml + + +def sanity_check(xml, ifo): + import RIFT.lalsimutils as lalsimutils + ps = lalsimutils.get_psd_series_from_xmldoc(xml, ifo) + assert ps.f0 == 0.0, "%s PSD does not start at f=0 (f0=%s)" % (ifo, ps.f0) + fmax = ps.f0 + ps.deltaF * (ps.data.length - 1) + print(" [ok] %s: f0=%.3f df=%.4f n=%d fmax=%.1f" + % (ifo, ps.f0, ps.deltaF, ps.data.length, fmax)) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--cache", required=True) + ap.add_argument("--channel", action="append", required=True, + help="IFO=IFO:CHANNEL (repeat per detector)") + ap.add_argument("--seg-start", type=float, required=True) + ap.add_argument("--seg-len", type=float, default=400.0) + ap.add_argument("--fftlen", type=float, default=4.0) + ap.add_argument("--srate", type=float, default=4096.0) + ap.add_argument("--outdir", default=".") + ap.add_argument("--rift-bin", + default=os.path.join(os.environ.get("RIFT_CODE", ""), "bin")) + a = ap.parse_args(argv) + chans = dict(c.split("=", 1) for c in a.channel) + os.makedirs(a.outdir, exist_ok=True) + print("[psd] off-source PSD from [%d, %d) (fft=%gs)" + % (a.seg_start, a.seg_start + a.seg_len, a.fftlen)) + for ifo, ch in chans.items(): + freq, psd = estimate(a.cache, ifo, ch, a.seg_start, a.seg_len, a.fftlen, a.srate) + xml = to_xml(freq, psd, ifo, a.outdir, a.rift_bin) + print(" PSD %s -> %s" % (ifo, os.path.basename(xml))) + sanity_check(xml, ifo) + print("[psd] done.") + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/fetch_gwosc_data.py b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/fetch_gwosc_data.py new file mode 100755 index 000000000..a92e8333a --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/fetch_gwosc_data.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +"""fetch_gwosc_data.py -- download GW150914 O1 open data + build the lal cache. + +Self-contained, no assumptions imported from other events. Uses gwosc.locate to +resolve the exact .gwf frame URLs for the 4096-s O1 4 kHz open-data block that +covers GPS 1126259462, curls them, writes data/event.cache over them, and reads +the REAL in-frame channel name from the frame table (never assumed). + +For O1 open data the in-frame channel is :LOSC-STRAIN (NOT the O4-era +GWOSC-4KHZ_R1_STRAIN). We store it WITHOUT the leading ':' because +ILE/pseudo_pipe form ':' themselves. + +Usage: fetch_gwosc_data.py --outdir data --event-time 1126259462.4 +""" +from __future__ import annotations +import argparse, json, os, re, subprocess, sys + + +def resolve_urls(ifos, gps): + from gwosc.locate import get_urls + urls = {} + for ifo in ifos: + u = get_urls(ifo, int(gps), int(gps) + 1, format="gwf", sample_rate=4096) + if not u: + raise SystemExit("no GWOSC .gwf URL for %s at %d" % (ifo, gps)) + urls[ifo] = u[0] + return urls + + +def curl(url, dest): + if os.path.exists(dest) and os.path.getsize(dest) > 1_000_000: + print(" have", os.path.basename(dest), "(skip)") + return + print(" curl", url) + subprocess.check_call(["curl", "-s", "-f", "-L", "-o", dest, url]) + + +def detect_channel(gwf, ifo): + """Read the REAL strain channel from the frame table (do not assume).""" + from gwpy.io.gwf import iter_channel_names + chans = list(iter_channel_names(gwf)) + strain = [c for c in chans if c.endswith("STRAIN") or c.endswith("STRAIN".lower())] + strain = [c for c in chans if "STRAIN" in c.upper()] + if not strain: + raise SystemExit("no STRAIN channel in %s: %s" % (gwf, chans)) + full = strain[0] # e.g. 'H1:LOSC-STRAIN' + bare = full.split(":", 1)[1] if ":" in full else full + return full, bare + + +def write_cache(frames, cache): + with open(cache, "w") as f: + for ifo, p in frames.items(): + base = os.path.basename(p) + m = re.match(r"([A-Z])-(\w+)-(\d+)-(\d+)\.gwf", base) + if not m: + raise SystemExit("cannot parse frame name %s" % base) + obs, tag, gps, dur = m.groups() + f.write("%s %s %s %s file://localhost%s\n" + % (obs, tag, gps, dur, os.path.abspath(p))) + print(" wrote", cache) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--outdir", default="data") + ap.add_argument("--event-time", type=float, default=1126259462.4) + ap.add_argument("--ifos", nargs="+", default=["H1", "L1"]) + a = ap.parse_args(argv) + + os.makedirs(a.outdir, exist_ok=True) + print("[fetch] GW150914 O1 open data ->", a.outdir) + urls = resolve_urls(a.ifos, a.event_time) + frames, channels_full, channels_bare = {}, {}, {} + for ifo in a.ifos: + dest = os.path.join(a.outdir, os.path.basename(urls[ifo])) + curl(urls[ifo], dest) + frames[ifo] = dest + write_cache(frames, os.path.join(a.outdir, "event.cache")) + for ifo in a.ifos: + full, bare = detect_channel(frames[ifo], ifo) + channels_full[ifo] = full + channels_bare[ifo] = bare + print(" %s channel: %s (bare: %s)" % (ifo, full, bare)) + + params = {"event": "GW150914", "event_time": a.event_time, + "ifos": a.ifos, "channels_full": channels_full, + "channels_bare": channels_bare, "frames": {k: os.path.abspath(v) for k, v in frames.items()}} + with open(os.path.join(a.outdir, "event_params.json"), "w") as f: + json.dump(params, f, indent=2) + print(" wrote", os.path.join(a.outdir, "event_params.json")) + print("[fetch] done.") + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/make_coinc.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/make_coinc.sh new file mode 100755 index 000000000..f2f815bc6 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/make_coinc.sh @@ -0,0 +1,32 @@ +#!/usr/bin/env bash +# make_coinc.sh -- build coinc.xml for GW150914 (time + IFOs; masses are a seed). +# +# There is no injection: we synthesize a minimal sim_inspiral row at seed +# intrinsics + the trigger time so util_SimInspiralToCoinc.py can emit a coinc +# carrying the right event time / IFOs / (nominal) SNR. RIFT only needs the +# coinc for time + IFOs; the intrinsic grid is proposed fresh from --force-mc-range. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config.sh" +rift_env +cd "$MODEL_DIR" + +echo "[coinc] seed sim_inspiral at m1=$SEED_M1 m2=$SEED_M2 s1z=$SEED_S1Z s2z=$SEED_S2Z t=$EVENT_TIME" +util_WriteInjectionFile.py \ + --parameter m1 --parameter-value "$SEED_M1" \ + --parameter m2 --parameter-value "$SEED_M2" \ + --parameter s1z --parameter-value "$SEED_S1Z" \ + --parameter s2z --parameter-value "$SEED_S2Z" \ + --parameter dist --parameter-value "$SEED_DIST" \ + --parameter tref --parameter-value "$EVENT_TIME" \ + --parameter fmin --parameter-value "$FMIN" \ + --approximant "$APPROX" --fname seed_inj + +# one --ifo per detector (H1 L1; no Virgo in O1) +IFO_ARGS=() +for ifo in $IFOS; do IFO_ARGS+=(--ifo "$ifo"); done + +util_SimInspiralToCoinc.py --sim-xml seed_inj.xml.gz --event 0 \ + "${IFO_ARGS[@]}" --output "$COINC" --injected-snr "$SEED_SNR" + +test -s "$COINC" || { echo "FAIL: coinc.xml not produced"; exit 1; } +echo "[coinc] wrote $COINC" diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/reconstruct_gw150914.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/reconstruct_gw150914.sh new file mode 100755 index 000000000..3c63d2108 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/reconstruct_gw150914.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# reconstruct_gw150914.sh -- the payoff: whitened strain band from the posterior. +# +# Runs once the DAG has produced rundir_gw150914_D/extrinsic_posterior_samples.dat. +# That file is a fair-draw posterior whose 'time' column varies per row (because +# ILE_extr.sub carried --fairdraw-extrinsic-output --resample-time-marginalization), +# so reconstruct_strain.py can place each IMRPhenomD realization at its OWN (time, +# phase) and get a phase-coherent 90% band with NO post-hoc alignment. +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config.sh" +rift_env +cd "$MODEL_DIR" + +test -s "$POSTERIOR" || { + echo "posterior not ready: $POSTERIOR" + echo " (the condor DAG must finish first -- check: condor_q ; make status)" + exit 1 +} +test -s "$H1_PSD" && test -s "$L1_PSD" || { echo "missing PSDs (run: make psd)"; exit 1; } + +echo "[reconstruct] $POSTERIOR -> $SAMPLES_NPZ" +"$PYBIN" "$DAT2COMPACT_PY" "$POSTERIOR" "$SAMPLES_NPZ" + +echo "[reconstruct] building whitened strain band -> $OUT_PNG" +"$PYBIN" "$RECON_PY" --samples "$SAMPLES_NPZ" --fair-draw --approx "$APPROX" \ + --psd-file H1="$H1_PSD" --psd-file L1="$L1_PSD" \ + --event-time "$EVENT_TIME" --event-name "$EVENT_NAME" --sim-id "$APPROX" \ + --srate "$SRATE" --flow "$FMIN" --fref "$FREF" --lmax "$LMAX" \ + --out "$OUT_PNG" +echo "[reconstruct] wrote $OUT_PNG" diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/submit.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/submit.sh new file mode 100755 index 000000000..8daaca164 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/model/submit.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# submit.sh -- apply CIT-local fixes to the built rundir, then condor_submit_dag. +# +# The DAG util_RIFT_pseudo_pipe.py emits uses the OSG/OSDF idioms (vanilla universe +# + MY.SingularityImage, osdf .sif delivery, getenv=True, no GPU capability floor) +# that the CIT local pool rejects or that fail to open the container there. These +# shared, tested fixes (read-only under $FIX_TOOLS) rewrite the subs in place: +# fix_getenv getenv=True -> getenv=* (CIT schedd bans getenv=True) +# fix_container_universe vanilla+SingularityImage -> container universe +# fix_container_filexfer deliver the LOCAL .sif by file transfer (OSDF .sif +# can't be opened on CIT execute nodes) +# fix_cip_single_container CIP runs on CPU slots (no GPU cap) -> single image +# pin_local pin worker jobs to the CIT-local GPU pool +# require_gpu_floor require GPU capability in [floor, ceil] the image supports +set -euo pipefail +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/config.sh" +rift_env +cd "$MODEL_DIR" + +test -s "$RUNDIR/$PP_DAG" || { echo "no DAG in $RUNDIR (run: make dag)"; exit 1; } +test -f "$CONTAINER_SIF" || { echo "container .sif not found: $CONTAINER_SIF"; exit 1; } +T="$FIX_TOOLS" + +# Convert 'vanilla + MY.SingularityImage=' subs to HTCondor +# container universe. This build bakes the LOCAL .sif directly into +# MY.SingularityImage (no OSDF / $$([...]) token), so the shared +# fix_container_universe.sh (which harvests the image from an osdf entry in +# transfer_input_files) is a no-op here. We take the image straight from +# MY.SingularityImage and emit container_image=. Idempotent. +convert_local_container_universe() { + local rd="$1" n=0 s img tmp + while IFS= read -r s; do + grep -qE '^universe[[:space:]]*=[[:space:]]*container' "$s" && continue + grep -qE '^[[:space:]]*MY\.SingularityImage[[:space:]]*=' "$s" || continue + img=$(sed -nE 's/^[[:space:]]*MY\.SingularityImage[[:space:]]*=[[:space:]]*"?([^"]+)"?[[:space:]]*$/\1/p' "$s" | head -1) + [ -n "$img" ] || { echo "WARN $(basename "$s"): no image path in MY.SingularityImage"; continue; } + tmp=$(mktemp) + awk -v img="$img" ' + /^universe[[:space:]]*=[[:space:]]*vanilla/ { print "universe = container"; print "container_image = " img; next } + /^[[:space:]]*MY\.SingularityImage[[:space:]]*=/ { next } + /^[[:space:]]*MY\.SingularityBindCVMFS[[:space:]]*=/ { next } + /^[[:space:]]*container_image[[:space:]]*=/ { next } + { print } + ' "$s" > "$tmp" + if grep -qE '^universe[[:space:]]*=[[:space:]]*container' "$tmp" \ + && grep -qE '^container_image[[:space:]]*=' "$tmp" \ + && ! grep -qE 'MY\.SingularityImage' "$tmp"; then + mv "$tmp" "$s"; echo "local-container-universe $(basename "$s")"; n=$((n+1)) + else + rm -f "$tmp"; echo "WARN $(basename "$s"): local container-universe conversion failed" + fi + done < <(find "$rd" -name '*.sub' 2>/dev/null) + echo "OK: converted $n sub(s) to container universe (local .sif) under $rd" +} + +echo "[submit] applying CIT-local fixes to $RUNDIR" +bash "$T/fix_getenv.sh" "$RUNDIR" +convert_local_container_universe "$RUNDIR" +bash "$T/fix_container_universe.sh" "$RUNDIR" # secondary pass (osdf builds); no-op here +bash "$T/fix_container_filexfer.sh" "$RUNDIR" "$CONTAINER_SIF" +bash "$T/fix_cip_single_container.sh" "$RUNDIR" "$CONTAINER_SIF" +bash "$T/pin_local.sh" "$RUNDIR" +bash "$T/require_gpu_floor.sh" "$RUNDIR" "$GPU_CAP_FLOOR" "$GPU_CAP_CEIL" + +echo "[submit] condor_submit_dag -f $PP_DAG" +cd "$RUNDIR" +condor_submit_dag -f "$PP_DAG" +echo "[submit] submitted. Watch: condor_q ; tail -f $RUNDIR/$PP_DAG.dagman.out" +echo " held? condor_q -held" diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/GW190521_reconstruction_example.png b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/GW190521_reconstruction_example.png new file mode 100644 index 000000000..4e759fba0 Binary files /dev/null and b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/GW190521_reconstruction_example.png differ diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/Makefile b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/Makefile new file mode 100644 index 000000000..8fa247648 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/Makefile @@ -0,0 +1,42 @@ +# demo/reconstruct -- whitened NR strain reconstruction with a 90% confidence band. +# +# See README.md. Each target reconstructs one event from a fixed NR simulation: +# it loops RIFT ILE fair-draw jobs until enough coherent samples accumulate, then +# builds the band vs the whitened data. +# +# Usage: +# make gw190521 # full example (needs the GW190521 RIT-Five run dir + NR data) +# make gw150914 # clean GWOSC-only example (estimates its own PSD) +# make plot-only CONFIG=config_gw190521.sh # re-plot from existing samples +# make clean + +SHELL := /bin/bash + +.PHONY: help +help: + @echo "targets: gw190521 gw150914 plot-only CONFIG=... clean" + @echo "edit a config_*.sh for your event; then 'make '" + +.PHONY: gw190521 +gw190521: + bash reconstruct.sh config_gw190521.sh + +.PHONY: gw150914 +gw150914: + bash reconstruct.sh config_gw150914.sh + +# Re-run only the reconstruction/plot step from whatever ile_fd_*_compact.npz exist +.PHONY: plot-only +plot-only: + @test -n "$(CONFIG)" || { echo "set CONFIG=config_xxx.sh"; exit 1; } + source $(CONFIG); \ + POOL=""; for f in $$WORKDIR/ile_fd_*_compact.npz; do POOL="$$POOL --samples $$f"; done; \ + $$PYBIN ../reconstruct_strain.py $$POOL --fair-draw \ + --group $$NR_GROUP --nr-param $$NR_PARAM $$PLOT_PSD_ARGS \ + --event-time $$EVENT_TIME --event-name $$EVENT_NAME --sim-id $$SIM_ID \ + --srate $$SRATE --flow $$FLOW --nproc $${NPROC:-8} \ + --tlo $${TLO:--0.10} --thi $${THI:-0.06} --out $$OUT_PNG + +.PHONY: clean +clean: + rm -f ile_fd_*_0_.xml.gz ile_fd_*_compact.npz ile_fd_*.dat *.png diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/README.md b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/README.md new file mode 100644 index 000000000..145534763 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/README.md @@ -0,0 +1,72 @@ +# nr : reconstruct a fixed NR simulation against an event + +Reconstruct the whitened detector strain, with a 90% band, using a **single +fixed numerical-relativity simulation** as the template (example: GW190521 with +RIT-Five `eBBH-1794`). This is the "does this NR waveform match the data, and +how well is the reconstruction constrained" task. + +![example](GW190521_reconstruction_example.png) + +## Quick start + +```bash +# edit config_gw190521.sh for your event / simulation, then: +bash reconstruct.sh config_gw190521.sh # -> $OUT_PNG +``` + +`reconstruct.sh` runs ILE fair-draw jobs until enough posterior samples +accumulate, then builds the band. To only (re)make the plot from samples you +already have: + +```bash +make plot-only CONFIG=config_gw190521.sh +``` + +## What each file does + +| file | role | +|---|---| +| `config_gw190521.sh` | event + simulation + data/PSD settings | +| `run_extrinsic_fairdraw.sh` | one ILE extrinsic job (correct flags) → compact `.npz` | +| `reconstruct.sh` | loop runs until `TARGET_SAMPLES`, then reconstruct | +| `Makefile` | `make gw190521`, `make plot-only CONFIG=...`, `make clean` | + +Shared tools live one level up: `../reconstruct_strain.py`, +`../extract_ile_samples.py`, `../make_reconstruct_subfile.sh`. + +## Required ILE flags (already set in run_extrinsic_fairdraw.sh) + +``` +--time-marginalization --fairdraw-extrinsic-output --resample-time-marginalization +--sampler-method adaptive_cartesian_gpu --no-adapt-after-first +--nr-lookup --nr-group --nr-param --nr-use-provided-strain +--d-min --d-max # bracket the distance posterior +``` + +`--fairdraw-extrinsic-output --resample-time-marginalization` are **required**: +they give each sample its own coalescence time so the band coheres with no +alignment (see ../README.md). `--no-adapt-after-first` keeps the sampler stable +so the fair-draw yield does not collapse. Set `--d-min/--d-max` to bracket the +distance posterior. + +## Notes specific to NR + +* The NR strain scales with **total mass**; component masses in the samples set + M and the simulation fixes the mass ratio / spins / eccentricity. With a + single-point intrinsic grid, `--intrinsic` is a no-op; for a mass curve it + weights the total mass with uniform placement. +* The fair-draw yield per ILE job is stochastic (often tens of samples for a + high-SNR event), so `reconstruct.sh` **pools several runs** until + `TARGET_SAMPLES`. Independent runs are entropy-seeded and pool correctly. +* NR data files may live in git-annex; `git annex get` the `ExtrapStrain_*.h5` + you name first. Do not set `RIFT_LOWLATENCY=1`. +* On a shared submit node, several concurrent ILE jobs can exhaust the per-user + thread cap (`pthread_create ... EAGAIN`); `reconstruct.sh` runs them + sequentially. On a cluster, submit under condor instead (see ../README.md and + `../make_reconstruct_subfile.sh`). + +## Reference figure + +`GW190521_reconstruction_example.png` was produced with the pooled fair-draw +samples from this recipe (57 waveforms, no alignment) and tracks the whitened +H1/L1 data through merger and ringdown. diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/config_gw190521.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/config_gw190521.sh new file mode 100755 index 000000000..083c7f762 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/config_gw190521.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Config for the GW190521 NR strain reconstruction (RIT-Five eBBH-1794). +# Sourced by reconstruct.sh / run_extrinsic_fairdraw.sh. + +# --- environment --- +ENV=/home/patricia.mcmillin/.conda/envs/myigwn-py311 +export PYBIN=$ENV/bin/python +export ILEBIN=$ENV/bin/integrate_likelihood_extrinsic_batchmode +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export EXTRACT_PY=$HERE/../extract_ile_samples.py + +# --- where to work / write --- +export WORKDIR=/home/patricia.mcmillin/rift_nr/gw190521/reconstruction_demo +mkdir -p "$WORKDIR" + +# --- event / analysis settings (match the intrinsic analysis) --- +export EVENT_TIME=1242442967.459473 +export EVENT_NAME=GW190521 +export SIM_ID=RIT-eBBH-1794 +export SRATE=512 +export FMAX=224.0 +export FLOW=20.0 +export D_MIN=2000 # Mpc; brackets the distance posterior, excludes <~2 Mpc NaN region +export D_MAX=13000 +export N_MAX=2000000 +export N_EFF=3000 + +# --- NR simulation (fixed) --- +export NR_GROUP=RIT-Five +export NR_PARAM=ExtrapStrain_RIT-eBBH-1794-n100.h5 +RD=/home/patricia.mcmillin/rift_nr/gw190521/190521_RIT_Five_aligned_only +export SIM_XML=/home/patricia.mcmillin/rift_nr/gw190521/lvk_peak_NR_wf_plot/RIT-Five_aligned_only/eBBH-1794/overlap-grid-extrinsic.xml.gz + +# --- data + PSDs for ILE (must be copied into WORKDIR or referenced by abs path) --- +export DATA_ARGS="--cache $RD/local.cache \ + --channel-name H1=DCS-CALIB_STRAIN_CLEAN_SUB60HZ_C01 --psd-file H1=$RD/H1-psd.xml.gz --fmin-ifo H1=20 \ + --channel-name L1=DCS-CALIB_STRAIN_CLEAN_SUB60HZ_C01 --psd-file L1=$RD/L1-psd.xml.gz --fmin-ifo L1=20 \ + --channel-name V1=Hrec_hoft_16384Hz --psd-file V1=$RD/V1-psd.xml.gz --fmin-ifo V1=20" + +# --- PSDs for the reconstruction plot (whitening; H1/L1 shown) --- +export PLOT_PSD_ARGS="--psd-file H1=$RD/H1-psd.xml.gz --psd-file L1=$RD/L1-psd.xml.gz" + +# --- optional: weight waveforms along the NR mass curve (single point here => no-op) --- +export INTRINSIC=/home/patricia.mcmillin/rift_nr/gw190521/lvk_peak_NR_wf_plot/RIT-Five_aligned_only/eBBH-1794/intrinsic_params_RIT-eBBH-1794-n100.dat + +# --- reconstruction / accumulation controls --- +export TARGET_SAMPLES=150 +export MAX_RUNS=12 +export NPROC=6 +export TLO=-0.10 +export THI=0.06 +export OUT_PNG=$WORKDIR/GW190521_reconstruction.png diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/reconstruct.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/reconstruct.sh new file mode 100755 index 000000000..05c257389 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/reconstruct.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# +# reconstruct.sh CONFIG.sh +# +# End-to-end driver: +# 1. run ILE fair-draw jobs SEQUENTIALLY until >= TARGET_SAMPLES fair draws +# accumulate (per-run yield is stochastic, often 5-40, so we loop); +# 2. pool all compact .npz and reconstruct the whitened 90% strain band. +# +# Concurrent ILE jobs on a shared node tend to hit pthread_create EAGAIN +# (per-user thread cap), so we run them one at a time. +set -e +CONFIG=$1 +[ -z "$CONFIG" ] && { echo "usage: $0 CONFIG.sh"; exit 1; } +source "$CONFIG" +HERE="$(cd "$(dirname "$0")" && pwd)" +cd "$WORKDIR" + +TARGET_SAMPLES=${TARGET_SAMPLES:-150} +MAX_RUNS=${MAX_RUNS:-12} + +count_samples() { # total fair-draw rows across all compact npz so far + "$PYBIN" - "$@" <<'PY' +import sys, glob, numpy as np +n=0 +for f in glob.glob(sys.argv[1]): + try: n+=len(np.load(f)['time']) + except Exception: pass +print(n) +PY +} + +i=0; total=0 +while [ "$total" -lt "$TARGET_SAMPLES" ] && [ "$i" -lt "$MAX_RUNS" ]; do + s=$(printf "%03d" "$i") + echo "=== ILE fair-draw run $s (have $total / $TARGET_SAMPLES) ===" + bash "$HERE/run_extrinsic_fairdraw.sh" "$CONFIG" "$s" || echo "run $s failed, continuing" + total=$(count_samples "$WORKDIR/ile_fd_*_compact.npz") + i=$((i+1)) +done +echo "=== accumulated $total fair-draw samples in $i runs ===" + +# pool everything and reconstruct +POOL=""; for f in "$WORKDIR"/ile_fd_*_compact.npz; do POOL="$POOL --samples $f"; done +"$PYBIN" "$HERE/../reconstruct_strain.py" $POOL --fair-draw \ + --group "$NR_GROUP" --nr-param "$NR_PARAM" $PLOT_PSD_ARGS \ + --event-time "$EVENT_TIME" --event-name "$EVENT_NAME" --sim-id "$SIM_ID" \ + --srate "$SRATE" --flow "$FLOW" ${INTRINSIC:+--intrinsic $INTRINSIC} \ + --nproc "${NPROC:-8}" --tlo "${TLO:--0.10}" --thi "${THI:-0.06}" \ + --out "$OUT_PNG" +echo "=== reconstruction written: $OUT_PNG ===" diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/run_extrinsic_fairdraw.sh b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/run_extrinsic_fairdraw.sh new file mode 100755 index 000000000..eb93301a4 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/nr/run_extrinsic_fairdraw.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# +# run_extrinsic_fairdraw.sh CONFIG.sh SUFFIX +# +# Run ONE RIFT ILE extrinsic job for a fixed NR simulation, configured to +# emit fair-draw posterior samples each carrying its OWN coalescence time, +# then extract them to a compact .npz. +# +# The flag choices below are the whole point -- see README.md "What went wrong". +# Correct, load-bearing flags: +# --time-marginalization : correct absolute lnL scale (peak lnL = SNR^2/2) +# --fairdraw-extrinsic-output : output rows are fair posterior draws +# --resample-time-marginalization : draw a geocent time per output row from that +# row's own lnL(t), coherent with its phase +# (=> reconstruction coheres with NO alignment) +# --no-adapt-after-first : keep the sampler stable; without it a mid-run +# adaptation reset crushes the fair-draw yield +# --d-min/--d-max : bracket the distance posterior; excludes the +# <~2 Mpc region where the likelihood overflows to NaN +# (do NOT use --maximize-only, and do NOT use --no-adapt-distance) +# +# CONFIG.sh must export: PYBIN ILEBIN EXTRACT_PY WORKDIR EVENT_TIME SRATE FMAX FLOW +# NR_GROUP NR_PARAM SIM_XML D_MIN D_MAX N_MAX N_EFF DATA_ARGS +# where DATA_ARGS is the detector/data/psd block, e.g. +# DATA_ARGS="--cache local.cache \ +# --channel-name H1=... --psd-file H1=H1-psd.xml.gz --fmin-ifo H1=20 \ +# --channel-name L1=... --psd-file L1=L1-psd.xml.gz --fmin-ifo L1=20" +set -e +CONFIG=$1; SUF=$2 +[ -z "$SUF" ] && { echo "usage: $0 CONFIG.sh SUFFIX"; exit 1; } +source "$CONFIG" +cd "$WORKDIR" +OUT=ile_fd_${SUF} + +# thread caps so several of these can (if needed) coexist without pthread EAGAIN +export OMP_NUM_THREADS=${OMP_NUM_THREADS:-2} OPENBLAS_NUM_THREADS=${OPENBLAS_NUM_THREADS:-2} +export MKL_NUM_THREADS=${MKL_NUM_THREADS:-2} NUMEXPR_NUM_THREADS=${NUMEXPR_NUM_THREADS:-2} + +"$ILEBIN" \ + --save-P 0.1 --save-deltalnL 20 --fmax "$FMAX" --srate "$SRATE" \ + --event-time "$EVENT_TIME" $DATA_ARGS \ + --fmin-template "$FLOW" --reference-freq "$FLOW" --inv-spec-trunc-time 0 --window-shape 0.2 \ + --time-marginalization --fairdraw-extrinsic-output --resample-time-marginalization \ + --inclination-cosine-sampler --declination-cosine-sampler \ + --n-max "$N_MAX" --n-eff "$N_EFF" --vectorized --gpu \ + --no-adapt-after-first --adapt-weight-exponent 0.1 --l-max 4 \ + --force-reset-all --sampler-method adaptive_cartesian_gpu --force-xpy \ + --d-min "$D_MIN" --d-max "$D_MAX" --n-events-to-analyze 1 \ + --nr-lookup --nr-lookup-group "$NR_GROUP" --nr-group "$NR_GROUP" \ + --nr-param "$NR_PARAM" --nr-use-provided-strain --save-eccentricity \ + --sim-xml "$SIM_XML" --save-samples --output-file "$OUT" + +"$PYBIN" "$EXTRACT_PY" ${OUT}_0_.xml.gz ${OUT}_compact.npz +echo "run_extrinsic_fairdraw: done $SUF" diff --git a/MonteCarloMarginalizeCode/Code/demo/reconstruct/reconstruct_strain.py b/MonteCarloMarginalizeCode/Code/demo/reconstruct/reconstruct_strain.py new file mode 100755 index 000000000..2aec0bba6 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/reconstruct/reconstruct_strain.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python +""" +reconstruct_strain.py -- whitened GW strain reconstruction with a confidence band. + +Given RIFT ILE extrinsic *fair-draw* samples (one or more compact .npz files +from extract_ile_samples.py), reconstruct the whitened detector strain with a +90% credible band and overlay it on the whitened data. + +Two waveform back-ends: + --approx IMRPhenomD : a waveform MODEL, built from the hlmoft modes + (RIFT.lalsimutils.hlmoft -> hoft_from_hlm) using each + sample's own m1,m2,spins(incl. in-plane),extrinsic. + This is the same mode construction ILE's likelihood uses, + so the time/phase reference matches the reported + geocent_end_time (avoids an fref-dependent ~1 ms bias + that the direct lalsimutils.hoft path has). + --group G --nr-param F.h5 : a fixed NR simulation, via real_hoft() from a + WaveformModeCatalog instantiated with the SAME options + ILE's likelihood uses (factored_likelihood.py:286: + align_at_peak_l2_m2_emission, shift_by_extraction_radius, + clean_*, perturbative_extraction=False; NOT + reference_phase_at_peak) so the time reference matches. + (real_hoft, not hlmoft->hoft_from_hlm: the generic mode + sum scrambles the NR phase; residual real_hoft-vs-hlmoff + time offset ~0.5 ms, below plot resolution.) + +The essential point: the ILE run MUST have been produced with + --fairdraw-extrinsic-output --resample-time-marginalization +so every sample carries its OWN coalescence time (the 'time' column varies), +coherent with its coa_phase. Each realization is generated at that (time, phase) +-> the band is phase-coherent and tight, with NO post-hoc alignment. + +Whitening is done in the frequency domain against the SAME PSD ILE used +(gwpy .whiten() is avoided: the analysis PSD often only reaches srate/2). +""" +import argparse +import os +import numpy as np +from concurrent.futures import ProcessPoolExecutor + +import lal +import lalsimulation as lalsim +import RIFT.lalsimutils as lalsimutils +try: + from scipy.signal.windows import tukey +except ImportError: + from scipy.signal import tukey + + +def get_args(argv=None): + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--samples", action="append", required=True, + help="compact .npz from extract_ile_samples.py (repeat to POOL runs)") + ap.add_argument("--fair-draw", action="store_true", + help="treat input rows as equal-weight fair posterior draws " + "(use with ILE --fairdraw-extrinsic-output output)") + ap.add_argument("--approx", default=None, help="waveform model, e.g. IMRPhenomD (model mode)") + ap.add_argument("--group", default=None, help="NR group, e.g. RIT-Five (NR mode)") + ap.add_argument("--nr-param", default=None, help="NR strain file (NR mode)") + ap.add_argument("--fref", type=float, default=20.0, help="reference frequency (model mode)") + ap.add_argument("--lmax", type=int, default=4) + ap.add_argument("--psd-file", action="append", required=True, + help="IFO=path.xml.gz (repeat per detector; must match ILE PSDs)") + ap.add_argument("--event-time", type=float, required=True) + ap.add_argument("--event-name", default="event") + ap.add_argument("--sim-id", default="model") + ap.add_argument("--data-txt", action="append", default=[], + help="IFO=path : 2-col (t, strain) data; else fetch from GWOSC") + ap.add_argument("--intrinsic", default=None, + help="NR mode only: intrinsic_params .dat to weight along the mass curve") + ap.add_argument("--ngen", type=int, default=1500, help="importance draws (ignored with --fair-draw)") + ap.add_argument("--nproc", type=int, default=8) + ap.add_argument("--srate", type=float, default=4096.0, help="sample rate (match ILE --srate)") + ap.add_argument("--flow", type=float, default=20.0) + ap.add_argument("--fhigh", type=float, default=None, help="whitening high cut (default srate/2)") + ap.add_argument("--tlo", type=float, default=-0.10) + ap.add_argument("--thi", type=float, default=0.06) + ap.add_argument("--out", required=True) + ap.add_argument("--cache-out", default=None) + ap.add_argument("--align", action="store_true", + help="DIAGNOSTIC ONLY. You should NOT need this. A visible offset " + "means the samples lack per-sample times (the ILE run was missing " + "--resample-time-marginalization).") + o = ap.parse_args(argv) + if not o.approx and not (o.group and o.nr_param): + ap.error("choose a back-end: --approx MODEL, or --group G --nr-param F.h5") + return o + + +def whiten_fd(x, psd, fs, flo, fhi): + x = np.asarray(x, float) + n = len(x) + x = x * tukey(n, alpha=min(0.2, 8.0 / (n / fs))) + f = np.fft.rfftfreq(n, 1.0 / fs) + pf = psd.f0 + psd.deltaF * np.arange(psd.data.length) + S = np.interp(f, pf, psd.data.data, left=psd.data.data[0], right=psd.data.data[-1]) + Xf = np.fft.rfft(x) / np.sqrt(S) + Xf[(f < flo) | (f > fhi)] = 0.0 + return np.fft.irfft(Xf, n=n) + + +_W = {} + + +def _init(mode, group, nr_param, approx, fref, psd_files, fs, flo, fhi, lmax, tev): + _W.update(mode=mode, fs=fs, flo=flo, fhi=fhi, tev=tev, fref=fref, lmax=lmax, + psd={ifo: lalsimutils.get_psd_series_from_xmldoc(f, ifo) for ifo, f in psd_files.items()}) + if mode == "nr": + import NRWaveformCatalogManager3 as nrwf + wfP = nrwf.WaveformModeCatalog(group, nr_param, + clean_initial_transient=True, clean_final_decay=True, + shift_by_extraction_radius=True, align_at_peak_l2_m2_emission=True, + perturbative_extraction=False, lmax=lmax, use_provided_strain=True) + wfP.P.taper = lalsimutils.lsu_TAPER_START + wfP.P.deltaF = 1.0 / 16.0 + wfP.P.deltaT = 1.0 / fs + wfP.P.radec = True + _W.update(nrwf=nrwf, wfP=wfP) + else: + _W["approx"] = lalsim.GetApproximantFromString(approx) + + +def _project_whiten(h, tev, fs, flo, fhi, psd): + t = lalsimutils.evaluate_tvals(h) - tev + return (t.astype(np.float32), whiten_fd(h.data.data, psd, fs, flo, fhi).astype(np.float32)) + + +def _gen(a): + (m1, m2, mtot, a1z, a2z, a1x, a1y, a2x, a2y, ecc, incl, dist, ra, dec, psi, phiorb, tgeo) = a + fs, tev, flo, fhi = _W["fs"], _W["tev"], _W["flo"], _W["fhi"] + out = {} + if _W["mode"] == "nr": + wfP, nrwf = _W["wfP"], _W["nrwf"] + wfP.P.m1, wfP.P.m2 = m1 * lal.MSUN_SI, m2 * lal.MSUN_SI + wfP.P.s1z, wfP.P.s2z = a1z, a2z + wfP.P.s1x = wfP.P.s1y = wfP.P.s2x = wfP.P.s2y = 0.0 + wfP.P.eccentricity = ecc + wfP.P.fmin = 2 * wfP.fOrbitLower / (mtot * nrwf.MsunInSec) + wfP.P.incl, wfP.P.dist = incl, dist * lal.PC_SI * 1e6 + wfP.P.phi, wfP.P.theta, wfP.P.psi = ra, dec, psi + wfP.P.phiref, wfP.P.tref = phiorb, tgeo + for ifo, psd in _W["psd"].items(): + wfP.P.detector = ifo + out[ifo] = _project_whiten(wfP.real_hoft(), tev, fs, flo, fhi, psd) + else: + P = lalsimutils.ChooseWaveformParams() + P.approx = _W["approx"] + P.m1, P.m2 = m1 * lal.MSUN_SI, m2 * lal.MSUN_SI + P.s1z, P.s2z = a1z, a2z + P.s1x, P.s1y, P.s2x, P.s2y = a1x, a1y, a2x, a2y # in-plane spins (precession) + P.fmin, P.fref = flo, _W["fref"] + P.deltaT = 1.0 / fs + P.deltaF = 1.0 / 16.0 + P.radec = True + P.incl, P.dist = incl, dist * lal.PC_SI * 1e6 + P.phi, P.theta, P.psi = ra, dec, psi + P.phiref, P.tref = phiorb, tgeo + # Build h(t) from the hlmoft modes (fd_standoff 0.9, as ILE's internal_hlm_generator uses) + # and combine with hoft_from_hlm. This is the SAME mode construction ILE's likelihood + # uses, so the coalescence-time (and phase) reference matches the geocent_end_time ILE + # reports. Reconstructing with lalsimutils.hoft() instead leaves an fref-dependent ~1 ms + # time bias (its coalescence reference differs from the hlmoff likelihood path). + hlms = lalsimutils.hlmoft(P, Lmax=_W["lmax"], fd_standoff_factor=0.9) + for ifo, psd in _W["psd"].items(): + P.detector = ifo + out[ifo] = _project_whiten(lalsimutils.hoft_from_hlm(hlms, P), tev, fs, flo, fhi, psd) + return out + + +def main(argv=None): + o = get_args(argv) + mode = "nr" if o.group else "model" + fs = o.srate + fhi = o.fhigh if o.fhigh else fs / 2.0 + PSD = {c.split("=")[0]: c.split("=")[1] for c in o.psd_file} + IFOS = list(PSD.keys()) + psd_series = {ifo: lalsimutils.get_psd_series_from_xmldoc(f, ifo) for ifo, f in PSD.items()} + + keys = ["m1", "m2", "a1z", "a2z", "a1x", "a1y", "a2x", "a2y", + "ra", "dec", "time", "phiorb", "incl", "psi", + "distance", "lnL", "p", "ps", "eccentricity"] + parts = {k: [] for k in keys} + for f in o.samples: + Z = np.load(f) + n = len(Z["m1"]) + for k in keys: # tolerate older npz lacking in-plane spins etc. + parts[k].append(np.asarray(Z[k], float) if k in Z.files else np.zeros(n)) + D = {k: np.concatenate(parts[k]) for k in keys} + lnL, p, ps = D["lnL"], D["p"], D["ps"] + fin = np.isfinite(lnL) & np.isfinite(p) & np.isfinite(ps) & (ps > 0) + + rng = np.random.default_rng(0) + if o.fair_draw: + sel = np.where(fin)[0] + wsel = np.full(len(sel), 1.0 / len(sel)) + print("[select] fair-draw: %d equal-weight samples from %d file(s)" + % (len(sel), len(o.samples)), flush=True) + else: + w = np.zeros_like(lnL) + w[fin] = np.exp(lnL[fin] - np.nanmax(lnL[fin])) * p[fin] / ps[fin] + w[~np.isfinite(w)] = 0.0 + w /= w.sum() + pos = (rng.random() + np.arange(o.ngen)) / o.ngen + sel, cnt = np.unique(np.searchsorted(np.cumsum(w), pos), return_counts=True) + wsel = cnt.astype(float) / cnt.sum() + print("[select] importance-resample: %d draws -> %d unique (weight-ESS=%.1f)" + % (o.ngen, len(sel), 1.0 / np.sum(w[fin] ** 2)), flush=True) + + if mode == "nr" and o.intrinsic and os.path.exists(o.intrinsic): + I = np.loadtxt(o.intrinsic) + if I.ndim == 2 and I.shape[0] > 1: + mt, lm = I[:, 1] + I[:, 2], I[:, 10] + g = np.isfinite(lm) + wM = np.exp(lm[g] - lm[g].max()); wM /= wM.sum() + mtot = rng.choice(mt[g], size=len(sel), p=wM) + else: + mtot = (D["m1"] + D["m2"])[sel] + else: + mtot = (D["m1"] + D["m2"])[sel] + + args = [(float(D["m1"][j]), float(D["m2"][j]), float(mtot[i]), + float(D["a1z"][j]), float(D["a2z"][j]), + float(D["a1x"][j]), float(D["a1y"][j]), float(D["a2x"][j]), float(D["a2y"][j]), + float(D["eccentricity"][j]), + float(D["incl"][j]), float(D["distance"][j]), float(D["ra"][j]), float(D["dec"][j]), + float(D["psi"][j]), float(D["phiorb"][j]), float(D["time"][j])) + for i, j in enumerate(sel)] + store = {("%s_%s" % (ifo, k)): [None] * len(sel) for ifo in IFOS for k in ("t", "h")} + with ProcessPoolExecutor(max_workers=o.nproc, initializer=_init, + initargs=(mode, o.group, o.nr_param, o.approx, o.fref, PSD, fs, + o.flow, fhi, o.lmax, o.event_time)) as ex: + for i, res in enumerate(ex.map(_gen, args, chunksize=4)): + for ifo in IFOS: + store["%s_t" % ifo][i], store["%s_h" % ifo][i] = res[ifo] + if (i + 1) % 100 == 0: + print(" ... %d/%d" % (i + 1, len(sel)), flush=True) + if o.cache_out: + np.savez(o.cache_out, wsel=wsel, ifos=np.array(IFOS), + **{k: np.array(v, dtype=object) for k, v in store.items()}) + + from gwpy.timeseries import TimeSeries + data_txt = {c.split("=")[0]: c.split("=")[1] for c in o.data_txt} + dw, dstd = {}, {} + for ifo in IFOS: + if ifo in data_txt: + arr = np.loadtxt(data_txt[ifo]); td, ww = arr[:, 0] - o.event_time, arr[:, 1] + else: + from pesummary.gw.file.strain import StrainData + Dt = StrainData.fetch_open_data(ifo, o.event_time - 16, o.event_time + 8) + Dt = TimeSeries(np.array(Dt.value), t0=float(Dt.t0.value), dt=float(Dt.dt.value)).resample(fs) + td = np.array(Dt.times.value) - o.event_time + ww = whiten_fd(Dt.value, psd_series[ifo], fs, o.flow, fhi) + q = (td > -4) & (td < -1) + dstd[ifo] = np.std(ww[q]) if q.any() else 1.0 + dw[ifo] = (td, ww / dstd[ifo]) + + from pesummary.core.plots.interpolate import Bounded_interp1d + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + def wquant(v, ww, qs): + ok = np.isfinite(v); v, ww = v[ok], ww[ok] + order = np.argsort(v); cw = np.cumsum(ww[order]); cw /= cw[-1] + return np.interp(qs, cw, v[order]) + + def band(ifo): + tt = [np.asarray(t, float) for t in store["%s_t" % ifo]] + hh = [np.asarray(h, float) / dstd[ifo] for h in store["%s_h" % ifo]] + lo = max(t[0] for t in tt); hi = min(t[-1] for t in tt) + g = np.arange(max(lo, o.tlo - 0.05), min(hi, o.thi + 0.05), 1.0 / fs) + A = np.array([Bounded_interp1d(t, h, xlow=t[0], xhigh=t[-1])(g) for t, h in zip(tt, hh)]) + up = np.array([wquant(A[:, k], wsel, 0.95) for k in range(A.shape[1])]) + dn = np.array([wquant(A[:, k], wsel, 0.05) for k in range(A.shape[1])]) + md = np.array([wquant(A[:, k], wsel, 0.50) for k in range(A.shape[1])]) + return g, up, dn, md + + fig, axes = plt.subplots(len(IFOS), 1, figsize=(15, 4 * len(IFOS)), sharex=True) + if len(IFOS) == 1: + axes = [axes] + labels = {"H1": "LIGO Hanford", "L1": "LIGO Livingston", "V1": "Virgo"} + for ax, ifo in zip(axes, IFOS): + td, hd = dw[ifo] + ax.plot(td, hd, color="0.5", alpha=0.7, lw=1.3, label="Whitened %s data" % ifo) + g, up, dn, md = band(ifo) + if o.align: + m = (td >= -0.06) & (td <= 0.04); tdp = td[m][np.argmax(np.abs(hd[m]))] + gm = (g >= -0.06) & (g <= 0.04); tmp = g[gm][np.argmax(np.abs(md[gm]))] + g = g + (tdp - tmp) + ax.fill_between(g, up, dn, color="tab:green", alpha=0.35, label="%s 90%% band" % o.sim_id) + ax.plot(g, md, color="tab:green", lw=1.6, alpha=0.9) + ax.set_xlim(o.tlo, o.thi); ax.set_ylabel("whitened strain"); ax.legend(loc="lower left") + ax.text(0.5, 0.92, labels.get(ifo, ifo), ha="center", transform=ax.transAxes) + neff = 1.0 / np.sum(wsel ** 2) + fig.suptitle("%s : whitened strain and %s 90 pct band (N=%d, eff=%d)" + % (o.event_name, o.sim_id, len(wsel), neff), fontsize=14, y=0.998) + axes[-1].set_xlabel("time (s) from %.6f" % o.event_time) + plt.tight_layout() + plt.savefig(o.out, bbox_inches="tight", dpi=110) + print("wrote", o.out, flush=True) + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/calmarg/Makefile b/MonteCarloMarginalizeCode/Code/demo/rift/calmarg/Makefile index 9ad0b543e..86b896e7d 100644 --- a/MonteCarloMarginalizeCode/Code/demo/rift/calmarg/Makefile +++ b/MonteCarloMarginalizeCode/Code/demo/rift/calmarg/Makefile @@ -549,14 +549,15 @@ endif # OVERRIDES the CLI, which previously left ILE stuck at 4M while cip/general got bumped. PP_DISK_FLAGS := --internal-ile-request-disk $(PP_DISK) --internal-cip-request-disk $(PP_DISK) --internal-general-request-disk $(PP_DISK) -# ILE memory request (Mb). pseudo_pipe defaults to 4096, far too tight here. Each job -# does 50 intrinsic points with the fused calmarg + distance-marg extrinsic sampler; the -# well-behaved points finish at ~7.3 GB, but pathological low-cal-n_eff points spin the AV -# sampler toward --n-max 4e6 and accumulate sample arrays past 8 GB -> held ("over cgroup -# memory limit"). Observed: 4096 and 8192 both held; completers peak ~7.3 GB. 16384 (2.2x) -# covers the hard-point spikes and still matches most GPU nodes (median ~27 GB RAM). Raise -# to 24576 if any still hold; ILE_extr auto-gets 2x this. -PP_MEM_ILE ?= 16384 +# ILE memory request (Mb). Sized for the DEFAULT (mild 1% cal) envelope: measured ILE RSS is +# ~1.2 GB, so 6144 is ~5x headroom and matches the WHOLE GPU pool. A 16384 default matched only +# ~55 of 3400 slots (the RAM-starved GPU pool has few >=16 GB slots) and, combined with a cc>=8 +# require_gpus floor, left runs idle for WEEKS. ILE_extr auto-gets 2x this (12288). NOTE the +# --wide STRESS envelope is different: there the sampler spins toward --n-max on collapsed-cal-n_eff +# points and accumulates arrays past 8 GB (completers ~7.3 GB) -- for a --wide run bump PP_MEM_ILE +# to 24576. Pair with RIFT_REQUIRE_GPUS floor 6.0 (setup_here.sh) so the ~529 uncontested cc6.1 +# slots are admitted; at ~6 min/job on the mild envelope they finish well inside the 120-min wall. +PP_MEM_ILE ?= 6144 PP_MEM_FLAGS := --internal-ile-request-memory $(PP_MEM_ILE) .PHONY: pp-run pp-run-build pp-coinc diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/.gitignore b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/.gitignore index 398582b84..ad9c5c0c0 100644 --- a/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/.gitignore +++ b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/.gitignore @@ -3,3 +3,5 @@ rundir_multigpu/ _smoke/ ci_coinc.xml proposed-grid* +rundir_combo/ +_combo.ini diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/Makefile b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/Makefile index ca2ce492d..4d6c97704 100644 --- a/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/Makefile +++ b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/Makefile @@ -54,7 +54,7 @@ FANOUT ?= 4 BUILD_DIR := $(CURDIR)/rundir_multigpu DAG := marginalize_intrinsic_parameters_BasicIterationWorkflow.dag -.PHONY: all smoke-local build verify inspect requests clean coinc help +.PHONY: all smoke-local build verify build-combo verify-combo inspect requests clean coinc help help: @sed -n '2,40p' Makefile @@ -144,6 +144,50 @@ verify: @echo "OK: ILE.sub requests $(FANOUT) GPUs + $(FANOUT) CPUs; ile_pre.sh bakes RIFT_ILE_GPU_FANOUT=$(FANOUT)" @echo " and will split each 100-point ILE block into $(FANOUT) shards, one per GPU." +# --------------------------------------------------------------------------- +# build-combo / verify-combo: the COMBINED example -- multi-GPU fan-out AND the +# multi-container (container family) selection, driven ENTIRELY from one ini +# (multigpu_multicontainer.ini), including the container manifest. Proves an +# end-user ini shape where every option lands. Deliberately does NOT export +# SINGULARITY_RIFT_IMAGE, so the container really comes from the ini. +# --------------------------------------------------------------------------- +COMBO_DIR := $(CURDIR)/rundir_combo +BASE_EXE_DIR ?= /usr/local/bin/ +build-combo: ci_coinc.xml + @test -f "$(MANIFEST)" || (echo "ERROR: container-family manifest not found: $(MANIFEST)"; false) + rm -rf $(COMBO_DIR) + @# Fill the ini placeholders (@MANIFEST@/@BASE_EXE_DIR@) into a run-local copy. + sed -e 's#@MANIFEST@#$(MANIFEST)#' -e 's#@BASE_EXE_DIR@#$(BASE_EXE_DIR)#' \ + multigpu_multicontainer.ini > $(CURDIR)/_combo.ini + @# NOTE: no SINGULARITY_RIFT_IMAGE / RIFT_ILE_GPU_FANOUT in the env -- both come from the ini. + env -u SINGULARITY_RIFT_IMAGE -u SINGULARITY_BASE_EXE_DIR -u RIFT_ILE_GPU_FANOUT -u RIFT_REQUIRE_GPUS \ + $(ENV) util_RIFT_pseudo_pipe.py \ + --use-ini $(CURDIR)/_combo.ini --use-coinc $(CURDIR)/ci_coinc.xml --use-rundir $(COMBO_DIR) \ + --fake-data-cache $(CACHE) --assume-nospin \ + --use-osg-file-transfer --internal-truncate-files-for-osg-file-transfer \ + --internal-force-iterations 2 + rm -rf $(COMBO_DIR)/frames_dir; cp -r $(FRAMES) $(COMBO_DIR)/frames_dir + for ifo in H1 L1 V1; do cp $(PSD) $(COMBO_DIR)/$$ifo-psd.xml.gz; done + @rm -f $(CURDIR)/_combo.ini + @echo; echo "Built $(COMBO_DIR). Now: make verify-combo" + +verify-combo: + @test -f $(COMBO_DIR)/ILE.sub || (echo "no $(COMBO_DIR)/ILE.sub -- run 'make build-combo' first"; false) + @echo "===== MULTI-CONTAINER (container family) =====" + @echo "--- per-machine image selection (>=2 images chosen by GPU capability) ---" + @grep -iE 'MY.SingularityImage|container_image' $(COMBO_DIR)/ILE.sub | sed 's/^/ /' + @grep -qi 'ifThenElse.*Capability.*\.sif' $(COMBO_DIR)/ILE.sub || (echo "FAIL: no per-machine image ifThenElse selection"; false) + @echo "--- selective osdf image transfer (only the matching .sif) ---" + @grep -qi 'osdf:.*\.sif' $(COMBO_DIR)/ILE.sub || (echo "FAIL: no osdf image transfer"; false) + @grep -oiE 'require_gpus = .*' $(COMBO_DIR)/ILE.sub | sed 's/^/ /' + @grep -qi 'Capability >= 8.0' $(COMBO_DIR)/ILE.sub || (echo "FAIL: ini ile_require_gpus floor did not land"; false) + @echo "===== MULTI-GPU FAN-OUT =====" + @grep -iE 'request_GPUs|request_CPUs' $(COMBO_DIR)/ILE.sub | sed 's/^/ /' + @grep -oE 'RIFT_ILE_GPU_FANOUT:-[A-Za-z0-9]*' $(COMBO_DIR)/ile_pre.sh | sed 's/^/ ile_pre.sh baked: /' + @grep -qE 'RIFT_ILE_GPU_FANOUT:-all' $(COMBO_DIR)/ile_pre.sh || (echo "FAIL: ini ile-gpu-fanout=all did not bake into ile_pre.sh"; false) + @echo; echo "OK: one ini -> multi-container per-machine .sif selection + capability floor + osdf transfer," + @echo " AND multi-GPU fan-out (grab all node GPUs). Every option landed." + # --------------------------------------------------------------------------- # requests: show the condor request_GPUs/request_CPUs + baked launcher directive # that each fan-out mode produces (fixed N vs. adaptive vs. all-physical). Fast: @@ -173,4 +217,4 @@ inspect: @grep -iE 'request_|require_gpus|SingularityImage|executable|when_to_transfer' $(BUILD_DIR)/ILE.sub clean: - rm -rf _smoke _req $(BUILD_DIR) ci_coinc.xml + rm -rf _smoke _req $(BUILD_DIR) $(COMBO_DIR) _combo.ini ci_coinc.xml diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/README.md b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/README.md index e4f3762c6..2b76b1e5f 100644 --- a/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/README.md +++ b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/README.md @@ -145,6 +145,46 @@ make inspect # show the generated launcher + sub resource lines `make build` only builds the DAG (it does not submit). To actually run it you need a pool with ≥ N-GPU nodes; submit with `condor_submit_dag` from the run dir. +### 2b. `make build-combo` + `make verify-combo` — fan-out **and** multi-container from one ini + +[`multigpu_multicontainer.ini`](multigpu_multicontainer.ini) is a copy-me template that +carries **both** features in a single ini — the multi-GPU fan-out *and* the +multi-container **container family** (per-machine `.sif` selection by GPU capability): + +```ini +[rift-pseudo-pipe] +# multi-GPU fan-out +ile-force-gpu=True +ile-gpu-fanout="all" # QUOTED (ini values are eval'd); grab all node GPUs +ile-jobs-per-worker=100 +# multi-container (container family) +use-osg=True +use-singularity=True +singularity_rift_image=/abs/path/rift_container_family.cit.yaml # the manifest of >=2 .sif images +singularity_base_exe_dir=/usr/local/bin/ +ile_require_gpus=(Capability >= 8.0) && (Capability <= 12.0) +``` + +``` +make build-combo # builds rundir_combo; the container comes from the INI (env not set) +make verify-combo # asserts BOTH landed +``` + +> If `make build`/`build-combo` dies in `util_ManualOverlapGrid.py` with an intermittent +> `from scipy import interpolate` import error, that is a known flaky scipy-import race in the +> grid step (unrelated to this feature) — just re-run the target. + +`verify-combo` confirms every option lands in the generated `ILE.sub`: +- **multi-container:** `MY.SingularityImage = ifThenElse(TARGET.GPUs_Capability >= 9.0, "…cc90-120.sif", "…cc60-90.sif")`, the selective `osdf:///…$$([...])` image transfer, and the combined `require_gpus` capability floor. +- **fan-out:** `request_GPUs` / `request_CPUs`, and `ile_pre.sh` baking `RIFT_ILE_GPU_FANOUT`. + +**ini value rules (important):** keys that match a `util_RIFT_pseudo_pipe.py` option are +`eval()`'d, so **string values must be quoted** (`ile-gpu-fanout="all"`, `approx="IMRPhenomD"`); +numbers/booleans are bare (`ile-jobs-per-worker=100`, `use-osg=True`). Three keys are read +as **raw strings into the environment** (not eval'd, not quoted) — +`singularity_rift_image`, `singularity_base_exe_dir`, `ile_require_gpus` — and the +environment still wins over the ini for those (export to override a shared ini). + ### 3. `blueprints/` — the asimov path - `rift-multigpu.yaml` — analysis blueprint showing **both** blueprint encodings diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/multigpu_multicontainer.ini b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/multigpu_multicontainer.ini new file mode 100644 index 000000000..36a0a1358 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/infra/multi_gpu/multigpu_multicontainer.ini @@ -0,0 +1,85 @@ +# ============================================================================= +# EXAMPLE ini shape: multi-GPU fan-out + multi-container (container family), together. +# +# This ONE ini carries both features so an end user can copy it as a template: +# * multi-GPU fan-out -> RIFT_ILE_GPU_FANOUT (here 'all': grab every node GPU) +# * multi-container -> the container-family manifest (per-machine .sif selection +# by GPU capability) + a GPU capability floor +# +# Build (no submit) with `make build-combo` (fills @MANIFEST@/@BASE_EXE_DIR@ + per-run +# paths) or directly: +# util_RIFT_pseudo_pipe.py --use-ini this.ini --use-coinc ci_coinc.xml \ +# --use-rundir rundir --fake-data-cache zero_noise.cache --assume-nospin +# +# It is a SILLY validation config (IMRPhenomD, zero spin, CI synthetic data) so it can +# actually build/run; swap the physics for a real analysis. +# +# ---- HOW ini VALUES ARE READ (important) ------------------------------------------ +# Keys in [rift-pseudo-pipe] that match a util_RIFT_pseudo_pipe.py option are eval()'d, +# so STRING values must be QUOTED: approx="IMRPhenomD", ile-gpu-fanout="all". +# Numbers/booleans are bare: ile-jobs-per-worker=100, ile-force-gpu=True. +# A few keys are consumed as raw strings into the environment (NOT eval'd, NOT quoted): +# singularity_rift_image, singularity_base_exe_dir, ile_require_gpus, accounting_group. +# The environment still WINS over the ini for those (export to override a shared ini). +# ============================================================================= + +[analysis] +ifos=['H1','L1','V1'] +singularity=True +osg=True + +[condor] +accounting_group=ligo.sim.o4.cbc.pe.rift +accounting_group_user=albert.einstein + +[datafind] +url-type=file +types = {'H1': 'fake_strain', 'L1': 'fake_strain', 'V1': 'fake_strain'} + +[data] +channels = {'H1': 'H1:FAKE-STRAIN','L1': 'L1:FAKE-STRAIN', 'V1': 'V1:FAKE-STRAIN'} + +[lalinference] +flow = {'H1': 10, 'L1': 10, 'V1': 10} +fhigh = { 'H1': 1700, 'L1': 1700, 'V1': 1700 } + +[engine] +fref=20 +amporder = -1 +seglen = 8 +srate = 4096 +a_spin1-max = 0.0 +a_spin2-max = 0.0 +chirpmass-min = 23.0 +chirpmass-max = 35.0 +comp-min = 1 +comp-max = 1000 +distance-max = 1000 +aligned-spin = +alignedspin-zprior = + +[rift-pseudo-pipe] +# ---- silly PE physics (matches the CI synthetic data; swap for a real run) -------- +approx="IMRPhenomD" +l-max=2 +fmin-template=10 +event-time=1000000014.236547946 +force-eta-range="[0.20,0.24999]" +internal-distance-max=1000 +n-output-samples=2000 +use-online-psd=False + +# ---- MULTI-GPU FAN-OUT ------------------------------------------------------------ +ile-force-gpu=True # ILE requests+uses a GPU (fan-out needs the GPU path) +ile-gpu-fanout="all" # QUOTED: grab every physical GPU on the node (default too). + # Use "1" for a single-GPU run, or a number N, or "auto-max-N". +ile-jobs-per-worker=100 # points evaluated per ILE job == what the fan-out splits +ile-n-eff=10 +ile-runtime-max-minutes=120 + +# ---- MULTI-CONTAINER (container family) ------------------------------------------- +use-osg=True # container-family per-machine image selection uses OSG-mode wiring +use-singularity=True +singularity_rift_image=@MANIFEST@ # the container-family .yaml (manifest of >=2 .sif images) +singularity_base_exe_dir=@BASE_EXE_DIR@ # dir of the RIFT bin INSIDE the images (e.g. /usr/local/bin/) +ile_require_gpus=(Capability >= 8.0) && (Capability <= 12.0) # capability floor (adds to the family's) diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/Makefile b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/Makefile new file mode 100644 index 000000000..55d0e0f7a --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/Makefile @@ -0,0 +1,38 @@ +# demo/rift/slowrot -- verify-anywhere value demos for the slow-response likelihoods. +# +# make demo run BOTH quick-looks (no condor, no GPU); writes outputs/*.{txt,png} +# make demo-rotation Path A/B : rotation-vs-static lnL gain grows with signal duration Omega*T +# make demo-finite-size Path D : finite-size-vs-LWL lnL gain grows with detector arm length (fL/c) +# make clean remove outputs/ +# +# Both show the maintained vectorized (NoLoop) likelihood RECOVERS SNR the standard analysis +# loses, on the SAME data at the true parameters. They exercise the exact functions wired into +# integrate_likelihood_extrinsic_batchmode as --rotation-slow / --rotation-p-max / --freqresponse. +# Backing code: RIFT/likelihood/{factored_likelihood_with_rotation,factored_likelihood_freqresponse, +# slowrot_freqresponse}.py; validation RIFT/likelihood/test_slowrot_*.py; SLOWROT_HANDOFF.md. + +PYTHON ?= python +# Code root is four levels up (demo/rift/slowrot -> demo/rift -> demo -> Code); put it on +# PYTHONPATH so `import RIFT...` resolves this worktree even without an editable install. +CODE_ROOT := $(abspath $(dir $(lastword $(MAKEFILE_LIST)))/../../..) +RUN = PYTHONPATH=$(CODE_ROOT):$$PYTHONPATH $(PYTHON) + +.PHONY: help demo demo-rotation demo-finite-size clean +help: + @echo "targets:" + @echo " demo both verify-anywhere quick-looks (rotation + finite-size)" + @echo " demo-rotation Path A/B: gain vs signal duration (Omega*T); null control at short T" + @echo " demo-finite-size Path D: gain vs arm length (fL/c); null control at LIGO 4 km" + @echo " clean remove outputs/" + @echo "(uses PYTHON=$(PYTHON); CODE_ROOT=$(CODE_ROOT))" + +demo: demo-rotation demo-finite-size + +demo-rotation: + $(RUN) demo_rotation.py + +demo-finite-size: + $(RUN) demo_finite_size.py + +clean: + rm -rf outputs diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/README.md b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/README.md new file mode 100644 index 000000000..bae9f955c --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/README.md @@ -0,0 +1,110 @@ +# demo/rift/slowrot — slow-response likelihood value demos + +Verify-anywhere (no condor, no GPU) quick-looks that RIFT's **time-/frequency-dependent +detector-response** likelihoods *add value*, not merely run. Both generalize the maintained +marginalized ILE likelihood so a long or long-armed signal is modeled with the correct +detector response instead of a single constant antenna pattern. + +``` +make demo # both demos below -> outputs/*.{txt,png} +make demo-rotation # Path A/B (Earth rotation) +make demo-finite-size # Path D (finite-size / frequency-dependent response) +``` + +Each demo builds data that carries the true response, then compares, **at the true +parameters on the same data and time grid**, the recovered time-maximized log-likelihood of +the standard analysis vs the response-aware one. The difference `gain = lnL_aware − lnL_standard` +is the SNR the standard analysis throws away. Both use the maintained vectorized (NoLoop) +path with cubic sub-bin time interpolation, so the peak-resolution floor cancels in the gain. + +--- + +## Path A/B — Earth rotation (`demo_rotation.py`) + +Over a long signal the antenna pattern `F(t)` drifts as the Earth rotates; the static +(Earth-fixed-response) likelihood loses match. RIFT injections already carry the true +time-varying response (`hoft → SimDetectorStrainREAL8TimeSeries`), so a standard injection is +"rotating". The gain grows with the rotation phase `Ω⊕·T` over the signal; a short signal is a +null control. Single H1, SNR≈30 held fixed so the gain tracks `Ω⊕·T`, not loudness: + +| config | seglen | Ω⊕·T | gain `lnL_rot − lnL_static` | +|---|---|---|---| +| null_bbh (30+25) | 2 s | 1.5e-4 | +0.004 (null control) | +| bbh_8_8 | 16 s | 1.2e-3 | +0.043 | +| bbh_4_4 | 64 s | 4.7e-3 | +0.187 | + +Grows ~50× from the short null to a minute-scale signal; extrapolated to a 90-minute XG BNS +(`Ω⊕·T`≈0.4) it is orders of magnitude larger — the 3G headline. Figure: +`outputs/rotation_gain_vs_duration.png`. ILE: `--rotation-slow` (Path A), +`--rotation-slow --rotation-p-max 2` (Path B, adds propagation-delay drift). + +## Path D — finite-size / frequency-dependent response (`demo_finite_size.py`) + +On a multi-km arm the light-travel time across the arm is not negligible vs the GW period, so +the response is per-frequency: `h_k(f) = F₊(f;sky) h₊(f) + Fₓ(f;sky) hₓ(f)`. We inject an exact +finite-size signal (`antenna_response_fd`) and compare the standard long-wavelength (LWL, +constant-response) likelihood to the finite-size one (`--freqresponse`, sky-harmonic route (b), +sky stays extrinsic). The gain grows with the arm length — i.e. with `fL/c`, the in-band +light-crossing phase. 15+13 M☉, fmax=2000 Hz, loud (SNR≈320), Qmax=6: + +| detector | arm L | fL/c @ fmax | gain `lnL_finite − lnL_LWL` | +|---|---|---|---| +| LIGO | 4 km | 0.027 | +0.25 (null control) | +| ET | 10 km | 0.067 | +3.5 | +| CE | 20 km | 0.133 | +12.4 | +| CE | 40 km | 0.267 | +39.6 | + +Null at the 4-km LIGO arm; tens of nats at a 40-km Cosmic Explorer. Only the +**direction-dependent** part of the response contributes — the common `e^{−i2πfL/c}` +light-crossing delay is degenerate with arrival time and is absorbed by both likelihoods' +time maximization, so it does not inflate the gain. Figure: +`outputs/finite_size_gain_vs_arm.png`. ILE: `--freqresponse` +`--freqresponse-arm-length 40000` `--freqresponse-qmax 6`. + +Higher `fL/c` (heavier system / higher fmax / longer arm) needs higher `--freqresponse-qmax` +(the response is a power series in `fL/c · (â·n̂)`); Qmax=6 converges through CE-40 km at this +config. + +--- + +## Full injection–recovery PE (cluster) + +These quick-looks are the likelihood-level core. The headline parameter-bias / single-network +sky-localization figures are DAG PE runs (structurally the standard RIFT pipeline with the +extra ILE flag appended: `--rotation-slow` / `--freqresponse`). The three analyses per event +differ *only* in that appended option. See `RIFT/likelihood/SLOWROT_HANDOFF.md` and +`~/RIFT_roboto_paper/analyses/slowrot_demo/` for the pipeline scaffolding. + +## Running on GPU (`--gpu`) + +Both likelihoods are GPU-enabled (`xpy=cupy`): the rotation/finite-size NoLoop reuses the +baseline fused `Q_inner_product` kernel per elementary template, so the GPU memory footprint +matches the baseline. Add `--gpu` to the ILE command (keep `--vectorized`; requires `n_cal=1`, +i.e. no glitch/calibration marginalization, and no distance/phase marginalization). Validated on +an A100: GPU↔CPU likelihood parity 7e-12, and the full ILE evidence agrees to sampler noise for +both paths (see `../slowrot_gpu_validate/`, `make local-gpu` and `make e2e`). + +**Two invocation gotchas — longstanding, RIFT-wide, NOT slowrot-specific:** + +1. **`--force-xpy` alone does NOT select the GPU path.** It only forces the xpy code path when + `--gpu` is *also* passed and cupy is unavailable (a debug fallback). By itself `opts.gpu` + stays `False` and you get the CPU-vectorized branch (see + `integrate_likelihood_extrinsic_batchmode` ~line 520). **To run on GPU, pass `--gpu`** (with + cupy present). A DAG that passes `--force-xpy` but not `--gpu` runs on CPU — correct, just + not GPU. +2. **In a cupy-enabled container, the pure-CPU path (no `--gpu`) fails** with + `ValueError: Unsupported dtype float128`: the legacy CPU-vectorized branch uses + `RiftFloat=float128`, which cupy rejects. So *inside a GPU/cupy container, run with `--gpu`*; + the CPU-only path is for CPU-only containers. (Pre-existing; independent of slowrot.) + +## Validation (the demos show value; these prove correctness) + +`RIFT/likelihood/test_slowrot_*.py` (run under the venv with this Code dir on `PYTHONPATH`): +- `test_slowrot_noloop.py` — rotation NoLoop == baseline at f_sidereal=0 (3.6e-12). +- `test_slowrot_headtohead.py` — matched-sample rotation vs baseline; Cauchy-Schwarz bound. +- `test_slowrot_freqresponse_likelihood.py` — finite-size: L→0 reduces to baseline (3e-9), + bound respected, and a **V4 positive control** asserting finite-size beats LWL by +38.9 nats + in an in-band-effect config. +- **GPU:** `test_slowrot_gpu.py` / `test_slowrot_freqresponse_gpu.py` — GPU↔CPU likelihood parity + (skip without a GPU). End-to-end ILE CPU-vs-GPU consistency: `../slowrot_gpu_validate/` `make e2e` + (self-contained — generates its own throwaway H1L1 injection; asserts GPU==CPU evidence to sampler noise). diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_finite_size.py b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_finite_size.py new file mode 100644 index 000000000..eee5ec0c7 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_finite_size.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python +""" +slowrot demo / demo_finite_size.py -- verify-anywhere (no condor, no GPU) quick-look that the +finite-size (frequency-dependent detector response) likelihood [Path D] ADDS VALUE on a +long-armed 3G detector. + +Beyond the long-wavelength (LWL) limit the detector strain is a per-frequency antenna +pattern, h_k(f) = F_+(f;sky) h_+(f) + F_x(f;sky) h_x(f), because the light-travel time across +a multi-km arm is no longer negligible vs the GW period. We build an EXACT finite-size +injection (from the same modes the likelihood uses -> internal_hlm_generator -> IFFT -> +antenna_response_fd) and, at the true parameters, compare the recovered log-likelihood of + + lnL_LWL : the standard RIFT likelihood (constant, frequency-independent response) + lnL_finite : the finite-size likelihood (--freqresponse, sky-harmonic route (b)) + +both TIME-MAXIMIZED on the SAME data and SAME time grid. The gain + + gain(L) = lnL_finite(truth) - lnL_LWL(truth) + +is ~0 for a LIGO 4-km arm (null control: the finite-size effect is below the floor) and grows +monotonically with the arm length -- i.e. with fL/c, the in-band light-crossing phase -- to +tens of nats for a 40-km Cosmic Explorer. That is exactly the SNR the LWL analysis throws +away on a 3G detector, and what --freqresponse recovers. + +The effect that matters is the DIRECTION-DEPENDENT part of the response; the common +e^{-i2pi f L/c} light-crossing delay is degenerate with the arrival time and is absorbed by +BOTH likelihoods' time maximization, so it does not inflate the gain. + +Run: python demo_finite_size.py (writes outputs/finite_size_gain_vs_arm.{txt,png}) + +Backing code: branch rift_slowrot; --freqresponse (+ --freqresponse-qmax/-arm-length) on +integrate_likelihood_extrinsic_batchmode; RIFT/likelihood/factored_likelihood_freqresponse.py, +slowrot_freqresponse.py. See RIFT/likelihood/SLOWROT_HANDOFF.md. +""" +from __future__ import print_function, division +import os +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.slowrot_freqresponse as sfr +import RIFT.likelihood.factored_likelihood_freqresponse as flfr + +EVENT_TIME = 1e9 +LMAX = 2 +DET = "H1" +PSD = lalsim.SimNoisePSDaLIGOZeroDetHighPower +# sky/pol/masses where the in-band finite-size effect is resolvable (heavier system -> high-f +# power; loud so the effect sits above the peak-resolution floor). +RA, DEC, PSI, INCL, PHIREF = 1.2, 0.3, 0.5, 0.4, 0.0 +M1, M2 = 15.0, 13.0 +FMAX = 2000.0 +SCALE = 40.0 # loudness: data-mode distance = distMpcRef/SCALE (SNR ~ 320) +SEGLEN = 8.0 +QMAX = 6 # response-basis order; higher needed as fL/c grows + +# (label, arm-length[m]) -- LIGO 4 km is the null control; ET ~10 km; CE 20/40 km. +CONFIGS = [ + ("LIGO_4km", 4000.0), + ("ET_10km", 10000.0), + ("CE_20km", 20000.0), + ("CE_40km", 40000.0), +] + +C_SI = sfr.C_SI + + +# --- minimal FD helpers (self-contained; same conventions as the validation test) --- +def _ifft(hf_d): + out = {} + for lm, hf in hf_d.items(): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); out[lm] = ht + return out + + +def _fwd_fd(re_series, epoch, dt, N): + ht = lal.CreateCOMPLEX16TimeSeries("h", epoch, 0., dt, lal.DimensionlessUnit, N) + ht.data.data[:] = re_series[:N] + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., 1. / dt / N, lsu.lsu_HertzUnit, N) + lal.COMPLEX16TimeFreqFFT(hf, ht, lal.CreateForwardCOMPLEX16FFTPlan(N, 0)); return hf + + +def _wrap_fd(arr, epoch, deltaF): + n = len(arr) + hf = lal.CreateCOMPLEX16FrequencySeries("hf", epoch, 0., deltaF, lsu.lsu_HertzUnit, n) + hf.data.data[:] = arr + return hf + + +def _rev_td(hf): + n = hf.data.length; dt = 1. / (n * hf.deltaF) + ht = lal.CreateCOMPLEX16TimeSeries("h", hf.epoch, 0., dt, lal.DimensionlessUnit, n) + lal.COMPLEX16FreqTimeFFT(ht, hf, lal.CreateReverseCOMPLEX16FFTPlan(n, 0)); return ht + + +def _peak(lt): + """Time-maximized value via a fine spline over the sampled lnL(t) window.""" + from scipy.interpolate import InterpolatedUnivariateSpline + lt = np.asarray(lt, float); x = np.arange(len(lt)) + sp = InterpolatedUnivariateSpline(x, lt, k=4) + xs = np.linspace(0, len(lt) - 1, len(lt) * 32) + return float(np.max(sp(xs))) + + +def run_config(label, L_arm): + deltaT = 1.0 / (2 * FMAX); fmin = 30.0; deltaF = 1.0 / SEGLEN + fNyq = 1.0 / 2.0 / deltaT; t_window = 0.1 + DLOUD = fl.distMpcRef * 1e6 * lsu.lsu_PC / SCALE + + Psig = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=M1 * lal.MSUN_SI, m2=M2 * lal.MSUN_SI, detector=DET, dist=200e6 * lal.PC_SI, + deltaT=deltaT, tref=EVENT_TIME, deltaF=deltaF) + Psig.approx = lalsim.GetApproximantFromString("IMRPhenomD") + Pm = Psig.manual_copy(); Pm.dist = DLOUD + + # --- exact finite-size injection from the SAME modes --- + hlms_fd, _ = fl.internal_hlm_generator(Pm, LMAX, verbose=False, quiet=True) + hlmsT = _ifft(hlms_fd); lm0 = list(hlmsT.keys())[0] + nn = hlmsT[lm0].data.length; dt = hlmsT[lm0].deltaT; e0 = float(hlmsT[lm0].epoch) + Sig = np.zeros(nn, complex) + for lm in hlmsT: + Sig += hlmsT[lm].data.data * lal.SpinWeightedSphericalHarmonic(INCL, -PHIREF, -2, lm[0], lm[1]) + dt_geo = float(fl.ComputeArrivalTimeAtDetector(DET, RA, DEC, EVENT_TIME)) - EVENT_TIME + data_epoch = lal.LIGOTimeGPS(e0 + EVENT_TIME) + fvals = flfr.evaluate_fvals_from_length(nn, deltaF) + Sig_fd = _fwd_fd(Sig, data_epoch, dt, nn).data.data + Sig_del = _rev_td(_wrap_fd(Sig_fd * np.exp(-1j * 2 * np.pi * fvals * dt_geo), data_epoch, deltaF)).data.data + hpf = _fwd_fd(np.real(Sig_del), data_epoch, dt, nn).data.data + hcf = _fwd_fd(-np.imag(Sig_del), data_epoch, dt, nn).data.data + gmst = float(lal.GreenwichMeanSiderealTime(lal.LIGOTimeGPS(EVENT_TIME))) + Fp, Fc = sfr.antenna_response_fd(DET, RA, DEC, PSI, fvals, gmst=gmst, L_arm=L_arm) + data = _wrap_fd(Fp * hpf + Fc * hcf, data_epoch, deltaF) + data_dict = {DET: data}; psd_dict = {DET: PSD} + IP = lsu.ComplexIP(fmin, FMAX, fNyq, data.deltaF, PSD, True, False, 0.) + snr = np.sqrt(IP.ip(data, data).real) + + Pv = Psig.manual_copy() + for k, v in [('phi', RA), ('theta', DEC), ('incl', INCL), ('phiref', PHIREF), + ('psi', PSI), ('dist', DLOUD)]: + setattr(Pv, k, np.ones(1) * v) + Pv.tref = EVENT_TIME; Pv.deltaT = deltaT + Nw = int(0.02 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + # baseline (long-wavelength) NoLoop, time-maximized, at the truth + ri, ct, ctV, rho, _, _ = fl.PrecomputeLikelihoodTerms( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, FMAX, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for d in data_dict: + a, b, c, U, V, r, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rho[d].keys()), None, rho[d], ct[d], ctV[d]) + lk[d] = a; rA[d] = r; cu[d] = U; cv[d] = V; ep[d] = e + lnL_lwl = _peak(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lk, rA, cu, cv, ep, Lmax=LMAX, xpy=np, return_lnLt=True, time_interp='cubic')[0]) + + # finite-size (Path D) NoLoop, time-maximized, at the truth + bk = flfr.PrecomputeLikelihoodTermsFreqResponse( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, FMAX, + Qmax=QMAX, L_arm=L_arm, analyticPSD_Q=True, verbose=False, quiet=True, skip_interpolation=True) + lkf, rbp, ubp, vbp, epf = flfr.pack_freqresponse_arrays(bk[4], bk[3], bk[1], bk[2]) + lnL_fin = _peak(flfr.DiscreteFactoredLogLikelihoodFreqResponseNoLoop( + tvals, Pv, bk[4], lkf, rbp, ubp, vbp, epf, Lmax=LMAX, array_output=True, time_interp='cubic')[0]) + + fLc = FMAX * L_arm / C_SI + return dict(label=label, L_arm=L_arm, fLc=fLc, snr=float(snr), + lnL_lwl=lnL_lwl, lnL_fin=lnL_fin, gain=lnL_fin - lnL_lwl) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + outdir = os.path.join(here, "outputs"); os.makedirs(outdir, exist_ok=True) + print("finite-size (Path D) value demo: %g+%g Msun, fmax=%g Hz, loudness SCALE=%g (SNR per row), Qmax=%d\n" + % (M1, M2, FMAX, SCALE, QMAX)) + rows = [] + for cfg in CONFIGS: + try: + r = run_config(*cfg) + except Exception as e: + print("%-9s SKIPPED (%s)" % (cfg[0], e)); continue + rows.append(r) + print("%-9s L=%6.0fm fL/c@fmax=%.3f SNR=%6.1f lnL_LWL=%11.3f lnL_finite=%11.3f GAIN=%+8.3f" + % (r['label'], r['L_arm'], r['fLc'], r['snr'], r['lnL_lwl'], r['lnL_fin'], r['gain'])) + if not rows: + print("no configs succeeded"); return + txt = os.path.join(outdir, "finite_size_gain_vs_arm.txt") + with open(txt, "w") as f: + f.write("# finite-size (Path D) demo: --freqresponse recovers SNR the long-wavelength analysis loses\n") + f.write("# label L_arm[m] fL/c@fmax SNR lnL_LWL lnL_finite gain=lnL_finite-lnL_LWL\n") + for r in rows: + f.write("%s %g %g %g %g %g %g\n" % (r['label'], r['L_arm'], r['fLc'], + r['snr'], r['lnL_lwl'], r['lnL_fin'], r['gain'])) + print("\nwrote", txt) + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + Lkm = [r['L_arm'] / 1e3 for r in rows]; gain = [r['gain'] for r in rows] + labs = [r['label'] for r in rows] + fig, ax = plt.subplots(figsize=(5, 3.4)) + ax.plot(Lkm, gain, 'o-', color="#9d0208") + for x, y, l in zip(Lkm, gain, labs): + ax.annotate(l, (x, y), textcoords="offset points", xytext=(4, 4), fontsize=8) + ax.set_xlabel(r"detector arm length $L$ [km] ($\propto fL/c$, in-band light-crossing)") + ax.set_ylabel(r"$\ln\mathcal{L}_{\rm finite}-\ln\mathcal{L}_{\rm LWL}$ (recovered)") + ax.set_title("Finite-size detector response recovers SNR on 3G arms") + ax.axhline(0, color="0.7", lw=0.8) + fig.tight_layout() + png = os.path.join(outdir, "finite_size_gain_vs_arm.png") + fig.savefig(png, dpi=140) + print("wrote", png) + except Exception as e: + print("(plot skipped:", e, ")") + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_rotation.py b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_rotation.py new file mode 100644 index 000000000..d1c7bc7c2 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/demo_rotation.py @@ -0,0 +1,162 @@ +#!/usr/bin/env python +""" +slowrot demo / demo_rotation.py -- verify-anywhere (no condor, no GPU) quick-look that the +slow-rotation likelihood [Path A/B] ADDS VALUE: on a long signal the static +(Earth-fixed-response) likelihood LOSES match with the data (which carries the true +time-varying antenna pattern, since RIFT injections go through +SimDetectorStrainREAL8TimeSeries), and --rotation-slow recovers it. The recovered +log-likelihood at the true parameters, + + gain(config) = lnL_rotation(truth) - lnL_static(truth), (both time-marginalized) + +grows with the rotation phase Omega*T over the signal; a short signal is a null control +(gain ~ 0). This is the likelihood-level core of the full injection-recovery PE; the headline +sky-localization / parameter-bias figure is a cluster run. + +Both likelihoods are the maintained vectorized (NoLoop) path evaluated on the SAME data and +SAME time grid, so the gain is a clean difference (peak-resolution floor cancels). + +Run: python demo_rotation.py (writes outputs/rotation_gain_vs_duration.{txt,png}) + +Backing code: branch rift_slowrot; --rotation-slow (Path A) and --rotation-p-max N (Path B) on +integrate_likelihood_extrinsic_batchmode. See RIFT/likelihood/SLOWROT_HANDOFF.md. +""" +from __future__ import print_function, division +import os +import numpy as np +import lal +import lalsimulation as lalsim + +import RIFT.lalsimutils as lsu +import RIFT.likelihood.factored_likelihood as fl +import RIFT.likelihood.factored_likelihood_with_rotation as flwr + +EVENT_TIME = 1e9 +LMAX = 2 +DET = "H1" # single detector: rotation's sky-info value is clearest here +PSD = lalsim.SimNoisePSDaLIGOZeroDetHighPower +# sky/pol chosen where the antenna pattern drifts appreciably over a sidereal day +RA, DEC, PSI, INCL, PHIREF = 1.0, -0.6, 0.4, 0.5, 0.0 +HARM = (-2, -1, 0, 1, 2) + +# (label, m1, m2, fmin, fmax, dist[Mpc]) -- increasing duration via lower mass / fmin. +# Segment length is AUTO-FIT to each waveform (next power of two). Distances tuned for a +# comparable (loud) SNR (~30) so the gain tracks Omega*T, not loudness. +CONFIGS = [ + ("null_bbh", 30.0, 25.0, 30.0, 1024.0, 900.0), # short BBH, ~2 s: control, gain~0 + ("bbh_8_8", 8.0, 8.0, 25.0, 1024.0, 380.0), # ~16 s + ("bbh_4_4", 4.0, 4.0, 22.0, 1024.0, 210.0), # ~64 s -> larger Omega*T +] +OMEGA_EARTH = flwr.OMEGA_EARTH + + +def _P(m1, m2, fmin, deltaT, deltaF): + P = lsu.ChooseWaveformParams( + fmin=fmin, radec=True, incl=INCL, phiref=PHIREF, theta=DEC, phi=RA, psi=PSI, + m1=m1 * lal.MSUN_SI, m2=m2 * lal.MSUN_SI, detector=DET, + dist=200e6 * lal.PC_SI, deltaT=deltaT, tref=EVENT_TIME, deltaF=deltaF) + P.approx = lalsim.GetApproximantFromString("IMRPhenomD") + return P + + +def _P_vec(Psig, dist_SI, K=1): + Pv = Psig.manual_copy() + Pv.phi = np.ones(K) * RA; Pv.theta = np.ones(K) * DEC; Pv.psi = np.ones(K) * PSI + Pv.incl = np.ones(K) * INCL; Pv.phiref = np.ones(K) * PHIREF + Pv.dist = np.ones(K) * dist_SI + Pv.tref = float(EVENT_TIME); Pv.deltaT = Psig.deltaT + return Pv + + +def run_config(label, m1, m2, fmin, fmax, dist_Mpc): + deltaT = 1.0 / (2 * fmax) if fmax > 1024 else 1.0 / 2048.0 + dist_SI = dist_Mpc * 1e6 * lal.PC_SI + t_window = 0.1 + # AUTO-FIT seglen: generate with deltaF=None so non_herm_hoff pads to the next power of + # two >= the waveform length; then read back the segment length / deltaF. + Psig = _P(m1, m2, fmin, deltaT, None) + Psig.dist = dist_SI + data = lsu.non_herm_hoff(Psig) # RIFT injection -> carries the true time-varying response + seglen = data.data.length * deltaT + Psig.deltaF = data.deltaF + data_dict = {DET: data}; psd_dict = {DET: PSD} + fNyq = 1.0 / (2 * deltaT) + IP = lsu.ComplexIP(fmin, fmax, fNyq, data.deltaF, PSD, True, False, 0.) + snr = np.sqrt(IP.ip(data, data).real) + Pv = _P_vec(Psig, dist_SI) + Nw = int(0.02 / deltaT); tvals = np.arange(-Nw, Nw) * deltaT + + # baseline (static) NoLoop, time-marginalized, at the truth + rib, ctb, ctVb, rhob, _, _ = fl.PrecomputeLikelihoodTerms( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, fmax, + analyticPSD_Q=True, verbose=False, quiet=True, ignore_threshold=None) + lk = {}; rA = {}; cu = {}; cv = {}; ep = {} + for d in data_dict: + a, b, c, U, V, rAr, rI, e = fl.PackLikelihoodDataStructuresAsArrays( + list(rhob[d].keys()), None, rhob[d], ctb[d], ctVb[d]) + lk[d] = a; rA[d] = rAr; cu[d] = U; cv[d] = V; ep[d] = e + lnL_static = float(fl.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoop( + tvals, Pv, lk, rA, cu, cv, ep, Lmax=LMAX, xpy=np, time_interp='cubic')[0]) + + # rotation (Path A) NoLoop at the truth + bk = flwr.PrecomputeLikelihoodTermsWithRotation( + EVENT_TIME, t_window, Psig, data_dict, psd_dict, LMAX, fmax, + harmonics=HARM, p_max=0, f_sidereal=flwr.F_SIDEREAL, analyticPSD_Q=True, + verbose=False, quiet=True, skip_interpolation=True) + lkr, rbn, ubn, vbn, epr = flwr.pack_rotation_arrays(bk[4], bk[3], bk[1], bk[2]) + lnL_rot = float(flwr.DiscreteFactoredLogLikelihoodViaArrayVectorNoLoopWithRotation( + tvals, Pv, bk[4], lkr, rbn, ubn, vbn, epr, Lmax=LMAX, time_interp='cubic')[0]) + + OmegaT = OMEGA_EARTH * seglen + return dict(label=label, seglen=seglen, snr=float(snr), OmegaT=OmegaT, + lnL_static=lnL_static, lnL_rot=lnL_rot, gain=lnL_rot - lnL_static) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + outdir = os.path.join(here, "outputs"); os.makedirs(outdir, exist_ok=True) + print("slow-rotation (Path A) value demo: single %s, SNR~30 held fixed so gain tracks Omega*T\n" % DET) + rows = [] + for cfg in CONFIGS: + try: + r = run_config(*cfg) + except Exception as e: + print("%-9s SKIPPED (%s)" % (cfg[0], e)); continue + rows.append(r) + print("%-9s seglen=%5.0fs SNR=%6.1f Omega*T=%.2e lnL_static=%12.4f lnL_rot=%12.4f GAIN=%+.4f" + % (r['label'], r['seglen'], r['snr'], r['OmegaT'], + r['lnL_static'], r['lnL_rot'], r['gain'])) + if not rows: + print("no configs succeeded"); return + txt = os.path.join(outdir, "rotation_gain_vs_duration.txt") + with open(txt, "w") as f: + f.write("# slow-rotation demo: --rotation-slow recovers SNR the static analysis loses on long signals\n") + f.write("# label seglen[s] SNR Omega*T lnL_static lnL_rot gain=lnL_rot-lnL_static\n") + for r in rows: + f.write("%s %g %g %g %g %g %g\n" % (r['label'], r['seglen'], r['snr'], + r['OmegaT'], r['lnL_static'], r['lnL_rot'], r['gain'])) + print("\nwrote", txt) + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + OmT = [r['OmegaT'] for r in rows]; gain = [r['gain'] for r in rows] + labs = [r['label'] for r in rows] + fig, ax = plt.subplots(figsize=(5, 3.4)) + ax.plot(OmT, gain, 'o-', color="#2a6f97") + for x, y, l in zip(OmT, gain, labs): + ax.annotate(l, (x, y), textcoords="offset points", xytext=(4, 4), fontsize=8) + ax.set_xlabel(r"$\Omega_\oplus T$ (rotation phase over the signal)") + ax.set_ylabel(r"$\ln\mathcal{L}_{\rm rot}-\ln\mathcal{L}_{\rm static}$ (recovered)") + ax.set_title("Accounting for Earth rotation recovers SNR on long signals") + ax.axhline(0, color="0.7", lw=0.8) + fig.tight_layout() + png = os.path.join(outdir, "rotation_gain_vs_duration.png") + fig.savefig(png, dpi=140) + print("wrote", png) + except Exception as e: + print("(plot skipped:", e, ")") + + +if __name__ == "__main__": + main() diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.png b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.png new file mode 100644 index 000000000..6ab85ae0f Binary files /dev/null and b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.png differ diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.txt b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.txt new file mode 100644 index 000000000..9719fe7fd --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/finite_size_gain_vs_arm.txt @@ -0,0 +1,6 @@ +# finite-size (Path D) demo: --freqresponse recovers SNR the long-wavelength analysis loses +# label L_arm[m] fL/c@fmax SNR lnL_LWL lnL_finite gain=lnL_finite-lnL_LWL +LIGO_4km 4000 0.0266851 318.345 50657 50657.3 0.250481 +ET_10km 10000 0.0667128 319.525 51029.7 51033.1 3.47031 +CE_20km 20000 0.133426 321.377 51613.5 51625.9 12.4271 +CE_40km 40000 0.266851 324.621 52633.5 52673 39.5546 diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.png b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.png new file mode 100644 index 000000000..532150b42 Binary files /dev/null and b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.png differ diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.txt b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.txt new file mode 100644 index 000000000..14bb8b27f --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot/outputs/rotation_gain_vs_duration.txt @@ -0,0 +1,5 @@ +# slow-rotation demo: --rotation-slow recovers SNR the static analysis loses on long signals +# label seglen[s] SNR Omega*T lnL_static lnL_rot gain=lnL_rot-lnL_static +null_bbh 2 30.0567 0.000145842 70.1014 70.105 0.00367852 +bbh_8_8 16 29.1207 0.00116674 106.254 106.297 0.0430423 +bbh_4_4 64 31.1072 0.00466695 107.632 107.819 0.186851 diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/.gitignore b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/.gitignore new file mode 100644 index 000000000..4c18c09c9 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/.gitignore @@ -0,0 +1,19 @@ +rift_code.tar.gz +_code/ +gpu_test.out +gpu_test.err +gpu_test.log +# numba JIT on-disk cache (regenerated per run via NUMBA_CACHE_DIR; never commit) +*.nbc +*.nbi +e2e_case/ +# numba cache dirs (module_/) written when tests run in this dir with NUMBA_CACHE_DIR=. +SWSH_*/ +WignerD_*/ +qnm_*/ +quaternionic_*/ +recursions_*/ +scri_*/ +spherical_*/ +utilities_*/ +utils_*/ diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/BREADCRUMB.md b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/BREADCRUMB.md new file mode 100644 index 000000000..2f16588bf --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/BREADCRUMB.md @@ -0,0 +1,101 @@ +# GPU rotation + freqresponse validation — breadcrumb / resume guide + +## ✅ VALIDATED (2026-07-09, ldas-pcdev12, NVIDIA A100-SXM4-80GB, cc 8.0, cupy 13.6 / CUDA 11.8) +Ran directly in the RIFT container on this GPU host (`make local-gpu`), no Condor needed: +- **Path A/B rotation** GPU↔CPU parity: nearest `7.3e-12`, cubic `8.2e-12` (< 1e-8 threshold) +- **Path D freqresponse** GPU↔CPU parity: nearest `7.3e-12`, cubic `7.3e-12` (NEW GPU port) +- CPU baseline-vs-rotation still `3.6e-12`; freqresponse CPU V4 positive control still `+38.88` nats. + +**A REAL BUG was caught that the no-cupy sandbox could not:** `TimeDelayFromEarthCenter` +(vectorized_lal_tools) defaults `xpy=xpy_default`, which is **cupy whenever cupy is importable**. +The rotation/freqresponse NoLoops keep the delay on the HOST (RA/DEC are `_h()` numpy copies), so the +default fed host arrays to `cupy.cos` and crashed on the GPU. Fixed by pinning `xpy=np` at both call +sites (`factored_likelihood_with_rotation.py`, `factored_likelihood_freqresponse.py`). Lesson: a +cupy-defaulting helper is invisible in a CPU-only sandbox; always validate on real hardware. + +**Path D freqresponse GPU port + ILE wiring done this pass** (mirrors the rotation port exactly): +`DiscreteFactoredLogLikelihoodFreqResponseNoLoop` gained `xpy=np` + the fused-kernel term1 GPU branch; +ILE `--freqresponse` now accepts `--gpu` (n_cal=1, no glitch/cal marg) — guard relaxed, precompute +`cupy.asarray`'s the Q/U/V banks, GPU `likelihood_function` gained the freqresponse branch. Test: +`RIFT/likelihood/test_slowrot_freqresponse_gpu.py`. + +**NOTE:** `setup_generic_env_vars.sh` no longer exports `SINGULARITY_RIFT_IMAGE`, so `make submit` +needs it set by hand (a cvmfs or local cc60-90 CUDA-11.8 image). `make local-gpu` sidesteps this by +pinning a local image (`RIFT_LOCAL_IMAGE` in the Makefile). + +**Goal (original):** validate the GPU (`xpy=cupy`) rotation likelihood on real hardware. It was +implemented and CPU-verified, but the dev sandbox had **no cupy/CUDA** (`libcuda.so.1` absent), so the +GPU path was UNTESTED. This kit runs one Condor GPU job to confirm GPU↔CPU parity. + +## What was done (commits on branch `rift_slowrot`) +- `d038f582` — GPU support for the rotation NoLoop (`factored_likelihood_with_rotation.py`): + term1 reuses the baseline fused `Q_inner_product_{cubic,}_cupy` kernel **per elementary + template** (A=conj(Ylm)) → no `(n_ext,npts,n_lms)` temporary, same GPU memory footprint as the + baseline. term2 = small `|a_list|²` einsums. Antenna/Ylm/delay stay on host; only Q banks + U/V + move to device. ILE (`integrate_likelihood_extrinsic_batchmode`): `--rotation-slow` now allowed + with `--gpu` (needs n_cal=1, no glitch/cal marg); precompute `cupy.asarray`'s rho/U/V; the GPU + `likelihood_function` (~line 2239) gained the rotation branch. +- `af68d7ec` — earlier: per-detector `--freqresponse-arm-length`, `--limit-*` zoom-box. +- CPU path is bit-identical: `test_slowrot_noloop` still 3.638e-12. +- **The validation test:** `RIFT/likelihood/test_slowrot_gpu.py` — runs the rotation NoLoop with + `xpy=np` vs `xpy=cupy` on the same packed data (nearest + cubic), asserts `max|diff| < 1e-8`. + Auto-SKIPS without a GPU. + +## To validate on a GPU host (RESUME HERE) +``` +source ~/setup_generic_env_vars.sh # sets SINGULARITY_RIFT_IMAGE, RIFT_REQUIRE_GPUS, + # LIGO_ACCOUNTING, LIGO_USER_NAME +cd # .../Code/demo/rift/slowrot_gpu_validate +make submit # tars the branch code + condor_submit gpu_test.sub +# ... wait for the 1 GPU job ... +make show # prints gpu_test.out +``` +**PASS criteria** (`gpu_test.out`): the cupy/device line prints (real GPU + CUDA), then +`(GPU) rotation NoLoop xpy=cupy vs xpy=np, interp=nearest/cubic : max|diff| = <~1e-9`, and the CPU +`ALL SLOWROT NOLOOP CHECKS PASSED`. + +## How it runs +- `gpu_test.sub`: `+SingularityImage=$ENV(SINGULARITY_RIFT_IMAGE)` (cvmfs RIFT container, has + cupy/CUDA), `request_gpus=1`, `Requirements = HAS_SINGULARITY && $ENV(RIFT_REQUIRE_GPUS)` + (the pool's capability band, default `(Capability>=6.0)&&(<=9.0)` = CUDA-11.8 container band), + accounting from `$ENV`. Transfers `rift_code.tar.gz` (the branch — production image predates it). +- `run_gpu_test.sh`: unpacks the tarball → `RIFT_CODE`, PYTHONPATH, prints GPU info, runs + `test_slowrot_gpu.py` + the CPU sanity test. + +## Likely gotchas / if it fails +- **cupy import fails** in the container → the image's CUDA doesn't match the matched GPU. Tighten + `RIFT_REQUIRE_GPUS` to the container's band (see setup_generic_env_vars.sh comments re Blackwell / + cc≤9.0 for the CUDA-11.8 image), or use a CUDA-12.8 image if matching Blackwell. +- **`optimized_gpu_tools.simps` / `Q_inner_product_cubic_cupy` errors** → those are the reused + baseline GPU kernels; a failure there is a real bug in the GPU rotation path — check dtypes + (`ifirst` int32, `frac` float64, Q contiguous complex128) in + `factored_likelihood_with_rotation.py` term1 GPU branch. +- **NUMBA_CACHE_DIR**: set to `.` (job scratch) via the sub's `environment`. + +## End-to-end GPU ILE — DONE (2026-07-09, commit 4c7ea1c2) +**Re-run any time with `make e2e`** (runs `run_e2e_consistency.sh` in the container on this host's GPU): +runs the real `integrate_likelihood_extrinsic_batchmode` four ways — {rotation,finite}×{cpu,gpu} — and +asserts CPU-vs-GPU marginalized lnL agree within `TOL_SIGMA` (4) × sampler error. **SELF-CONTAINED by +default**: `make_e2e_inputs.py` generates a throwaway H1/L1 IMRPhenomD BNS injection (frames/PSD/grid + +case.json, 40-km arm) — no paper-repo dependency. Set `RIFT_E2E_CASE=/path/to/case` to reuse an existing ILE +case dir instead. Args built by `e2e_mkargs.py`. Verified PASS on the A100 (self-contained: rotation +ΔlnL=0.039, freqresponse ΔlnL=0.005, both < 4σ; also on the finite-size CE-ET SNR30 inputs: ΔlnL 0.085/0.005 +at n_eff~130k, GPU 2–4× faster). Three more bugs the unit tests could NOT catch (only the real GPU ILE did): +- **AV sampler `prior_prod`** fed the host CPU sample copy to mcsamplerGPU prior helpers that default + `xpy=cupy` → `cupy.sin(numpy)` raised "Unsupported type numpy.ndarray" (broke ANY AV run in a cupy + container). Fixed: pass `xpy=numpy` to helpers that accept it. +- **`mcsamplerGPU.cupy_pi`** was a device scalar (`cupy.array(pi)`) → `numpy_array/cupy_pi` re-dispatched to + cupy → same error. Fixed: `cupy_pi = np.pi`. +- **freqresponse GPU port** missed the `_h()` host-copy of `P_vec` extrinsic params; under `--gpu` the ILE + hands RA/DEC/incl as cupy arrays and `np.atleast_1d(cupy)` raised in ComputeYlmsArrayVector. Fixed. +**Invocation note:** `--force-xpy` alone is INERT (opts.gpu stays False unless `--gpu` is *also* passed and +cupy is absent, batchmode ~L520). Use `--gpu` (cupy present) to actually hit the xpy GPU likelihood_function. +Pure-CPU baseline (no `--gpu`) FAILS in a cupy container ("Unsupported dtype float128", old CPU-vectorized +path) — pre-existing, unrelated; use `--gpu` in a cupy container. + +## Remaining follow-ups +- A dedicated benchmark container (see `~/rift_cit_build_container_family/` build kit in memory) for a + self-contained end-user image; and a `create_event_parameter_pipeline`-based standard-pipeline example + (like `.travis/ILE-GPU-Paper`). +- The finite-size sky-loc DAG (`~/RIFT_roboto_paper/analyses/slowrot_finite-size/3g/`) is a + SEPARATE, already-launched run; `make post` there builds the figure once it finishes. diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/Makefile b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/Makefile new file mode 100644 index 000000000..ab3275116 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/Makefile @@ -0,0 +1,57 @@ +# Validate the GPU (xpy=cupy) rotation + freqresponse likelihood on a real GPU. +# make local-gpu run the LIKELIHOOD parity unit tests in the container on THIS host's GPU <-- fastest +# make e2e run the REAL ILE CPU-vs-GPU consistency check (rotation + freqresponse) <-- end-to-end +# make submit tar the branch code + condor_submit gpu_test.sub (source your env first!) +# make local run the wrapper locally (validates plumbing; SKIPS the GPU test if no cupy) +# make show print the job stdout once it finishes +# make clean +# Condor path requires (from ~/setup_generic_env_vars.sh, sourced): SINGULARITY_RIFT_IMAGE, +# RIFT_REQUIRE_GPUS, LIGO_ACCOUNTING, LIGO_USER_NAME. +RIFT_CODE ?= /home/richard.oshaughnessy/RIFT_slowrot/MonteCarloMarginalizeCode/Code +PYTHON ?= python3 +CODE_PARENT := $(dir $(RIFT_CODE:/=)) +# Direct-on-this-host GPU run (no Condor). Point at a cc-matched CUDA-11.8 RIFT image; the +# A100/V100/Turing/Pascal boxes are all cc 6.0-9.0. (setup_generic_env_vars.sh no longer exports +# SINGULARITY_RIFT_IMAGE, so `make submit` needs it set by hand; `make local-gpu` does not.) +RIFT_LOCAL_IMAGE ?= /home/richard.oshaughnessy/rift_cit_build_container_family/built_containers/rift_o4d-calmarg_in_loop_cc60-90_cuda118_20260615b.sif + +# End-to-end ILE consistency case. EMPTY = self-contained (make_e2e_inputs.py generates a throwaway +# 2-detector injection). Override to reuse an existing ILE case dir: `make e2e RIFT_E2E_CASE=/path/to/case`. +RIFT_E2E_CASE ?= + +.PHONY: tarball submit local local-gpu e2e show clean +tarball: + # write OUTSIDE the Code tree (tar can't read a file it is growing), then move it in; + # exclude this kit's own scratch so a stale tarball is never re-packed. + tar --exclude='*/slowrot_gpu_validate/rift_code.tar.gz' --exclude='*/slowrot_gpu_validate/_code' \ + --exclude='*/slowrot_gpu_validate/gpu_test.*' \ + -czf $(CODE_PARENT)/.rift_code.tar.gz -C $(CODE_PARENT) $(notdir $(RIFT_CODE:/=)) + mv $(CODE_PARENT)/.rift_code.tar.gz rift_code.tar.gz + @echo "tarball: $$(du -h rift_code.tar.gz | cut -f1)" + +submit: tarball + @test -n "$$SINGULARITY_RIFT_IMAGE" || (echo 'ERROR: source setup_generic_env_vars.sh first (SINGULARITY_RIFT_IMAGE unset)'; exit 1) + condor_submit gpu_test.sub + +local: + RIFT_CODE=$(RIFT_CODE) PYTHON=$(PYTHON) ./run_gpu_test.sh + +local-gpu: + @test -f "$(RIFT_LOCAL_IMAGE)" || (echo "ERROR: RIFT_LOCAL_IMAGE not found: $(RIFT_LOCAL_IMAGE)"; exit 1) + @nvidia-smi -L | head -1 + CUDA_VISIBLE_DEVICES=$${CUDA_VISIBLE_DEVICES:-0} singularity exec --nv "$(RIFT_LOCAL_IMAGE)" \ + bash -c 'export RIFT_CODE=$(RIFT_CODE); export NUMBA_CACHE_DIR=$$(mktemp -d); RIFT_CODE=$(RIFT_CODE) PYTHON=$(PYTHON) ./run_gpu_test.sh' 2>&1 | grep -v 'underlay of' + +# Real ILE CPU-vs-GPU consistency: runs {rotation,finite} x {cpu,gpu} on RIFT_E2E_CASE and asserts +# the marginalized lnL agrees to sampler noise. Runs in the container on THIS host's GPU. +e2e: + @test -f "$(RIFT_LOCAL_IMAGE)" || (echo "ERROR: RIFT_LOCAL_IMAGE not found: $(RIFT_LOCAL_IMAGE)"; exit 1) + @nvidia-smi -L | head -1 + CUDA_VISIBLE_DEVICES=$${CUDA_VISIBLE_DEVICES:-0} singularity exec --nv "$(RIFT_LOCAL_IMAGE)" \ + bash -c 'RIFT_CODE=$(RIFT_CODE) PYTHON=$(PYTHON) RIFT_E2E_CASE=$(RIFT_E2E_CASE) ./run_e2e_consistency.sh' 2>&1 | grep -v 'underlay of' + +show: + @echo '--- gpu_test.out ---'; cat gpu_test.out 2>/dev/null; echo '--- gpu_test.err (tail) ---'; tail -20 gpu_test.err 2>/dev/null + +clean: + rm -rf _code rift_code.tar.gz gpu_test.out gpu_test.err gpu_test.log diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/e2e_mkargs.py b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/e2e_mkargs.py new file mode 100644 index 000000000..cf218916c --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/e2e_mkargs.py @@ -0,0 +1,45 @@ +""" +e2e_mkargs.py -- build integrate_likelihood_extrinsic_batchmode arg lists for the GPU<->CPU +end-to-end consistency check, from a finite-size ILE case.json (frames/PSD/grid + ile_common). + +Usage: python3 e2e_mkargs.py + mode in {baseline_cpu, baseline_gpu, rotation_cpu, rotation_gpu, finite_cpu, finite_gpu} + *_gpu -> add --gpu (hits the xpy/cupy likelihood_function; needs cupy + a GPU) + rotation* -> add --rotation-slow (Path A/B) [mutually exclusive with finite*] + finite* -> add case.json ile_finite_extra (Path D --freqresponse) + +Env overrides (for a quick check): E2E_NEFF (default 200), E2E_NMAX (600000), E2E_NCHUNK (20000), +E2E_SEED (1234). --force-xpy is stripped from ile_common: it is INERT for branch selection +(opts.gpu stays False unless --gpu is ALSO passed and cupy is absent) -- you must pass --gpu to +actually exercise the GPU code path. +""" +import json, os, sys + +c = json.load(open('case.json')) +common = list(c['ile_common']) +fin = list(c.get('ile_finite_extra', [])) +while '--force-xpy' in common: # inert for branch selection; --gpu is what matters + common.remove('--force-xpy') + +def override(args, k, v): + if k in args: + args[args.index(k) + 1] = v + else: + args += [k, v] + +for k, env, dflt in [('--n-eff', 'E2E_NEFF', '200'), + ('--n-max', 'E2E_NMAX', '600000'), + ('--n-chunk', 'E2E_NCHUNK', '20000')]: + override(common, k, os.environ.get(env, dflt)) +common += ['--seed', os.environ.get('E2E_SEED', '1234'), '--internal-hard-fail-on-error'] + +mode, out = sys.argv[1], sys.argv[2] +args = list(common) +if 'finite' in mode: + args += fin +if 'rotation' in mode: + args += ['--rotation-slow', '--rotation-n-harmonics', '2', '--rotation-p-max', '1'] +if 'gpu' in mode: + args += ['--gpu'] +args += ['--output-file', out] +print(' '.join(args)) diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/gpu_test.sub b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/gpu_test.sub new file mode 100644 index 000000000..4d7449781 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/gpu_test.sub @@ -0,0 +1,26 @@ +# One GPU job to validate the xpy=cupy rotation likelihood (test_slowrot_gpu.py) on real +# hardware, inside the RIFT container, with the rift_slowrot code transferred. +universe = vanilla +executable = run_gpu_test.sh + ++SingularityImage = "$ENV(SINGULARITY_RIFT_IMAGE)" +# HAS_SINGULARITY + the pool's GPU-capability band (from setup_generic_env_vars.sh). +Requirements = (HAS_SINGULARITY =?= True) && $ENV(RIFT_REQUIRE_GPUS) + +request_gpus = 1 +request_cpus = 1 +request_memory = 4096 +request_disk = 4096 + +should_transfer_files = YES +when_to_transfer_output = ON_EXIT +transfer_input_files = rift_code.tar.gz +environment = "NUMBA_CACHE_DIR=. PYTHON=python3" + +accounting_group = $ENV(LIGO_ACCOUNTING) +accounting_group_user = $ENV(LIGO_USER_NAME) + +output = gpu_test.out +error = gpu_test.err +log = gpu_test.log +queue diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/make_e2e_inputs.py b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/make_e2e_inputs.py new file mode 100755 index 000000000..01380d42e --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/make_e2e_inputs.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python +""" +make_e2e_inputs.py -- generate a SELF-CONTAINED 2-detector injection (frames + PSD + grid + case.json) +so run_e2e_consistency.sh has NO external (paper-repo) dependency. Pure RIFT + LAL. + +Writes into (argv[1], default ./e2e_case): + -FAKE--.gwf frames (H1, L1; IMRPhenomD BNS via lsu.hoft, a fixed-seglen REAL8TimeSeries) + data.cache absolute-path frame cache + -psd.xml.gz analytic aLIGOZeroDetHighPower PSDs + grid.xml.gz small (mc, delta_mc) intrinsic grid (util_ManualOverlapGrid) + case.json ile_common + ile_finite_extra (40 km arm -> nontrivial finite-size response) + +This is a GPU<->CPU PARITY fixture, not a physics demo: the injection just needs to be some real +2-detector signal that both the rotation and freqresponse likelihoods can evaluate; parity holds +regardless of whether the data literally carries the modeled effect. +""" +from __future__ import print_function, division +import os, sys, json, subprocess +import numpy as np +import lal +import lal.series +import lalsimulation as lalsim +import RIFT.lalsimutils as lsu + +EVENT_TIME = 1000000000.0 +FMIN, FMAX, SEGLEN, SRATE = 50., 1024., 32., 2048. +M1, M2 = 1.6, 1.4 # Msun +RA, DEC, PSI, INCL, PHIREF = 1.2, 0.3, 0.5, 0.4, 0.0 +DIST_MPC = 300. +DETS = ["H1", "L1"] +ARM = 40000.0 # 40-km arm -> direction-dependent finite-size response +QMAX = 6 +PSDFUNC = lalsim.SimNoisePSDaLIGOZeroDetHighPower + + +def base_params(det): + P = lsu.ChooseWaveformParams( + m1=M1 * lal.MSUN_SI, m2=M2 * lal.MSUN_SI, fmin=FMIN, radec=True, + theta=DEC, phi=RA, psi=PSI, incl=INCL, phiref=PHIREF, detector=det, + dist=DIST_MPC * 1e6 * lal.PC_SI, deltaT=1. / SRATE, tref=EVENT_TIME, + deltaF=1. / SEGLEN) + P.approx = lalsim.GetApproximantFromString("IMRPhenomD") + P.taper = lsu.lsu_TAPER_START + return P + + +def write_psd_xml(det, deltaF, fmax, path): + n = int(fmax / deltaF) + 1 + s = lal.CreateREAL8FrequencySeries(det, lal.LIGOTimeGPS(0), 0., deltaF, lal.SecondUnit, n) + f = np.arange(n) * deltaF + vals = np.array([PSDFUNC(max(fi, 1.0)) for fi in f]) + vals[~np.isfinite(vals)] = 0.0 + s.data.data[:] = vals + xmldoc = lal.series.make_psd_xmldoc({det: s}) + from ligo.lw import utils as ligolw_utils + ligolw_utils.write_filename(xmldoc, path, compress="gz") + + +def main(outdir): + os.makedirs(outdir, exist_ok=True) + deltaF = 1. / SEGLEN + + # --- frames (fixed-seglen detector strain via lsu.hoft; deltaF set -> zero-padded to seglen*srate) --- + frame_paths = []; t0 = None; dur = None + for det in DETS: + ht = lsu.hoft(base_params(det)) # REAL8TimeSeries, length = SEGLEN*SRATE + t0 = float(ht.epoch); dur = ht.data.length * ht.deltaT + fname = os.path.join(outdir, "%s-FAKE-%d-%d.gwf" % (det, int(np.floor(t0)), int(np.ceil(dur)))) + lsu.hoft_to_frame_data(fname, det + ":FAKE-STRAIN", ht) + frame_paths.append(os.path.abspath(fname)) + with open(os.path.join(outdir, "data.cache"), "w") as fc: + for det, p in zip(DETS, frame_paths): + base = os.path.basename(p)[:-4]; parts = base.split("-") + fc.write("%s %s %s %s file://localhost%s\n" % (parts[0], "-".join(parts[1:-2]), parts[-2], parts[-1], p)) + + # --- PSD xmls --- + psd_paths = {} + for det in DETS: + p = os.path.join(outdir, "%s-psd.xml.gz" % det) + write_psd_xml(det, deltaF, FMAX, p) + psd_paths[det] = p + + # --- intrinsic (mc, delta_mc) grid via util_ManualOverlapGrid (invoke with THIS interpreter) --- + mc0 = lsu.mchirp(M1, M2) + grid_base = os.path.join(outdir, "grid") + tool = os.path.join(os.environ.get('RIFT_CODE', ''), 'bin', 'util_ManualOverlapGrid.py') + if not os.path.isfile(tool): + tool = os.path.join(os.path.dirname(os.path.dirname(lsu.__file__)), 'bin', 'util_ManualOverlapGrid.py') + subprocess.check_call([sys.executable, tool, + "--parameter", "mc", "--parameter-range", "[%f,%f]" % (mc0 * 0.98, mc0 * 1.02), + "--parameter", "delta_mc", "--parameter-range", "[0.0,0.25]", + "--grid-cartesian", "--grid-cartesian-npts", "1", + "--skip-overlap", "--fname", grid_base]) + grid_xml = grid_base + ".xml.gz" + assert os.path.isfile(grid_xml) + + # --- data times: pad in from the segment edges; keep event_time strictly inside --- + data_start = int(np.floor(t0)) + 2 + data_end = int(np.ceil(t0 + dur)) - 2 + assert data_start < EVENT_TIME < data_end, \ + "event_time %.1f not inside [%d,%d] (epoch=%.3f dur=%.3f)" % (EVENT_TIME, data_start, data_end, t0, dur) + + # --- box limits centered on the injected truth (so the AV sampler resolves the peak) --- + box = dict(ra=[RA - 0.8, RA + 0.8], dec=[DEC - 0.8, DEC + 0.8], + incl=[max(0.0, INCL - 0.7), INCL + 0.7], psi=[max(0.0, PSI - 0.6), PSI + 0.6], + dist=[DIST_MPC * 0.3, DIST_MPC * 3.0]) + + ile_common = ["--sim-xml", os.path.basename(grid_xml), "--cache-file", "data.cache", + "--event-time", repr(EVENT_TIME), + "--data-start-time", str(data_start), "--data-end-time", str(data_end), + "--fmin-template", str(FMIN), "--fmax", str(FMAX), + "--approximant", "IMRPhenomD", "--l-max", "2", "--srate", str(int(SRATE)), + "--vectorized", "--internal-use-lnL", "--time-marginalization", + "--sampler-method", "AV", "--n-eff", "300", "--n-max", "800000", "--n-chunk", "20000", + "--d-min", "%.4f" % box['dist'][0], "--d-max", "%.4f" % box['dist'][1], + "--limit-right-ascension", "%.6f,%.6f" % tuple(box['ra']), + "--limit-declination", "%.6f,%.6f" % tuple(box['dec']), + "--limit-inclination", "%.6f,%.6f" % tuple(box['incl']), + "--limit-psi", "%.6f,%.6f" % tuple(box['psi']), + "--fairdraw-extrinsic-output", "--fairdraw-extrinsic-output-n-max", "200", "--save-samples"] + for det in DETS: + ile_common += ["--channel-name", "%s=FAKE-STRAIN" % det, + "--psd-file", "%s=%s" % (det, os.path.basename(psd_paths[det]))] + arm_str = ",".join("%s=%.1f" % (det, ARM) for det in DETS) + ile_finite_extra = ["--freqresponse", "--freqresponse-qmax", str(QMAX), "--freqresponse-arm-length", arm_str] + + info = dict(network="H1L1", event_time=EVENT_TIME, fmin=FMIN, fmax=FMAX, seglen=SEGLEN, + m1=M1, m2=M2, data_start=data_start, data_end=data_end, arm={d: ARM for d in DETS}, + grid=os.path.basename(grid_xml), box=box, + frames=[os.path.basename(p) for p in frame_paths], + ile_common=ile_common, ile_finite_extra=ile_finite_extra) + with open(os.path.join(outdir, "case.json"), "w") as fj: + json.dump(info, fj, indent=2) + print("built self-contained e2e case in %s (H1L1 BNS %.1f+%.1f, data [%d,%d])" + % (outdir, M1, M2, data_start, data_end)) + + +if __name__ == "__main__": + main(sys.argv[1] if len(sys.argv) > 1 else "e2e_case") diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/run_e2e_consistency.sh b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/run_e2e_consistency.sh new file mode 100755 index 000000000..19886d514 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/run_e2e_consistency.sh @@ -0,0 +1,94 @@ +#!/bin/bash +# run_e2e_consistency.sh -- GPU<->CPU END-TO-END parity for the slowrot ILE (rotation + freqresponse). +# +# Runs the REAL integrate_likelihood_extrinsic_batchmode on a finite-size ILE case (frames/PSD/grid + +# case.json) four ways -- {rotation,finite} x {cpu,gpu} -- and asserts the marginalized lnL (evidence, +# .dat col $(NF-3)) agrees between CPU and GPU within TOL_SIGMA * combined sampler error. This is the +# end-to-end complement to test_slowrot_gpu.py / test_slowrot_freqresponse_gpu.py (which prove the +# likelihood is bit-identical; this proves the WHOLE ILE -- precompute, device transfer, GPU +# likelihood_function branch, AV sampler -- runs and agrees to sampler noise). Run in a cupy container +# on a GPU host (e.g. `make e2e`). Exit 0 = PASS. +# +# Env: RIFT_CODE (required), PYTHON (python3), RIFT_E2E_CASE (an ILE case dir), TOL_SIGMA (4), +# E2E_NEFF/E2E_NMAX/E2E_NCHUNK/E2E_SEED (see e2e_mkargs.py). +set -u +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +: "${RIFT_CODE:?set RIFT_CODE to the Code dir}" +PYTHON="${PYTHON:-python3}" +TOL_SIGMA="${TOL_SIGMA:-4}" +# By default this is SELF-CONTAINED: it generates its own throwaway 2-detector injection (frames + PSD + +# grid + case.json) via make_e2e_inputs.py. Set RIFT_E2E_CASE to reuse an existing ILE case dir instead +# (frames *.gwf + PSD *-psd.xml.gz + grid.xml.gz + case.json carrying ile_common / ile_finite_extra). +RIFT_E2E_CASE="${RIFT_E2E_CASE:-}" +export PYTHONPATH="$RIFT_CODE:${PYTHONPATH:-}" +export NUMBA_CACHE_DIR="${NUMBA_CACHE_DIR:-$(mktemp -d)}" + +WORK="$(mktemp -d)" +if [ -n "$RIFT_E2E_CASE" ]; then + for f in case.json grid.xml.gz; do + if [ ! -e "$RIFT_E2E_CASE/$f" ]; then + echo "ERROR: RIFT_E2E_CASE is missing '$f': $RIFT_E2E_CASE" + echo " It must hold frames (*.gwf) + PSD (*-psd.xml.gz) + grid.xml.gz + case.json" + echo " (with ile_common / ile_finite_extra). Unset RIFT_E2E_CASE to auto-generate one." + exit 2 + fi + done + echo "=== slowrot GPU<->CPU end-to-end consistency (external case) ===" + echo " case: $RIFT_E2E_CASE" + cp "$RIFT_E2E_CASE"/*.gwf "$RIFT_E2E_CASE"/*.xml.gz "$RIFT_E2E_CASE"/case.json "$WORK"/ 2>/dev/null + # Rebuild data.cache against the LOCAL frame copies (the case's own absolute paths may not resolve here). + ( cd "$WORK" && $PYTHON - <<'PY' +import glob, os +lines = [] +for g in sorted(glob.glob("*.gwf")): + base = g[:-4]; parts = base.split("-") # OBS-desc-START-DUR.gwf + obs = parts[0]; dur = parts[-1]; start = parts[-2]; desc = "-".join(parts[1:-2]) + lines.append("%s %s %s %s file://localhost%s\n" % (obs, desc, start, dur, os.path.abspath(g))) +open("data.cache", "w").writelines(lines) +PY + ) +else + echo "=== slowrot GPU<->CPU end-to-end consistency (self-contained) ===" + echo " generating a throwaway 2-detector injection ..." + $PYTHON "$HERE/make_e2e_inputs.py" "$WORK" || { echo "RESULT: FAILED (input generation)"; exit 1; } +fi +cp "$HERE/e2e_mkargs.py" "$WORK"/ +cd "$WORK" +echo " work: $WORK (n-eff=${E2E_NEFF:-200}, tol=${TOL_SIGMA} sigma)" + +declare -A LNL ERR +FAIL=0 +for mode in rotation_cpu rotation_gpu finite_cpu finite_gpu; do + args="$($PYTHON e2e_mkargs.py "$mode" "ile_$mode")" + t0=$SECONDS + $PYTHON "$RIFT_CODE/bin/integrate_likelihood_extrinsic_batchmode" $args > "run_$mode.log" 2>&1 + ec=$?; dt=$((SECONDS - t0)) + if grep -q "FAILED ANALYSIS" "run_$mode.log" || [ $ec -ne 0 ] || [ ! -s "ile_${mode}_0_.dat" ]; then + echo " $mode: FAILED (exit $ec, ${dt}s) -> $(grep -A1 'FAILED ANALYSIS' "run_$mode.log" | tail -1)" + FAIL=1; continue + fi + # .dat trailing columns are always [... lnL sigma_lnL ntotal neff]; lnL=$(NF-3), sigma=$(NF-2). + read -r lnL err < <(awk 'END{print $(NF-3), $(NF-2)}' "ile_${mode}_0_.dat") + LNL[$mode]="$lnL"; ERR[$mode]="$err" + echo " $mode: OK (${dt}s) lnL=$lnL sigma=$err" +done +[ $FAIL -ne 0 ] && { echo "RESULT: FAILED (a mode crashed) -- see $WORK/run_*.log"; exit 1; } + +$PYTHON - "$TOL_SIGMA" \ + "${LNL[rotation_cpu]}" "${ERR[rotation_cpu]}" "${LNL[rotation_gpu]}" "${ERR[rotation_gpu]}" \ + "${LNL[finite_cpu]}" "${ERR[finite_cpu]}" "${LNL[finite_gpu]}" "${ERR[finite_gpu]}" <<'PY' +import sys, math +tol = float(sys.argv[1]) +rc, rce, rg, rge, fc, fce, fg, fge = map(float, sys.argv[2:10]) +ok = True +print("--- parity (marginalized lnL, GPU vs CPU) ---") +for name, (cpu, ecpu, gpu, egpu) in [("rotation", (rc, rce, rg, rge)), + ("freqresponse", (fc, fce, fg, fge))]: + d = abs(gpu - cpu); sig = math.hypot(ecpu, egpu); lim = tol * sig + verdict = "PASS" if d <= lim else "FAIL" + ok = ok and verdict == "PASS" + print(" %-13s CPU=%+8.4f GPU=%+8.4f |d|=%.4f <= %g*sigma=%.4f ? %s" + % (name, cpu, gpu, d, tol, lim, verdict)) +print("RESULT:", "PASS -- GPU matches CPU within sampler noise" if ok else "FAIL") +sys.exit(0 if ok else 1) +PY diff --git a/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/run_gpu_test.sh b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/run_gpu_test.sh new file mode 100755 index 000000000..f9c24bf95 --- /dev/null +++ b/MonteCarloMarginalizeCode/Code/demo/rift/slowrot_gpu_validate/run_gpu_test.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# run_gpu_test.sh -- validate the GPU (xpy=cupy) rotation likelihood on a real GPU. +# Runs inside the RIFT singularity container (has cupy/CUDA); the modified rift_slowrot code +# is transferred as rift_code.tar.gz (the production image predates this branch). +set -e +PYTHON="${PYTHON:-python3}" +if [ -f rift_code.tar.gz ]; then + mkdir -p _code && tar xzf rift_code.tar.gz -C _code + export RIFT_CODE="$PWD/_code/Code" +fi +: "${RIFT_CODE:?set RIFT_CODE or provide rift_code.tar.gz}" +export PYTHONPATH="$RIFT_CODE:$PYTHONPATH" +export NUMBA_CACHE_DIR="${NUMBA_CACHE_DIR:-$PWD}" + +echo "=== node / GPU / cupy ===" +hostname +$PYTHON - <<'PY' || echo "!!! cupy import FAILED (no GPU visible or CUDA/container mismatch)" +import cupy +p = cupy.cuda.runtime.getDeviceProperties(0) +print("cupy", cupy.__version__, "| device", p['name'].decode(), + "| ccap %d.%d" % (p['major'], p['minor']), + "| CUDA runtime", cupy.cuda.runtime.runtimeGetVersion()) +PY + +echo "=== GPU<->CPU rotation consistency (Path A/B, the actual validation) ===" +$PYTHON "$RIFT_CODE/RIFT/likelihood/test_slowrot_gpu.py" + +echo "=== GPU<->CPU freqresponse consistency (Path D finite-size, same fused-kernel port) ===" +$PYTHON "$RIFT_CODE/RIFT/likelihood/test_slowrot_freqresponse_gpu.py" + +echo "=== CPU rotation sanity (baseline, must still pass) ===" +$PYTHON "$RIFT_CODE/RIFT/likelihood/test_slowrot_noloop.py" 2>&1 | grep -E 'PASSED|Assert|\(B\)' +echo "=== done ===" diff --git a/MonteCarloMarginalizeCode/Code/test/test_like_and_samp.py b/MonteCarloMarginalizeCode/Code/test/test_like_and_samp.py index d8eb68095..dcb7fc168 100755 --- a/MonteCarloMarginalizeCode/Code/test/test_like_and_samp.py +++ b/MonteCarloMarginalizeCode/Code/test/test_like_and_samp.py @@ -97,10 +97,10 @@ import lalsimulation as lalsim import lalsimutils try: - hasNR=True - import NRWaveformCatalogManager3 as nrwf -except: - hasNR=False + from RIFT.physics._nrwf_loader import get_nrwf as _rift_get_nrwf + nrwf, hasNR = _rift_get_nrwf() +except ImportError: + nrwf = None; hasNR = False print(" - no NR waveforms -") try: hasEOB=True diff --git a/pixi.toml b/pixi.toml index ed59bdf85..c34de766c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -85,6 +85,7 @@ PATH = "$PIXI_PROJECT_ROOT/MonteCarloMarginalizeCode/Code/bin:$PATH" install-rift = "python -m pip install --no-deps --editable ." swig-version = "swig -version" import-check = "python .travis/test-all-mod.py" +dependency-compat-check = "python .travis/test-dependency-compat.py" help-check = "bash .travis/test-all-bin.sh" sim-manager-check = "python MonteCarloMarginalizeCode/Code/test/test_simulation_manager.py -v" test-likelihood = "python MonteCarloMarginalizeCode/Code/test/test_likelihood.py"