diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 5c9f89c76..d37b24db1 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,10 +1,13 @@ -name: Deploy docs to GitHub Pages +name: Documentation on: push: branches: - main - + pull_request: + branches: + - devel + - main workflow_dispatch: defaults: @@ -13,20 +16,15 @@ defaults: permissions: contents: read - pages: write - id-token: write packages: read concurrency: - group: "pages" - cancel-in-progress: false + group: "docs-${{ github.ref }}" + cancel-in-progress: true jobs: - build-and-deploy: + build-docs: runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} steps: - name: Checkout uses: actions/checkout@v5 @@ -34,7 +32,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.11" - name: Install prerequisites (Ubuntu) uses: ./.github/actions/install/ubuntu-latest @@ -52,18 +50,32 @@ jobs: uses: ./.github/actions/compile - name: Build Sphinx docs + env: + STRUPHY_DOCS_SKIP_TUTORIALS: "1" run: | cd doc make html - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload built docs + if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: actions/upload-pages-artifact@v3 with: path: doc/_build/html/ + deploy-docs: + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: build-docs + runs-on: ubuntu-latest + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 diff --git a/.github/workflows/reusable-unit-testing.yml b/.github/workflows/reusable-unit-testing.yml index 2e8f59e7b..129324ec9 100644 --- a/.github/workflows/reusable-unit-testing.yml +++ b/.github/workflows/reusable-unit-testing.yml @@ -125,6 +125,12 @@ jobs: with: env-name: /struphy_fortran_/env_fortran_ + - name: Test plasma-plots integration + if: matrix.shard == 'shard-2' + run: | + source /struphy_fortran_/env_fortran_/bin/activate + python -m pytest "$STRUPHY_PATH/post_processing/tests/test_plasma_plots_integration.py" -v + - name: Run unit tests env: TESTMON_DATAFILE: ${{ github.workspace }}/.testmondata-unit-${{ matrix.shard }} @@ -143,4 +149,4 @@ jobs: $UNINSTALL_MPI git status || true echo "Running $PYTEST_CMD $TEST_FILES" - $PYTEST_CMD $TEST_FILES \ No newline at end of file + $PYTEST_CMD $TEST_FILES diff --git a/doc/conf.py b/doc/conf.py index 7dcde51b6..ca0bf3aaf 100644 --- a/doc/conf.py +++ b/doc/conf.py @@ -50,9 +50,14 @@ "sphinx_design", "sphinx_copybutton", "rst_docstring", # Custom extension to use __doc_rst__ - "sphinx_collections", ] +_skip_tutorials = os.environ.get("STRUPHY_DOCS_SKIP_TUTORIALS") == "1" +if not _skip_tutorials: + extensions.append("sphinx_collections") +else: + tags.add("skip_tutorials") # noqa: F821 - injected by Sphinx + def _struphy_is_compiled(): """Whether `struphy compile` has been run (state.yml exists in the installed package).""" @@ -75,14 +80,15 @@ def _struphy_is_compiled(): napoleon_attr_annotations = False # Set path to outside sources for sphinxcontrib-collections. -collections = { - "notebooks": { - "driver": "copy_folder", - "source": "../tutorials/", - "target": "tutorials/", - "ignore": ["*.py", ".sh"], +if not _skip_tutorials: + collections = { + "notebooks": { + "driver": "copy_folder", + "source": "../tutorials/", + "target": "tutorials/", + "ignore": ["*.py", ".sh"], + } } -} # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -91,6 +97,8 @@ def _struphy_is_compiled(): # directories to ignore when looking for source files. # This pattern also affects html_static_path and html_extra_path. exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +if _skip_tutorials: + exclude_patterns.extend(["sections/tutorials.rst", "sections/dev_guide.rst"]) # mock imports # autodoc_mock_imports = ["mpi4py"] diff --git a/doc/index.rst b/doc/index.rst index f0df95a1f..c56911517 100644 --- a/doc/index.rst +++ b/doc/index.rst @@ -30,19 +30,22 @@ The code is freely available under an `MIT license =0.1.0", + "plasma-plots>=0.1.1", ] dev = [ "struphy[mpi]", diff --git a/src/struphy/post_processing/tests/test_plasma_plots_integration.py b/src/struphy/post_processing/tests/test_plasma_plots_integration.py new file mode 100644 index 000000000..658d04e20 --- /dev/null +++ b/src/struphy/post_processing/tests/test_plasma_plots_integration.py @@ -0,0 +1,132 @@ +"""Small real simulation exercising optional plasma-plots and the output CLI.""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +import numpy as np +import pytest +import xarray as xr + +from struphy import DerhamOptions, EnvironmentOptions, Output, Simulation, Time, grids, perturbations +from struphy.models import Maxwell + + +def run_command(*args): + """Include CLI diagnostics in pytest's failure report.""" + result = subprocess.run( + list(map(str, args)), + capture_output=True, + text=True, + env={**os.environ, "MPLBACKEND": "Agg"}, + timeout=120, + ) + assert result.returncode == 0, ( + f"Command failed ({result.returncode}): {result.args}\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + return result.stdout + + +@pytest.fixture(scope="module") +def raw_run(tmp_path_factory): + root = tmp_path_factory.mktemp("maxwell") + model = Maxwell() + model.em_fields.e_field.save_data = True + model.em_fields.b_field.save_data = True + model.em_fields.e_field.add_perturbation( + perturbations.ModesCos(ls=(0,), ms=(0,), ns=(1,), amps=(0.1,), comp=0, given_in_basis="1") + ) + sim = Simulation( + model=model, + env=EnvironmentOptions(out_folders=str(root), sim_folder="raw", save_step=1), + time_opts=Time(dt=0.025, Tend=0.2), + grid=grids.TensorProductGrid(num_elements=(1, 1, 8)), + derham_opts=DerhamOptions(degree=(1, 1, 2)), + ) + out = sim.run() + assert (out.path_out / "run_metadata.json").is_file() + assert not out.is_processed + assert out.scalars.sizes["t"] >= 8 + assert np.isfinite(out.scalars.to_array()).all() + out.close() + return out.path_out + + +@pytest.fixture +def run_path(raw_run, tmp_path): + # Each interface must create its own products from the original raw output. + return Path(shutil.copytree(raw_run, tmp_path / "run")) + + +def assert_image(path): + assert path.is_file() and path.stat().st_size > 100 + assert path.read_bytes().startswith(b"\x89PNG\r\n\x1a\n") + + +def test_python_api(run_path): + # No explicit plasma_plots import: Output must register its accessors. + with Output(run_path) as out: + out.pproc(physical=True) + assert out.is_processed + assert "em_fields/e_field" in out.keys() + field = out.evaluate("em_fields/e_field", representation="1") + assert isinstance(field, xr.DataArray) + assert np.isfinite(field).all() + signal = field.isel(component=0, eta1=0, eta2=0) + assert float(signal.std("t").max()) > 0 + + spectrum = out.analysis.time_fft(signal) + assert "omega" in spectrum.dims + assert np.isfinite(spectrum.power).all() + np.testing.assert_allclose(spectrum.power.sum("omega"), (signal**2).mean("t"), atol=1e-12) + filtered = signal.plasma.analysis.filter_time() + assert filtered.filtered.dims == signal.dims + assert bool(filtered.spectrum.has_peak.all()) + assert np.isfinite(filtered.filtered).all() + + energies = run_path / "energies.png" + out.plot.energies(parts=["electric_energy", "magnetic_energy"], total="total_energy").save(energies) + profile = run_path / "electric-field.png" + signal.plasma.plot.lineout(x="eta3", t=-1).save(profile) + assert_image(energies) + assert_image(profile) + + +def test_cli(run_path): + cli = Path(sys.executable).parent / "struphy" + + def command(*args): + return run_command(cli, "output", *args) + + command("pproc", run_path, "--physical") + with Output(run_path) as out: + assert out.is_processed + assert np.isfinite(out.evaluate("em_fields/e_field_xyz")).all() + assert "em_fields/e_field" in command("keys", run_path) + assert "em_fields/e_field" in command("info", run_path) + report_dir = run_path / "cli-report" + command("report", run_path, "--format", "html", "--directory", report_dir) + reports = list(report_dir.glob("*.html")) + assert reports and "total_energy" in reports[0].read_text() + + def plasma_command(*args): + return run_command(sys.executable, "-m", "plasma_plots", *args) + + assert "em_fields/e_field" in plasma_command("info", run_path) + energy_plot = run_path / "cli-energies.png" + plasma_command( + "plot", + run_path, + ".", + "energies", + 'parts=["electric_energy","magnetic_energy"]', + "total=total_energy", + "-o", + energy_plot, + ) + spectrum_plot = run_path / "cli-spectrum.png" + plasma_command("plot", run_path, "em_fields/e_field", "power_spectrum", "component=0", "-o", spectrum_plot) + assert_image(energy_plot) + assert_image(spectrum_plot)