diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..fada210 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,24 @@ +{ + "name": "gwmemory 3.14", + "image": "mcr.microsoft.com/devcontainers/python:3.14-trixie", + "features": { + "ghcr.io/devcontainers/features/node:1": { + "version": "latest" + } + }, + "customizations": { + "vscode": { + "settings": { + "python.defaultInterpreterPath": "/usr/local/bin/python", + "python.linting.enabled": true + }, + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "ms-python.vscode-python-envs", + "GitHub.copilot" + ] + } + }, + "postCreateCommand": "sudo apt-get update && sudo apt-get install -y libgsl-dev && pip install uv" +} \ No newline at end of file diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 072bfb4..3aa2d5d 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -18,31 +18,25 @@ jobs: concurrency: group: ${{ github.workflow }}-${{ github.ref }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Fetch all history for all tags and branches run: git fetch --prune --unshallow - - uses: s-weigand/setup-conda@v1 + - uses: astral-sh/setup-uv@v8.1.0 + - name: Setup Python + uses: actions/setup-python@v6 with: - python-version: 3.11 + python-version: 3.13 - name: Install dependencies run: | sudo apt-get update - sudo apt-get install texlive-latex-extra texlive-fonts-recommended dvipng cm-super - conda install pip setuptools - conda install -c conda-forge --file requirements.txt --file optional_requirements.txt --file pages_requirements.txt - python -m pip install nrsur7dq2 - - name: Install gwmemory - run: | - pip install . - - name: List installed - run: | - conda list + sudo apt-get install texlive-latex-extra texlive-fonts-recommended dvipng cm-super pandoc libgsl-dev + uv sync --extra full --extra docs - name: Run notebook run: | cd examples - jupyter nbconvert --to html --execute *.ipynb + uv run jupyter nbconvert --to html --execute *.ipynb cd .. - name: Build documentation @@ -51,15 +45,15 @@ jobs: mv Makefile.gh_pages Makefile cp ../examples/GWMemory.ipynb ./example.ipynb cp ../examples/Comparison.ipynb ./comparison.ipynb - make clean - make html + uv run make clean + uv run make html cd ../ touch _gh-pages/latest/html/.nojekyll rm -r docs mv _gh-pages/latest/html docs - name: Deploy - uses: peaceiris/actions-gh-pages@v3 + uses: peaceiris/actions-gh-pages@v4 if: ${{ github.ref == 'refs/heads/master' }} with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml index 97c1866..0beb31d 100644 --- a/.github/workflows/pypi.yml +++ b/.github/workflows/pypi.yml @@ -14,13 +14,13 @@ jobs: name: Build and publish Python 🐍 distributions 📦 to PyPI and TestPyPI runs-on: ubuntu-20.04 steps: - - uses: actions/checkout@master + - uses: actions/checkout@v6 - name: Fetch all history for all tags and branches run: git fetch --prune --unshallow - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - name: Set up Python 3.13 + uses: actions/setup-python@v6 with: - python-version: 3.9 + python-version: 3.13 - name: Install pypa/build run: >- python -m pip install build setuptools_scm --user @@ -28,12 +28,12 @@ jobs: run: >- python -m build --sdist --wheel --outdir dist/ . # - name: Publish distribution 📦 to Test PyPI -# uses: pypa/gh-action-pypi-publish@master +# uses: pypa/gh-action-pypi-publish@v1 # with: # password: ${{ secrets.TEST_PYPI_API_TOKEN }} # repository_url: https://test.pypi.org/legacy/ - name: Publish distribution 📦 to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@v1 with: password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.github/workflows/unit_test.yml b/.github/workflows/unit_test.yml index 7aea908..61033e0 100644 --- a/.github/workflows/unit_test.yml +++ b/.github/workflows/unit_test.yml @@ -24,41 +24,32 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.8", "3.9", "3.10"] + python-version: ["3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v2 - - name: Setup conda - uses: s-weigand/setup-conda@v1 + - uses: actions/checkout@v6 + - uses: astral-sh/setup-uv@v8.1.0 + - name: Setup Python + uses: actions/setup-python@v6 with: - update-conda: true python-version: ${{ matrix.python-version }} - conda-channels: anaconda, conda-forge - name: Install dependencies run: | - conda install pip setuptools - conda install flake8 pytest-cov - conda install -c conda-forge --file requirements.txt --file optional_requirements.txt - python -m pip install nrsur7dq2 - - name: Install gwmemory - run: | - pip install . - - name: List installed - run: | - conda list + sudo apt-get install libgsl-dev + uv sync --extra full - name: Test with pytest run: | - pytest --cov gwmemory -ra --color yes --cov-report=xml --junitxml=pytest.xml + uv run pytest --cov gwmemory -ra --color yes --cov-report=xml --junitxml=pytest.xml - name: Publish coverage to Codecov - uses: codecov/codecov-action@v1.2.1 + uses: codecov/codecov-action@v6 with: files: coverage.xml flags: python${{ matrix.python-version }} - name: Coverage report - run: python -m coverage report --show-missing + run: uv run python -m coverage report --show-missing - name: Upload test results if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v7 with: name: pytest-${{ matrix.python-version }} path: pytest.xml diff --git a/.gitignore b/.gitignore index 8113911..85c8e6b 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ wheels/ .installed.cfg *.egg MANIFEST +uv.lock # PyInstaller # Usually these files are written by a python script from a template diff --git a/README.md b/README.md index ff06fad..ee18ce2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ `GWMemory` is available via `conda-forge` and `pypi` ```console +$ uv pip install gwmemory $ conda install -c conda-forge gwmemory $ python -m pip install gwmemory ``` @@ -20,9 +21,11 @@ You can also install from this repository in the usual way: ```console $ git clone https://github.com/ColmTalbot/gwmemory.git $ cd gwmemory -$ python -m pip install . +$ uv sync ``` +Use `uv sync --extra full` for the optional runtime and test dependencies, and `uv sync --extra full --extra docs` when building the documentation stack. + ## Examples Demonstrations of how to calculate memory waveforms can be found in the [documentation](https://colmtalbot.github.io/gwmemory/) and in the examples directory. diff --git a/gwmemory/angles.py b/gwmemory/angles.py index 89fca44..adb274e 100644 --- a/gwmemory/angles.py +++ b/gwmemory/angles.py @@ -76,92 +76,6 @@ def analytic_gamma(lm1: Tuple[int, int], lm2: Tuple[int, int], ell: int) -> floa ) -def gamma( - lm1: str, - lm2: str, - incs: np.ndarray = None, - theta: np.ndarray = None, - phi: np.ndarray = None, - y_lmlm_factor: np.ndarray = None, -) -> list: - """ - Coefficients mapping the spherical harmonic components of the oscillatory - strain to the memory. - - Computed according to equation 8 of Talbot et al. (2018), arXiv:1807.00990. - Output modes with l=range(2, 20), m=m1-m2. - - Parameters - ---------- - lm1: str - first input spherical harmonic mode - lm2: str - second input spherical haromonic mode - incs: array, optional - observer inclination values over which to compute the final integral - theta: array, optional - 1d array of binary inclination values, over which to compute first - integral - phi: array, optional - 1d array of binary polarisation values, over which to compute the first - integral - y_lmlm_factor: array, optional - Array over of spherical harmonic factor evaluated on meshgrid of theta, - phi - - Returns - ------- - gammas: list - List of coefficients for output modes, l=range(2, 20), m=m1-m2 - - Notes - ----- - I recommend using :code:`analytic_gamma` instead, it is much more precise. - """ - l1, m1 = int(lm1[0]), int(lm1[1:]) - l2, m2 = int(lm2[0]), int(lm2[1:]) - - if incs is None: - incs = np.linspace(0, np.pi, 500) - phase = 0 - - if y_lmlm_factor is None: - y_lmlm_factor, theta, phi = ylmlm_factor(theta=theta, phi=phi, lm1=lm1, lm2=lm2) - - lambda_lm1_lm2 = np.array( - [lambda_lmlm(inc, phase, lm1, lm2, theta, phi, y_lmlm_factor) for inc in incs] - ) - - sin_inc = -np.sin(incs) - - harm = {} - for l, m in harmonics.lmax_modes(20): - harm[f"{l}{m}"] = harmonics.sYlm(-2, l, m, incs, phase) - - ells = np.arange(2, 21, 1) - - delta_m = m1 - m2 - gammas = [] - for ell in ells: - if ell < abs(delta_m): - gammas.append(0) - else: - gammas.append( - np.real( - 2 - * np.pi - * np.trapz( - lambda_lm1_lm2 - * np.conjugate(harm[f"{ell}{-delta_m}"]) - * sin_inc, - incs, - ) - ) - ) - - return gammas - - def ylmlm_factor(theta: np.ndarray, phi: np.ndarray, lm1: str, lm2: str) -> np.ndarray: if theta is None: theta = np.linspace(0, np.pi, 250) @@ -183,151 +97,6 @@ def ylmlm_factor(theta: np.ndarray, phi: np.ndarray, lm1: str, lm2: str) -> np.n return y_lmlm_factor, theta, phi -def lambda_matrix( - inc: float, - phase: float, - lm1: str, - lm2: str, - theta: np.ndarray = None, - phi: np.ndarray = None, - y_lmlm_factor: np.ndarray = None, -) -> np.ndarray: - r""" - Angular integral for a specific ll'mm' as given by equation 7 of Talbot - et al. (2018), arXiv:1807.00990. - - The transverse traceless part of the integral over all binary orientations - is returned. - - The integral is given by: - \int_{S^{2}} d\Omega' Y^{-2}_{\ell_1 m_1}(\Omega') - \bar{Y}^{-2}_{\ell_2 m_2}(\Omega') \times \\ - \left[\frac{n_jn_k}{1-n_{l}N_{l}} \right]^{TT} - - Parameters - ---------- - inc: float - binary inclination - phase: float - binary phase at coalescence - lm1: str - first lm value format is e.g., '22' - lm2: str - second lm value format is e.g., '22' - theta: array, optional - 1d array of binary inclination values, over which to integrate - phi: array, optional - 1d array of binary polarisation values, over which to integrate - y_lmlm_factor: array, optional - Array over of spherical harmonic factor evaluated on meshgrid of - theta, phi - - Returns - ------- - lambda_mat: array - three by three transverse traceless matrix of the appropriate integral - """ - if y_lmlm_factor is None: - y_lmlm_factor, theta, phi = ylmlm_factor(theta=theta, phi=phi, lm1=lm1, lm2=lm2) - - n = np.array( - [ - np.outer(np.cos(phi), np.sin(theta)), - np.outer(np.sin(phi), np.sin(theta)), - np.outer(np.ones_like(phi), np.cos(theta)), - ] - ) - line_of_sight = np.array( - [np.sin(inc) * np.cos(phase), np.sin(inc) * np.sin(phase), np.cos(inc)] - ) - n_dot_line_of_sight = sum(n_i * N_i for n_i, N_i in zip(n, line_of_sight)) - n_dot_line_of_sight[n_dot_line_of_sight == 1] = 0 - denominator = 1 / (1 - n_dot_line_of_sight) - - sin_array = np.outer(phi**0, np.sin(theta)) - - angle_integrals_r = np.zeros((3, 3)) - angle_integrals_i = np.zeros((3, 3)) - for j in range(3): - for k in range(j + 1): - # projection done here to avoid divergences - integrand = ( - sin_array - * denominator - * y_lmlm_factor - * ( - n[j] * n[k] - - (n[j] * line_of_sight[k] + n[k] * line_of_sight[j]) - * n_dot_line_of_sight - + line_of_sight[j] * line_of_sight[k] * n_dot_line_of_sight**2 - ) - ) - angle_integrals_r[j, k] = np.trapz(np.trapz(np.real(integrand), theta), phi) - angle_integrals_i[j, k] = np.trapz(np.trapz(np.imag(integrand), theta), phi) - angle_integrals_r[k, j] = np.trapz(np.trapz(np.real(integrand), theta), phi) - angle_integrals_i[k, j] = np.trapz(np.trapz(np.imag(integrand), theta), phi) - - proj = np.identity(3) - np.outer(line_of_sight, line_of_sight) - lambda_mat = angle_integrals_r + 1j * angle_integrals_i - lambda_mat -= proj * np.trace(lambda_mat) / 2 - - return lambda_mat - - -def lambda_lmlm( - inc: float, - phase: float, - lm1: str, - lm2: str, - theta: np.ndarray = None, - phi: np.ndarray = None, - y_lmlm_factor: np.ndarray = None, -) -> complex: - r""" - Angular integral for a specific ll'mm' as given by equation 7 of Talbot - et al. (2018), arXiv:1807.00990. - - The transverse traceless part of the integral over all binary orientations - is returned. - - The integral is given by: - \frac{1}{2} \int_{S^{2}} d\Omega' Y^{-2}_{\ell_1 m_1}(\Omega') - \bar{Y}^{-2}_{\ell_2 m_2}(\Omega') \times \\ - \left[\frac{n_jn_k}{1-n_{l}N_{l}} \right]^{TT} (e^{+}_{jk} - - i e^{\times}_{jk}) - - Parameters - ---------- - inc: float - binary inclination - phase: float - binary phase at coalescence - lm1: str - first lm value format is e.g., '22' - lm2: str - second lm value format is e.g., '22' - theta: array, optional - 1d array of binary inclination values, over which to integrate - phi: array, optional - 1d array of binary polarisation values, over which to integrate - y_lmlm_factor: array, optional - Array over of spherical harmonic factor evaluated on meshgrid of - theta, phi - - Returns - ------- - lambda_lmlm: float, complex - lambda_plus - i lambda_cross - """ - lambda_mat = lambda_matrix(inc, phase, lm1, lm2, theta, phi, y_lmlm_factor) - - plus, cross = omega_ij_to_omega_pol(lambda_mat, inc, phase) - - lambda_lmlm = (plus - 1j * cross) / 2 - - return lambda_lmlm - - def omega_ij_to_omega_pol( omega_ij: np.ndarray, inc: float, phase: float ) -> Tuple[np.ndarray, np.ndarray]: diff --git a/gwmemory/utils.py b/gwmemory/utils.py index 9ba3d57..70f14c5 100644 --- a/gwmemory/utils.py +++ b/gwmemory/utils.py @@ -8,7 +8,8 @@ # taken from astropy==5.0.1 CC = 299792458.0 GG = 6.6743e-11 -SOLAR_MASS = 1.988409870698051e30 +# SOLAR_MASS = 1.988409870698051e30 +SOLAR_MASS = 1.9884099021470415e30 KG = 1 / SOLAR_MASS METRE = CC**2 / (GG * SOLAR_MASS) SECOND = CC * METRE diff --git a/gwmemory/waveforms/surrogate.py b/gwmemory/waveforms/surrogate.py index 62e6f22..751976b 100644 --- a/gwmemory/waveforms/surrogate.py +++ b/gwmemory/waveforms/surrogate.py @@ -104,7 +104,7 @@ def __init__( import gwsurrogate except ModuleNotFoundError: print("gwsurrogate is required for the Surrogate memory generator.") - print("$ conda install -c conda-forge gwsurrogate") + print("$ python -m pip install gwsurrogate") raise try: self.surrogate = gwsurrogate.LoadSurrogate(name) diff --git a/optional_requirements.txt b/optional_requirements.txt deleted file mode 100644 index bb79fd2..0000000 --- a/optional_requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -python-lalsimulation -gwsurrogate -sxs -pytest diff --git a/pages_requirements.txt b/pages_requirements.txt deleted file mode 100644 index dc248c0..0000000 --- a/pages_requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -basemap>=1.3.6 -ipykernel -ipython -ipython_genutils -jinja2 -nbsphinx -numpydoc -pandoc -pygments -pytest-cov -sphinx -sphinx_rtd_theme \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 86d8757..2dc6302 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -33,3 +33,27 @@ dependencies = [ dev = [ "numba", ] +full = [ + "gwsurrogate", + "lalsuite", + "nrsur7dq2", + "pytest", + "pytest-cov", + "sxs", +] +docs = [ + "basemap>=1.3.6", + "ipykernel", + "ipython", + "ipython_genutils", + "jinja2", + "nbsphinx", + "numpydoc", + "pandoc", + "pygments", + "sphinx", + "sphinx_rtd_theme", +] + +[tool.uv.extra-build-dependencies] +nrsur7dq2 = ["numpy"] diff --git a/test/angles_test.py b/test/angles_test.py index 7962dd9..bfe2b93 100644 --- a/test/angles_test.py +++ b/test/angles_test.py @@ -1,25 +1,10 @@ import numpy as np -import pytest from sxs.waveforms import WaveformModes from sxs.waveforms.memory import Dinverse from gwmemory import angles -@pytest.mark.parametrize("lm", [(2, 2), (2, -2), (3, 3), (3, 1)]) -def test_numeric_gamma_agrees_with_analytic(lm): - numeric = np.real(angles.gamma("22", f"{lm[0]}{lm[1]}")) - analytic = np.array( - [angles.analytic_gamma((2, 2), lm, ell) for ell in range(2, len(numeric) + 2)] - ) - assert max(abs(numeric - analytic)) < 1e-4 - numeric = np.real(angles.gamma(f"{lm[0]}{lm[1]}", "22")) - analytic = np.array( - [angles.analytic_gamma(lm, (2, 2), ell) for ell in range(2, len(numeric) + 2)] - ) - assert max(abs(numeric - analytic)) < 1e-4 - - def test_memory_factor_matches_sxs(): """ The analytic gamma calculation should agree with the combination of two