diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3d0cb16..2b8ce335 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,16 +67,8 @@ jobs: python-version: ["3.11", "3.13"] runs-on: [ubuntu-latest, macos-latest, windows-latest] - # TODO: figure out OpenBLAS install - # ``ERROR: Dependency "OpenBLAS" not found, tried pkgconfig and cmake`` - # include: - # - python-version: pypy-3.11 - # runs-on: ubuntu-latest - steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v5 @@ -113,8 +105,6 @@ jobs: steps: - uses: actions/checkout@v4 - with: - fetch-depth: 0 - name: Install uv uses: astral-sh/setup-uv@v5 @@ -139,3 +129,31 @@ jobs: uses: codecov/codecov-action@v5.3.1 with: token: ${{ secrets.CODECOV_TOKEN }} + + check_figures: + name: Check Figures + runs-on: ${{ matrix.runs-on }} + needs: [smoke] + strategy: + fail-fast: false + matrix: + python-version: ["3.12"] + runs-on: [ubuntu-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python ${{ matrix.python-version }} + run: uv python install ${{ matrix.python-version }} + + - name: Install the project + run: uv sync --extra all --group test-all + + - name: Test package + run: uv run nox -s test_mpl diff --git a/.gitignore b/.gitignore index 37093200..41961d3b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Project files +/tests/mpl_figure/baseline + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/conftest.py b/conftest.py index d539a261..ec07c071 100644 --- a/conftest.py +++ b/conftest.py @@ -1,21 +1,37 @@ """Doctest configuration.""" +from collections.abc import Callable, Iterable, Sequence from doctest import ELLIPSIS, NORMALIZE_WHITESPACE -from sybil import Sybil -from sybil.parsers.rest import DocTestParser, PythonCodeBlockParser, SkipParser +from sybil import Document, Region, Sybil +from sybil.parsers import myst, rest +from sybil.sybil import SybilCollection from optional_dependencies import OptionalDependencyEnum, auto from optional_dependencies.utils import chain_checks, get_version, is_installed -pytest_collect_file = Sybil( +optionflags = ELLIPSIS | NORMALIZE_WHITESPACE + +parsers: Sequence[Callable[[Document], Iterable[Region]]] = [ + myst.DocTestDirectiveParser(optionflags=optionflags), + myst.PythonCodeBlockParser(doctest_optionflags=optionflags), + myst.SkipParser(), +] + +readme = Sybil(parsers=parsers, patterns=["*.md"]) +docs = Sybil( parsers=[ - DocTestParser(optionflags=NORMALIZE_WHITESPACE | ELLIPSIS), - PythonCodeBlockParser(), - SkipParser(), + rest.DocTestParser(optionflags=NORMALIZE_WHITESPACE | ELLIPSIS), + rest.PythonCodeBlockParser(), + rest.SkipParser(), ], - patterns=["*.rst", "*.py"], -).pytest() + patterns=["*.rst"], +) +python = Sybil( + parsers=[*parsers, rest.DocTestParser(optionflags=optionflags)], patterns=["*.py"] +) + +pytest_collect_file = SybilCollection((readme, docs, python)).pytest() class OptDeps(OptionalDependencyEnum): diff --git a/noxfile.py b/noxfile.py index d532569c..0d136282 100644 --- a/noxfile.py +++ b/noxfile.py @@ -13,6 +13,9 @@ nox.options.sessions = ["lint", "tests", "doctests"] nox.options.default_venv_backend = "uv|virtualenv" +# =================================================================== +# Linting + @nox.session def lint(session: nox.Session) -> None: @@ -36,38 +39,73 @@ def pylint(session: nox.Session) -> None: session.run("pylint", "galax", *session.posargs) +# =================================================================== +# Testing + + @nox.session -def tests(session: nox.Session) -> None: - """Run the unit and regular tests.""" +def tests_standard(session: nox.Session) -> None: + """Run the regular tests: src, README, docs, tests/unit.""" session.install("-e", ".[test]") - os.environ["GALAX_ENABLE_RUNTIME_TYPECHECKS"] = "1" # TODO: set in a better way - session.run("pytest", *session.posargs) + os.environ["GALAX_ENABLE_RUNTIME_TYPECHECKS"] = "1" + session.run("pytest", "src", "README", "docs", "tests/unit", *session.posargs) @nox.session def tests_all(session: nox.Session) -> None: - """Run the unit and regular tests.""" + """Run all the tests.""" session.install("-e", ".[test-all]") - os.environ["GALAX_ENABLE_RUNTIME_TYPECHECKS"] = "1" # TODO: set in a better way + os.environ["GALAX_ENABLE_RUNTIME_TYPECHECKS"] = "1" session.run("pytest", *session.posargs) @nox.session def doctests(session: nox.Session) -> None: - """Run the regular tests and doctests.""" - session.install(".[test]") + """Run the doctests: README, docs, src -- including mpl tests.""" + session.install(".[test,test-mpl]") + os.environ["GALAX_ENABLE_RUNTIME_TYPECHECKS"] = "1" + session.run( + "pytest", + *("README", "docs", "src/galax"), + "--mpl", + *session.posargs, + ) + + +@nox.session +def generate_mpl_tests(session: nox.Session) -> None: + """Generate the mpl tests.""" + session.install(".[test,test-mpl]") + os.environ["GALAX_ENABLE_RUNTIME_TYPECHECKS"] = "1" + session.run( + "pytest", + "tests", + "-m mpl_image_compare", # only run the mpl tests + "--mpl-generate-hash-library=tests/mpl_figure/hashes.json", + "--mpl-generate-path=tests/mpl_figure/baseline", + "--mpl-generate-summary=html,json,basic-json", + *session.posargs, + ) + + +@nox.session +def test_mpl(session: nox.Session) -> None: + """Test the figures.""" + session.install(".[test,test-mpl]") + os.environ["GALAX_ENABLE_RUNTIME_TYPECHECKS"] = "1" session.run( "pytest", - "--doctest-modules", - '--doctest-glob="*.rst"', - '--doctest-glob="*.md"', - '--doctest-glob="*.py"', - "docs", - "src/galax", + "tests", + "--mpl", + "-m mpl_image_compare", # only run the mpl tests + "--mpl-generate-summary=basic-html,json", *session.posargs, ) +# =================================================================== + + @nox.session(reuse_venv=True) def docs(session: nox.Session) -> None: """Build the docs. Pass "--serve" to serve. Pass "-b linkcheck" to check links.""" diff --git a/pyproject.toml b/pyproject.toml index 720e49a6..bcb7d89c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -103,13 +103,17 @@ ] test = [ "nox>=2024.10.9", + "optype[numpy]>=0.9.0", "pre-commit>=4.0.1", "pytest-arraydiff>=0.6.1", "pytest-cov>=5", "pytest>=8.3", "sybil>=8.0.0", - ] - test-mpl = ["pytest-mpl>=0.17.0"] +] + test-mpl = [ + "pytest-mpl>=0.17.0", + "matplotlib==3.9.2", +] [tool.hatch] @@ -229,6 +233,13 @@ testpaths = ["docs", "src/galax", "tests/"] xfail_strict = true + # pytest-mpl settings + mpl-use-full-test-name = true + mpl-hash-library = "tests/mpl_figure/hashes.json" + mpl-baseline-path = "https://raw.githubusercontent.com/GalacticDynamics/galax-figure-tests/main/figures/" + mpl-deterministic = true + + [tool.ruff] diff --git a/tests/integration/matplotlib/baseline/test_kepler_potential_contours.png b/tests/integration/matplotlib/baseline/test_kepler_potential_contours.png deleted file mode 100644 index e5f91761..00000000 Binary files a/tests/integration/matplotlib/baseline/test_kepler_potential_contours.png and /dev/null differ diff --git a/tests/integration/matplotlib/test_dynamicssolver.py b/tests/integration/matplotlib/test_dynamicssolver.py new file mode 100644 index 00000000..75718665 --- /dev/null +++ b/tests/integration/matplotlib/test_dynamicssolver.py @@ -0,0 +1,86 @@ +"""Test the `galax.dynamics.orbit` package contents.""" + +from itertools import combinations +from typing import Literal, TypeAlias + +import matplotlib.pyplot as plt +import numpy as np +import optype.numpy as onp +import pytest +from matplotlib.axes import Axes +from matplotlib.figure import Figure + +import quaxed.numpy as jnp +import unxt as u + +import galax.coordinates as gc +import galax.dynamics as gd +import galax.potential as gp + + +@pytest.fixture +def potential() -> gp.KeplerPotential: + """Kepler potential fixture.""" + return gp.KeplerPotential(m_tot=u.Quantity(1e12, "Msun"), units="galactic") + + +@pytest.fixture +def field(potential: gp.KeplerPotential) -> gd.fields.HamiltonianField: + """Hamiltonian field fixture.""" + return gd.fields.HamiltonianField(potential) + + +@pytest.fixture +def solver() -> gd.DynamicsSolver: + """Dynamics solver fixture.""" + return gd.DynamicsSolver() + + +FigAx23: TypeAlias = tuple[Figure, onp.Array[tuple[Literal[2], Literal[3]], Axes]] + + +@pytest.fixture +def sixaxfig() -> FigAx23: + """Six axis figure fixture.""" + fig, axs = plt.subplots(2, 3, figsize=(12, 8)) + return fig, axs + + +# ============================================================================= + + +@pytest.mark.mpl_image_compare +def test_solution_plot( + solver: gd.DynamicsSolver, field: gd.fields.HamiltonianField, sixaxfig: FigAx23 +) -> Figure: + """Test plotting an orbit in a Kepler potential.""" + # Solve the dynamical system + w0 = gc.PhaseSpacePosition( + q=u.Quantity([8.0, 0.0, 0.5], "kpc"), + p=u.Quantity([0.0, 220.0, 0.0], "km/s"), + t=u.Quantity(0.0, "Gyr"), + ) + tf = u.Quantity(200, "Myr") + saveat = jnp.linspace(w0.t, tf, 1000) + soln = solver.solve(field, w0, tf, saveat=saveat) + q, p = soln.ys + + # Plot the solution + fig, axs = sixaxfig + fig.suptitle("Phase space solution") + + usys = field.units + labels = np.empty((2, 3), dtype=" gd.Orbi # ============================================================================= -@pytest.mark.mpl_image_compare(deterministic=True) +@pytest.mark.mpl_image_compare def test_orbit_plot(orbit: gd.Orbit) -> Figure: """Test plotting an orbit in a Kepler potential.""" ax = orbit.plot(x="x", y="y") @@ -46,7 +46,7 @@ def test_orbit_plot(orbit: gd.Orbit) -> Figure: return ax.figure -@pytest.mark.mpl_image_compare(deterministic=True) +@pytest.mark.mpl_image_compare def test_orbit_plot_represent_as(orbit: gd.Orbit) -> Figure: """Test plotting an orbit in a Kepler potential.""" ax = orbit.plot(x="rho", y="d_z", vector_representation=cx.vecs.CylindricalPos) @@ -54,7 +54,7 @@ def test_orbit_plot_represent_as(orbit: gd.Orbit) -> Figure: return ax.figure -@pytest.mark.mpl_image_compare(deterministic=True) +@pytest.mark.mpl_image_compare def test_orbit_plot_scatter(orbit: gd.Orbit) -> Figure: """Test plotting an orbit in a Kepler potential.""" ax = orbit.plot(x="x", y="y", plot_function="scatter") @@ -62,7 +62,7 @@ def test_orbit_plot_scatter(orbit: gd.Orbit) -> Figure: return ax.figure -@pytest.mark.mpl_image_compare(deterministic=True) +@pytest.mark.mpl_image_compare def test_orbit_plot_time_color(orbit: gd.Orbit) -> Figure: """Test plotting an orbit in a Kepler potential.""" ax = orbit.plot(x="x", y="y", plot_function="scatter", c="orbit.t") diff --git a/tests/integration/matplotlib/test_potential.py b/tests/integration/matplotlib/test_potential.py index 52cd2fca..86e92236 100644 --- a/tests/integration/matplotlib/test_potential.py +++ b/tests/integration/matplotlib/test_potential.py @@ -9,7 +9,7 @@ import galax.potential as gp -@pytest.mark.mpl_image_compare(deterministic=True) +@pytest.mark.mpl_image_compare def test_kepler_potential_contours() -> Figure: """Test plotting Kepler potential contours.""" pot = gp.KeplerPotential( @@ -24,7 +24,7 @@ def test_kepler_potential_contours() -> Figure: return fig -@pytest.mark.mpl_image_compare(deterministic=True) +@pytest.mark.mpl_image_compare def test_kernel_density_contours() -> Figure: """Test plotting kernel density contours.""" pot = gp.KeplerPotential( diff --git a/tests/mpl_figure/hashes.json b/tests/mpl_figure/hashes.json new file mode 100644 index 00000000..b78d7218 --- /dev/null +++ b/tests/mpl_figure/hashes.json @@ -0,0 +1,9 @@ +{ + "integration.matplotlib.test_dynamicssolver.test_solution_plot": "cf2546a91582065e84f8e5494b31eff6219533bf7ce66000f835c5aaf9d6cc89", + "integration.matplotlib.test_orbit.test_orbit_plot": "4766db5ca76f7c347d4811587e3798f42eec1ae9234832459478d54e8563dbb0", + "integration.matplotlib.test_orbit.test_orbit_plot_represent_as": "83ca861ec069a9ddda18b09b469ed27c3b953dc87184116e17e2cb1f1c829c51", + "integration.matplotlib.test_orbit.test_orbit_plot_scatter": "e7f337382dada6558022a0888f2f741c90e74a6531ececa293e7754df273a4ee", + "integration.matplotlib.test_orbit.test_orbit_plot_time_color": "3a12252a7e2b676d8c3427b36b6d8c5753023e3a0b416b0d7433a9e7dcd91c92", + "integration.matplotlib.test_potential.test_kepler_potential_contours": "f21ac32338c8120f0f8851f1fef3c516bc906bb21452539868361cf86833e561", + "integration.matplotlib.test_potential.test_kernel_density_contours": "51f9e6ca266b063efdd9fd6914b09d689b9ff4be55554a5b0a981e54b972e8d3" +} diff --git a/uv.lock b/uv.lock index ecd88eee..3e20402f 100644 --- a/uv.lock +++ b/uv.lock @@ -729,8 +729,10 @@ dev = [ { name = "cz-conventional-gitmoji" }, { name = "furo" }, { name = "ipykernel" }, + { name = "matplotlib" }, { name = "myst-parser" }, { name = "nox" }, + { name = "optype", extra = ["numpy"] }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-arraydiff" }, @@ -750,6 +752,7 @@ docs = [ ] test = [ { name = "nox" }, + { name = "optype", extra = ["numpy"] }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-arraydiff" }, @@ -757,7 +760,9 @@ test = [ { name = "sybil" }, ] test-all = [ + { name = "matplotlib" }, { name = "nox" }, + { name = "optype", extra = ["numpy"] }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-arraydiff" }, @@ -766,6 +771,7 @@ test-all = [ { name = "sybil" }, ] test-mpl = [ + { name = "matplotlib" }, { name = "pytest-mpl" }, ] @@ -814,8 +820,10 @@ dev = [ { name = "cz-conventional-gitmoji", specifier = ">=0.6.1" }, { name = "furo", specifier = ">=2024.8.6" }, { name = "ipykernel", specifier = ">=6.29.5" }, + { name = "matplotlib", specifier = "==3.9.2" }, { name = "myst-parser", specifier = ">=4.0" }, { name = "nox", specifier = ">=2024.10.9" }, + { name = "optype", extras = ["numpy"], specifier = ">=0.9.0" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest", specifier = ">=8.3.3" }, @@ -836,6 +844,7 @@ docs = [ ] test = [ { name = "nox", specifier = ">=2024.10.9" }, + { name = "optype", extras = ["numpy"], specifier = ">=0.9.0" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-arraydiff", specifier = ">=0.6.1" }, @@ -843,7 +852,9 @@ test = [ { name = "sybil", specifier = ">=8.0.0" }, ] test-all = [ + { name = "matplotlib", specifier = "==3.9.2" }, { name = "nox", specifier = ">=2024.10.9" }, + { name = "optype", extras = ["numpy"], specifier = ">=0.9.0" }, { name = "pre-commit", specifier = ">=4.0.1" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-arraydiff", specifier = ">=0.6.1" }, @@ -851,7 +862,10 @@ test-all = [ { name = "pytest-mpl", specifier = ">=0.17.0" }, { name = "sybil", specifier = ">=8.0.0" }, ] -test-mpl = [{ name = "pytest-mpl", specifier = ">=0.17.0" }] +test-mpl = [ + { name = "matplotlib", specifier = "==3.9.2" }, + { name = "pytest-mpl", specifier = ">=0.17.0" }, +] [[package]] name = "galpy" @@ -1428,6 +1442,11 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/08/c2/0d5694ea30f6b852955a86e13e6a28387f60079b0b10e2ca9658fbf5cca5/optype-0.9.0-py3-none-any.whl", hash = "sha256:19dbbb71622961e903e60c12f370d910498e81bc4ae8b40d18c94dd106f4ce6b", size = 81554 }, ] +[package.optional-dependencies] +numpy = [ + { name = "numpy" }, +] + [[package]] name = "packaging" version = "24.1"