diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 0e812dd..be7861e 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -22,7 +22,7 @@ jobs: uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' cache: pip @@ -36,7 +36,10 @@ jobs: - name: Build Sphinx docs working-directory: docs - run: make html + # ``-W`` promotes warnings to errors so doc regressions break CI + # rather than slipping through silently; ``--keep-going`` reports + # every offender in one run instead of stopping at the first. + run: python -m sphinx -W --keep-going -b html source build/html - name: Fix permissions run: | diff --git a/.github/workflows/draft-pdf.yml b/.github/workflows/draft-pdf.yml new file mode 100644 index 0000000..afa699b --- /dev/null +++ b/.github/workflows/draft-pdf.yml @@ -0,0 +1,34 @@ +name: Draft PDF +on: + push: + paths: + - wetting_angle_kit_JOSS/** + - .github/workflows/draft-pdf.yml + +jobs: + paper: + runs-on: ubuntu-latest + name: Paper Draft + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build draft PDF + uses: openjournals/openjournals-draft-action@master + with: + journal: joss + # This should be the path to the paper within your repo. + paper-path: wetting_angle_kit_JOSS/paper.md + - name: Upload + uses: actions/upload-artifact@v4 + with: + name: paper + # This is the output path where Pandoc will write the compiled + # PDF. Note, this should be the same directory as the input + # paper.md + path: wetting_angle_kit_JOSS/paper.pdf + - name: Commit PDF to repository + uses: EndBug/add-and-commit@v9 + with: + message: '(auto) Paper PDF Draft' + # This should be the path to the paper within your repo. + add: 'wetting_angle_kit_JOSS/paper.pdf' # 'paper/*.pdf' to commit all PDFs in the paper directory \ No newline at end of file diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 411dbc5..3228342 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -25,7 +25,7 @@ jobs: fetch-depth: 0 - name: Set up Python 3.11 - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: '3.11' @@ -47,6 +47,15 @@ jobs: release_branch: ${{ env.PUBLISH_UPDATE_BRANCH }} exclude_labels: "duplicate,question,invalid,wontfix,dependency_updates,skip_changelog" + # CharMixer/auto-changelog-action above rewrites the changelog on the + # working tree; this step force-pushes the result back to the protected + # ``main`` branch. ``force: true`` is required because the changelog + # commit is fabricated by the action and would otherwise diverge from + # ``origin/main``; ``unprotect_reviews: true`` lifts branch-protection + # review requirements for the duration of the push so the workflow can + # publish the release autonomously. The push triggers + # ``deploy-docs.yml`` (push-to-main), which rebuilds and redeploys the + # documentation alongside this publish. - name: Update '${{ env.PUBLISH_UPDATE_BRANCH }}' uses: CasperWA/push-protected@v2 with: @@ -57,30 +66,6 @@ jobs: force: true tags: true - - name: Install docs dependencies - run: | - # Required to generate rst files from markdown - sudo apt install pandoc - pip install .[doc] - - - name: Build Sphinx docs - working-directory: docs - run: | - # cannot use sphinx build directly as the makefile handles generation - # of some rst files - make html - - - name: Fix permissions # following https://github.com/actions/upload-pages-artifact?tab=readme-ov-file#file-permissions - run: | - chmod -c -R +rX "./docs/build" | while read line; do - echo "::warning title=Invalid file permissions automatically fixed::$line" - done - - - name: Upload docs artifact - uses: actions/upload-pages-artifact@v3 - with: - path: ./docs/build/html - - name: Get tagged versions run: echo "PREVIOUS_VERSION=$(git tag -l --sort -version:refname | sed -n 2p)" >> $GITHUB_ENV @@ -117,17 +102,7 @@ jobs: user: __token__ password: ${{ secrets.PYPI_PASSWORD }} - deploy_docs: - if: github.repository == 'Matgenix/wetting-angle-kit' && startsWith(github.ref, 'refs/tags/v') - runs-on: ubuntu-latest - permissions: - pages: write # to deploy to Pages - id-token: write # to verify the deployment originates from an appropriate source - needs: publish - environment: - name: "Documentation" - url: https://Matgenix.github.io/wetting-angle-kit - - steps: - - name: Deploy docs - uses: actions/deploy-pages@v4 + # Documentation deployment is handled by ``deploy-docs.yml`` on push to + # ``main`` (which the ``Update main`` step above triggers). Keeping the + # build+deploy in a single workflow avoids the race that arises when + # both workflows publish to GitHub Pages simultaneously. diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index d648511..d683e80 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v5 with: python-version: '3.10' cache: pip @@ -36,7 +36,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip @@ -50,7 +50,7 @@ jobs: pip install .[dev,all] - name: Test - run: pytest --cov=wetting_angle_kit --cov-report=xml --cov-fail-under=70 + run: pytest --cov=wetting_angle_kit --cov-report=xml --cov-fail-under=80 - name: Upload coverage to Codecov if: matrix.python-version == '3.11' @@ -70,7 +70,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v5 with: python-version: '3.11' cache: pip @@ -81,11 +81,11 @@ jobs: python -m pip install --upgrade pip pip install .[dev,ase] - - name: Test (skip OVITO-dependent tests) - run: | - pytest \ - --ignore=tests/test_parser/test_parser_dump.py \ - --ignore=tests/test_analysis + - name: Test + # OVITO-dependent test modules call ``pytest.importorskip("ovito")`` + # at import time, so the full suite can run on macOS: those modules + # are skipped automatically and the ASE-backed tests still execute. + run: pytest docs: runs-on: ubuntu-latest @@ -93,7 +93,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: actions/setup-python@v6 + - uses: actions/setup-python@v5 with: python-version: '3.11' cache: pip @@ -108,7 +108,7 @@ jobs: - name: Build Sphinx docs working-directory: docs - run: | - # cannot use sphinx build directly as the makefile handles generation - # of some rst files - make html + # ``-W`` promotes warnings to errors so doc regressions break CI + # rather than slipping through silently; ``--keep-going`` reports + # every offender in one run instead of stopping at the first. + run: python -m sphinx -W --keep-going -b html source build/html diff --git a/.gitignore b/.gitignore index b3216da..8392b0c 100644 --- a/.gitignore +++ b/.gitignore @@ -77,6 +77,8 @@ docs/build/doctrees docs/build/generate-stamp docs/source/changelog.rst +docs/build_log.txt +docs/tutorials # PyBuilder .pybuilder/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5f84c3b..95b38e3 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -30,7 +30,7 @@ repos: - id: rst-directive-colons - id: rst-inline-touching-normal - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.10.0 + rev: v1.18.2 hooks: - id: mypy files: ^src/wetting_angle_kit/ diff --git a/CITATION.cff b/CITATION.cff index 6018ed4..e55ec1b 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -1,6 +1,8 @@ cff-version: 1.2.0 message: "If you use wetting-angle-kit in your research, please cite it using the metadata below." title: "wetting-angle-kit: a Python package to streamline the computation of wetting contact angles of nanodroplets on surfaces" +version: 0.1.2 +date-released: "2026-06-01" type: software license: BSD-3-Clause repository-code: "https://github.com/Matgenix/wetting-angle-kit" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2e9bccf..4a3964f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -86,10 +86,9 @@ parsers' handling of orthogonal cells and periodic boundary conditions. ## Adding a new contact-angle method -Subclass `BaseContactAngleAnalyzer` +Subclass `BaseTrajectoryAnalyzer` ([src/wetting_angle_kit/analysis/analyzer.py](src/wetting_angle_kit/analysis/analyzer.py)) -and register it in the analyzer factory so it can be picked up by name. -Add an integration test in `tests/test_analysis/` that +and add an integration test in `tests/test_analysis/` that exercises the method on one of the fixture trajectories. ## Pull requests diff --git a/README.md b/README.md index ed08870..cfcfac5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # wetting-angle-kit [![tests](https://img.shields.io/github/actions/workflow/status/Matgenix/wetting-angle-kit/testing.yml?branch=main&label=tests)](https://github.com/Matgenix/wetting-angle-kit/actions/workflows/testing.yml) +[![docs](https://img.shields.io/github/actions/workflow/status/Matgenix/wetting-angle-kit/deploy-docs.yml?branch=main&label=docs)](https://github.com/Matgenix/wetting-angle-kit/actions/workflows/deploy-docs.yml) [![code coverage](https://codecov.io/gh/Matgenix/wetting-angle-kit/branch/main/graph/badge.svg)](https://codecov.io/gh/Matgenix/wetting-angle-kit) [![pypi version](https://img.shields.io/pypi/v/wetting-angle-kit?color=blue)](https://pypi.org/project/wetting-angle-kit/) [![Python versions](https://img.shields.io/pypi/pyversions/wetting-angle-kit)](https://pypi.org/project/wetting-angle-kit/) @@ -55,8 +56,8 @@ conda install --strict-channel-priority -c https://conda.ovito.org -c conda-forg ```python from wetting_angle_kit.analysis import ( - BinningContactAngleAnalyzer, - SlicingContactAngleAnalyzer, + BinningTrajectoryAnalyzer, + SlicingTrajectoryAnalyzer, ) from wetting_angle_kit.parsers import XYZParser, XYZWaterFinder @@ -69,22 +70,20 @@ oxygen_ids = finder.get_water_oxygen_indices(frame_index=0) parser = XYZParser(trajectory_file) -slicing = SlicingContactAngleAnalyzer( +slicing = SlicingTrajectoryAnalyzer( parser, - output_dir="out_slicing", atom_indices=oxygen_ids, droplet_geometry="spherical", delta_gamma=5, ) results = slicing.analyze(frame_range=range(0, 50)) -print(results["mean_angle"], results["std_angle"]) +print(results.mean_angle, results.std_angle) -binning = BinningContactAngleAnalyzer( +binning = BinningTrajectoryAnalyzer( parser, - output_dir="out_binned", atom_indices=oxygen_ids, droplet_geometry="spherical", ) results_binning = binning.analyze(frame_range=range(0, 200)) -print(results_binning["mean_angle"], results_binning["std_angle"]) +print(results_binning.mean_angle, results_binning.std_angle) ``` diff --git a/docs/Makefile b/docs/Makefile index d0c3cbf..20f0d45 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -3,7 +3,11 @@ # You can set these variables from the command line, and also # from the environment for the first two. -SPHINXOPTS ?= +# ``-W`` turns Sphinx warnings into errors; ``--keep-going`` finishes the +# build so the user sees every offender rather than stopping at the first. +# Override on the command line with ``SPHINXOPTS= make html`` for a lenient +# local build. +SPHINXOPTS ?= -W --keep-going SPHINXBUILD ?= sphinx-build SOURCEDIR = source BUILDDIR = build diff --git a/docs/build_log.txt b/docs/build_log.txt deleted file mode 100644 index be9db6d..0000000 --- a/docs/build_log.txt +++ /dev/null @@ -1,83 +0,0 @@ -Running Sphinx v8.2.3 -loading translations [en]... done -making output directory... done -[autosummary] generating autosummary for: API/index.rst, examples/index.rst, index.rst, tutorials/Binning_method_tuto.rst, tutorials/Parser_tutorial.rst, tutorials/Sliced_method_tuto.rst, tutorials/Visualisation_size_changing_droplet_infinit_angle.rst, tutorials/Visualization_sliced_droplet.rst, tutorials/Visualization_trajectories_comparison_methods.rst, tutorials/index.rst -WARNING: Failed to import wetting_angle_kit.contact_angle_method.binned_method. -Possible hints: -* ModuleNotFoundError: No module named 'wetting_angle_kit.contact_angle_method.binned_method' -* AttributeError: module 'wetting_angle_kit.contact_angle_method' has no attribute 'binned_method' -building [mo]: targets for 0 po files that are out of date -writing output... -building [html]: targets for 10 source files that are out of date -updating environment: [new config] 10 added, 0 changed, 0 removed -reading sources... [ 10%] API/index -reading sources... [ 20%] examples/index -reading sources... [ 30%] index -reading sources... [ 40%] tutorials/Binning_method_tuto -reading sources... [ 50%] tutorials/Parser_tutorial -reading sources... [ 60%] tutorials/Sliced_method_tuto -reading sources... [ 70%] tutorials/Visualisation_size_changing_droplet_infinit_angle -reading sources... [ 80%] tutorials/Visualization_sliced_droplet -reading sources... [ 90%] tutorials/Visualization_trajectories_comparison_methods -reading sources... [100%] tutorials/index - -/auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/wetting_angle_kit/contact_angle_method/sliced_method/__init__.py:docstring of wetting_angle_kit.contact_angle_method.sliced_method.angle_fitting_sliced.ContactAngleSliced:1: WARNING: duplicate object description of wetting_angle_kit.contact_angle_method.sliced_method.angle_fitting_sliced.ContactAngleSliced, other instance in API/index, use :no-index: for one of them -/auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/wetting_angle_kit/contact_angle_method/sliced_method/__init__.py:docstring of wetting_angle_kit.contact_angle_method.sliced_method.multi_processing.ContactAngleSlicedParallel:1: WARNING: duplicate object description of wetting_angle_kit.contact_angle_method.sliced_method.multi_processing.ContactAngleSlicedParallel, other instance in API/index, use :no-index: for one of them -/auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/wetting_angle_kit/contact_angle_method/sliced_method/__init__.py:docstring of wetting_angle_kit.contact_angle_method.sliced_method.surface_defined.SurfaceDefinition.analyze_lines:5: ERROR: Unexpected indentation. [docutils] -/auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/wetting_angle_kit/contact_angle_method/sliced_method/__init__.py:docstring of wetting_angle_kit.contact_angle_method.sliced_method.surface_defined.SurfaceDefinition.analyze_lines:6: WARNING: Block quote ends without a blank line; unexpected unindent. [docutils] -WARNING: autodoc: failed to import module 'binned_method' from module 'wetting_angle_kit.contact_angle_method'; the following exception was raised: -['Traceback (most recent call last):\n', ' File "/home/ucl/modl/gtaillan/miniconda3/lib/python3.12/site-packages/sphinx/ext/autodoc/importer.py", line 269, in import_object\n module = import_module(modname, try_reload=True)\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n', ' File "/home/ucl/modl/gtaillan/miniconda3/lib/python3.12/site-packages/sphinx/ext/autodoc/importer.py", line 172, in import_module\n raise ModuleNotFoundError(msg, name=modname) # NoQA: TRY301\n ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n', "ModuleNotFoundError: No module named 'wetting_angle_kit.contact_angle_method.binned_method'\n"] [autodoc.import_object] -/auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/wetting_angle_kit/parser/__init__.py:docstring of wetting_angle_kit.parser.parser_xyz.XYZParser.box_length_max:6: ERROR: Undefined substitution referenced: "a_i". [docutils] -looking for now-outdated files... none found -pickling environment... done -checking consistency... done -preparing documents... done -copying assets... -copying static files... -Writing evaluated template result to /auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/docs/build/html/_static/language_data.js -Writing evaluated template result to /auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/docs/build/html/_static/basic.css -Writing evaluated template result to /auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/docs/build/html/_static/documentation_options.js -Writing evaluated template result to /auto/home/users/g/t/gtaillan/lib_python_Hydro/wetting_angle_kit/docs/build/html/_static/alabaster.css -copying static files: done -copying extra files... -copying extra files: done -copying assets: done -writing output... [ 10%] API/index -writing output... [ 20%] examples/index -writing output... [ 30%] index -writing output... [ 40%] tutorials/Binning_method_tuto -writing output... [ 50%] tutorials/Parser_tutorial -writing output... [ 60%] tutorials/Sliced_method_tuto -writing output... [ 70%] tutorials/Visualisation_size_changing_droplet_infinit_angle -writing output... [ 80%] tutorials/Visualization_sliced_droplet -writing output... [ 90%] tutorials/Visualization_trajectories_comparison_methods -writing output... [100%] tutorials/index - -generating indices... genindex py-modindex done -highlighting module code... [ 6%] wetting_angle_kit.contact_angle_method.binning_method.angle_fitting_binning -highlighting module code... [ 12%] wetting_angle_kit.contact_angle_method.contact_angle_analyzer -highlighting module code... [ 19%] wetting_angle_kit.contact_angle_method.factory -highlighting module code... [ 25%] wetting_angle_kit.contact_angle_method.sliced_method.angle_fitting_sliced -highlighting module code... [ 31%] wetting_angle_kit.contact_angle_method.sliced_method.multi_processing -highlighting module code... [ 38%] wetting_angle_kit.contact_angle_method.sliced_method.surface_defined -highlighting module code... [ 44%] wetting_angle_kit.parser.base_parser -highlighting module code... [ 50%] wetting_angle_kit.parser.parser_ase -highlighting module code... [ 56%] wetting_angle_kit.parser.parser_dump -highlighting module code... [ 62%] wetting_angle_kit.parser.parser_xyz -highlighting module code... [ 69%] wetting_angle_kit.visualization_statistics_angles.base_trajectory_analyzer -highlighting module code... [ 75%] wetting_angle_kit.visualization_statistics_angles.binning_trajectory_evolution -highlighting module code... [ 81%] wetting_angle_kit.visualization_statistics_angles.comparison_methods -highlighting module code... [ 88%] wetting_angle_kit.visualization_statistics_angles.graphs_circle_slice -highlighting module code... [ 94%] wetting_angle_kit.visualization_statistics_angles.sliced_trajectory_evolution -highlighting module code... [100%] wetting_angle_kit.visualization_statistics_angles.tools_visu - -writing additional pages... search done -copying images... [ 33%] ../images/logo_wetting_angle_kit.png -copying images... [ 67%] ../images/bin_plot.png -copying images... [100%] ../images/droplet_plot.png - -dumping search index in English (code: en)... done -dumping object inventory... done -build succeeded, 7 warnings. - -The HTML pages are in build/html. diff --git a/docs/examples/binning_ca.py b/docs/examples/binning_ca.py index a8c9225..01972d4 100644 --- a/docs/examples/binning_ca.py +++ b/docs/examples/binning_ca.py @@ -1,5 +1,5 @@ # Import necessary modules -from wetting_angle_kit.analysis import contact_angle_analyzer +from wetting_angle_kit.analysis import BinningTrajectoryAnalyzer from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder # --- Step 1: Define the trajectory file --- @@ -9,7 +9,6 @@ # This identifies O and H atoms in water molecules wat_find = LammpsDumpWaterFinder( filename, - particle_type_wall={3}, # Wall atom types oxygen_type=1, # Oxygen atom type hydrogen_type=2, # Hydrogen atom type ) @@ -32,16 +31,14 @@ parser = LammpsDumpParser(filename) # --- Step 6: Create the contact angle analyzer --- -analyzer = contact_angle_analyzer( - method="binning", +analyzer = BinningTrajectoryAnalyzer( parser=parser, - output_dir="results_binning_example", atom_indices=oxygen_indices, droplet_geometry="spherical", # Interface fitting model binning_params=binning_params, - plot_graphs=True, # Enable plotting for automated runs ) # --- Step 7: Run analysis for a frame range --- results = analyzer.analyze([1]) # Analyze frame 1 -print("Analysis results:", results) +print("Mean contact angle (°):", results.mean_angle) +print("Std contact angle (°):", results.std_angle) diff --git a/docs/examples/parsing_trajectory_files.py b/docs/examples/parsing_trajectory_files.py index caa476f..fc3bb9e 100644 --- a/docs/examples/parsing_trajectory_files.py +++ b/docs/examples/parsing_trajectory_files.py @@ -20,7 +20,6 @@ # --- Initialize water molecule finder --- wat_find = LammpsDumpWaterFinder( filename, - particle_type_wall={3}, # atom type for wall oxygen_type=1, # atom type for oxygen hydrogen_type=2, # atom type for hydrogen ) @@ -57,7 +56,6 @@ # --- Initialize water molecule finder --- wat_find = AseWaterFinder( filename, - particle_type_wall=["C"], # element name for wall oh_cutoff=1.2, # O–H bond cutoff (Å); ASE NeighborList handles the # per-atom splitting internally now. ) diff --git a/docs/examples/slicing_ca.py b/docs/examples/slicing_ca.py index 5499180..7f9b614 100644 --- a/docs/examples/slicing_ca.py +++ b/docs/examples/slicing_ca.py @@ -4,7 +4,7 @@ file and prints the resulting mean contact angle. """ -from wetting_angle_kit.analysis import contact_angle_analyzer +from wetting_angle_kit.analysis import SlicingTrajectoryAnalyzer from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder # --- Step 1: Define the trajectory file --- @@ -13,7 +13,6 @@ # --- Step 2: Identify the water molecules (oxygen-bonded-to-two-H) --- wat_find = LammpsDumpWaterFinder( filename, - particle_type_wall={3}, # Wall atom types oxygen_type=1, hydrogen_type=2, ) @@ -24,10 +23,8 @@ # --- Step 3: Build the slicing analyzer --- parser = LammpsDumpParser(filename) -analyzer = contact_angle_analyzer( - method="slicing", +analyzer = SlicingTrajectoryAnalyzer( parser=parser, - output_dir="results_slicing_example", atom_indices=oxygen_indices, droplet_geometry="spherical", delta_gamma=20, # Azimuthal step for spherical slicing (degrees) @@ -35,5 +32,5 @@ # --- Step 4: Run analysis for a frame range --- results = analyzer.analyze([1]) -print("Mean contact angle (°):", results["mean_angle"]) -print("Frames analyzed:", results["frames_analyzed"]) +print("Frames analyzed:", results.frames) +print("Mean contact angle (°):", results.mean_angle) diff --git a/docs/examples/visualisation_slicing_traj.py b/docs/examples/visualisation_slicing_traj.py index f7b4222..ff89a2f 100644 --- a/docs/examples/visualisation_slicing_traj.py +++ b/docs/examples/visualisation_slicing_traj.py @@ -7,7 +7,7 @@ import numpy as np -from wetting_angle_kit.analysis.slicing import ContactAngleSlicing +from wetting_angle_kit.analysis.slicing import SlicingFrameFitter from wetting_angle_kit.parsers import ( LammpsDumpParser, LammpsDumpWallParser, @@ -20,9 +20,7 @@ filename = "../../tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" # --- 2. Identify Water Molecules --- -wat_find = LammpsDumpWaterFinder( - filename, particle_type_wall={3}, oxygen_type=1, hydrogen_type=2 -) +wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) print("Number of water molecules detected:", len(oxygen_indices)) @@ -36,28 +34,27 @@ wall_coords = coord_wall.parse(frame_index=10) # --- 4. Compute Contact Angles --- -processor = ContactAngleSlicing( +processor = SlicingFrameFitter( liquid_coordinates=oxygen_position, liquid_geom_center=np.mean(oxygen_position, axis=0), droplet_geometry="cylinder_y", delta_cylinder=5, max_dist=100, - width_cylinder=21, ) list_alfas, array_surfaces, array_popt = processor.predict_contact_angle() print("Per-slice contact angles (°):", list_alfas) # --- 5. Visualize the Droplet --- -plotter = DropletSlicePlotter(center=True, show_wall=True, molecule_view=True) +plotter = DropletSlicePlotter(center=True) -plotter.plot_surface_points( +fig = plotter.plot_surface_points( oxygen_position=oxygen_position, surface_data=array_surfaces, popt=array_popt[0], wall_coords=wall_coords, - output_filename="droplet_plot.png", alpha=list_alfas[0], ) -print("Plot saved as 'droplet_plot.png'") +fig.write_html("droplet_plot.html") +print("Plot saved as 'droplet_plot.html'") diff --git a/docs/source/API/index.rst b/docs/source/API/index.rst index 97e82cc..b6188ad 100644 --- a/docs/source/API/index.rst +++ b/docs/source/API/index.rst @@ -22,27 +22,26 @@ Base Analyzer :show-inheritance: Slicing Method -^^^^^^^^^^^^^ +^^^^^^^^^^^^^^ .. automodule:: wetting_angle_kit.analysis.slicing :members: :undoc-members: :show-inheritance: - :exclude-members: ContactAngleSlicing, ContactAngleSlicingParallel + :exclude-members: SlicingFrameFitter Binning Method -^^^^^^^^^^^^^ +^^^^^^^^^^^^^^ .. automodule:: wetting_angle_kit.analysis.binning :members: :undoc-members: :show-inheritance: - :exclude-members: ContactAngleBinning + :exclude-members: BinningBatchFitter Visualization and Statistics ----------------------------- .. automodule:: wetting_angle_kit.visualization :members: - :undoc-members: :show-inheritance: diff --git a/docs/source/conf.py b/docs/source/conf.py index c026a02..103f105 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -9,13 +9,18 @@ sys.path.insert(0, os.path.abspath("../../src")) +from wetting_angle_kit._version import __version__ # noqa: E402 + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = "wetting-angle-kit" -copyright = "2025, Gabriel" -author = "Gabriel" -release = "0.1.2" +copyright = "2025, Matgenix (Gabriel Taillandier)" +author = "Gabriel Taillandier" +# Pull the release from the package's auto-generated version file so the +# docs always advertise the same version as the wheel. +release = __version__ +version = __version__.split("+", 1)[0] # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration @@ -39,7 +44,6 @@ # Autosummary settings autosummary_generate = True -templates_path = ["_templates"] exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # Exclude input prompts from copybutton @@ -49,7 +53,6 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output html_theme = "sphinx_rtd_theme" -html_static_path = ["_static"] # Path to GitHub repo {group}/{project} issues_github_path = "Matgenix/wetting-angle-kit" diff --git a/docs/source/introduction/index.rst b/docs/source/introduction/index.rst index aa0b168..8a3b0a0 100644 --- a/docs/source/introduction/index.rst +++ b/docs/source/introduction/index.rst @@ -6,6 +6,6 @@ Learn about wetting_angle_kit's theoretical foundations and package architecture .. toctree:: :maxdepth: 1 - Introduction - Installation - Theoretical_foundations + introduction + installation + theoretical_foundations diff --git a/docs/source/introduction/Installation.rst b/docs/source/introduction/installation.rst similarity index 100% rename from docs/source/introduction/Installation.rst rename to docs/source/introduction/installation.rst diff --git a/docs/source/introduction/Introduction.rst b/docs/source/introduction/introduction.rst similarity index 90% rename from docs/source/introduction/Introduction.rst rename to docs/source/introduction/introduction.rst index 288ee7b..a43d474 100644 --- a/docs/source/introduction/Introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -66,7 +66,7 @@ Both methods are capable of analyzing: * **Cylindrical Droplets**: Cylindrical droplets (e.g., water on a nanowire or with periodic boundary conditions), analyzed along the cylinder's axis (x or y). **Slicing Method** -^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^ The **Slicing Method** is ideal for analyzing the evolution of the contact angle over time or for symmetric droplets. @@ -112,10 +112,10 @@ Examples of these visualizations can be found in the respective tutorials for ea Troubleshooting --------------- -* **NaN angles**: Usually occur when the surface filter removes too many points (empty slice). Adjust ``surface_filter_offset`` (default 2.0) in ``ContactAngleSlicing`` or relax slice width. Ensure enough atoms remain after filtering (>=3) for circle fitting. +* **NaN angles**: Usually occur when the surface filter removes too many points (empty slice). Adjust ``surface_filter_offset`` (default 2.0) in ``SlicingFrameFitter`` or relax slice width. Ensure enough atoms remain after filtering (>=3) for circle fitting. -* **Empty outputs / NoneType failures**: Confirm ``width_cylinder`` and ``delta_cylinder`` are passed for cylindrical models and ``delta_gamma`` for spherical model. Parser must supply box dimensions for automatic max distance estimation. +* **Empty outputs / NoneType failures**: Confirm ``delta_cylinder`` is passed for cylindrical models and ``delta_gamma`` for the spherical model. Parser must supply box dimensions for automatic max distance estimation. -* **Multiprocessing hangs**: Use the batch-parallel wrapper (``ContactAngleSlicingParallel.process_frames_parallel``) which employs spawn start method; avoid invoking OVITO parsers inside global contexts before multiprocessing starts. +* **Multiprocessing hangs**: ``SlicingTrajectoryAnalyzer.analyze`` uses the spawn start method; avoid invoking OVITO parsers inside global contexts before multiprocessing starts. * **OVITO ImportError**: Install with the ovito extra or via the Conda command listed above. Verify channel priority and version pin if dependency resolution fails. diff --git a/docs/source/introduction/Theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst similarity index 98% rename from docs/source/introduction/Theoretical_foundations.rst rename to docs/source/introduction/theoretical_foundations.rst index 096935b..bc1ac0f 100644 --- a/docs/source/introduction/Theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -1,5 +1,5 @@ Theoretical foundations -====================== +======================= The contact angle is defined as the angle between the tangent to the liquid-vapor interface and the normal to the substrate. It is a measure of the wetting properties of a droplet on a surface. @@ -9,7 +9,7 @@ The contact angle is defined as the angle between the tangent to the liquid-vapo The slicing method ----------------- +------------------ .. image:: ../../images/wetting_angle_kit_3d_droplet.jpg :align: center diff --git a/docs/source/tutorials/Visualization_trajectories_comparison_methods.rst b/docs/source/tutorials/Visualization_trajectories_comparison_methods.rst deleted file mode 100644 index 908d91e..0000000 --- a/docs/source/tutorials/Visualization_trajectories_comparison_methods.rst +++ /dev/null @@ -1,168 +0,0 @@ -Tutorial: Comparing Trajectory Analysis Methods -================================================ - -This tutorial demonstrates how to use the ``BinningTrajectoryAnalyzer`` and ``SlicingTrajectoryAnalyzer`` classes to analyze and compare contact angle and surface area data from trajectory simulations. - ----- - -Introduction ------------- - -The ``BinningTrajectoryAnalyzer`` and ``SlicingTrajectoryAnalyzer`` classes are designed to analyze trajectory data, specifically focusing on **surface area** and **contact angle** statistics. These tools are useful for comparing different analysis methods and visualizing results. - ----- - -Setup and Initialization -------------------------- - -Import the Classes -^^^^^^^^^^^^^^^^^^ - -Ensure you have the required classes imported: - -.. code-block:: python - - from wetting_angle_kit.visualization import ( - BinningTrajectoryAnalyzer, - MethodComparison, - SlicingTrajectoryAnalyzer, - ) - -Initialize the Analyzers -^^^^^^^^^^^^^^^^^^^^^^^^^ - -Specify the directories containing your trajectory data: - -.. code-block:: python - - directories = [ - "slicing_analysis_CA/result_dump_traj_2k_reduce_binned", - "slicing_analysis_CA/result_dump_traj_500_reduce_binned", - "slicing_analysis_CA/result_dump_traj_1k_reduce_binned", - "slicing_analysis_CA/result_dump_traj_8k_reduce_binned", - ] - - # Initialize the analyzers - slicing = SlicingTrajectoryAnalyzer(directories) - binning = BinningTrajectoryAnalyzer(directories) - ----- - -Running the Analysis --------------------- - -Analyze Data -^^^^^^^^^^^^ - -Run the analysis for both methods: - -.. code-block:: python - - slicing.analyze() - binning.analyze() - -Example Output -^^^^^^^^^^^^^^ - -:: - - Directory: slicing_analysis_CA/result_dump_traj_2k_reduce_binned - Method: Slicing Analysis - Mean Surface Area: 2770.0659 - Mean Contact Angle: 91.7015° - - Directory: binning_analysis_CA/result_dump_traj_2k_reduce_binned - Method: Binning Analysis - Mean Surface Area: 2748.5427 - Mean Contact Angle: 91.9236° - ----- - -Interpreting the Output ------------------------- - -- **Mean Surface Area**: The average surface area for each trajectory. -- **Mean Contact Angle**: The average contact angle for each trajectory. -- **Standard Deviation**: Indicates the variability of the data. - ----- - -Visualisation -------------- - -Plot Mean Angle vs Surface Area -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -.. code-block:: python - - slicing.plot_mean_angle_vs_surface(save_path="mean_angle_vs_surface_slicing.png") - binning.plot_mean_angle_vs_surface(save_path="mean_angle_vs_surface_binning.png") - -Plot Median Angle Evolution -^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -For the slicing method, plot the evolution of median angles: - -.. code-block:: python - - slicing.plot_median_alfas_evolution(save_path="evolution_of_angles_slicing_method.png") - ----- - -Method Comparison ------------------ - -Compare Statistics -^^^^^^^^^^^^^^^^^^ - -Use the ``MethodComparison`` class to compare the two methods: - -.. code-block:: python - - comparison = MethodComparison([slicing, binning]) - comparison.plot_side_by_side_comparison(save_path="comparison.png") - print(comparison.compare_statistics()) - -Example Output -^^^^^^^^^^^^^^ - -:: - - ====================================================================== - METHOD COMPARISON STATISTICS - ====================================================================== - Slicing Analysis: - ---------------------------------------------------------------------- - slicing_analysis_CA/traj_2k/: - Mean Surface Area: 2770.0659 ± 15.2001 - Mean Angle: 91.7015° ± 5.6130° - Overall Statistics: - Total samples: 196 - Mean Surface Area: 4001.0215 - Mean Angle: 91.8326° - Std Angle: 6.2027° - - Binning Analysis: - ---------------------------------------------------------------------- - binning_analysis_CA/traj_2k: - Mean Surface Area: 2748.5427 ± 0.0000 - Mean Angle: 91.9236° ± 0.0000° - Overall Statistics: - Total samples: 4 - Mean Surface Area: 4022.1019 - Mean Angle: 92.0876° - Std Angle: 0.2391° - ----- - -Conclusion ----------- - -- The ``SlicingTrajectoryAnalyzer`` provides more detailed statistics with higher sample counts. -- The ``BinningTrajectoryAnalyzer`` offers a simplified, binning approach. -- Use the comparison tools to visualize and interpret differences between methods. - -Additional Notes ----------------- - -- Ensure your data directories are correctly formatted and contain the required log files. diff --git a/docs/source/tutorials/Binning_method_tuto.rst b/docs/source/tutorials/binning_method_tuto.rst similarity index 65% rename from docs/source/tutorials/Binning_method_tuto.rst rename to docs/source/tutorials/binning_method_tuto.rst index b323530..6d70f64 100644 --- a/docs/source/tutorials/Binning_method_tuto.rst +++ b/docs/source/tutorials/binning_method_tuto.rst @@ -21,10 +21,10 @@ The **binning method** works by: 2. Prerequisites ---------------- -Your trajectory file (e.g., a LAMMPS dump file) contain: +Your trajectory file (e.g., a LAMMPS dump file) should contain: - Atom IDs, types, and positions -- Liquid particles (in this cas Water molecules: O and H atoms) +- Liquid particles (in this case, water molecules: O and H atoms) Example trajectory:: @@ -39,7 +39,7 @@ Example trajectory:: # Import necessary modules from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - from wetting_angle_kit.analysis import contact_angle_analyzer + from wetting_angle_kit.analysis import BinningTrajectoryAnalyzer # --- Step 1: Define the trajectory file --- filename = "../../tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" @@ -71,20 +71,16 @@ Example trajectory:: parser = LammpsDumpParser(filename) # --- Step 6: Create the contact angle analyzer --- - analyzer = contact_angle_analyzer( - method="binning", + analyzer = BinningTrajectoryAnalyzer( parser=parser, - output_dir="results_binned_example", atom_indices=oxygen_indices, droplet_geometry="cylinder_y", # Interface fitting model - width_cylinder=21, # Width parameter for interface fit binning_params=binning_params, - plot_graphs=False, # Disable plotting for automated runs ) # --- Step 7: Run analysis for a frame range --- results = analyzer.analyze([1]) # Analyze frame 1 - print("Analysis results:", results) + print("Mean contact angle (°):", results.mean_angle) ---- @@ -93,39 +89,30 @@ Example trajectory:: Running this example will: -- Parse the trajectory -- Compute the interface shape and local contact angle -- Save results (if enabled) under ``results_binned_example/`` +- Parse the trajectory. +- Compute the interface shape and local contact angle for each batch. +- Return a :class:`BinningResults` dataclass holding angles, density fields + and fitted isolines for every batch (no files written). Example printed output:: Number of water molecules: 4000 + Mean contact angle (°): 94.58987060394456 - xi range: (0.22795857644950415,41.63623606829102) - zi range: (7.54989,47.3742) +The returned ``results`` object exposes ``mean_angle``, ``std_angle``, +``angles_per_batch`` and a ``batches`` list whose entries carry the +density field (``xi_cc``, ``zi_cc``, ``rho_cc``) and the fitted +droplet / wall isoline coordinates. Feed it directly to +:class:`BinningTrajectoryPlotter` to draw the interactive density +contour with the fitted semi-circle: - Number of fluid particles in batch: 4000.0 - - Binning with model: spherical ... - Advancement: 0.00% - Advancement: 35.71% - Advancement: 71.43% - - Fitted parameters for batch: - rho1:-3.387136459516587e-05 - rho2:0.03389671977759232 - R_eq:37.22899870907881 - zi_c:9.244210981996149 - zi_0:6.265045941194059 - t1:-4.384696208816467 - t2:0.07378719793487698 - - Contact angle for batch: 94.58987060394456 +.. code-block:: python -A heat map representation of the particles density and the fitted semi-circle to get the contact angle. + from wetting_angle_kit.visualization import BinningTrajectoryPlotter -.. image:: ../../images/bin_plot.png - :alt: Heat maps density particles + plotter = BinningTrajectoryPlotter(results) + fig = plotter.plot_density_contour(batch_index=0) + fig.show() ---- @@ -134,5 +121,4 @@ A heat map representation of the particles density and the fitted semi-circle to - Adjust ``xi_f``, ``zi_f``, and the bin counts (``nbins_xi``, ``nbins_zi``) according to your simulation box dimensions. - If the wall surface is not flat or the system is tilted, pre-align it before analysis. -- Use ``plot_graphs=True`` to visualize the binning density and interface fitting. -- For multiple frames: ``analyzer.analyze(range(0, 100, 10))``. +- Multi-batch analysis: ``analyzer.analyze(range(0, 100), split_factor=10)`` splits the frame range into batches of ten frames each. diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 6c96180..4903ad1 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -7,8 +7,7 @@ Step-by-step guides for using wetting_angle_kit. :maxdepth: 1 :caption: Available Tutorials: - Parser_tutorial - Binning_method_tuto - Slicing_method_tuto - Visualization_slicing_droplet - Visualization_trajectories_comparison_methods + parser_tutorial + binning_method_tuto + slicing_method_tuto + visualization_slicing_droplet diff --git a/docs/source/tutorials/Parser_tutorial.rst b/docs/source/tutorials/parser_tutorial.rst similarity index 97% rename from docs/source/tutorials/Parser_tutorial.rst rename to docs/source/tutorials/parser_tutorial.rst index 91860ff..ded5e5d 100644 --- a/docs/source/tutorials/Parser_tutorial.rst +++ b/docs/source/tutorials/parser_tutorial.rst @@ -5,7 +5,7 @@ This tutorial shows how to load different trajectory formats using the ``wetting The parser provides a unified interface to read atomic coordinates from: -- LAMMPS dump files (``LammpsDumpParser``, `` LammpsDumpWaterFinder``) +- LAMMPS dump files (``LammpsDumpParser``, ``LammpsDumpWaterFinder``) - ASE ``.traj`` files (``AseParser``, ``AseWaterFinder``) - XYZ files (``XYZParser``) @@ -74,7 +74,7 @@ The ``.parse()`` method always returns a NumPy array of shape ``(N, 3)`` contain wat_find = AseWaterFinder( filename, particle_type_wall=["C"], # Wall elements (e.g., carbon) - oh_cutoff=0.4, # O–H bond cutoff distance + oh_cutoff=1.2, # O–H bond cutoff distance (Å) ) # --- Step 3: Identify water oxygens for frame 0 --- diff --git a/docs/source/tutorials/Slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst similarity index 76% rename from docs/source/tutorials/Slicing_method_tuto.rst rename to docs/source/tutorials/slicing_method_tuto.rst index 8f8f941..652697a 100644 --- a/docs/source/tutorials/Slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -9,7 +9,7 @@ This tutorial explains how to compute the contact angle of a droplet using the * ----------- The **slicing method** divides the droplet into slices (along the z-axis) and fits a geometric model (e.g. spherical) to the liquid–solid interface profile. -This is ideal for study the evolution of the angles among a trajectory. +This is ideal for studying the evolution of the angle along a trajectory. ---- @@ -35,7 +35,7 @@ Example trajectory:: # Import necessary modules from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - from wetting_angle_kit.analysis import contact_angle_analyzer + from wetting_angle_kit.analysis import SlicingTrajectoryAnalyzer # --- Step 1: Define the trajectory file --- filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" @@ -56,11 +56,9 @@ Example trajectory:: parser = LammpsDumpParser(filename) # --- Step 5: Create the contact angle analyzer --- - # Using the 'slicing' method with a spherical model - analyzer = contact_angle_analyzer( - method="slicing", + # Using the slicing method with a spherical model + analyzer = SlicingTrajectoryAnalyzer( parser=parser, - output_dir="result_dump_spherical_slicing", atom_indices=oxygen_indices, droplet_geometry="spherical", # Geometry fitting model delta_gamma=20, # Azimuthal step (deg) for spherical slicing @@ -70,7 +68,9 @@ Example trajectory:: results = analyzer.analyze([1]) # Analyze frame 1 # --- Step 7: Display results --- - print("Analysis results:", results) + print("Mean contact angle (°):", results.mean_angle) + print("Std contact angle (°):", results.std_angle) + print("Frames analyzed:", results.frames) ---- @@ -80,26 +80,28 @@ Example trajectory:: After running the example, you'll see something like:: Number of water molecules: 1320 - Analysis results: { - 'mean_angle': 94.46, - 'std_angle': 0.0, - 'angles': {1: 94.46}, - 'frames_analyzed': [1], - 'method_metadata': {'frames_per_angle': 1}, - } + Mean contact angle (°): 94.46 + Std contact angle (°): 0.0 + Frames analyzed: [1] -The ``analyze`` return dict has these keys: +The standard deviation is reported as ``0.0`` because the example only +analyzes a single frame. ``std_angle`` is computed across frames — pass a +multi-frame ``frame_range`` (e.g. ``range(0, 50)``) to see a non-zero +spread. + +``analyze`` returns a :class:`SlicingResults` dataclass with the +following convenience attributes: * ``mean_angle`` — mean contact angle (°) across the analyzed frames. * ``std_angle`` — standard deviation across frames. -* ``angles`` — mapping ``frame_index -> mean angle for that frame``. -* ``frames_analyzed`` — list of frame indices that were processed. +* ``per_frame_mean_angles`` — array of per-frame mean angles (one per slice + aggregated to a single number). +* ``frames`` — list of frame indices that were processed. +* ``angles`` / ``surfaces`` / ``popts`` — raw per-frame data passed + directly to :class:`SlicingTrajectoryPlotter` for visualization. * ``method_metadata`` — method-specific info (e.g. number of frames per angle value). -Per-frame raw outputs (alfas, surfaces, popt) are saved as ``.npy`` files -inside the output directory. - ---- 5. Tips @@ -136,7 +138,7 @@ inside the output directory. """ from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - from wetting_angle_kit.analysis import contact_angle_analyzer + from wetting_angle_kit.analysis import SlicingTrajectoryAnalyzer # --- Step 1: Define input trajectory --- filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" @@ -153,10 +155,8 @@ inside the output directory. parser = LammpsDumpParser(filename) # --- Step 4: Create analyzer for the slicing method --- - analyzer = contact_angle_analyzer( - method="slicing", + analyzer = SlicingTrajectoryAnalyzer( parser=parser, - output_dir="result_dump_spherical_slicing", atom_indices=oxygen_indices, droplet_geometry="spherical", # Fitting model delta_gamma=20, # Azimuthal step (deg) for spherical slicing @@ -166,4 +166,5 @@ inside the output directory. results = analyzer.analyze([1]) # Analyze frame 1 # --- Step 6: Display results --- - print("Analysis results:", results) + print("Mean contact angle (°):", results.mean_angle) + print("Std contact angle (°):", results.std_angle) diff --git a/docs/source/tutorials/Visualization_slicing_droplet.rst b/docs/source/tutorials/visualization_slicing_droplet.rst similarity index 74% rename from docs/source/tutorials/Visualization_slicing_droplet.rst rename to docs/source/tutorials/visualization_slicing_droplet.rst index 5bd4830..ee0c01e 100644 --- a/docs/source/tutorials/Visualization_slicing_droplet.rst +++ b/docs/source/tutorials/visualization_slicing_droplet.rst @@ -28,7 +28,7 @@ The visualization workflow involves the following steps: LammpsDumpWaterFinder, LammpsDumpWallParser, ) - from wetting_angle_kit.analysis.slicing import ContactAngleSlicing + from wetting_angle_kit.analysis.slicing import SlicingFrameFitter from wetting_angle_kit.visualization import DropletSlicePlotter ---- @@ -78,17 +78,16 @@ The visualization workflow involves the following steps: .. code-block:: python - processor = ContactAngleSlicing( + processor = SlicingFrameFitter( liquid_coordinates=oxygen_position, liquid_geom_center=np.mean(oxygen_position, axis=0), droplet_geometry="cylinder_y", delta_cylinder=5, max_dist=100, - width_cylinder=21, ) - list_alfas, array_surfaces, array_popt = processor.predict_contact_angle() - print("Mean contact angles (°):", list_alfas) + list_angles, array_surfaces, array_popt = processor.predict_contact_angle() + print("Mean contact angles (°):", list_angles) ---- @@ -97,21 +96,23 @@ The visualization workflow involves the following steps: .. code-block:: python - plotter = DropletSlicePlotter(center=True, show_wall=True, molecule_view=True) + plotter = DropletSlicePlotter(center=True) - plotter.plot_surface_points( + # ``predict_contact_angle`` returns three parallel lists (one entry per + # slice that produced a usable angle); pick a single index across all + # three so the overlay refers to one and the same slice. + slice_idx = 0 + fig = plotter.plot_surface_points( oxygen_position=oxygen_position, - surface_data=array_surfaces, - popt=array_popt[0], + surface_data=[array_surfaces[slice_idx]], + popt=array_popt[slice_idx], wall_coords=wall_coords, - output_filename="droplet_plot.png", - alpha=list_alfas[0], + alpha=list_angles[slice_idx], ) - print(" Plot saved as 'droplet_plot.png'") + # Interactive view in a notebook + fig.show() -Outputs -------- - -.. image:: ../../images/droplet_plot.png - :alt: Droplet slicing method visualization + # Or save a standalone HTML page + fig.write_html("droplet_plot.html") + print("Plot saved as 'droplet_plot.html'") diff --git a/docs/tutorials/Binning_method_tuto.md b/docs/tutorials/Binning_method_tuto.md deleted file mode 100644 index 745bfc5..0000000 --- a/docs/tutorials/Binning_method_tuto.md +++ /dev/null @@ -1,131 +0,0 @@ -# Tutorial: Contact Angle Analysis (Binning Method) - -This tutorial demonstrates how to compute the contact angle using the **binning method** in `wetting_angle_kit`. -The method divides the simulation box into spatial bins to calculate the liquid–solid interface and the corresponding contact angle, for a group of frames. - ---- - -## 1. Overview - -The **binning method** works by: -1. Collecting the positions of water molecules (typically oxygen atoms). -2. Dividing the region of interest into bins in the **x–z** plane. -3. Computing density profiles and fitting the interface shape. -4. Deriving the contact angle from the interface curvature. - ---- - -## 2. Prerequisites - -Your trajectory file (e.g., a LAMMPS dump file) contain: -- Atom IDs, types, and positions -- Liquid particles (in this cas Water molecules: O and H atoms) - -Example trajectory: -``` -tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj -``` - ---- - -## 3. Example Script - -```python -# Import necessary modules -from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder -from wetting_angle_kit.analysis import contact_angle_analyzer - -# --- Step 1: Define the trajectory file --- -filename = "../../tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" - -# --- Step 2: Initialize the water molecule finder --- -# This identifies O and H atoms in water molecules -wat_find = LammpsDumpWaterFinder( - filename, - particle_type_wall={3}, # Wall atom types - oxygen_type=1, # Oxygen atom type - hydrogen_type=2, # Hydrogen atom type -) - -# --- Step 3: Get oxygen atom indices for the first frame --- -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) -print("Number of water molecules:", len(oxygen_indices)) - -# --- Step 4: Define binning parameters --- -binning_params = { - "xi_0": 0.0, # Minimum x-coordinate - "xi_f": 100.0, # Maximum x-coordinate - "nbins_xi": 50, # Number of bins along x - "zi_0": 0.0, # Minimum z-coordinate - "zi_f": 100.0, # Maximum z-coordinate - "nbins_zi": 25, # Number of bins along z -} - -# --- Step 5: Initialize the parser --- -parser = LammpsDumpParser(filename) - -# --- Step 6: Create the contact angle analyzer --- -analyzer = contact_angle_analyzer( - method="binning", - parser=parser, - output_dir="results_binning_example", - atom_indices=oxygen_indices, - droplet_geometry="cylinder_y", # Interface fitting model - width_cylinder=21, # Width parameter for interface fit - binning_params=binning_params, - plot_graphs=False, # Disable plotting for automated runs -) - -# --- Step 7: Run analysis for a frame range --- -results = analyzer.analyze([1]) # Analyze frame 1 -print("Analysis results:", results) -``` - ---- - -## 4. Output - -Running this example will: -- Parse the trajectory -- Compute the interface shape and local contact angle -- Save results (if enabled) under `results_binned_example/` - -Example printed output: -``` -Number of water molecules: 4000 - -```sh -xi range: (0.22795857644950415,41.63623606829102) -zi range: (7.54989,47.3742) - -Number of fluid particles in batch: 4000.0 - -Binning with model: spherical ... -Advancement: 0.00% -Advancement: 35.71% -Advancement: 71.43% - -Fitted parameters for batch: -rho1:-3.387136459516587e-05 -rho2:0.03389671977759232 -R_eq:37.22899870907881 -zi_c:9.244210981996149 -zi_0:6.265045941194059 -t1:-4.384696208816467 -t2:0.07378719793487698 - -Contact angle for batch: 94.58987060394456 -``` -A heat map representation of the particles density and the fitted semi-circle to get the contact angle. - -![Heat maps density particles](../images/bin_plot.png) ---- - -## 5. Tips - -- Adjust `xi_f`, `zi_f`, and the bin counts (`nbins_xi`, `nbins_zi`) according to your simulation box dimensions. -- If the wall surface is not flat or the system is tilted, pre-align it before analysis. -- Use `plot_graphs=True` to visualize the binning density and interface fitting. -- For multiple frames: `analyzer.analyze(range(0, 100, 10))`. - ---- diff --git a/docs/tutorials/Parser_tutorial.md b/docs/tutorials/Parser_tutorial.md deleted file mode 100644 index 31dce30..0000000 --- a/docs/tutorials/Parser_tutorial.md +++ /dev/null @@ -1,117 +0,0 @@ -# Tutorial: Using the Parser Module - -This tutorial shows how to load different trajectory formats using the `wetting_angle_kit.parsers` submodule. - -The parser provides a unified interface to read atomic coordinates from: -- LAMMPS dump files (`LammpsDumpParser`, ` LammpsDumpWaterFinder`) -- ASE `.traj` files (`AseParser`, `AseWaterFinder`) -- XYZ files (`XYZParser`) - -Each parser can extract atomic positions for selected frames and atoms, allowing efficient and flexible analysis of molecular simulations. - ---- - -## 1. General Workflow - -Every parser follows the same pattern: - -1. Initialize the parser with your trajectory file. -2. (Optional) Use a `WaterMoleculeFinder` class to locate oxygen atoms belonging to water molecules. -3. Extract coordinates of all atoms or only selected indices using `.parse(frame_index, indices)`. - -The `.parse()` method always returns a NumPy array of shape `(N, 3)` containing the atomic coordinates. - ---- - -## 2. Example: LAMMPS Dump File -```python -from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - -# --- Step 1: Define the trajectory file --- -filename = "../../tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" - -# --- Step 2: Initialize the water molecule finder --- -# Specify particle types for the wall and for water oxygens and hydrogens -wat_find = LammpsDumpWaterFinder( - filename, particle_type_wall={3}, oxygen_type=1, hydrogen_type=2 -) - -# --- Step 3: Identify oxygen atoms for frame 0 --- -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) -print("Number of water molecules:", len(oxygen_indices)) - -# --- Step 4: Initialize the parser --- -parser = LammpsDumpParser(filename) - -# --- Step 5: Extract coordinates of only the water oxygens --- -oxygen_positions = parser.parse(frame_index=0, indices=oxygen_indices) -print("Extracted positions for", len(oxygen_positions), "oxygen atoms.") -``` - -**Notes:** -- Use `indices=None` to parse all atoms. -- `.parse()` returns NumPy coordinates for the selected frame. - ---- - -## 3. Example: ASE Trajectory File -```python -from wetting_angle_kit.parsers import AseParser, AseWaterFinder - -# --- Step 1: Define the trajectory file --- -filename = "../../tests/trajectories/slice_10_mace_mlips_cylindrical_2_5.traj" - -# --- Step 2: Initialize the water molecule finder --- -wat_find = AseWaterFinder( - filename, - particle_type_wall=["C"], # Wall elements (e.g., carbon) - oh_cutoff=1.2, # O–H bond cutoff (Å); ASE NeighborList uses half this per atom -) - -# --- Step 3: Identify water oxygens for frame 0 --- -oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) -print("Number of water molecules:", len(oxygen_indices)) - -# --- Step 4: Initialize the parser --- -parser = AseParser(filename) - -# --- Step 5: Extract oxygen atom positions only --- -oxygen_positions = parser.parse(frame_index=0, indices=oxygen_indices) -print("Extracted positions for", len(oxygen_positions), "oxygen atoms.") -``` - -**Tip:** The ASE parser works for any ASE-compatible trajectory (e.g., `.traj`, `.extxyz`, etc.). - ---- - -## 4. Example: XYZ File - -```python -from wetting_angle_kit.parsers import XYZParser - -# --- Step 1: Define the trajectory file --- -filename = "../../tests/trajectories/slice_10_mace_mlips_cylindrical_2_5.xyz" - -# --- Step 2: Initialize the parser --- -xyz_parser = XYZParser(filename) - -# --- Step 3: Retrieve positions for the first frame --- -positions = xyz_parser.parse(frame_index=0) -print("Loaded frame with", len(positions), "atoms.") - -# --- Step 4 (Optional): Parse only a subset of atoms --- -# For example, extract the first 50 atoms -subset_positions = xyz_parser.parse(frame_index=0, indices=list(range(50))) -print("Subset of 50 atoms extracted successfully.") -``` - ---- - -## 5. Summary - -The parser module provides: -- **Unified interface** across LAMMPS, ASE, and XYZ formats -- **Selective parsing** using frame number and atom indices -- **Water molecule identification** to filter oxygen atoms from bulk water with tools from ase and ovito library - -All parsers return NumPy arrays of shape `(N, 3)` for direct use in analysis pipelines. diff --git a/docs/tutorials/Slicing_method_tuto.md b/docs/tutorials/Slicing_method_tuto.md deleted file mode 100644 index dfb809d..0000000 --- a/docs/tutorials/Slicing_method_tuto.md +++ /dev/null @@ -1,153 +0,0 @@ -# Tutorial: Contact Angle Analysis (Slicing Method) - -This tutorial explains how to compute the contact angle of a droplet using the **slicing method** in `wetting_angle_kit`. - ---- - -## 1. Overview - -The **slicing method** divides the droplet into slices (along the z-axis) and fits a geometric model (e.g. spherical) to the liquid–solid interface profile. -This is ideal for study the evolution of the angles among a trajectory. - ---- - -## 2. Requirements - -Before running the example, ensure you have installed: -````bash -pip install wetting-angle-kit ase numpy -```` - -Example trajectory: -````bash -tests/trajectories/traj_spherical_drop_4k.lammpstrj -```` - ---- - -## 3. Example Code - -````python -# Import necessary modules -from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder -from wetting_angle_kit.analysis import contact_angle_analyzer - -# --- Step 1: Define the trajectory file --- -filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" - -# --- Step 2: Initialize the water molecule finder --- -wat_find = LammpsDumpWaterFinder( - filename, - particle_type_wall={3}, # Wall particle types - oxygen_type=1, # Oxygen atom type - hydrogen_type=2 ) # Hydrogen atom type - -# --- Step 3: Identify oxygen atom indices --- -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) -print("Number of water molecules:", len(oxygen_indices)) - -# --- Step 4: Initialize the parser --- -parser = LammpsDumpParser(filename) - -# --- Step 5: Create the contact angle analyzer --- -# Using the 'slicing' method with a spherical model -analyzer = contact_angle_analyzer( - method='slicing', - parser=parser, - output_dir='result_dump_spherical_slicing', - atom_indices=oxygen_indices, - droplet_geometry='spherical', # Geometry fitting model - delta_gamma=20 # Smoothing parameter -) - -# --- Step 6: Run the analysis --- -results = analyzer.analyze([1]) # Analyze frame 1 - -# --- Step 7: Display results --- -print("Analysis results:", results) -```` - ---- - -## 4. Expected Output - -After running the example, you'll see something like: -```` -Number of water molecules: 4000 -2026-04-06 20:47:54,562 - INFO - Processing 1 frames in 1 batches with 4 workers -2026-04-06 20:47:54,907 - INFO - Detected parser type: dump -2026-04-06 20:47:55,137 - INFO - START processing frame 1 -2026-04-06 20:47:55,144 - INFO - Frame 1: Parsed 4000 liquid particles with max_dist 59 -2026-04-06 20:47:59,686 - INFO - Frame 1 - mean angle: 94.46° -2026-04-06 20:47:59,687 - INFO - Completed batch 1/1 (1 frames) -2026-04-06 20:47:59,807 - INFO - Successfully processed 1/1 frames -Analysis results: {'mean_angle': 94.4618784164532, 'std_angle': 0.0, 'angles': {1: 94.4618784164532}, 'frames_analyzed': [1], 'method_metadata': {'frames_per_angle': 1}} - -```` - -If plotting is enabled, a visualization of the droplet profile and the fitted spherical interface is generated in `result_dump_spherical_slicing/`. - ---- - -## 5. Tips - -- Use `droplet_geometry='spherical'` for droplets and `droplet_geometry='cylinder_y'` for cylindrical droplet on the y axis or `'cylinder_x'`for cylinder on the x axis. -- Adjust `delta_gamma` for smoother or sharper slicing (larger = smoother). -- To analyze multiple frames: -````python -results = analyzer.analyze(range(0, 50, 10)) -```` - -- Output files include raw interface data and optional plots (if enabled). - ---- - -## 6. Related Files - -**Example Script:** `docs/examples/contact_angle_slicing/example_slicing.py` -````python -""" -Example: Contact Angle Analysis Using the Slicing Method - -This example demonstrates how to perform a contact angle analysis -using the 'slicing' method on a spherical droplet from a LAMMPS dump trajectory. -""" - -from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder -from wetting_angle_kit.analysis import contact_angle_analyzer - -# --- Step 1: Define input trajectory --- -filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" - -# --- Step 2: Identify water molecules --- -wat_find = LammpsDumpWaterFinder( - filename, - particle_type_wall={3}, # Wall atom types - oxygen_type=1, - hydrogen_type=2 -) - -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) -print(f"Number of water molecules: {len(oxygen_indices)}") - -# --- Step 3: Initialize parser --- -parser = LammpsDumpParser(filename) - -# --- Step 4: Create analyzer for the slicing method --- -analyzer = contact_angle_analyzer( - method='slicing', - parser=parser, - output_dir='result_dump_spherical_slicing', - atom_indices=oxygen_indices, - droplet_geometry='spherical', - delta_gamma=20 -) - -# --- Step 5: Run analysis --- -results = analyzer.analyze([1]) # Analyze frame 1 - -# --- Step 6: Display results --- -print("Analysis results:", results) -```` - ---- diff --git a/docs/tutorials/Visualization_slicing_droplet.md b/docs/tutorials/Visualization_slicing_droplet.md deleted file mode 100644 index ccbf4c3..0000000 --- a/docs/tutorials/Visualization_slicing_droplet.md +++ /dev/null @@ -1,103 +0,0 @@ -# Visualization Tutorial — Droplet Surface and Contact Angle - -This tutorial demonstrates how to visualize a droplet and compute its contact angle using the **wetting_angle_kit** package. We'll use the `slicing` contact angle method and visualize the resulting droplet with the `DropletSlicePlotter` class. - ---- - -## 1. Overview - -The visualization workflow involves the following steps: - -1. Parse atomic positions from a trajectory file. -2. Identify water molecules (oxygen and hydrogen atoms). -3. Compute the droplet surface and contact angle using the *slicing method*. -4. Visualize the droplet, fitted circle, tangent, and wall. - ---- - -## 2. Import Required Modules -```python -import matplotlib - -matplotlib.use("Agg") # Required to prevent Qt conflicts with Ovito - -import numpy as np -from wetting_angle_kit.parsers import ( - LammpsDumpParser, - LammpsDumpWaterFinder, - LammpsDumpWallParser, -) -from wetting_angle_kit.analysis.slicing import ContactAngleSlicing -from wetting_angle_kit.visualization import DropletSlicePlotter -``` - ---- - -## 3. Define the Input Trajectory -```python -filename = ( - "../wetting_angle_kit/tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" -) -``` - ---- - -## 4. Identify Water Molecules -```python -wat_find = LammpsDumpWaterFinder( - filename, particle_type_wall={3}, oxygen_type=1, hydrogen_type=2 -) - -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) -print("Number of water molecules detected:", len(oxygen_indices)) -``` - ---- - -## 5. Parse Atomic Coordinates -```python -parser = LammpsDumpParser(filepath=filename) -oxygen_position = parser.parse(frame_index=10, indices=oxygen_indices) - -coord_wall = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) -wall_coords = coord_wall.parse(frame_index=1) -``` - ---- - -## 6. Compute Contact Angles -```python -processor = ContactAngleSlicing( - liquid_coordinates=oxygen_position, - liquid_geom_center=np.mean(oxygen_position, axis=0), - droplet_geometry="cylinder_y", - delta_cylinder=5, - max_dist=100, - width_cylinder=21, -) - -list_alfas, array_surfaces, array_popt = processor.predict_contact_angle() -print("Mean contact angles (°):", list_alfas) -``` - ---- - -## 7. Visualize the Droplet -```python -plotter = DropletSlicePlotter(center=True, show_wall=True, molecule_view=True) - -plotter.plot_surface_points( - oxygen_position=oxygen_position, - surface_data=array_surfaces, - popt=array_popt[0], - wall_coords=wall_coords, - output_filename="droplet_plot.png", - alpha=list_alfas[0], -) - -print(" Plot saved as 'droplet_plot.png'") -``` -## Outputs - - -![Droplet slicing method visualization](../images/droplet_plot.png) diff --git a/docs/tutorials/Visualization_trajectories_comparison_methods.md b/docs/tutorials/Visualization_trajectories_comparison_methods.md deleted file mode 100644 index 45e77c5..0000000 --- a/docs/tutorials/Visualization_trajectories_comparison_methods.md +++ /dev/null @@ -1,144 +0,0 @@ -# Tutorial: Comparing Trajectory Analysis Methods - -This tutorial demonstrates how to use the `BinningTrajectoryAnalyzer` and `SlicingTrajectoryAnalyzer` classes to analyze and compare contact angle and surface area data from trajectory simulations. - ---- - -## Table of Contents -1. [Introduction](#introduction) -2. [Setup and Initialization](#setup-and-initialization) -3. [Running the Analysis](#running-the-analysis) -4. [Interpreting the Output](#interpreting-the-output) -5. [Visualization](#visualization) -6. [Method Comparison](#method-comparison) -7. [Conclusion](#conclusion) - ---- - -## Introduction -The `BinningTrajectoryAnalyzer` and `SlicingTrajectoryAnalyzer` classes are designed to analyze trajectory data, specifically focusing on **surface area** and **contact angle** statistics. These tools are useful for comparing different analysis methods and visualizing results. - ---- - -## Setup and Initialization - -### Import the Classes -Ensure you have the required classes imported: - -```python -from wetting_angle_kit.visualization import ( - BinningTrajectoryAnalyzer, - MethodComparison, - SlicingTrajectoryAnalyzer, -) -``` ---- - -## Initialize the Analyzers -Specify the directories containing your trajectory data: - -```python -directories = [ - "slicing_analysis_CA/result_dump_traj_500_binned", - "slicing_analysis_CA/result_dump_traj_1k_binned", - "slicing_analysis_CA/result_dump_traj_2k_binned", - "slicing_analysis_CA/result_dump_traj_4k_binned", -] - -# Initialize the analyzers -slicing = SlicingTrajectoryAnalyzer(directories) -binning = BinningTrajectoryAnalyzer(directories) -``` ---- -## Running the Analysis - -### Analyze Data - -Run the analysis for both methods: - -```python -slicing.analyze() -binning.analyze() -``` - -### Example Output: - -```text -Directory: slicing_analysis_CA/result_dump_traj_2k_reduce_binned - Method: Slicing Analysis - Mean Surface Area: 2770.0659 - Mean Contact Angle: 91.7015° - -Directory: binning_analysis_CA/result_dump_traj_2k_reduce_binned - Method: Binning Analysis - Mean Surface Area: 2748.5427 - Mean Contact Angle: 91.9236° -``` ---- -### Interpreting the Output - -- Mean Surface Area: The average surface area for each trajectory. -- Mean Contact Angle: The average contact angle for each trajectory. -- Standard Deviation: Indicates the variability of the data. ---- -## Visualisation - -### Plot Mean Angle vs Surface Area - -```python -slicing.plot_mean_angle_vs_surface(save_path="mean_angle_vs_surface_slicing.png") -binning.plot_mean_angle_vs_surface(save_path="mean_angle_vs_surface_binning.png") -``` -### Plot Median Angle Evolution -For the slicing method, plot the evolution of median angles: -```python -slicing.plot_median_alfas_evolution(save_path="evolution_of_angles_slicing_method.png") -``` -## Method Comparison - -### Compare Statistics -Use the MethodComparison class to compare the two methods: - -```python -comparison = MethodComparison([slicing, binning]) -comparison.plot_side_by_side_comparison(save_path="comparison.png") -print(comparison.compare_statistics()) -``` -### Example Output: - -```text -====================================================================== -METHOD COMPARISON STATISTICS -====================================================================== -Slicing Analysis: ----------------------------------------------------------------------- - slicing_analysis_CA/traj_2k/: - Mean Surface Area: 2770.0659 ± 15.2001 - Mean Angle: 91.7015° ± 5.6130° - Overall Statistics: - Total samples: 196 - Mean Surface Area: 4001.0215 - Mean Angle: 91.8326° - Std Angle: 6.2027° - -Binning Analysis: ----------------------------------------------------------------------- - binning_analysis_CA/traj_2k: - Mean Surface Area: 2748.5427 ± 0.0000 - Mean Angle: 91.9236° ± 0.0000° - Overall Statistics: - Total samples: 4 - Mean Surface Area: 4022.1019 - Mean Angle: 92.0876° - Std Angle: 0.2391° -``` - -## Conclusion -- The SlicingTrajectoryAnalyzer provides more detailed statistics with higher sample counts. - -- The BinningTrajectoryAnalyzer offers a simplified, binning approach. - -- Use the comparison tools to visualize and interpret differences between methods. - -## Additional Notes -- Ensure your data directories are correctly formatted and contain the required log files. diff --git a/docs/tutorials/result_dump_spherical_slicing/all_alfas.npy b/docs/tutorials/result_dump_spherical_slicing/all_alfas.npy deleted file mode 100644 index e075e96..0000000 Binary files a/docs/tutorials/result_dump_spherical_slicing/all_alfas.npy and /dev/null differ diff --git a/docs/tutorials/result_dump_spherical_slicing/all_popts.npy b/docs/tutorials/result_dump_spherical_slicing/all_popts.npy deleted file mode 100644 index fdf39e8..0000000 Binary files a/docs/tutorials/result_dump_spherical_slicing/all_popts.npy and /dev/null differ diff --git a/docs/tutorials/result_dump_spherical_slicing/all_surfaces.npy b/docs/tutorials/result_dump_spherical_slicing/all_surfaces.npy deleted file mode 100644 index 7a9a58e..0000000 Binary files a/docs/tutorials/result_dump_spherical_slicing/all_surfaces.npy and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index ab9fbf2..90a777f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,8 +1,8 @@ [project] name = "wetting-angle-kit" -description = "A Python library to parse MD trajectories from LAMMPS and ASE and measure the contact through different methods" +description = "A Python library to parse MD trajectories from LAMMPS and ASE and measure the contact angle through different methods" authors = [ - { name = "Gabriel", email = "gabriel.taillandier@matgenix.com" }, + { name = "Gabriel Taillandier", email = "gabriel.taillandier@matgenix.com" }, { name = "Matgenix", email = "info@matgenix.com" }, ] license = "BSD-3-Clause" @@ -21,7 +21,8 @@ dependencies = [ "numpy>=1.26.0", "scipy>=1.13.0", "matplotlib>=3.9.0", - "plotly>=5.24.1" + "plotly>=5.24.1", + "tqdm>=4.66.0" ] [project.optional-dependencies] @@ -57,6 +58,7 @@ all = ["ase>=3.23.0", "ovito~=3.11.3", "ipython>=8.0.0"] [project.urls] "Homepage" = "https://github.com/Matgenix/wetting-angle-kit" +"Source" = "https://github.com/Matgenix/wetting-angle-kit" "Bug Tracker" = "https://github.com/Matgenix/wetting-angle-kit/issues" "Documentation" = "https://matgenix.github.io/wetting-angle-kit" @@ -89,31 +91,38 @@ multi_line_output = 3 [tool.mypy] python_version = "3.10" +strict = true # NumPy reductions (np.mean, etc.) and ASE attributes (frame.positions) are # typed as Any in their stubs, so functions returning them legitimately trip -# no-any-return. Re-enable if/when those returns are wrapped in np.asarray() -# or cast(np.ndarray, ...). +# no-any-return under ``strict``. Re-enable if/when those returns are wrapped +# in ``np.asarray`` or ``cast(np.ndarray, ...)``. warn_return_any = false -warn_unused_configs = true -disallow_untyped_defs = true -disallow_incomplete_defs = true +# NumPy's ndarray accepts shape and dtype generic parameters that almost no +# call-site is prepared to specify (``np.ndarray[Any, np.dtype[Any]]`` is the +# closest equivalent of a bare ``np.ndarray`` annotation). Disabling +# disallow_any_generics keeps the rest of ``strict`` active while letting +# arrays continue to be annotated as ``np.ndarray``. Reach for +# ``numpy.typing.NDArray`` (or ``cast``) at the boundaries where a more +# precise type would genuinely help readers. +disallow_any_generics = false -# scipy, mpl_toolkits and plotly have no stubs installed. Disable only +# scipy, mpl_toolkits and plotly have no stubs installed. Disable only # import-untyped (module exists but lacks py.typed / stubs); import-not-found # (module doesn't exist at all — catches typos and removed dependencies) stays # active. disable_error_code = ["import-untyped"] [[tool.mypy.overrides]] -# ASE's ase.io.read() is typed as Union[Atom, Atoms]; all call-sites in -# parsers/ase.py receive Atoms in practice, so suppress only the union-attr -# cascade rather than silencing the entire file. +# ASE's ase.io.read(index=":") is typed as Union[Atoms, list[Atoms]] even +# though it always returns a list when ``index=":"``; suppress only the +# resulting union-attr / arg-type cascade in parsers/ase.py rather than +# silencing the whole module. ``no-untyped-call`` covers ase.neighborlist's +# NeighborList API, which has no inline type information in current stubs. module = ["ase.*", "wetting_angle_kit.parsers.ase"] -disable_error_code = ["union-attr"] +disable_error_code = ["union-attr", "arg-type", "no-untyped-call"] [tool.pytest.ini_options] testpaths = ["tests"] -pythonpath = ["."] python_files = "test_*.py" python_classes = "Test*" python_functions = "test_*" diff --git a/src/wetting_angle_kit/__init__.py b/src/wetting_angle_kit/__init__.py index e69de29..9b436fa 100644 --- a/src/wetting_angle_kit/__init__.py +++ b/src/wetting_angle_kit/__init__.py @@ -0,0 +1 @@ +from wetting_angle_kit._version import __version__ as __version__ diff --git a/src/wetting_angle_kit/analysis/__init__.py b/src/wetting_angle_kit/analysis/__init__.py index 7abcf09..7333577 100644 --- a/src/wetting_angle_kit/analysis/__init__.py +++ b/src/wetting_angle_kit/analysis/__init__.py @@ -1,62 +1,23 @@ """Contact-angle analysis orchestrators and per-method engines.""" -from typing import Any - -from wetting_angle_kit.analysis.analyzer import ( - BaseContactAngleAnalyzer, - BinningContactAngleAnalyzer, - SlicingContactAngleAnalyzer, +from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer +from wetting_angle_kit.analysis.binning.analyzer import ( + BinningTrajectoryAnalyzer, ) from wetting_angle_kit.analysis.binning.angle_fitting import ( - ContactAngleBinning, + BinningBatchFitter, ) -from wetting_angle_kit.analysis.slicing.angle_fitting import ( - ContactAngleSlicing, +from wetting_angle_kit.analysis.slicing.analyzer import ( + SlicingTrajectoryAnalyzer, ) -from wetting_angle_kit.analysis.slicing.parallel import ( - ContactAngleSlicingParallel, +from wetting_angle_kit.analysis.slicing.angle_fitting import ( + SlicingFrameFitter, ) - -def contact_angle_analyzer( - method: str, parser: Any, output_dir: str, **kwargs: Any -) -> BaseContactAngleAnalyzer: - """Return an analyzer instance for the requested contact-angle method. - - Parameters - ---------- - method : str - Analysis method; one of ``"slicing"`` or ``"binning"``. - parser : BaseParser - Trajectory parser instance. - output_dir : str - Directory for output files. - **kwargs - Forwarded to the selected analyzer constructor. - - Returns - ------- - BaseContactAngleAnalyzer - Configured analyzer ready to call ``analyze()``. - """ - if method == "slicing": - return SlicingContactAngleAnalyzer( - parser=parser, output_dir=output_dir, **kwargs - ) - elif method == "binning": - return BinningContactAngleAnalyzer( - parser=parser, output_dir=output_dir, **kwargs - ) - else: - raise ValueError(f"Unknown method '{method}'. Expected 'slicing' or 'binning'.") - - __all__ = [ - "BaseContactAngleAnalyzer", - "SlicingContactAngleAnalyzer", - "BinningContactAngleAnalyzer", - "contact_angle_analyzer", - "ContactAngleBinning", - "ContactAngleSlicing", - "ContactAngleSlicingParallel", + "BaseTrajectoryAnalyzer", + "SlicingTrajectoryAnalyzer", + "BinningTrajectoryAnalyzer", + "BinningBatchFitter", + "SlicingFrameFitter", ] diff --git a/src/wetting_angle_kit/analysis/analyzer.py b/src/wetting_angle_kit/analysis/analyzer.py index 65a70fa..03b6e84 100644 --- a/src/wetting_angle_kit/analysis/analyzer.py +++ b/src/wetting_angle_kit/analysis/analyzer.py @@ -1,172 +1,13 @@ +"""Abstract base class for contact-angle analyzers.""" + from abc import ABC, abstractmethod from typing import Any -import numpy as np - -from wetting_angle_kit.analysis.binning.angle_fitting import ( - ContactAngleBinning, -) -from wetting_angle_kit.analysis.slicing.parallel import ( - ContactAngleSlicingParallel, -) - -class BaseContactAngleAnalyzer(ABC): +class BaseTrajectoryAnalyzer(ABC): """Abstract base for contact angle analysis across trajectory files.""" @abstractmethod - def analyze( - self, frame_range: list[int] | None = None, **kwargs: Any - ) -> dict[str, Any]: - """Run the analysis and return statistics.""" - pass - - @abstractmethod - def get_method_name(self) -> str: - """Return the method name identifier.""" + def analyze(self, frame_range: list[int] | None = None) -> Any: + """Run the analysis and return a method-specific results object.""" pass - - def summary(self) -> dict[str, float]: - """Return quick summary statistics.""" - results = self.analyze() - return { - "mean": results["mean_angle"], - "std": results["std_angle"], - "n_samples": len(results["angles"]), - } - - -class SlicingContactAngleAnalyzer(BaseContactAngleAnalyzer): - """BaseContactAngleAnalyzer implementation using the slicing parallel method.""" - - def __init__( - self, - parser: Any, - output_dir: str, - **kwargs: Any, - ): - """ - Parameters - ---------- - parser : BaseParser - Trajectory parser instance. - output_dir : str - Directory for output files. - **kwargs - Forwarded to ContactAngleSlicingParallel. - """ - self.parser = parser - self.output_dir = output_dir - self._processor = ContactAngleSlicingParallel( - filename=parser.filepath, output_dir=output_dir, **kwargs - ) - - def analyze( - self, frame_range: list[int] | None = None, **kwargs: Any - ) -> dict[str, Any]: - """Run the slicing parallel analysis and return statistics. - - Parameters - ---------- - frame_range : list[int], optional - Frame indices to process. If None, all frames are used. - **kwargs - Forwarded to process_frames_parallel. - - Returns - ------- - dict - Keys: mean_angle, std_angle, angles, frames_analyzed, method_metadata. - """ - if frame_range is None: - frame_range = list(range(self.parser.frame_count())) - - frame_to_angle = self._processor.process_frames_parallel( - frames_to_process=frame_range, **kwargs - ) - angles = np.array(list(frame_to_angle.values())) - - return { - "mean_angle": np.mean(angles), - "std_angle": np.std(angles), - "angles": frame_to_angle, - "frames_analyzed": list(frame_to_angle.keys()), - "method_metadata": {"frames_per_angle": 1}, - } - - def get_method_name(self) -> str: - """Return "slicing_parallel".""" - return "slicing_parallel" - - -class BinningContactAngleAnalyzer(BaseContactAngleAnalyzer): - """BaseContactAngleAnalyzer implementation using the density-binning method.""" - - def __init__(self, parser: Any, output_dir: str, **kwargs: Any) -> None: - """ - Parameters - ---------- - parser : BaseParser - Trajectory parser instance. - output_dir : str - Directory for output files. - **kwargs - Forwarded to ContactAngleBinning. - """ - self.parser = parser - self.output_dir = output_dir - self._analyzer = ContactAngleBinning( - parser=parser, output_dir=output_dir, **kwargs - ) - - def analyze( - self, - frame_range: list[int] | None = None, - split_factor: int | None = None, - **kwargs: Any, - ) -> dict[str, Any]: - """Run the binning analysis and return statistics. - - Parameters - ---------- - frame_range : list[int], optional - Frame indices to process. If None, all frames are used. - split_factor : int, optional - If given, split frame_range into sub-batches of this size and - compute one angle per batch; if None, all frames form a single batch. - **kwargs - Reserved for future use. - - Returns - ------- - dict - Keys: mean_angle, std_angle, angles, frames_analyzed, method_metadata. - """ - if frame_range is None: - frame_range = list(range(self.parser.frame_count())) - if split_factor is None: - angle, _ = self._analyzer.process_batch(frame_range) - angles = np.array([angle]) - method_metadata = {"frames_per_angle": len(frame_range)} - else: - angles_list: list[float] = [] - for batch_idx, start in enumerate(range(0, len(frame_range), split_factor)): - end = min(start + split_factor, len(frame_range)) - angle, _ = self._analyzer.process_batch( - frame_range[start:end], - batch_index=batch_idx + 1, # Pass batch index - ) - angles_list.append(angle) - angles = np.array(angles_list) - method_metadata = {"frames_per_trajectory": split_factor} - return { - "mean_angle": np.mean(angles), - "std_angle": np.std(angles), - "angles": angles, - "frames_analyzed": frame_range, - "method_metadata": method_metadata, - } - - def get_method_name(self) -> str: - """Return "binning_density".""" - return "binning_density" diff --git a/src/wetting_angle_kit/analysis/binning/__init__.py b/src/wetting_angle_kit/analysis/binning/__init__.py index 0e1b379..c3b400c 100644 --- a/src/wetting_angle_kit/analysis/binning/__init__.py +++ b/src/wetting_angle_kit/analysis/binning/__init__.py @@ -1,10 +1,17 @@ """Public exports for binning contact angle method.""" +from wetting_angle_kit.analysis.binning.analyzer import ( + BinningTrajectoryAnalyzer, +) from wetting_angle_kit.analysis.binning.angle_fitting import ( - ContactAngleBinning, + BinningBatchFitter, ) from wetting_angle_kit.analysis.binning.surface_definition import ( HyperbolicTangentModel, ) -__all__ = ["ContactAngleBinning", "HyperbolicTangentModel"] +__all__ = [ + "BinningTrajectoryAnalyzer", + "BinningBatchFitter", + "HyperbolicTangentModel", +] diff --git a/src/wetting_angle_kit/analysis/binning/analyzer.py b/src/wetting_angle_kit/analysis/binning/analyzer.py new file mode 100644 index 0000000..f53f85a --- /dev/null +++ b/src/wetting_angle_kit/analysis/binning/analyzer.py @@ -0,0 +1,92 @@ +"""Trajectory-level binning contact-angle analyzer.""" + +from typing import Any + +from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer +from wetting_angle_kit.analysis.binning.angle_fitting import ( + BinningBatchFitter, +) +from wetting_angle_kit.analysis.binning.results import BinningResults + + +class BinningTrajectoryAnalyzer(BaseTrajectoryAnalyzer): + """BaseTrajectoryAnalyzer implementation using the density-binning method.""" + + def __init__( + self, + parser: Any, + atom_indices: Any, + droplet_geometry: str = "spherical", + binning_params: dict[str, Any] | None = None, + precentered: bool = False, + ) -> None: + """ + Parameters + ---------- + parser : BaseParser + Trajectory parser providing coordinates and box dimensions. + atom_indices : Any + Indices (or IDs) of liquid atoms to include in the density field. + droplet_geometry : str, default "spherical" + One of ``"spherical"``, ``"cylinder_x"``, ``"cylinder_y"``. + binning_params : dict, optional + Grid definition with keys ``xi_0``, ``xi_f``, ``nbins_xi``, + ``zi_0``, ``zi_f``, ``nbins_zi``. A heuristic default is used if None. + precentered : bool, default False + Skip per-frame circular-mean PBC recentering. Setting this on a + trajectory that does NOT satisfy the precondition will produce + wrong results. + """ + self.parser = parser + self._analyzer = BinningBatchFitter( + parser=parser, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + binning_params=binning_params, + precentered=precentered, + ) + + def analyze( + self, + frame_range: list[int] | None = None, + split_factor: int | None = None, + **kwargs: Any, + ) -> BinningResults: + """Run the binning analysis. + + Parameters + ---------- + frame_range : list[int], optional + Frame indices to process. If None, all frames are used. + split_factor : int, optional + If given, split ``frame_range`` into sub-batches of this size and + compute one angle per batch; if None, all frames form a single batch. + **kwargs + Reserved for future use. + + Returns + ------- + BinningResults + Per-batch contact angles, density fields and isoline data. + """ + if frame_range is None: + frame_range = list(range(self.parser.frame_count())) + if split_factor is None: + batch = self._analyzer.process_batch(frame_range) + return BinningResults( + batches=[batch], + method_metadata={"frames_per_angle": len(frame_range)}, + ) + batches = [] + for batch_idx, start in enumerate(range(0, len(frame_range), split_factor)): + end = min(start + split_factor, len(frame_range)) + batches.append( + self._analyzer.process_batch( + frame_range[start:end], + batch_index=batch_idx + 1, + ) + ) + return BinningResults( + batches=batches, + method_metadata={"frames_per_trajectory": split_factor}, + ) diff --git a/src/wetting_angle_kit/analysis/binning/angle_fitting.py b/src/wetting_angle_kit/analysis/binning/angle_fitting.py index bcb772f..776c851 100644 --- a/src/wetting_angle_kit/analysis/binning/angle_fitting.py +++ b/src/wetting_angle_kit/analysis/binning/angle_fitting.py @@ -12,9 +12,10 @@ Per-bin volume elements: -* ``cylinder_x`` / ``cylinder_y``: ``dV = 2 * width_cylinder * dxi * dzi``. - The factor of 2 accounts for folding the symmetric distribution into - positive ``xi`` via ``|x_centered|``. +* ``cylinder_x`` / ``cylinder_y``: ``dV = 2 * box_dimension * dxi * dzi``, + where ``box_dimension`` is the box length along the cylinder axis read + from the parser. The factor of 2 accounts for folding the symmetric + distribution into positive ``xi`` via ``|x_centered|``. * ``spherical``: ``dV = 2 * pi * xi_cc * dxi * dzi`` — the annular shell volume of cylindrical coordinates. @@ -25,15 +26,13 @@ """ import logging -import os import warnings from collections.abc import Sequence from typing import Any -import matplotlib -import matplotlib.pyplot as plt import numpy as np +from wetting_angle_kit.analysis.binning.results import BinningBatch from wetting_angle_kit.analysis.binning.surface_definition import ( HyperbolicTangentModel, ) @@ -44,17 +43,10 @@ logger = logging.getLogger(__name__) -# Force a non-interactive backend before pyplot is imported so figure -# generation works in headless environments (CI, OVITO subprocesses). -# Only switch if no backend is already attached to an open figure. -if matplotlib.get_backend().lower() != "agg": - try: - matplotlib.use("Agg", force=False) - except (ImportError, ValueError): - pass +_PARAM_NAMES = ("rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2") -class ContactAngleBinning: +class BinningBatchFitter: """Binning-based contact angle estimator using density field fitting. Frames aggregated in spatial bins form a time-averaged density field. @@ -67,10 +59,7 @@ def __init__( parser: Any, atom_indices: Any, droplet_geometry: str = "spherical", - width_cylinder: float | None = None, binning_params: dict[str, Any] | None = None, - output_dir: str = "output_analysis/", - plot_graphs: bool = True, precentered: bool = False, ) -> None: """ @@ -82,15 +71,9 @@ def __init__( Indices (or IDs) of liquid atoms to include in the density field. droplet_geometry : str, default "spherical" One of ``"spherical"``, ``"cylinder_x"``, ``"cylinder_y"``. - width_cylinder : float, optional - Box length along the cylinder axis; inferred from the parser if None. binning_params : dict, optional Grid definition with keys ``xi_0``, ``xi_f``, ``nbins_xi``, ``zi_0``, ``zi_f``, ``nbins_zi``. A heuristic default is used if None. - output_dir : str, default "output_analysis/" - Directory for log files and density field CSVs. - plot_graphs : bool, default True - Whether to generate density contour plots. precentered : bool, default False Set True to declare that the trajectory already recenters the droplet at every frame and atoms are not wrapped across periodic @@ -103,9 +86,6 @@ def __init__( self.parser = parser self.atom_indices = atom_indices self.droplet_geometry = droplet_geometry - self.width_cylinder = width_cylinder - self.output_dir = output_dir - self.plot_graphs = plot_graphs self.precentered = precentered if binning_params is None: max_dist = int( @@ -140,13 +120,12 @@ def __init__( else: self.binning_params = binning_params self._initialize_grid() - if self.width_cylinder is None: - if self.droplet_geometry in ("cylinder_x", "cylinder_y"): - if self.droplet_geometry == "cylinder_x": - self.width_cylinder = self.parser.box_size_x(frame_index=0) - elif self.droplet_geometry == "cylinder_y": - self.width_cylinder = self.parser.box_size_y(frame_index=0) - os.makedirs(self.output_dir, exist_ok=True) + if self.droplet_geometry == "cylinder_x": + self.box_dimension = self.parser.box_size_x(frame_index=0) + elif self.droplet_geometry == "cylinder_y": + self.box_dimension = self.parser.box_size_y(frame_index=0) + else: + self.box_dimension = None def _initialize_grid(self) -> None: """Initialize bin edges, centers and cell sizes from parameters.""" @@ -192,33 +171,16 @@ def get_profile_coordinates( validate_droplet_geometry(self.droplet_geometry) r_chunks: list[np.ndarray] = [] z_chunks: list[np.ndarray] = [] - # If the user has declared the trajectory pre-centered, skip the - # box probe entirely and use the legacy arithmetic-mean path. - # Otherwise probe the parser once for lateral box info (skip if no - # frames were requested). If unavailable -- e.g. plain XYZ without - # a Lattice= line, or a custom parser that doesn't expose - # box_size_x/y -- fall back to the legacy centering. That is - # correct only when the trajectory already recenters the droplet - # at every frame and atoms are not wrapped across periodic - # boundaries. + # ``precentered=True`` skips the box probe and uses arithmetic-mean + # centering; otherwise box_size is queried per-frame for PBC-aware + # recentering. The parser ABC enforces box_size_x/y, so no fallback + # is needed. box_size: tuple[float, float] | None = None if frame_indices and not self.precentered: - try: - box_size = ( - self.parser.box_size_x(frame_index=frame_indices[0]), - self.parser.box_size_y(frame_index=frame_indices[0]), - ) - except (NotImplementedError, ValueError): - warnings.warn( - "Parser does not expose lateral box sizes; falling back " - "to arithmetic-mean droplet centering. This is correct " - "only if the trajectory already recenters the droplet at " - "every frame and atoms are not wrapped across periodic " - "boundaries. Provide lattice information in the " - "trajectory to enable PBC-aware recentering.", - UserWarning, - stacklevel=2, - ) + box_size = ( + self.parser.box_size_x(frame_index=frame_indices[0]), + self.parser.box_size_y(frame_index=frame_indices[0]), + ) for frame_idx in frame_indices: positions = self.parser.parse(frame_idx, self.atom_indices) if box_size is not None: @@ -281,12 +243,7 @@ def binning( bins=(self.xi, self.zi), ) if self.droplet_geometry in ("cylinder_x", "cylinder_y"): - if self.width_cylinder is None: - raise ValueError( - "width_cylinder is required for " - f"droplet_geometry={self.droplet_geometry!r}" - ) - dV = 2.0 * self.width_cylinder * self.dxi * self.dzi + dV = 2.0 * self.box_dimension * self.dxi * self.dzi rho_cc = counts / dV else: # spherical droplet geometry dV_per_row = 2.0 * np.pi * self.xi_cc * self.dxi * self.dzi @@ -295,126 +252,30 @@ def binning( rho_cc /= len_frames return rho_cc - def plot_density_with_isoline( - self, - xi_cc: np.ndarray, - zi_cc: np.ndarray, - rho_cc: np.ndarray, - circle_xi: np.ndarray, - circle_zi: np.ndarray, - wall_line_xi: np.ndarray, - wall_line_zi: np.ndarray, - batch_index: int | None = None, - clevels: int = 20, - scale: float = 0.75, - close: bool = True, - ) -> None: - """Plot density contour with fitted iso-surface approximations. - - Parameters - ---------- - xi_cc, zi_cc : ndarray - Cell center coordinates. - rho_cc : ndarray - Density field. - circle_xi, circle_zi : ndarray - Fitted circle isoline coordinates. - wall_line_xi, wall_line_zi : ndarray - Wall line coordinates. - batch_index : int, optional - Batch identifier for file naming. - clevels : int, default 20 - Number of contour levels. - scale : float, default 0.75 - Figure size scaling factor. - close : bool, default True - If True, close figure after saving. - """ - name = ( - f"bin_plot_batch_{batch_index}.png" - if batch_index is not None - else "bin_plot.png" - ) - plt.figure(dpi=300, figsize=(4 * scale, 3 * scale)) - plt.contourf(xi_cc, zi_cc, np.transpose(rho_cc), levels=clevels, cmap="jet") - plt.colorbar() - plt.plot(circle_xi, circle_zi, "--", color="black") - plt.plot(wall_line_xi, wall_line_zi, "--", color="black") - plt.savefig(os.path.join(self.output_dir, name)) - if close: - plt.close() - - def save_logfile( - self, - n_particles: float, - param_strings: list[str], - theta: float, - xi_cc: np.ndarray, - zi_cc: np.ndarray, - rho_cc: np.ndarray, - batch_index: int | None = None, - ) -> None: - """Write fitted parameters and density field CSV for a batch. - - Parameters - ---------- - n_particles : float - Average number of particles per frame in batch. - param_strings : list[str] - Formatted parameter lines from model. - theta : float - Contact angle in degrees. - xi_cc, zi_cc : ndarray - Cell centers. - rho_cc : ndarray - Density field. - batch_index : int, optional - Batch identifier for file naming. - """ - batch_str = f"_batch_{batch_index}" if batch_index is not None else "" - with open(os.path.join(self.output_dir, f"log_data{batch_str}.txt"), "w") as f: - f.write("Simulation parameters:\n") - f.write(f"reduced_particles_number:{n_particles}\n") - f.write(f"model_type:{self.droplet_geometry}\n") - if self.droplet_geometry in ("cylinder_x", "cylinder_y"): - f.write(f"width_cylinder:{self.width_cylinder}\n") - f.write("Fitted parameters:\n") - for param in param_strings: - f.write(param) - f.write(f"\n\nContact angle:{theta}") - msh_zi_cc_grid, msh_xi_cc_grid = np.meshgrid(zi_cc, xi_cc) - msh_zi_cc = msh_zi_cc_grid.reshape((len(xi_cc) * len(zi_cc)), order="F") - msh_xi_cc = msh_xi_cc_grid.reshape((len(xi_cc) * len(zi_cc)), order="F") - msh_rho_cc = rho_cc.reshape((len(xi_cc) * len(zi_cc)), order="F") - csv_data = np.c_[msh_xi_cc, msh_zi_cc, msh_rho_cc] - np.savetxt( - os.path.join(self.output_dir, f"rho_field{batch_str}.csv"), - csv_data, - delimiter=",", - header=(f"x_{len(xi_cc)},y_{len(zi_cc)},rho_{len(xi_cc) * len(zi_cc)}"), - ) - def process_batch( self, frame_list: list[int], model: Any | None = None, batch_index: int | None = None, - ) -> tuple[float, Any]: - """Process a batch of frames and compute contact angle. + ) -> BinningBatch: + """Process a batch of frames and return its fitted contact-angle data. Parameters ---------- frame_list : sequence[int] Frame indices in the batch. model : SurfaceModel, optional - Pre-existing fitted model instance; new model created if None. + Pre-existing fitted model instance; a new + :class:`HyperbolicTangentModel` is created if None. batch_index : int, optional - Identifier appended to output filenames. + Sequential identifier copied into the returned :class:`BinningBatch` + (defaults to 1 when not supplied). Returns ------- - tuple(float, SurfaceModel) - (contact_angle_degrees, fitted_model). + BinningBatch + Per-batch container with contact angle, density field, fitted + isoline coordinates and fitted parameters. """ xi_par, zi_par, len_frames = self.get_profile_coordinates( frame_indices=frame_list, @@ -437,80 +298,37 @@ def process_batch( msh_rho_cc = rho_cc.reshape((len(self.xi_cc) * len(self.zi_cc)), order="F") x_data = (msh_xi_cc, msh_zi_cc) model.fit(x_data, msh_rho_cc) - param_strings = model.get_parameter_strings() logger.info( - f"Fitted parameters for batch{batch_label}:\n{''.join(param_strings)}" + f"Fitted parameters for batch{batch_label}:\n" + f"{''.join(model.get_parameter_strings())}" ) contact_angle = model.compute_contact_angle() logger.info(f"Contact angle for batch{batch_label}: {contact_angle}") - if self.plot_graphs: - try: - ( - circle_xi, - circle_zi, - wall_line_xi, - wall_line_zi, - ) = model.compute_isoline() - except ValueError as exc: - warnings.warn( - f"Skipping isoline plot for batch {batch_index}: {exc}", - RuntimeWarning, - stacklevel=2, - ) - else: - self.plot_density_with_isoline( - self.xi_cc, - self.zi_cc, - rho_cc, - circle_xi, - circle_zi, - wall_line_xi, - wall_line_zi, - batch_index, - ) - self.save_logfile( - n_particles, - param_strings, - contact_angle, - self.xi_cc, - self.zi_cc, - rho_cc, - batch_index, - ) - return contact_angle, model - - def process_all_batches( - self, batch_size: int = 100, save_angles: bool = True - ) -> list[float]: - """Process all frames in batches returning list of contact angles. - - Parameters - ---------- - batch_size : int, default 100 - Number of frames per batch. - save_angles : bool, default True - If True, save angle list as numpy file. - - Returns - ------- - list[float] - Contact angles per processed batch. - """ - frames_tot = self.parser.frame_count() - logger.info(f"Total frames: {frames_tot}") - angles: list[float] = [] - for batch_index, start_frame in enumerate(range(0, frames_tot, batch_size)): - frame_list = list( - range(start_frame, min(start_frame + batch_size, frames_tot)) + try: + circle_xi, circle_zi, wall_line_xi, wall_line_zi = model.compute_isoline() + except ValueError as exc: + warnings.warn( + f"Isoline unavailable for batch {batch_index}: {exc}", + RuntimeWarning, + stacklevel=2, ) - angle, _ = self.process_batch(frame_list, batch_index=batch_index + 1) - angles.append(angle) - if save_angles: - np.save( - os.path.join( - self.output_dir, f"all_angles_{self.droplet_geometry}.npy" - ), - np.array(angles), + circle_xi = circle_zi = wall_line_xi = wall_line_zi = None + params = model.params + if params is None: + raise RuntimeError( + f"Hyperbolic tangent fit did not set model parameters for batch " + f"{batch_index}; cannot build BinningBatch." ) - logger.info(f"List of contact angles by batch: {angles}") - return angles + return BinningBatch( + batch_index=batch_index if batch_index is not None else 1, + angle=float(contact_angle), + n_particles=float(n_particles), + xi_cc=self.xi_cc.copy(), + zi_cc=self.zi_cc.copy(), + rho_cc=rho_cc, + circle_xi=circle_xi, + circle_zi=circle_zi, + wall_line_xi=wall_line_xi, + wall_line_zi=wall_line_zi, + fitted_params=dict(zip(_PARAM_NAMES, params, strict=False)), + ) diff --git a/src/wetting_angle_kit/analysis/binning/results.py b/src/wetting_angle_kit/analysis/binning/results.py new file mode 100644 index 0000000..15d9202 --- /dev/null +++ b/src/wetting_angle_kit/analysis/binning/results.py @@ -0,0 +1,91 @@ +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + + +@dataclass +class BinningBatch: + """Per-batch output of the binning analysis. + + A batch is the fitting unit: a contiguous group of frames whose + coordinates are aggregated into a single 2D density field that is then + fitted to extract one contact angle. + + Attributes + ---------- + batch_index : int + Sequential identifier (starting at 1) for the batch. + angle : float + Fitted contact angle in degrees (``nan`` if the fit failed). + n_particles : float + Average number of fluid particles per frame within the batch. + xi_cc : np.ndarray + Cell-center coordinates along the radial/in-plane axis (1D). + zi_cc : np.ndarray + Cell-center coordinates along the vertical axis (1D). + rho_cc : np.ndarray + 2D density field on the ``xi_cc × zi_cc`` grid (particles · Å⁻³). + circle_xi : np.ndarray | None + Fitted droplet circle iso-line, radial coordinates. ``None`` when + :meth:`HyperbolicTangentModel.compute_isoline` failed (non-physical + fit). + circle_zi : np.ndarray | None + Fitted droplet circle iso-line, vertical coordinates. + wall_line_xi : np.ndarray | None + Fitted wall position, radial coordinates. + wall_line_zi : np.ndarray | None + Fitted wall position, vertical coordinates. + fitted_params : dict[str, float] + Fitted model parameters (e.g. ``R_eq``, ``zi_c``, ``zi_0``). + """ + + batch_index: int + angle: float + n_particles: float + xi_cc: np.ndarray + zi_cc: np.ndarray + rho_cc: np.ndarray + circle_xi: np.ndarray | None + circle_zi: np.ndarray | None + wall_line_xi: np.ndarray | None + wall_line_zi: np.ndarray | None + fitted_params: dict[str, float] = field(default_factory=dict) + + +@dataclass +class BinningResults: + """In-memory container for the binning method output. + + Replaces the legacy ``log_data_batch_*.txt`` / ``rho_field_batch_*.csv`` + round-trip: every quantity needed downstream (statistics, contour plot, + per-batch angle evolution) is carried as attributes on the batches. + + Attributes + ---------- + batches : list[BinningBatch] + One entry per fitted batch, in batch order. + method_metadata : dict + Free-form method descriptor (e.g. ``{"frames_per_trajectory": 100}``). + """ + + batches: list[BinningBatch] + method_metadata: dict[str, Any] = field(default_factory=dict) + + def __len__(self) -> int: + return len(self.batches) + + @property + def angles_per_batch(self) -> np.ndarray: + """Per-batch fitted contact angle, in degrees.""" + return np.array([b.angle for b in self.batches]) + + @property + def mean_angle(self) -> float: + """Mean contact angle across batches, in degrees.""" + return float(np.mean(self.angles_per_batch)) + + @property + def std_angle(self) -> float: + """Standard deviation of the per-batch contact angle, in degrees.""" + return float(np.std(self.angles_per_batch)) diff --git a/src/wetting_angle_kit/analysis/binning/surface_definition.py b/src/wetting_angle_kit/analysis/binning/surface_definition.py index cf6c6c8..e9153be 100644 --- a/src/wetting_angle_kit/analysis/binning/surface_definition.py +++ b/src/wetting_angle_kit/analysis/binning/surface_definition.py @@ -100,11 +100,12 @@ def evaluate_on_grid(self, xi_grid: np.ndarray, zi_grid: np.ndarray) -> np.ndarr ndarray, shape (len(xi_grid), len(zi_grid)) 2D array of evaluated density values. """ - out_fitted = np.zeros((len(xi_grid), len(zi_grid))) - for i in range(len(xi_grid)): - for j in range(len(zi_grid)): - out_fitted[i, j] = self.evaluate((xi_grid[i], zi_grid[j])) - return out_fitted + # ``evaluate`` is expected to broadcast over its inputs, so the grid + # is evaluated in a single call instead of a nested Python loop. + xi_mesh, zi_mesh = np.meshgrid( + np.asarray(xi_grid), np.asarray(zi_grid), indexing="ij" + ) + return np.asarray(self.evaluate((xi_mesh, zi_mesh))) class HyperbolicTangentModel(SurfaceModel): @@ -277,10 +278,20 @@ def compute_isoline( ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Compute an iso-surface circle and wall line approximation. + Notes + ----- + ``scale_factor`` shrinks the fitted equivalent radius before tracing + the iso-line. It is a **visualization-only** parameter: the contact + angle reported by :meth:`compute_contact_angle` is derived from the + unscaled fit. The default of 0.95 makes the overlaid circle sit + slightly inside the density isosurface so the underlying contour + plot stays visible — it is not meant to encode anything physical. + Parameters ---------- scale_factor : float, default 0.95 - Factor applied to fitted radius for visualization. + Visualization-only scaling applied to the fitted equivalent + radius before computing the iso-line traces. Returns ------- diff --git a/src/wetting_angle_kit/analysis/slicing/__init__.py b/src/wetting_angle_kit/analysis/slicing/__init__.py index cdea9c4..770bf52 100644 --- a/src/wetting_angle_kit/analysis/slicing/__init__.py +++ b/src/wetting_angle_kit/analysis/slicing/__init__.py @@ -1,17 +1,17 @@ """Public exports for the slicing contact angle method.""" -from wetting_angle_kit.analysis.slicing.angle_fitting import ( - ContactAngleSlicing, +from wetting_angle_kit.analysis.slicing.analyzer import ( + SlicingTrajectoryAnalyzer, ) -from wetting_angle_kit.analysis.slicing.parallel import ( - ContactAngleSlicingParallel, +from wetting_angle_kit.analysis.slicing.angle_fitting import ( + SlicingFrameFitter, ) from wetting_angle_kit.analysis.slicing.surface_definition import ( SurfaceDefinition, ) __all__ = [ - "ContactAngleSlicing", - "ContactAngleSlicingParallel", + "SlicingFrameFitter", + "SlicingTrajectoryAnalyzer", "SurfaceDefinition", ] diff --git a/src/wetting_angle_kit/analysis/slicing/analyzer.py b/src/wetting_angle_kit/analysis/slicing/analyzer.py new file mode 100644 index 0000000..bc24d4e --- /dev/null +++ b/src/wetting_angle_kit/analysis/slicing/analyzer.py @@ -0,0 +1,299 @@ +"""Trajectory-level slicing contact-angle analyzer.""" + +import logging +import multiprocessing as mp +from typing import Any, NamedTuple + +import numpy as np +from tqdm.auto import tqdm + +from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer +from wetting_angle_kit.analysis.slicing.angle_fitting import ( + SlicingFrameFitter, +) +from wetting_angle_kit.analysis.slicing.results import SlicingResults +from wetting_angle_kit.io_utils import ( + detect_parser_type, + recenter_droplet_pbc, + validate_droplet_geometry, +) +from wetting_angle_kit.parsers.ase import AseParser +from wetting_angle_kit.parsers.base import BaseParser +from wetting_angle_kit.parsers.lammps_dump import LammpsDumpParser +from wetting_angle_kit.parsers.xyz import XYZParser + +# "spawn" is required because parser instances may hold un-picklable handles +# (OVITO pipelines, ASE Atoms with C extensions). Using a scoped context +# rather than mutating the global start method keeps this side-effect-free +# when the package is imported. +_MP_CONTEXT = mp.get_context("spawn") + +logger = logging.getLogger(__name__) + + +class _SlicingFrameResult(NamedTuple): + """Per-frame output of the slicing worker.""" + + frame_num: int + mean_angle: float | None + angles: list + surfaces: list + popts: list + + +class SlicingTrajectoryAnalyzer(BaseTrajectoryAnalyzer): + """Trajectory-level slicing contact-angle analyzer. + + Frames are dispatched one-by-one to a ``multiprocessing.Pool`` whose + workers each build their own parser once and reuse it for every frame + they receive. The per-frame fitting work is delegated to + :class:`SlicingFrameFitter`. + """ + + # Per-worker state populated by ``_init_worker`` in each child process. + # In the parent this stays empty; ``spawn`` gives each child its own + # fresh module-level class object, so the dict is effectively per-process. + _WORKER_STATE: dict[str, Any] = {} + + def __init__( + self, + parser: Any, + droplet_geometry: str = "spherical", + atom_indices: np.ndarray | None = None, + delta_gamma: float | None = None, + delta_cylinder: float | None = None, + points_per_angstrom: float = 1.0, + precentered: bool = False, + ) -> None: + """ + Parameters + ---------- + parser : BaseParser + Trajectory parser instance. Only ``parser.filepath`` and + ``parser.frame_count()`` are read in the parent process; each + worker rebuilds its own parser from ``filepath``. + droplet_geometry : str, default "spherical" + One of ``"spherical"``, ``"cylinder_x"``, ``"cylinder_y"``. + atom_indices : ndarray, optional + Indices of liquid particles. Empty array selects none. + delta_gamma : float, optional + Azimuthal step (degrees) for spherical analysis (required if + ``droplet_geometry == "spherical"``). + delta_cylinder : float, optional + Slice spacing along the cylinder axis (required for + cylinder_x / cylinder_y). + points_per_angstrom : float, default 1.0 + Sampling density along each radial ray. + precentered : bool, default False + Skip per-frame circular-mean PBC recentering. Setting this on a + trajectory that does NOT satisfy the precondition will produce + wrong results. + """ + # Fail fast in the parent process so the user gets the error at + # construction instead of a uniform "all frames failed" later. + detect_parser_type(parser.filepath) + validate_droplet_geometry(droplet_geometry) + if droplet_geometry == "spherical": + if delta_gamma is None: + raise ValueError("delta_gamma must be provided for spherical analysis") + if delta_cylinder is not None: + raise ValueError( + "delta_cylinder must not be set for spherical analysis " + "(it is only valid for cylinder_x / cylinder_y)." + ) + elif droplet_geometry in ("cylinder_x", "cylinder_y"): + if delta_cylinder is None: + raise ValueError( + f"delta_cylinder must be provided for {droplet_geometry}." + ) + if delta_gamma is not None: + raise ValueError( + f"delta_gamma must not be set for {droplet_geometry} " + "(it is only valid for spherical)." + ) + self.parser = parser + self.droplet_geometry = droplet_geometry + self.atom_indices = atom_indices if atom_indices is not None else np.array([]) + self.delta_gamma = delta_gamma + self.delta_cylinder = delta_cylinder + self.points_per_angstrom = points_per_angstrom + self.precentered = precentered + + def analyze( + self, + frame_range: list[int] | None = None, + n_jobs: int | None = None, + ) -> SlicingResults: + """Run the slicing analysis in parallel across frames. + + Parameters + ---------- + frame_range : list[int], optional + Frame indices to process. Defaults to all frames. + n_jobs : int, optional + Number of worker processes. ``None`` lets ``multiprocessing.Pool`` + pick the default (``os.cpu_count()``). + + Returns + ------- + SlicingResults + Per-frame angles, surface contours, fit parameters and method + metadata. Frames whose worker failed to produce a mean angle are + omitted. + """ + if frame_range is None: + frame_range = list(range(self.parser.frame_count())) + if not frame_range: + return SlicingResults( + frames=[], + angles=[], + surfaces=[], + popts=[], + method_metadata={"frames_per_angle": 1}, + ) + init_args = ( + self.parser.filepath, + self.droplet_geometry, + self.atom_indices, + self.delta_gamma, + self.delta_cylinder, + self.points_per_angstrom, + self.precentered, + ) + logger.info(f"Processing {len(frame_range)} frames with n_jobs={n_jobs}") + results_by_frame: dict[int, _SlicingFrameResult] = {} + running_sum = 0.0 + running_count = 0 + with ( + _MP_CONTEXT.Pool( + processes=n_jobs, + initializer=self._init_worker, + initargs=init_args, + ) as pool, + tqdm(total=len(frame_range), desc="Slicing frames", unit="frame") as pbar, + ): + for result in pool.imap_unordered(self._run_one_frame, frame_range): + if result.mean_angle is not None: + results_by_frame[result.frame_num] = result + running_sum += result.mean_angle + running_count += 1 + pbar.set_postfix(mean_angle=f"{running_sum / running_count:.2f}°") + pbar.update(1) + sorted_frames = sorted(results_by_frame) + logger.info( + f"Successfully processed {len(sorted_frames)}/{len(frame_range)} frames" + ) + if not sorted_frames: + raise RuntimeError( + f"None of the {len(frame_range)} requested frames produced " + "any contact-angle slices. Check the worker logs above for the " + "underlying parser, geometry, or fit errors." + ) + return SlicingResults( + frames=sorted_frames, + angles=[np.asarray(results_by_frame[f].angles) for f in sorted_frames], + surfaces=[results_by_frame[f].surfaces for f in sorted_frames], + popts=[np.asarray(results_by_frame[f].popts) for f in sorted_frames], + method_metadata={"frames_per_angle": 1}, + ) + + @staticmethod + def _build_parser(filename: str) -> BaseParser: + parser_type = detect_parser_type(filename) + if parser_type == "dump": + return LammpsDumpParser(filepath=filename) + if parser_type == "ase": + return AseParser(filepath=filename) + if parser_type == "xyz": + return XYZParser(filepath=filename) + raise ValueError(f"Unsupported parser type: {parser_type}") + + @staticmethod + def _init_worker( + filename: str, + droplet_geometry: str, + atom_indices: np.ndarray, + delta_gamma: float | None, + delta_cylinder: float | None, + points_per_angstrom: float, + precentered: bool, + ) -> None: + cls = SlicingTrajectoryAnalyzer + cls._WORKER_STATE.clear() + cls._WORKER_STATE.update( + parser=cls._build_parser(filename), + droplet_geometry=droplet_geometry, + atom_indices=atom_indices, + delta_gamma=delta_gamma, + delta_cylinder=delta_cylinder, + points_per_angstrom=points_per_angstrom, + precentered=precentered, + ) + + @staticmethod + def _run_one_frame(frame_num: int) -> _SlicingFrameResult: + state = SlicingTrajectoryAnalyzer._WORKER_STATE + parser: BaseParser = state["parser"] + droplet_geometry: str = state["droplet_geometry"] + atom_indices: np.ndarray = state["atom_indices"] + delta_gamma = state["delta_gamma"] + delta_cylinder = state["delta_cylinder"] + points_per_angstrom: float = state["points_per_angstrom"] + precentered: bool = state["precentered"] + try: + liquid_positions = parser.parse( + frame_index=frame_num, + indices=atom_indices, + ) + max_dist = int( + np.max( + np.array( + [ + parser.box_size_y(frame_index=frame_num), + parser.box_size_x(frame_index=frame_num), + ] + ) + ) + / 2 + ) + # Fold the droplet into the minimum-image frame around its + # circular-mean COM before any cylinder_x axis swap, so the + # ``box_size`` argument is in the parser's native frame. This + # makes downstream radial sampling robust to droplets that + # straddle a periodic boundary, and is idempotent for + # trajectories already recentered during dynamics. Skipped + # (with a plain arithmetic mean) when the user has declared + # the trajectory pre-centered. + if precentered: + mean_liquid_position = np.mean(liquid_positions, axis=0) + else: + box_size_xy = ( + parser.box_size_x(frame_index=frame_num), + parser.box_size_y(frame_index=frame_num), + ) + liquid_positions, mean_liquid_position = recenter_droplet_pbc( + liquid_positions, droplet_geometry, box_size=box_size_xy + ) + if droplet_geometry == "cylinder_x": + liquid_positions = liquid_positions[:, [1, 0, 2]] + mean_liquid_position = mean_liquid_position[[1, 0, 2]] + predictor = SlicingFrameFitter( + liquid_coordinates=liquid_positions, + max_dist=max_dist, + liquid_geom_center=mean_liquid_position, + droplet_geometry=droplet_geometry, + delta_gamma=delta_gamma, + delta_cylinder=delta_cylinder, + points_per_angstrom=points_per_angstrom, + ) + angles, surfaces, popt_arrays = predictor.predict_contact_angle() + if not angles: + logger.warning(f"Frame {frame_num}: No angles computed (empty list).") + return _SlicingFrameResult(frame_num, None, [], [], []) + mean_angle = float(np.mean(angles)) + return _SlicingFrameResult( + frame_num, mean_angle, angles, surfaces, popt_arrays + ) + except Exception as e: + logger.error(f"Error processing frame {frame_num}: {e}", exc_info=True) + return _SlicingFrameResult(frame_num, None, [], [], []) diff --git a/src/wetting_angle_kit/analysis/slicing/angle_fitting.py b/src/wetting_angle_kit/analysis/slicing/angle_fitting.py index 7714bae..4196968 100644 --- a/src/wetting_angle_kit/analysis/slicing/angle_fitting.py +++ b/src/wetting_angle_kit/analysis/slicing/angle_fitting.py @@ -1,8 +1,4 @@ -import warnings -from collections.abc import Sequence - import numpy as np -from scipy.optimize import curve_fit from wetting_angle_kit.analysis.slicing.surface_definition import ( SurfaceDefinition, @@ -10,7 +6,7 @@ from wetting_angle_kit.io_utils import validate_droplet_geometry -class ContactAngleSlicing: +class SlicingFrameFitter: """Slicing radial line method to estimate contact angle via circle fitting. Depending on ``droplet_geometry`` the droplet is analyzed by sweeping in y @@ -28,9 +24,8 @@ def __init__( liquid_coordinates: np.ndarray, max_dist: float, liquid_geom_center: np.ndarray, - droplet_geometry: str = "cylinder_y", + droplet_geometry: str = "spherical", delta_gamma: float | None = None, - width_cylinder: float | None = None, delta_cylinder: float | None = None, surface_filter_offset: float = 2.0, points_per_angstrom: float = 1.0, @@ -47,16 +42,15 @@ def __init__( liquid_geom_center : ndarray, shape (3,) Geometric droplet center; y component overridden per slice in cylinder modes. - droplet_geometry : str, default 'cylinder_y' - One of ``{'cylinder_y', 'cylinder_x', 'spherical'}`` controlling slicing + droplet_geometry : str, default 'spherical' + One of ``{'spherical', 'cylinder_x', 'cylinder_y'}`` controlling slicing axis. delta_gamma : float, optional Angular step (degrees) for spherical droplet geometry (required if spherical). - width_cylinder : float, optional - Extent in slicing axis direction (y or x) for cylindrical droplet geometry. delta_cylinder : float, optional - Step size along slicing axis. + Step size along the slicing axis for cylindrical droplet geometry + (required if cylinder_x / cylinder_y). surface_filter_offset : float, default 2.0 Offset added to minimum droplet height for interface point filtering. points_per_angstrom : float, default 1.0 @@ -67,6 +61,24 @@ def __init__( Azimuthal spacing (degrees) between radial lines. """ validate_droplet_geometry(droplet_geometry) + if droplet_geometry == "spherical": + if delta_gamma is None: + raise ValueError("delta_gamma must be provided for spherical analysis") + if delta_cylinder is not None: + raise ValueError( + "delta_cylinder must not be set for spherical analysis " + "(it is only valid for cylinder_x / cylinder_y)." + ) + else: # cylinder_x / cylinder_y + if delta_cylinder is None: + raise ValueError( + f"delta_cylinder must be provided for {droplet_geometry}." + ) + if delta_gamma is not None: + raise ValueError( + f"delta_gamma must not be set for {droplet_geometry} " + "(it is only valid for spherical)." + ) self.liquid_coordinates = liquid_coordinates self.max_dist = max_dist # Store a copy: predict_contact_angle mutates this in-place per slice @@ -74,7 +86,6 @@ def __init__( self.liquid_geom_center = np.array(liquid_geom_center, copy=True) self.droplet_geometry = droplet_geometry self.delta_gamma = delta_gamma - self.width_cylinder = width_cylinder self.delta_cylinder = delta_cylinder self.surface_filter_offset = surface_filter_offset # Sampling density along each radial ray; raise this (e.g. 2.0 or @@ -87,78 +98,46 @@ def __init__( # at room temperature by default; adjust for other liquids. self.density_sigma = density_sigma self.delta_angle = delta_angle - if self.droplet_geometry in ("cylinder_y", "cylinder_x") and ( - width_cylinder is None or delta_cylinder is None - ): - warnings.warn( - "width_cylinder and delta_cylinder recommended for " - f"{self.droplet_geometry}", - UserWarning, - stacklevel=2, + + def _slice_sweep(self) -> tuple[list[float], list[float]]: + """Build the per-slice ``(axis_values, gammas)`` sweep once. + + Cylindrical mode sweeps the axial extent of ``liquid_coordinates`` + in ``delta_cylinder`` steps with ``gamma = 0``. Spherical mode + repeats the droplet's y-center and rotates ``gamma`` from 0° to + 180° in ``delta_gamma`` steps. The two public list accessors + below project this single source of truth. + """ + if self.droplet_geometry in ("cylinder_y", "cylinder_x"): + axis_values = self.liquid_coordinates[:, 1] + ys = list( + np.arange( + float(axis_values.min()), + float(axis_values.max()), + self.delta_cylinder, + ) ) - if self.droplet_geometry == "spherical" and delta_gamma is None: - raise ValueError("delta_gamma must be provided for spherical analysis") + return ys, [0.0] * len(ys) + if self.delta_gamma is None: + raise ValueError("delta_gamma is required for droplet_geometry='spherical'") + n_slices = int(180 / self.delta_gamma) + gammas = list(np.linspace(0.0, 180.0, n_slices)) + return [float(self.liquid_geom_center[1])] * n_slices, gammas def calculate_y_axis_list(self) -> list[float]: - """Return axis position list for the chosen droplet geometry. - - For cylindrical droplets the slice positions sweep from 0 to - ``width_cylinder`` in steps of ``delta_cylinder``. This assumes the - simulation box origin is at 0 along the slicing axis (the LAMMPS - convention). If your box uses a non-zero origin, supply - ``liquid_geom_center`` already shifted into a 0-based frame, or - pre-translate the trajectory before analysis. + """Return the per-slice center position along the slicing axis. Returns ------- list[float] - Y (or X if 'cylinder_x') positions; spherical returns repeated center y. + Y positions of slice centers; for spherical, the droplet center + y is repeated ``180 / delta_gamma`` times. """ - if self.droplet_geometry in ("cylinder_y", "cylinder_x"): - if self.width_cylinder is None or self.delta_cylinder is None: - raise ValueError( - "width_cylinder and delta_cylinder are required for " - f"droplet_geometry={self.droplet_geometry!r}" - ) - return list(np.arange(0, self.width_cylinder, self.delta_cylinder)) - if self.droplet_geometry == "spherical": - if self.delta_gamma is None: - raise ValueError( - "delta_gamma is required for droplet_geometry='spherical'" - ) - return [self.liquid_geom_center[1]] * int(180 / self.delta_gamma) - return [] + return self._slice_sweep()[0] def calculate_gammas_list(self) -> list[float]: - """Return the gamma tilt angle (degrees) for each slice - of the chosen droplet geometry.""" - if self.droplet_geometry in ("cylinder_y", "cylinder_x"): - if self.width_cylinder is None or self.delta_cylinder is None: - raise ValueError( - "width_cylinder and delta_cylinder are required for " - f"droplet_geometry={self.droplet_geometry!r}" - ) - return [ - 0.0 - for _ in np.arange( - 0, - self.width_cylinder, - self.delta_cylinder, - ) - ] - if self.droplet_geometry == "spherical": - if self.delta_gamma is None: - raise ValueError( - "delta_gamma is required for droplet_geometry='spherical'" - ) - return list( - np.linspace( - 0.0, - 180.0, - int(180 / self.delta_gamma), - ) - ) - return [] + """Return the gamma tilt angle (degrees) for each slice.""" + return self._slice_sweep()[1] def surface_definition(self, v_gamma: float) -> tuple[np.ndarray, np.ndarray]: """Sample interface lines for a given gamma. @@ -202,13 +181,16 @@ def separate_surface_data(self, surf: np.ndarray, limit_med: float) -> np.ndarra """ return surf[surf[:, 1] > limit_med] - def fit_circle( - self, - x_data: np.ndarray, - y_data: np.ndarray, - initial_guess: Sequence[float], - ) -> np.ndarray: - """Perform non-linear least squares circle fit. + @staticmethod + def fit_circle(x_data: np.ndarray, y_data: np.ndarray) -> np.ndarray: + """Algebraic (Kasa) least-squares circle fit. + + Linearises ``(x - xc)^2 + (z - zc)^2 = R^2`` into + ``2 xc·x + 2 zc·z + c = x^2 + z^2`` with ``c = R^2 - xc^2 - zc^2``, + and solves the resulting overdetermined linear system in one + ``np.linalg.lstsq`` call. Replaces the previous SciPy non-linear + fit, which was the slicing hot path's main per-slice cost and + which depended on a sensible initial guess. Parameters ---------- @@ -216,24 +198,33 @@ def fit_circle( X coordinates. y_data : ndarray Z coordinates. - initial_guess : sequence - Initial parameters [x_center, z_center, radius]. Returns ------- - ndarray - Optimized parameters [x_center, z_center, radius]. + ndarray, shape (3,) + ``[x_center, z_center, radius]``. + + Raises + ------ + np.linalg.LinAlgError + If the input points are collinear (rank-deficient system). + ValueError + If the algebraic solution yields a non-positive squared radius + (degenerate sample, e.g. all points on a line). """ - popt, _ = curve_fit( - self.circle_equation, - (x_data, y_data), - np.zeros_like(x_data), - p0=initial_guess, - ) - # The residual sqrt((x-xc)^2 + (z-zc)^2) - R is symmetric in the sign - # of R, so curve_fit may converge to a negative radius. Normalize. - popt[2] = float(abs(popt[2])) - return popt + x = np.asarray(x_data, dtype=float) + y = np.asarray(y_data, dtype=float) + a_matrix = np.column_stack((2.0 * x, 2.0 * y, np.ones_like(x))) + rhs = x * x + y * y + sol, _, _, _ = np.linalg.lstsq(a_matrix, rhs, rcond=None) + xc, zc, c = float(sol[0]), float(sol[1]), float(sol[2]) + r_sq = c + xc * xc + zc * zc + if r_sq <= 0.0: + raise ValueError( + f"Algebraic circle fit produced non-positive R^2 ({r_sq:.3g}); " + "the surface points are likely degenerate." + ) + return np.array([xc, zc, float(np.sqrt(r_sq))]) def find_intersection(self, popt: np.ndarray, y_line: float) -> float | None: """Compute contact angle from circle intersection with a baseline. @@ -258,35 +249,6 @@ def find_intersection(self, popt: np.ndarray, y_line: float) -> float | None: theta = np.arccos(delta_z / radius) return float(np.degrees(theta)) - def circle_equation( - self, - xy_data: tuple[np.ndarray, np.ndarray], - x_center: float, - z_center: float, - radius: float, - ) -> np.ndarray: - """Return the residuals of the circle equation - used in fitting. - - Parameters - ---------- - xy_data : tuple(ndarray, ndarray) - (x_data, y_data) coordinate arrays. - x_center : float - Circle center x. - z_center : float - Circle center z. - radius : float - Circle radius. - - Returns - ------- - ndarray - Residuals sqrt((x-xc)^2+(z-zc)^2) - R. - """ - x_data, y_data = xy_data - return np.sqrt((x_data - x_center) ** 2 + (y_data - z_center) ** 2) - radius - def predict_contact_angle( self, ) -> tuple[list[float], list[np.ndarray], list[np.ndarray]]: @@ -324,15 +286,9 @@ def predict_contact_angle( continue x_data = surf_line[:, 0] y_data = surf_line[:, 1] - mean_rr = float(np.mean(rr[:, 0])) if rr.size else self.max_dist / 2 - initial_guess = [ - self.liquid_geom_center[0], - self.liquid_geom_center[2], - mean_rr, - ] try: - popt = self.fit_circle(x_data, y_data, initial_guess) - except Exception: + popt = self.fit_circle(x_data, y_data) + except (np.linalg.LinAlgError, ValueError): continue angle = self.find_intersection(popt, min_drop) if angle is None: diff --git a/src/wetting_angle_kit/analysis/slicing/parallel.py b/src/wetting_angle_kit/analysis/slicing/parallel.py deleted file mode 100644 index fd67f17..0000000 --- a/src/wetting_angle_kit/analysis/slicing/parallel.py +++ /dev/null @@ -1,321 +0,0 @@ -import logging -import math -import multiprocessing as mp -import os -from concurrent.futures import ProcessPoolExecutor, as_completed -from typing import NamedTuple - -import numpy as np - -from wetting_angle_kit.io_utils import recenter_droplet_pbc -from wetting_angle_kit.parsers import BaseParser - -# "spawn" is required because parser instances may hold un-picklable handles -# (OVITO pipelines, ASE Atoms with C extensions). Using a scoped context -# rather than mutating the global start method keeps this side-effect-free -# when the package is imported. -_MP_CONTEXT = mp.get_context("spawn") - -logger = logging.getLogger(__name__) - - -class SlicingFrameResult(NamedTuple): - """Per-frame output from the slicing parallel worker. - - Attributes - ---------- - frame_num : int - Frame index this result refers to. - mean_angle : float | None - Mean contact angle across successful slices, or ``None`` if no - slice produced an angle. - angles : list[float] - Per-slice contact angles. Same length as ``surfaces`` and ``popts``. - surfaces : list[ndarray] - Per-slice surface point arrays of shape (M, 2). - popts : list[ndarray] - Per-slice fitted circle parameters with the baseline appended. - """ - - frame_num: int - mean_angle: float | None - angles: list - surfaces: list - popts: list - - -class ContactAngleSlicingParallel: - """Batch-parallel contact angle analyzer for slicing method. - - The frames are grouped into batches to reduce problems - related to serialization by the parser and to distribute - the cost of creating objects. Each batch is processed in - a separate process using ``ProcessPoolExecutor``. - """ - - def __init__( - self, - filename: str, - output_dir: str, - droplet_geometry: str = "spherical", - atom_indices: np.ndarray | None = None, - delta_gamma: float | None = None, - delta_cylinder: float | None = None, - points_per_angstrom: float = 1.0, - precentered: bool = False, - ): - """ - Parameters - ---------- - filename : str - Path to trajectory file. - output_dir : str - Directory to write per-frame results. - droplet_geometry : str, default "spherical" - Geometric model identifier (e.g. "cylinder_x", "cylinder_y", "spherical"). - atom_indices : ndarray, optional - Indices of liquid particles (subset). Empty array selects none. - delta_gamma : float, optional - Additional gamma constraint / filtering distance if used by slicing method. - delta_cylinder : float, optional - Y (or X) half-width of selection cylinder in cylindrical modes. - points_per_angstrom : float, default 1.0 - Sampling density along each radial ray for the surface fit. - Influences the computational cost. - precentered : bool, default False - Set True to declare that the trajectory already recenters the - droplet at every frame and atoms are not wrapped across periodic - boundaries. The per-frame circular-mean recentering is then - skipped (using a plain arithmetic mean instead), removing the - associated overhead. Setting this on a trajectory that does NOT - satisfy the precondition will produce wrong results. - """ - self.filename = filename - self.output_dir = output_dir - self.delta_gamma = delta_gamma - self.delta_cylinder = delta_cylinder - self.droplet_geometry = droplet_geometry - self.points_per_angstrom = points_per_angstrom - self.atom_indices = atom_indices if atom_indices is not None else np.array([]) - self.precentered = precentered - os.makedirs(self.output_dir, exist_ok=True) - - def process_frames_parallel( - self, - frames_to_process: list[int], - num_batches: int = 4, - max_workers: int | None = None, - ) -> dict[int, float]: - """Process many frames in parallel batches. - - Parameters - ---------- - frames_to_process : list[int] - Frame numbers to analyze. - num_batches : int, default 4 - Number of batches to partition frames into. - max_workers : int, optional - Maximum number of worker processes. Defaults to ``num_batches``. - - Returns - ------- - dict[int, float] - Mapping frame number -> mean contact angle (failed frames excluded). - """ - if max_workers is None: - max_workers = num_batches - batches = self._create_batches(frames_to_process, num_batches) - logger.info( - f"Processing {len(frames_to_process)} frames in {len(batches)} batches " - f"with {max_workers} workers" - ) - results: dict[int, float] = {} - with ProcessPoolExecutor( - max_workers=max_workers, mp_context=_MP_CONTEXT - ) as executor: - future_to_batch = { - executor.submit(self._process_batch_worker, batch_frames): batch_frames - for batch_frames in batches - } - completed_batches = 0 - all_angles: dict[int, list] = {} - all_surfaces: dict[int, list] = {} - all_popts: dict[int, list] = {} - for future in as_completed(future_to_batch): - batch_frames = future_to_batch[future] - try: - batch_results = future.result() - for frame_num, mean_angle, angles, surfaces, popts in batch_results: - if mean_angle is not None: - results[frame_num] = mean_angle - all_angles[frame_num] = angles - all_surfaces[frame_num] = surfaces - all_popts[frame_num] = popts - completed_batches += 1 - logger.info( - f"Completed batch {completed_batches}/{len(batches)} " - f"({len(batch_results)} frames)" - ) - except Exception as e: - logger.error( - f"Error in batch for frames {batch_frames}: {e}", - exc_info=True, - ) - sorted_frames = sorted(all_angles.keys()) - - angles_with_frames = [(f, all_angles[f]) for f in sorted_frames] - np.save( - f"{self.output_dir}/all_angles.npy", - np.array(angles_with_frames, dtype=object), - ) - - surfaces_with_frames = [(f, all_surfaces[f]) for f in sorted_frames] - np.save( - f"{self.output_dir}/all_surfaces.npy", - np.array(surfaces_with_frames, dtype=object), - ) - - popts_with_frames = [(f, all_popts[f]) for f in sorted_frames] - np.save( - f"{self.output_dir}/all_popts.npy", - np.array(popts_with_frames, dtype=object), - ) - logger.info( - f"Successfully processed {len(results)}/{len(frames_to_process)} frames" - ) - - return results - - def _create_batches(self, frames: list[int], num_batches: int) -> list[list[int]]: - """Return frame batches of near-equal size.""" - if num_batches >= len(frames): - return [[frame] for frame in frames] - batch_size = math.ceil(len(frames) / num_batches) - return [frames[i : i + batch_size] for i in range(0, len(frames), batch_size)] - - def _process_batch_worker( - self, batch_frames: list[int] - ) -> list[SlicingFrameResult]: - """Worker routine executed in child process for a batch.""" - try: - from wetting_angle_kit.io_utils import detect_parser_type - from wetting_angle_kit.parsers.ase import AseParser - from wetting_angle_kit.parsers.base import BaseParser - from wetting_angle_kit.parsers.lammps_dump import LammpsDumpParser - from wetting_angle_kit.parsers.xyz import XYZParser - except ImportError as e: - logger.error(f"Failed to import required classes: {e}") - return [ - SlicingFrameResult(frame, None, [], [], []) for frame in batch_frames - ] - try: - parser_type = detect_parser_type(self.filename) - logger.info(f"Detected parser type: {parser_type}") - parser: BaseParser - if parser_type == "dump": - parser = LammpsDumpParser(filepath=self.filename) - elif parser_type == "ase": - parser = AseParser(filepath=self.filename) - elif parser_type == "xyz": - parser = XYZParser(filepath=self.filename) - else: - raise ValueError(f"Unsupported parser type: {parser_type}") - except Exception as e: - logger.error(f"Error initializing parser: {e}") - return [ - SlicingFrameResult(frame, None, [], [], []) for frame in batch_frames - ] - batch_results: list[SlicingFrameResult] = [] - for frame_num in batch_frames: - try: - result = self._process_single_frame_with_parsers( - frame_num, self.atom_indices, parser - ) - batch_results.append(result) - except Exception as e: - logger.error(f"Error processing frame {frame_num}: {e}") - batch_results.append(SlicingFrameResult(frame_num, None, [], [], [])) - return batch_results - - def _process_single_frame_with_parsers( - self, frame_num: int, atom_indices: np.ndarray, parser: BaseParser - ) -> SlicingFrameResult: - """Process a single frame and compute mean contact angle.""" - try: - from wetting_angle_kit.analysis.slicing.angle_fitting import ( - ContactAngleSlicing, - ) - - except ImportError as e: - logger.error(f"Missing slicing predictor dependency: {e}") - return SlicingFrameResult(frame_num, None, [], [], []) - logger.info(f"START processing frame {frame_num}") - try: - liquid_positions = parser.parse( - frame_index=frame_num, - indices=atom_indices, - ) - max_dist = int( - np.max( - np.array( - [ - parser.box_size_y(frame_index=frame_num), - parser.box_size_x(frame_index=frame_num), - ] - ) - ) - / 2 - ) - logger.info( - f"Frame {frame_num}: Parsed {len(liquid_positions)} liquid " - f"particles with max_dist {max_dist}" - ) - # Fold the droplet into the minimum-image frame around its - # circular-mean COM before the cylinder_x axis swap, so the - # box_size argument is in the parser's native frame. This makes - # downstream radial sampling robust to droplets that straddle a - # periodic boundary, and is idempotent for trajectories already - # recentered during dynamics. Skipped (with a plain arithmetic - # mean) when the user has declared the trajectory pre-centered. - if self.precentered: - mean_liquid_position = np.mean(liquid_positions, axis=0) - else: - box_size_xy = ( - parser.box_size_x(frame_index=frame_num), - parser.box_size_y(frame_index=frame_num), - ) - liquid_positions, mean_liquid_position = recenter_droplet_pbc( - liquid_positions, self.droplet_geometry, box_size=box_size_xy - ) - if self.droplet_geometry == "cylinder_x": - liquid_positions = liquid_positions[:, [1, 0, 2]] - mean_liquid_position = mean_liquid_position[[1, 0, 2]] - box_dimensions = parser.box_size_x(frame_index=frame_num) - elif self.droplet_geometry == "cylinder_y": - box_dimensions = parser.box_size_y(frame_index=frame_num) - else: - box_dimensions = None - predictor = ContactAngleSlicing( - liquid_coordinates=liquid_positions, - max_dist=max_dist, - liquid_geom_center=mean_liquid_position, - droplet_geometry=self.droplet_geometry, - delta_gamma=self.delta_gamma, - width_cylinder=box_dimensions, - delta_cylinder=self.delta_cylinder, - points_per_angstrom=self.points_per_angstrom, - ) - angles, surfaces, popt_arrays = predictor.predict_contact_angle() - if len(angles) == 0: - logger.warning(f"Frame {frame_num}: No angles computed (empty list).") - mean_angle = None - else: - mean_angle = float(np.mean(angles)) - if mean_angle is not None: - logger.info(f"Frame {frame_num} - mean angle: {mean_angle:.2f}°") - return SlicingFrameResult( - frame_num, mean_angle, angles, surfaces, popt_arrays - ) - except Exception as e: - logger.error(f"Error processing frame {frame_num}: {e}", exc_info=True) - return SlicingFrameResult(frame_num, None, [], [], []) diff --git a/src/wetting_angle_kit/analysis/slicing/results.py b/src/wetting_angle_kit/analysis/slicing/results.py new file mode 100644 index 0000000..769749b --- /dev/null +++ b/src/wetting_angle_kit/analysis/slicing/results.py @@ -0,0 +1,53 @@ +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + + +@dataclass +class SlicingResults: + """In-memory container for the per-frame output of the slicing method. + + Replaces the legacy ``all_angles.npy`` / ``all_surfaces.npy`` / + ``all_popts.npy`` round-trip. The three parallel lists share the same + indexing as ``frames``: entry ``i`` describes frame ``frames[i]``. + + Attributes + ---------- + frames : list[int] + Frame indices that were successfully processed, sorted ascending. + angles : list[np.ndarray] + Per-frame array of contact angles (one value per slice). + surfaces : list[list[np.ndarray]] + Per-frame list of slice surface contours; each contour is an + ``(N, 2)`` array of ``(x, z)`` vertex coordinates. + popts : list[np.ndarray] + Per-frame array of fitted circle parameters; each entry has shape + ``(n_slices, 4)`` with columns ``(x_center, z_center, radius, extra)``. + method_metadata : dict + Free-form method descriptor (e.g. ``{"frames_per_angle": 1}``). + """ + + frames: list[int] + angles: list[np.ndarray] + surfaces: list[list[np.ndarray]] + popts: list[np.ndarray] + method_metadata: dict[str, Any] = field(default_factory=dict) + + def __len__(self) -> int: + return len(self.frames) + + @property + def per_frame_mean_angles(self) -> np.ndarray: + """Per-frame mean contact angle, taken across slices, in degrees.""" + return np.array([float(np.mean(a)) for a in self.angles]) + + @property + def mean_angle(self) -> float: + """Mean contact angle across frames, in degrees.""" + return float(np.mean(self.per_frame_mean_angles)) + + @property + def std_angle(self) -> float: + """Standard deviation of the per-frame mean contact angle, in degrees.""" + return float(np.std(self.per_frame_mean_angles)) diff --git a/src/wetting_angle_kit/analysis/slicing/surface_definition.py b/src/wetting_angle_kit/analysis/slicing/surface_definition.py index 619d2a7..216a6f0 100644 --- a/src/wetting_angle_kit/analysis/slicing/surface_definition.py +++ b/src/wetting_angle_kit/analysis/slicing/surface_definition.py @@ -23,7 +23,7 @@ """ import numpy as np -from scipy.optimize import curve_fit +from scipy.spatial import cKDTree class SurfaceDefinition: @@ -43,6 +43,13 @@ class SurfaceDefinition: # larger values broaden contributions and smooth the interface. DEFAULT_DENSITY_SIGMA = 3.0 + # Per-atom truncation radius for the Gaussian kernel, in units of + # ``density_sigma``. At 5 sigma each excluded atom contributes + # exp(-12.5) ≈ 3.7e-6 of the peak per-atom density: well below the + # noise of a single-frame fit, while shrinking the inner kernel sum + # from O(N) to the active neighbourhood of each sample point. + DEFAULT_CUTOFF_SIGMA = 5.0 + def __init__( self, atom_coords: np.ndarray, @@ -53,6 +60,7 @@ def __init__( density_conversion: float = 1.0, points_per_angstrom: float = 1.0, density_sigma: float = DEFAULT_DENSITY_SIGMA, + cutoff_sigma: float = DEFAULT_CUTOFF_SIGMA, ) -> None: """ Parameters @@ -73,6 +81,11 @@ def __init__( Sampling density along each ray. density_sigma : float, default DEFAULT_DENSITY_SIGMA Gaussian kernel width (Å) for density smoothing. + cutoff_sigma : float, default DEFAULT_CUTOFF_SIGMA + Multiple of ``density_sigma`` beyond which atoms are excluded + from each sample's density sum. Set higher for stricter + agreement with the dense kernel; the cost grows roughly as + ``cutoff_sigma ** 3`` (volume of the neighbour sphere). """ self.atom_coords = atom_coords self.center_geom = center_geom @@ -82,33 +95,52 @@ def __init__( self.max_dist = max_dist self.points_per_angstrom = points_per_angstrom self.density_sigma = density_sigma + self.cutoff_sigma = cutoff_sigma + # Spatial index over the atomic coordinates so each ray's density + # sum touches only the active neighbourhood of every sample point + # instead of the O(M*N) broadcast that previously dominated the + # slicing hot path. None for the empty-input case, which causes + # density_contribution to short-circuit to zeros. + self._atom_tree: cKDTree | None = ( + cKDTree(atom_coords) if len(atom_coords) > 0 else None + ) - @staticmethod - def density_contribution( - positions: np.ndarray, coords: np.ndarray, sigma: float = 2.0 - ) -> np.ndarray: - """Return Gaussian-smoothed density contributions at sampling positions. + def density_contribution(self, positions: np.ndarray) -> np.ndarray: + """Return Gaussian-smoothed density contributions at sample positions. + + Atoms farther than ``cutoff_sigma * density_sigma`` from a sample + point are skipped; their kernel weight is below ~4e-6 of the peak + at the 5 sigma default. Every (sample, atom) pair within the cutoff + is enumerated in a single C-side call via + ``cKDTree.sparse_distance_matrix`` so the per-sample work happens in + one vectorised numpy pass instead of an M-iteration Python loop. Parameters ---------- positions : ndarray, shape (M, 3) - Ray sampling coordinates. - coords : ndarray, shape (N, 3) - Atom coordinates contributing to density. - sigma : float, default 2.0 - Gaussian standard deviation (Å). Larger values broaden contributions. + Ray sampling coordinates. ``M`` is typically the sample count of + one ray, or the stacked count of all rays of a slice when + :meth:`analyze_lines` batches the per-slice fan. Returns ------- ndarray, shape (M,) Density values at each sampling position. """ - sigma2 = sigma * sigma + n_samples = len(positions) + if self._atom_tree is None or n_samples == 0: + return np.zeros(n_samples) + sigma2 = self.density_sigma * self.density_sigma prefactor = 1.0 / (2 * np.pi * sigma2) ** 1.5 - differences = positions[:, np.newaxis, :] - coords[np.newaxis, :, :] - ri2 = np.sum(differences**2, axis=-1) - den_contributions = prefactor * np.exp(-ri2 / (2 * sigma2)) - return np.sum(den_contributions, axis=1) + cutoff = self.cutoff_sigma * self.density_sigma + sample_tree = cKDTree(positions) + pairs = sample_tree.sparse_distance_matrix( + self._atom_tree, max_distance=cutoff, output_type="ndarray" + ) + if pairs.size == 0: + return np.zeros(n_samples) + contribs = prefactor * np.exp(-(pairs["v"] ** 2) / (2.0 * sigma2)) + return np.bincount(pairs["i"], weights=contribs, minlength=n_samples) @staticmethod def density_profile(z: np.ndarray, zd: float, d: float, h: float) -> np.ndarray: @@ -133,35 +165,112 @@ def density_profile(z: np.ndarray, zd: float, d: float, h: float) -> np.ndarray: """ return np.tanh(-z + zd) * d + h - def fit_density_profile( + def _fit_density_profiles_batched( self, - z_data: np.ndarray, - density: np.ndarray, - param_bounds: tuple[list[float], list[float]], - ) -> float: - """Fit the profile and return estimated interface position. + distances: np.ndarray, + densities: np.ndarray, + *, + max_iter: int = 25, + tol: float = 1e-9, + ) -> np.ndarray: + """Fit ``rho(s) = d * tanh(zd - s) + h`` to every ray of a slice at once. + + All rays of the slice share the same sampling grid, so the + Jacobian's structure is identical across rays and the per-ray + normal equations are independent 3x3 systems. A batched + Gauss-Newton solver assembles those systems on numpy tensors and + calls ``np.linalg.solve`` once per iteration, replacing the + per-ray ``scipy.optimize.curve_fit`` (TRF + finite-difference + Jacobian) that dominated the slicing hot path after 4.1/4.2. + + The closed-form initial guess (``h ~ midpoint``, ``d ~ + half-amplitude``, ``zd ~ midpoint crossing``) seeds each ray in + the basin of the global minimum, so plain Gauss-Newton without + damping converges in 3–6 iterations. Rays whose normal equations + become singular (e.g. constant density) fall back to that + initial guess. Parameters ---------- - z_data : ndarray - Distances along the ray. - density : ndarray - Observed (smoothed) density values. - param_bounds : tuple(list, list) - Lower and upper bounds for ``(zd, d, h)``. + distances : ndarray, shape (M,) + Sample distances along the ray (same for every ray of a slice). + densities : ndarray, shape (R, M) + Density values per ray. + max_iter : int, default 25 + Hard cap on Gauss-Newton iterations. + tol : float, default 1e-9 + Convergence threshold on the max absolute parameter step + across all rays. Returns ------- - float - Fitted ``zd`` value (interface location). + ndarray, shape (R,) + Fitted ``zd`` (interface position) per ray, clipped into + ``[0, max_dist]`` to match the bounded behaviour of the + original per-ray fit. """ - popt, _ = curve_fit(self.density_profile, z_data, density, bounds=param_bounds) - zd, d, h = popt - return zd + z = np.ascontiguousarray(distances, dtype=np.float64) + y = np.ascontiguousarray(densities, dtype=np.float64) + n_rays, n_samples = y.shape + + rho_max = y.max(axis=1) + rho_min = y.min(axis=1) + h0 = 0.5 * (rho_max + rho_min) + d0 = 0.5 * (rho_max - rho_min) + zd0 = z[np.argmin(np.abs(y - h0[:, None]), axis=1)] + zd0 = np.clip(zd0, 0.0, float(self.max_dist)) + params = np.stack([zd0, d0, h0], axis=1) + params_init = params.copy() + + for _ in range(max_iter): + zd = params[:, 0] + d = params[:, 1] + h = params[:, 2] + # u = tanh(zd - z), shape (R, M). + u = np.tanh(zd[:, None] - z[None, :]) + residuals = y - (d[:, None] * u + h[:, None]) + # J columns are d/dzd, d/dd, d/dh. J_h = 1 is folded into the + # normal equations directly (sums / counts), so only J_zd and + # J_d are materialised here. + j_zd = d[:, None] * (1.0 - u * u) + j_d = u + # Symmetric 3x3 normal-equations matrix per ray. + normal = np.empty((n_rays, 3, 3)) + normal[:, 0, 0] = np.einsum("rm,rm->r", j_zd, j_zd) + normal[:, 0, 1] = normal[:, 1, 0] = np.einsum("rm,rm->r", j_zd, j_d) + normal[:, 0, 2] = normal[:, 2, 0] = j_zd.sum(axis=1) + normal[:, 1, 1] = np.einsum("rm,rm->r", j_d, j_d) + normal[:, 1, 2] = normal[:, 2, 1] = j_d.sum(axis=1) + normal[:, 2, 2] = n_samples + rhs = np.empty((n_rays, 3)) + rhs[:, 0] = np.einsum("rm,rm->r", j_zd, residuals) + rhs[:, 1] = np.einsum("rm,rm->r", j_d, residuals) + rhs[:, 2] = residuals.sum(axis=1) + try: + # ``solve`` interprets the last two axes of the RHS as + # ``(M, K)`` for batched LHS, so feed it a trailing K=1 + # axis to keep each ray's RHS a 3-vector. + step = np.linalg.solve(normal, rhs[..., None])[..., 0] + except np.linalg.LinAlgError: + break + params += step + if not np.isfinite(params).all(): + params = params_init.copy() + break + if np.max(np.abs(step)) < tol: + break + + return np.clip(params[:, 0], 0.0, float(self.max_dist)) def analyze_lines(self) -> tuple[list[list[float]], list[list[float]]]: """Sample density along radial lines and fit interface positions. + All rays of the slice share the same sampling distances and the + same atomic neighbourhood, so their sample positions are stacked + into a single ``(R * M, 3)`` array and the truncated density is + evaluated in one ``density_contribution`` call. Only the tanh fit + and the (x, z) projection are still done per ray. + Returns ------- rr : list[list[float]] @@ -170,36 +279,33 @@ def analyze_lines(self) -> tuple[list[list[float]], list[list[float]]]: Projected interface coordinates ``[x_proj, z_proj]`` in XZ plane. """ beta = np.linspace(0, 360, int(360 / self.delta_angle), endpoint=False) - rr = [] - xz = [] - nn = max(int(self.max_dist * self.points_per_angstrom), self.MIN_POINTS_PER_RAY) - param_bounds = ([0.0, -10.0, -10.0], [self.max_dist, 10.0, 10.0]) + n_samples = max( + int(self.max_dist * self.points_per_angstrom), self.MIN_POINTS_PER_RAY + ) cos_beta = np.cos(np.deg2rad(beta)) sin_beta = np.sin(np.deg2rad(beta)) cos_gamma = np.cos(np.deg2rad(self.gamma)) sin_gamma = np.sin(np.deg2rad(self.gamma)) - for i in range(len(beta)): - x_dir = cos_beta[i] * cos_gamma - y_dir = sin_gamma * cos_beta[i] - z_dir = sin_beta[i] - direction = np.array([x_dir, y_dir, z_dir]) - positions = np.linspace( - self.center_geom, - self.center_geom + self.max_dist * direction, - int(nn), - ) - distances = np.linspace(0.0, self.max_dist, int(nn)) - density = self.density_conversion * self.density_contribution( - positions, - self.atom_coords, - sigma=self.density_sigma, - ) - interface_re = self.fit_density_profile(distances, density, param_bounds) - rr.append([interface_re, beta[i]]) - xz.append( - [ - cos_beta[i] * interface_re + self.center_geom[0], - sin_beta[i] * interface_re + self.center_geom[2], - ] - ) + + # Per-ray unit direction vectors, shape (R, 3). Matches the original + # per-iteration construction ``(cos_beta * cos_gamma, + # cos_beta * sin_gamma, sin_beta)``. + directions = np.column_stack( + (cos_beta * cos_gamma, cos_beta * sin_gamma, sin_beta) + ) + distances = np.linspace(0.0, self.max_dist, n_samples) + + # positions[r, m, :] = center_geom + distances[m] * directions[r, :] + positions_rm = ( + self.center_geom[None, None, :] + + distances[None, :, None] * directions[:, None, :] + ) + density_flat = self.density_contribution(positions_rm.reshape(-1, 3)) + densities = self.density_conversion * density_flat.reshape(len(beta), n_samples) + interface_re = self._fit_density_profiles_batched(distances, densities) + + x_proj = cos_beta * interface_re + self.center_geom[0] + z_proj = sin_beta * interface_re + self.center_geom[2] + rr = [[float(interface_re[i]), float(beta[i])] for i in range(len(beta))] + xz = [[float(x_proj[i]), float(z_proj[i])] for i in range(len(beta))] return rr, xz diff --git a/src/wetting_angle_kit/io_utils.py b/src/wetting_angle_kit/io_utils.py index 183bb26..ad9c95d 100644 --- a/src/wetting_angle_kit/io_utils.py +++ b/src/wetting_angle_kit/io_utils.py @@ -59,60 +59,6 @@ def assert_orthogonal_cell( ) -def load_dump_ovito(filepath: str) -> Any: - """Load a LAMMPS dump file via OVITO and return the pipeline. - - Parameters - ---------- - filepath : str - Path to the LAMMPS dump file. - - Returns - ------- - Any - OVITO pipeline object (typed as Any because OVITO lacks Python type stubs). - """ - try: - from ovito.io import import_file - except ImportError as e: # add exception chaining - raise ImportError( - "The 'ovito' package is required for load dump_ovito. Install it with: " - "pip install wetting_angle_kit[ovito]" - ) from e - pipeline = import_file(filepath) - # Add necessary modifiers - return pipeline - - -def save_array_as_txt(array: np.ndarray, filename: str) -> None: - """Save a numpy array to a whitespace-delimited text file. - - Parameters - ---------- - array : ndarray - Array to save. - filename : str - Output file path. - """ - np.savetxt(filename, array, fmt="%f") - - -def geometric_center(list_xyz_point: np.ndarray) -> np.ndarray: - """Return the geometric center (mean position) of a point cloud. - - Parameters - ---------- - list_xyz_point : ndarray, shape (N, 3) - Cartesian coordinates of the points. - - Returns - ------- - ndarray, shape (3,) - Mean position vector. - """ - return np.mean(list_xyz_point, axis=0) - - def detect_parser_type(filename: str) -> str: """Infer the parser type from a trajectory file extension. diff --git a/src/wetting_angle_kit/parsers/__init__.py b/src/wetting_angle_kit/parsers/__init__.py index 9aa3068..cb62f20 100644 --- a/src/wetting_angle_kit/parsers/__init__.py +++ b/src/wetting_angle_kit/parsers/__init__.py @@ -4,6 +4,7 @@ AseWaterFinder, ) from wetting_angle_kit.parsers.base import BaseParser +from wetting_angle_kit.parsers.factory import get_water_finder from wetting_angle_kit.parsers.lammps_dump import ( LammpsDumpParser, LammpsDumpWallParser, @@ -11,6 +12,7 @@ ) from wetting_angle_kit.parsers.xyz import ( XYZParser, + XYZWallParser, XYZWaterFinder, ) @@ -23,5 +25,7 @@ "LammpsDumpWallParser", "LammpsDumpWaterFinder", "XYZParser", + "XYZWallParser", "XYZWaterFinder", + "get_water_finder", ] diff --git a/src/wetting_angle_kit/parsers/ase.py b/src/wetting_angle_kit/parsers/ase.py index ff96f1b..c5a5c1d 100644 --- a/src/wetting_angle_kit/parsers/ase.py +++ b/src/wetting_angle_kit/parsers/ase.py @@ -114,7 +114,6 @@ class AseWaterFinder: def __init__( self, filepath: str, - particle_type_wall: list[str], oxygen_type: str = "O", hydrogen_type: str = "H", oh_cutoff: float = 1.2, @@ -124,9 +123,6 @@ def __init__( ---------- filepath : str Path to ASE-readable trajectory. - particle_type_wall : sequence[str] - Symbols representing wall particles (unused presently, reserved for - filtering). oxygen_type : str, default "O" Oxygen atom symbol. hydrogen_type : str, default "H" @@ -146,7 +142,6 @@ def __init__( self._NeighborList = NeighborList self.trajectory = self._ase_read(filepath, index=":") _validate_ase_trajectory_orthogonal(self.trajectory) - self.particle_type_wall = particle_type_wall self.oxygen_type = oxygen_type self.hydrogen_type = hydrogen_type self.oh_cutoff = oh_cutoff @@ -171,7 +166,7 @@ def get_water_oxygen_indices(self, frame_index: int) -> np.ndarray: # ASE's NeighborList uses pairwise cutoff = cutoffs[i] + cutoffs[j]. # Use half the bond cutoff per atom so the effective pair cutoff # equals self.oh_cutoff. - cutoffs = [self.oh_cutoff / 2.0] * len(frame) # type: ignore[arg-type] + cutoffs = [self.oh_cutoff / 2.0] * len(frame) nl = self._NeighborList(cutoffs, self_interaction=False, bothways=True) nl.update(frame) water_oxygens = [] diff --git a/src/wetting_angle_kit/parsers/base.py b/src/wetting_angle_kit/parsers/base.py index fd9c097..0caac47 100644 --- a/src/wetting_angle_kit/parsers/base.py +++ b/src/wetting_angle_kit/parsers/base.py @@ -6,10 +6,12 @@ class BaseParser(ABC): """Abstract interface for trajectory parsers consumed by analyzers. - Subclasses must implement :meth:`parse` and :meth:`frame_count`. The - geometric helpers (:meth:`box_size_x`, :meth:`box_size_y`, - :meth:`box_length_max`) raise ``NotImplementedError`` by default and - can be overridden where the underlying format exposes that information. + Subclasses must implement :meth:`parse`, :meth:`frame_count`, and the + cell-geometry helpers :meth:`box_size_x`, :meth:`box_size_y`, and + :meth:`box_length_max`. The cell helpers are abstract because the + analyzers rely on per-frame box information (PBC-aware droplet + recentering, default sampling extent); a parser without it would + silently degrade their accuracy. """ @abstractmethod @@ -42,36 +44,18 @@ def parse(self, frame_index: int, indices: np.ndarray | None = None) -> np.ndarr def frame_count(self) -> int: """Return the total number of frames in the trajectory.""" - def frame_tot(self) -> int: - """Return the total number of frames available. (Legacy name).""" - import warnings - - warnings.warn( - "frame_tot is deprecated, use frame_count instead.", - DeprecationWarning, - stacklevel=2, - ) - return self.frame_count() - + @abstractmethod def box_size_x(self, frame_index: int) -> float: - """Return the x-dimension of the simulation box for a frame. - - Override in subclasses where the underlying format exposes it. - """ - raise NotImplementedError("box_size_x not implemented for this parser.") + """Return the length of the first lattice vector for a frame.""" + @abstractmethod def box_size_y(self, frame_index: int) -> float: - """Return the y-dimension of the simulation box for a frame. - - Override in subclasses where the underlying format exposes it. - """ - raise NotImplementedError("box_size_y not implemented for this parser.") + """Return the length of the second lattice vector for a frame.""" + @abstractmethod def box_length_max(self, frame_index: int) -> float: """Return the maximum lattice vector length for a frame. - Override in subclasses where the underlying format exposes it. - Parameters ---------- frame_index : int @@ -82,4 +66,3 @@ def box_length_max(self, frame_index: int) -> float: float Max ``|a_i|`` over lattice vectors. """ - raise NotImplementedError("box_length_max not implemented for this parser.") diff --git a/src/wetting_angle_kit/parsers/factory.py b/src/wetting_angle_kit/parsers/factory.py index 2928cb8..2983e2f 100644 --- a/src/wetting_angle_kit/parsers/factory.py +++ b/src/wetting_angle_kit/parsers/factory.py @@ -8,7 +8,6 @@ def get_water_finder( filename: str, - particle_type_wall: Any, oxygen_type: Any, hydrogen_type: Any, ) -> Any: @@ -18,8 +17,6 @@ def get_water_finder( ---------- filename : str Path to trajectory file; extension determines the finder class. - particle_type_wall : Any - Wall particle type identifiers forwarded to the finder constructor. oxygen_type : Any Oxygen type identifier (symbol or integer depending on file format). hydrogen_type : Any @@ -33,20 +30,16 @@ def get_water_finder( ext = os.path.splitext(filename)[-1].lower() if ext == ".lammpstrj": - return LammpsDumpWaterFinder( - filename, particle_type_wall, oxygen_type, hydrogen_type - ) + return LammpsDumpWaterFinder(filename, oxygen_type, hydrogen_type) elif ext in (".traj", ".ase"): return AseWaterFinder( filename, - particle_type_wall, oxygen_type=oxygen_type, hydrogen_type=hydrogen_type, ) elif ext == ".xyz": return XYZWaterFinder( filename, - particle_type_wall, oxygen_type=oxygen_type, hydrogen_type=hydrogen_type, ) diff --git a/src/wetting_angle_kit/parsers/lammps_dump.py b/src/wetting_angle_kit/parsers/lammps_dump.py index 5afb094..8efd3ca 100644 --- a/src/wetting_angle_kit/parsers/lammps_dump.py +++ b/src/wetting_angle_kit/parsers/lammps_dump.py @@ -161,7 +161,7 @@ def load_dump_ovito(self) -> Any: pipeline = import_file(self.filepath) pipeline.modifiers.append( SelectTypeModifier( - property="Particle Type", types=self.liquid_particle_types + property="Particle Type", types=set(self.liquid_particle_types) ) ) pipeline.modifiers.append(DeleteSelectedModifier()) @@ -266,9 +266,8 @@ class LammpsDumpWaterFinder: def __init__( self, filepath: str, - particle_type_wall: set, - oxygen_type: int = 3, - hydrogen_type: int = 2, + oxygen_type: int, + hydrogen_type: int, oh_cutoff: float = 1.2, ): """ @@ -276,18 +275,16 @@ def __init__( ---------- filepath : str Path to LAMMPS dump file. - particle_type_wall : set - Particle type IDs corresponding to wall atoms (reserved for future - filtering). - oxygen_type : int, default 3 - LAMMPS particle type ID for oxygen atoms. - hydrogen_type : int, default 2 - LAMMPS particle type ID for hydrogen atoms. + oxygen_type : int + LAMMPS particle type ID for oxygen atoms (required; LAMMPS + type numbering is system-specific so there is no safe default). + hydrogen_type : int + LAMMPS particle type ID for hydrogen atoms (required; LAMMPS + type numbering is system-specific so there is no safe default). oh_cutoff : float, default 1.2 O-H distance cutoff (Å) for water molecule detection. """ self.filepath = filepath - self.particle_type_wall = particle_type_wall self.oxygen_type = oxygen_type self.hydrogen_type = hydrogen_type self.oh_cutoff = oh_cutoff @@ -302,6 +299,10 @@ def _setup_pipeline(self) -> Any: """ try: from ovito.io import import_file + + # OVITO's type stubs omit ``CoordinationAnalysisModifier`` even + # though it exists at runtime; silence the spurious attr-defined + # error rather than blanket-ignoring the whole import block. from ovito.modifiers import ( ComputePropertyModifier, CoordinationAnalysisModifier, diff --git a/src/wetting_angle_kit/parsers/xyz.py b/src/wetting_angle_kit/parsers/xyz.py index cb58ea5..7ae944d 100644 --- a/src/wetting_angle_kit/parsers/xyz.py +++ b/src/wetting_angle_kit/parsers/xyz.py @@ -1,6 +1,7 @@ from typing import Any import numpy as np +from scipy.spatial import cKDTree from wetting_angle_kit.io_utils import assert_orthogonal_cell from wetting_angle_kit.parsers.base import BaseParser @@ -142,7 +143,6 @@ class XYZWaterFinder: def __init__( self, filepath: str, - particle_type_wall: Any, oxygen_type: str = "O", hydrogen_type: str = "H", oh_cutoff: float = 1.2, @@ -152,8 +152,6 @@ def __init__( ---------- filepath : str Path to XYZ file. - particle_type_wall : sequence[str] - Symbols that represent wall (excluded) particles. oxygen_type : str, default "O" Oxygen atom symbol. hydrogen_type : str, default "H" @@ -162,7 +160,6 @@ def __init__( Distance cutoff (Å) for O-H bonding to identify water molecules. """ self.filepath = filepath - self.particle_type_wall = particle_type_wall self.oxygen_type = oxygen_type self.hydrogen_type = hydrogen_type self.oh_cutoff = oh_cutoff @@ -170,7 +167,7 @@ def __init__( def load_xyz_file(self) -> list[dict[str, Any]]: """Load frames including the lattice matrix for box-size queries.""" - frames: list[dict[str, np.ndarray]] = [] + frames: list[dict[str, np.ndarray | None]] = [] with open(self.filepath) as file: lines = file.readlines() frame_start = 0 @@ -259,10 +256,11 @@ def get_water_oxygen_indices(self, frame_index: int) -> np.ndarray: data = self.frames[frame_index] positions = data["positions"] symbols = data["symbols"] + lattice_matrix = data.get("lattice_matrix") oxygen_indices = np.where(symbols == self.oxygen_type)[0] hydrogen_indices = np.where(symbols == self.hydrogen_type)[0] return self._manual_water_identification( - positions, oxygen_indices, hydrogen_indices + positions, oxygen_indices, hydrogen_indices, lattice_matrix ) def get_water_oxygen_positions(self, frame_index: int) -> np.ndarray: @@ -289,9 +287,16 @@ def _manual_water_identification( positions: np.ndarray, oxygen_indices: np.ndarray, hydrogen_indices: np.ndarray, + lattice_matrix: np.ndarray | None = None, ) -> np.ndarray: """Identify water oxygens by counting hydrogens within cutoff distance. + Uses a :class:`scipy.spatial.cKDTree` over the hydrogen positions. + When ``lattice_matrix`` is provided, the kd-tree's ``boxsize`` is + set from its diagonal (the cell is enforced orthogonal upstream) so + O–H pairs are matched under minimum-image convention; otherwise the + match is done in open space. + Parameters ---------- positions : ndarray, shape (N, 3) @@ -300,20 +305,107 @@ def _manual_water_identification( Candidate oxygen indices. hydrogen_indices : ndarray Hydrogen indices to check. + lattice_matrix : ndarray, shape (3, 3), optional + Orthogonal cell. If given, pairwise distances are evaluated + under PBC; otherwise raw Cartesian distances are used. Returns ------- ndarray Oxygen indices with exactly two nearby hydrogens. """ - water_oxygens = [] - for o_idx in oxygen_indices: - o_pos = positions[o_idx] - h_count = 0 - for h_idx in hydrogen_indices: - h_pos = positions[h_idx] - if np.linalg.norm(o_pos - h_pos) <= self.oh_cutoff: - h_count += 1 - if h_count == 2: - water_oxygens.append(o_idx) - return np.array(water_oxygens) + if oxygen_indices.size == 0 or hydrogen_indices.size == 0: + return np.array([], dtype=int) + + o_pos = positions[oxygen_indices] + h_pos = positions[hydrogen_indices] + + if lattice_matrix is not None: + # Orthogonal cell — diagonal entries are the axis-aligned box + # lengths. cKDTree requires coordinates inside ``[0, L)``, so + # wrap with a modulo before building the tree. + box = np.abs(np.diag(np.asarray(lattice_matrix, dtype=float))) + o_pos = o_pos - np.floor(o_pos / box) * box + h_pos = h_pos - np.floor(h_pos / box) * box + tree = cKDTree(h_pos, boxsize=box) + else: + tree = cKDTree(h_pos) + + neighbours = tree.query_ball_point(o_pos, r=self.oh_cutoff) + h_counts = np.fromiter( + (len(n) for n in neighbours), dtype=int, count=len(o_pos) + ) + return oxygen_indices[h_counts == 2] + + +class XYZWallParser(BaseParser): + """Parser extracting wall particle coordinates from an XYZ trajectory. + + Wall particles are everything *not* in ``liquid_particle_types``; the + mask is applied at :meth:`parse` time over the per-frame symbol array. + The ``indices`` argument of :meth:`parse` is treated as 0-based + positional indices into the wall-only positions, mirroring + :class:`~wetting_angle_kit.parsers.ase.AseWallParser`. + """ + + def __init__(self, filepath: str, liquid_particle_types: list[str]) -> None: + """ + Parameters + ---------- + filepath : str + Path to extended XYZ trajectory. + liquid_particle_types : sequence[str] + Atomic symbols representing liquid particles to exclude. + """ + self.filepath = filepath + self.liquid_particle_types = liquid_particle_types + # Reuse ``XYZParser`` for loading: it already validates orthogonal + # cells and stores symbols + positions + lattice per frame. + self.frames = XYZParser(filepath).frames + + def parse(self, frame_index: int, indices: np.ndarray | None = None) -> np.ndarray: + """Return wall atom positions for a frame. + + Parameters + ---------- + frame_index : int + Frame index. + indices : ndarray, optional + 0-based indices into the wall-only positions to further + restrict the result; if None all wall atoms are returned. + + Returns + ------- + ndarray, shape (M, 3) + Wall atom coordinates. + """ + frame = self.frames[frame_index] + mask = ~np.isin(frame["symbols"], self.liquid_particle_types) + x_par = frame["positions"][mask] + if indices is not None: + x_par = x_par[np.asarray(indices, dtype=int)] + return x_par + + def find_highest_wall_particle(self, frame_index: int) -> float: + """Return the maximum z-coordinate among wall particles for a frame.""" + x_wall = self.parse(frame_index) + return float(np.max(x_wall[:, 2])) + + def box_size_x(self, frame_index: int) -> float: + """Return the length of the first lattice vector for a frame.""" + lattice_matrix = self.frames[frame_index]["lattice_matrix"] + return float(np.linalg.norm(lattice_matrix[0])) + + def box_size_y(self, frame_index: int) -> float: + """Return the length of the second lattice vector for a frame.""" + lattice_matrix = self.frames[frame_index]["lattice_matrix"] + return float(np.linalg.norm(lattice_matrix[1])) + + def box_length_max(self, frame_index: int) -> float: + """Return the maximum lattice vector length for a frame.""" + lattice_matrix = self.frames[frame_index]["lattice_matrix"] + return float(np.max(np.linalg.norm(lattice_matrix, axis=1))) + + def frame_count(self) -> int: + """Return the total number of frames in the trajectory.""" + return len(self.frames) diff --git a/src/wetting_angle_kit/visualization/__init__.py b/src/wetting_angle_kit/visualization/__init__.py index 1a02dea..bf10e4d 100644 --- a/src/wetting_angle_kit/visualization/__init__.py +++ b/src/wetting_angle_kit/visualization/__init__.py @@ -1,37 +1,19 @@ -from wetting_angle_kit.visualization.base_trajectory_analyzer import ( - BaseTrajectoryAnalyzer, +from wetting_angle_kit.visualization.base_trajectory_plotter import ( + BaseTrajectoryPlotter, ) -from wetting_angle_kit.visualization.binning_trajectory_analyzer import ( - BinningTrajectoryAnalyzer, +from wetting_angle_kit.visualization.binning_trajectory_plotter import ( + BinningTrajectoryPlotter, ) -from wetting_angle_kit.visualization.droplet_slice_plots import ( - ContactAngleAnimator, - DropletSlicePlotlyPlotter, - DropletSlicePlotter, -) -from wetting_angle_kit.visualization.method_comparison import MethodComparison -from wetting_angle_kit.visualization.slicing_trajectory_analyzer import ( - SlicingTrajectoryAnalyzer, -) -from wetting_angle_kit.visualization.surface_plots import ( - plot_liquid_particles, - plot_slice, - plot_surface_and_points, - plot_surface_file, - read_surface_file, +from wetting_angle_kit.visualization.droplet_slice_plot import DropletSlicePlotter +from wetting_angle_kit.visualization.slicing_trajectory_plotter import ( + SlicingTrajectoryPlotter, ) +from wetting_angle_kit.visualization.stats import TrajectoryStats __all__ = [ - "BaseTrajectoryAnalyzer", - "BinningTrajectoryAnalyzer", - "MethodComparison", + "BaseTrajectoryPlotter", + "BinningTrajectoryPlotter", "DropletSlicePlotter", - "DropletSlicePlotlyPlotter", - "ContactAngleAnimator", - "SlicingTrajectoryAnalyzer", - "plot_slice", - "plot_surface_file", - "read_surface_file", - "plot_surface_and_points", - "plot_liquid_particles", + "SlicingTrajectoryPlotter", + "TrajectoryStats", ] diff --git a/src/wetting_angle_kit/visualization/base_trajectory_analyzer.py b/src/wetting_angle_kit/visualization/base_trajectory_analyzer.py deleted file mode 100644 index 6cfe6cb..0000000 --- a/src/wetting_angle_kit/visualization/base_trajectory_analyzer.py +++ /dev/null @@ -1,298 +0,0 @@ -import logging -import os -from abc import ABC, abstractmethod -from typing import Any - -import matplotlib.pyplot as plt -import numpy as np - -logger = logging.getLogger(__name__) - - -class BaseTrajectoryAnalyzer(ABC): - """Abstract base for trajectory analyzers that compute contact angle statistics.""" - - def __init__(self, directories: list[str], time_unit: str = "ps") -> None: - """ - Initialize the analyzer with a list of directory paths. - - Parameters - ---------- - directories : list of str - List of directory paths containing analysis results. - time_unit : str, optional - Time unit for the x-axis (e.g., "ps", "ns", "fs"). - """ - self.directories = directories - self.data: dict[str, Any] = {} - self.time_unit = time_unit - self._initialize_data_structure() - - @abstractmethod - def _initialize_data_structure(self) -> None: - """Initialize the data dictionary structure for each directory.""" - pass - - @abstractmethod - def load_data(self) -> None: - """Read and parse data from files in each directory.""" - pass - - @abstractmethod - def get_surface_areas(self, directory: str) -> np.ndarray: - """ - Get surface areas for a given directory. - - Parameters - ---------- - directory : str - Directory path. - - Returns - ------- - numpy.ndarray - Array of surface area values. - """ - pass - - @abstractmethod - def get_contact_angles(self, directory: str) -> np.ndarray: - """ - Get contact angles for a given directory. - - Parameters - ---------- - directory : str - Directory path. - - Returns - ------- - numpy.ndarray - Array of contact angle values. - """ - pass - - @abstractmethod - def get_method_name(self) -> str: - """ - Return the name of this analysis method. - - Returns - ------- - str - Method name for labels and titles. - """ - pass - - def compute_statistics(self, directory: str) -> tuple[float, float, float]: - """ - Compute mean surface area, mean angle, and standard error. - - Parameters - ---------- - directory : str - Directory path. - - Returns - ------- - tuple - (x_value, y_value, y_error) where: - - x_value: 1/sqrt(mean_surface_area) - - y_value: mean contact angle - - y_error: standard error of the mean - """ - surface_areas = self.get_surface_areas(directory) - contact_angles = self.get_contact_angles(directory) - - x = 1 / np.sqrt(np.mean(surface_areas)) - y = np.mean(contact_angles) - yerr = np.std(contact_angles) / np.sqrt(len(contact_angles)) - - return x, y, yerr - - def get_clean_label(self, directory: str) -> str: - """ - Generate a clean label from directory name. - - Parameters - ---------- - directory : str - Directory path. - - Returns - ------- - str - Cleaned directory name for plotting. - """ - return ( - directory.replace("_reduce_slicing", "") - .replace("_reduce_binning", "") - .replace("result_dump_", "") - ) - - def analyze(self, output_filename: str = "output_stats.txt") -> None: - """Load data and write per-directory statistics to a text file. - - Parameters - ---------- - output_filename : str, default "output_stats.txt" - File name written inside each directory. - """ - self.load_data() - for directory in self.directories: - output_path = f"{directory}/{output_filename}" - with open(output_path, "w", encoding="utf-8") as f: - f.write(f"Directory: {directory}\n") - f.write(f"Method: {self.get_method_name()}\n") - f.write( - "Mean Surface Area: " - f"{np.mean(self.get_surface_areas(directory)):.4f}\n" - ) - f.write( - "Mean Contact Angle: " - f"{np.mean(self.get_contact_angles(directory)):.4f}\u00b0\n" - ) - f.write( - "Std Contact Angle: " - f"{np.std(self.get_contact_angles(directory)):.4f}\u00b0\n" - ) - logger.info("Analysis saved to: %s", output_path) - - def plot_mean_angle_vs_surface( - self, - labels: list[str] | None = None, - color: str | None = None, - save_path: str | None = None, - ) -> None: - """ - Generate a plot comparing mean angle vs surface - area scaling. If no analysis output is found, run the analysis first. - - Parameters - ---------- - labels : list of str, optional - Labels for each dataset. If None, directory names are used. - color : str, optional - Base color for all datasets. If None, a default - set of unique colors is used. - save_path : str, optional - Path to save the figure. - """ - # Check if analysis output files exist; if not, run analysis - for directory in self.directories: - output_file = f"{directory}/output_stats.txt" - if not os.path.exists(output_file): - logger.info("No analysis found for %s. Running analysis...", directory) - self.analyze() # Run analysis to generate output files - break # Only need to run once - - # Read data if not already loaded - if not hasattr(self, "data") or not self.data: - self.load_data() - - # Set up plot parameters - plt.rcParams.update( - { - "font.family": "serif", - "font.size": 13, - "axes.labelsize": 14, - "axes.titlesize": 15, - "legend.fontsize": 12, - "xtick.direction": "in", - "ytick.direction": "in", - "axes.linewidth": 1.0, - "errorbar.capsize": 3, - } - ) - - # Create the plot - fig, ax = plt.subplots(figsize=(7, 4.5)) - - # Set default labels and colors if not provided - if labels is None: - labels = [self.get_clean_label(d) for d in self.directories] - - if color is None: - color = "purple" - colors = [color] * len(self.directories) - # Collect data for plotting - xvals, yvals = [], [] - for d, label, color in zip(self.directories, labels, colors, strict=False): - # Read data from the analysis output file - output_file = f"{d}/output_stats.txt" - with open(output_file, encoding="utf-8") as f: - lines = f.readlines() - mean_surface_area = float(lines[2].split(": ")[1].strip()) - mean_contact_angle = float( - lines[3].split(": ")[1].strip().replace("°", "") - ) - std_contact_angle = float( - lines[4].split(": ")[1].strip().replace("°", "") - ) - - # Use the data for plotting - x = 1 / np.sqrt(mean_surface_area) - # Convert angle to radians for cosine calculation - mean_angle_rad = np.radians(mean_contact_angle) - y = np.cos(mean_angle_rad) - - # Propagate error: d(cos(theta)) = |-sin(theta)| * d(theta) - # d(theta) must be in radians - std_angle_rad = np.radians(std_contact_angle) - yerr = np.abs(np.sin(mean_angle_rad)) * (std_angle_rad / 5) - - ax.errorbar( - x, y, yerr=yerr, fmt="o", color=color, markersize=6, capsize=3, lw=1.2 - ) - ax.annotate( - label, - xy=(x, y), - xytext=(5, 5), - textcoords="offset points", - ha="left", - va="center", - fontsize=6, - color="black", - ) - xvals.append(x) - yvals.append(y) - - # Linear fit - if len(xvals) >= 2: - xvals_arr, yvals_arr = np.array(xvals), np.array(yvals) - coeffs = np.polyfit(xvals_arr, yvals_arr, 1) - fit_line = np.poly1d(coeffs) - intercept = coeffs[1] - - intercept_clipped = np.clip(intercept, -1.0, 1.0) - theta_inf_deg = np.degrees(np.arccos(intercept_clipped)) - - x_fit = np.linspace(0, max(xvals) * 1.1, 100) - ax.plot( - x_fit, - fit_line(x_fit), - "--", - color="gray", - lw=1.5, - label=( - f"Fit: $\\cos(\\theta) = {coeffs[0]:.2f}x + {coeffs[1]:.2f}$\n" - f"$\\theta_\\infty = {theta_inf_deg:.1f}^\\circ$" - ), - ) - - # Set plot labels and title - ax.set_xlabel(r"$1 / \sqrt{A} \; (\mathrm{\AA^{-1}})$") - ax.set_ylabel(r"$\cos(\theta)$") - ax.set_title(f"{self.get_method_name()} - Modified Young's Eq Plot", pad=10) - ax.legend(frameon=False, loc="best") - ax.grid(False) - ax.set_xlim(left=-0.001) - if len(yvals) > 0: - margin = (max(yvals) - min(yvals)) * 0.2 if len(yvals) > 1 else 0.1 - if margin == 0: - margin = 0.1 - ax.set_ylim(bottom=min(yvals) - margin, top=max(yvals) + margin) - plt.tight_layout() - if save_path: - plt.savefig(save_path, dpi=400, bbox_inches="tight") - plt.close() diff --git a/src/wetting_angle_kit/visualization/base_trajectory_plotter.py b/src/wetting_angle_kit/visualization/base_trajectory_plotter.py new file mode 100644 index 0000000..1254408 --- /dev/null +++ b/src/wetting_angle_kit/visualization/base_trajectory_plotter.py @@ -0,0 +1,16 @@ +from abc import ABC, abstractmethod + +from wetting_angle_kit.visualization.stats import TrajectoryStats + + +class BaseTrajectoryPlotter(ABC): + """Abstract base for trajectory plotters. + + Subclasses own their own data layout (per-method result containers, + directories, etc.) and must implement :meth:`summary` returning one + :class:`TrajectoryStats` per trajectory. + """ + + @abstractmethod + def summary(self) -> list[TrajectoryStats]: + """Return per-trajectory summary statistics.""" diff --git a/src/wetting_angle_kit/visualization/binning_trajectory_analyzer.py b/src/wetting_angle_kit/visualization/binning_trajectory_analyzer.py deleted file mode 100644 index ca15706..0000000 --- a/src/wetting_angle_kit/visualization/binning_trajectory_analyzer.py +++ /dev/null @@ -1,168 +0,0 @@ -import glob -import logging -import os -import re - -import numpy as np - -from wetting_angle_kit.visualization.base_trajectory_analyzer import ( - BaseTrajectoryAnalyzer, -) - -logger = logging.getLogger(__name__) - - -class BinningTrajectoryAnalyzer(BaseTrajectoryAnalyzer): - """Analyze binning trajectory data using circular segment calculations.""" - - def __init__( - self, - directories: list[str], - split_factor: int = 1, - time_steps: dict[str, float] | None = None, - time_unit: str = "ps", - ) -> None: - """ - Initialize the analyzer with a list of directory paths and split factor. - - Parameters - ---------- - directories : list of str - List of directory paths containing analysis results. - split_factor : int, optional - Number of batches/splits to process in each directory. - time_steps : dict, optional - Dictionary mapping directory to its time step. - time_unit : str, optional - Time unit for the x-axis (e.g., "ps", "ns", "fs"). - """ - self.split_factor = split_factor - self.time_steps = time_steps if time_steps else {d: 1.0 for d in directories} - - # Initialize Base Class (this will trigger _initialize_data_structure) - super().__init__(directories, time_unit=time_unit) - - def _initialize_data_structure(self) -> None: - """Initialize data structure for binning analysis.""" - for directory in self.directories: - self.data[directory] = { - "R_eq": [], - "zi_c": [], - "zi_0": [], - "contact_angles": [], - "surface_areas": [], - "time_step": self.time_steps.get(directory, 1.0), - } - - def get_method_name(self) -> str: - """Return method name.""" - return "Binning Analysis" - - @staticmethod - def circular_segment_area(R: float, z_center: float, z_cut: float) -> float: - """Return the area of the circular cap below ``z_cut``. - - Parameters - ---------- - R : float - Circle radius. - z_center : float - z-coordinate of the circle center. - z_cut : float - Height of the cutting plane. - - Returns - ------- - float - Area of the circular segment below the cut. - """ - h = (z_center + R) - z_cut # height of the cap - if h <= 0: - return 0.0 - if h >= 2 * R: - return np.pi * R**2 - if h <= R: - return R**2 * np.arccos((R - h) / R) - (R - h) * np.sqrt(2 * R * h - h**2) - else: - h_small = 2 * R - h - return np.pi * R**2 - ( - R**2 * np.arccos((R - h_small) / R) - - (R - h_small) * np.sqrt(2 * R * h_small - h_small**2) - ) - - def load_files(self) -> None: - """Load and sort all relevant log files from each directory.""" - for directory in self.directories: - log_files = sorted( - glob.glob(os.path.join(directory, "log_data_batch_*.txt")), - key=lambda x: int(re.search(r"batch_(\d+)", x).group(1)), # type: ignore[union-attr] - ) - if not log_files: - raise ValueError( - f"No log_data_batch_*.txt files found in directory: {directory}" - ) - self.data[directory]["log_files"] = log_files - - def read_data(self) -> None: - """Alias for load_data for backward compatibility.""" - self.load_data() - - def load_data(self) -> None: - """Read and parse data from log files in each directory.""" - self.load_files() - for directory in self.directories: - # Clear previous data for this directory - self.data[directory]["R_eq"] = [] - self.data[directory]["zi_c"] = [] - self.data[directory]["zi_0"] = [] - self.data[directory]["contact_angles"] = [] - self.data[directory]["surface_areas"] = [] - logger.debug( - "Log files for %s: %s", directory, self.data[directory]["log_files"] - ) - # Read all batch log files for this directory - for log_file in self.data[directory]["log_files"]: - with open(log_file) as f: - text = f.read() - - # Extract R_eq - R_eq_match = re.search(r"R_eq:([0-9\.\-eE]+)", text) - if not R_eq_match: - raise ValueError(f"R_eq not found in file: {log_file}") - R_eq = float(R_eq_match.group(1)) - - # Extract zi_c - zi_c_match = re.search(r"zi_c:([0-9\.\-eE]+)", text) - if not zi_c_match: - raise ValueError(f"zi_c not found in file: {log_file}") - zi_c = float(zi_c_match.group(1)) - - # Extract zi_0 - zi_0_match = re.search(r"zi_0:([0-9\.\-eE]+)", text) - if not zi_0_match: - raise ValueError(f"zi_0 not found in file: {log_file}") - zi_0 = float(zi_0_match.group(1)) - - # Extract contact angle - angle_match = re.search(r"Contact angle:([0-9\.\-eE]+)", text) - if not angle_match: - raise ValueError(f"Contact angle not found in file: {log_file}") - angle = float(angle_match.group(1)) - - # Calculate surface area - A_seg = self.circular_segment_area(R_eq, zi_c, zi_0) - - # Append data - self.data[directory]["R_eq"].append(R_eq) - self.data[directory]["zi_c"].append(zi_c) - self.data[directory]["zi_0"].append(zi_0) - self.data[directory]["contact_angles"].append(angle) - self.data[directory]["surface_areas"].append(A_seg) - - def get_surface_areas(self, directory: str) -> np.ndarray: - """Return surface areas for a directory.""" - return np.array(self.data[directory]["surface_areas"]) - - def get_contact_angles(self, directory: str) -> np.ndarray: - """Return contact angles for a directory.""" - return np.array(self.data[directory]["contact_angles"]) diff --git a/src/wetting_angle_kit/visualization/binning_trajectory_plotter.py b/src/wetting_angle_kit/visualization/binning_trajectory_plotter.py new file mode 100644 index 0000000..277ffbb --- /dev/null +++ b/src/wetting_angle_kit/visualization/binning_trajectory_plotter.py @@ -0,0 +1,224 @@ +from collections.abc import Iterable + +import numpy as np +import plotly.graph_objects as go + +from wetting_angle_kit.analysis.binning.results import BinningResults +from wetting_angle_kit.visualization.base_trajectory_plotter import ( + BaseTrajectoryPlotter, +) +from wetting_angle_kit.visualization.stats import TrajectoryStats + + +class BinningTrajectoryPlotter(BaseTrajectoryPlotter): + """Plot statistics derived from one or more :class:`BinningResults`.""" + + @staticmethod + def circular_segment_area(R: float, z_center: float, z_cut: float) -> float: + """Area of the circular cap of radius ``R`` below height ``z_cut``.""" + h = (z_center + R) - z_cut + if h <= 0: + return 0.0 + if h >= 2 * R: + return float(np.pi * R**2) + if h <= R: + return float( + R**2 * np.arccos((R - h) / R) - (R - h) * np.sqrt(2 * R * h - h**2) + ) + h_small = 2 * R - h + return float( + np.pi * R**2 + - ( + R**2 * np.arccos((R - h_small) / R) + - (R - h_small) * np.sqrt(2 * R * h_small - h_small**2) + ) + ) + + def __init__( + self, + results: BinningResults | Iterable[BinningResults], + labels: list[str] | None = None, + time_steps: list[float] | None = None, + time_unit: str = "ps", + ) -> None: + """ + Parameters + ---------- + results : BinningResults or iterable of BinningResults + One results container per trajectory. + labels : list of str, optional + Display labels (one per results container). Defaults to + ``["trajectory_0", ...]``. + time_steps : list of float, optional + Per-trajectory time step applied to ``batch_index`` for the + time axis of evolution plots. Defaults to ``1.0`` for each. + time_unit : str, optional + Time unit shown on x-axis labels. + """ + if isinstance(results, BinningResults): + results = [results] + else: + results = list(results) + self.results = results + self.labels = labels or [f"trajectory_{i}" for i in range(len(results))] + self.time_steps = time_steps or [1.0] * len(results) + self.time_unit = time_unit + + def _surface_areas(self, result: BinningResults) -> np.ndarray: + """Per-batch circular-cap surface area from fitted (R_eq, zi_c, zi_0).""" + return np.array( + [ + self.circular_segment_area( + batch.fitted_params["R_eq"], + batch.fitted_params["zi_c"], + batch.fitted_params["zi_0"], + ) + for batch in result.batches + ] + ) + + def summary(self) -> list[TrajectoryStats]: + stats: list[TrajectoryStats] = [] + for label, result in zip(self.labels, self.results, strict=False): + surfaces = self._surface_areas(result) + stats.append( + TrajectoryStats( + method_name="Binning Analysis", + label=label, + mean_surface_area=float(np.mean(surfaces)), + mean_contact_angle=result.mean_angle, + std_contact_angle=result.std_angle, + n_samples=len(result), + ) + ) + return stats + + def plot_angle_evolution(self, save_path: str | None = None) -> go.Figure: + """Plot per-batch contact angle as a function of batch time. + + Parameters + ---------- + save_path : str, optional + If provided, write the figure as standalone HTML. + + Returns + ------- + plotly.graph_objects.Figure + Figure with one line per trajectory. + """ + fig = go.Figure() + for label, result, dt in zip( + self.labels, self.results, self.time_steps, strict=False + ): + times = np.array([b.batch_index for b in result.batches]) * dt + fig.add_trace( + go.Scatter( + x=times, + y=result.angles_per_batch, + mode="lines+markers", + name=label, + line=dict(width=2), + ) + ) + fig.update_layout( + title="Contact angle evolution (per batch)", + xaxis_title=f"Batch time ({self.time_unit})", + yaxis_title="Contact angle (°)", + template="plotly_white", + ) + if save_path: + fig.write_html(save_path) + return fig + + def plot_density_contour( + self, + result_index: int = 0, + batch_index: int = 0, + save_path: str | None = None, + ) -> go.Figure: + """Plot the density field of one batch with the fitted isoline. + + Parameters + ---------- + result_index : int, default 0 + Index into the results list (selects which trajectory). + batch_index : int, default 0 + Index of the batch within that trajectory. + save_path : str, optional + If provided, write the figure as standalone HTML. + + Returns + ------- + plotly.graph_objects.Figure + Filled contour of the density field plus dashed circle / wall + isoline traces when available. + """ + batch = self.results[result_index].batches[batch_index] + dxi = batch.xi_cc[-1] - batch.xi_cc[-2] + xi_f = float(batch.xi_cc[-1] + dxi / 2) + fig = go.Figure() + fig.add_trace( + go.Contour( + x=batch.xi_cc, + y=batch.zi_cc, + z=np.transpose(batch.rho_cc), + colorscale="Jet", + name="Liquid density", + colorbar=dict( + title=dict(text="ρ", font=dict(size=16)), + tickfont=dict(size=14), + len=0.75, + y=0, + yanchor="bottom", + ), + ) + ) + if batch.circle_xi is not None and batch.circle_zi is not None: + fig.add_trace( + go.Scatter( + x=batch.circle_xi, + y=batch.circle_zi, + mode="lines", + name="Fitted droplet", + line=dict(color="black", dash="dash", width=2), + ) + ) + if batch.wall_line_xi is not None and batch.wall_line_zi is not None: + fig.add_trace( + go.Scatter( + x=batch.wall_line_xi, + y=batch.wall_line_zi, + mode="lines", + name="Fitted wall", + line=dict(color="black", dash="dot", width=2), + ) + ) + fig.update_layout( + title=( + f"Density field — {self.labels[result_index]} " + f"(batch {batch.batch_index})" + ), + template="plotly_white", + xaxis=dict( + title=dict(text="ξ (Å)", font=dict(size=16)), + tickfont=dict(size=14), + range=[0, xi_f], + constrain="domain", + ), + yaxis=dict( + title=dict(text="z (Å)", font=dict(size=16)), + tickfont=dict(size=14), + scaleanchor="x", + scaleratio=1, + constrain="domain", + ), + legend=dict( + x=1.02, + y=1.0, + xanchor="left", + yanchor="top", + ), + ) + if save_path: + fig.write_html(save_path) + return fig diff --git a/src/wetting_angle_kit/visualization/droplet_slice_plot.py b/src/wetting_angle_kit/visualization/droplet_slice_plot.py new file mode 100644 index 0000000..8e69204 --- /dev/null +++ b/src/wetting_angle_kit/visualization/droplet_slice_plot.py @@ -0,0 +1,237 @@ +from collections.abc import Sequence +from typing import Any + +import numpy as np +import plotly.graph_objects as go + + +class DropletSlicePlotter: + """Interactive Plotly slice visualization with toggleable layers.""" + + def __init__(self, center: bool = True): + """ + Parameters + ---------- + center : bool, default True + If True recentre z coordinates by subtracting mean wall z. + """ + self.center = center + # Colors + self.oxygen_color = "#d62828" + self.hydrogen_color = "#FFFFFF" + self.surface_color = "#000000" + self.circle_color = "#0A9396" + self.wall_color = "#000000" + self.tangent_color = "#bb3e03" + + def plot_surface_points( + self, + oxygen_position: np.ndarray, + surface_data: list[np.ndarray], + popt: Sequence[float], + wall_coords: np.ndarray, + alpha: float | None = None, + y_com: float | None = None, + pbc_y: float | None = None, + show_water: bool = True, + show_surface: bool = True, + show_circle: bool = True, + show_tangent: bool = True, + show_wall: bool = True, + ) -> Any: + """Create interactive Plotly figure for a single frame slice. + + Parameters + ---------- + oxygen_position : ndarray (N, 3) + Oxygen atom coordinates. + surface_data : list[array] + List of surface contours for selected slice. + popt : sequence + Fitted circle parameters (x_center, z_center, radius, extra). + wall_coords : ndarray (M, 3) + Wall particle coordinates. + alpha : float, optional + Contact angle for tangent construction. + y_com : float, optional + Mean y used for slicing; computed if None. + pbc_y : float, optional + Y box length for periodic slicing. + show_water, show_surface, show_circle, show_tangent, show_wall : bool + Layer visibility toggles. + + Returns + ------- + plotly.graph_objects.Figure + Configured figure object (not saved). + """ + if y_com is None: + y_com = np.mean(oxygen_position[:, 1]) + # Select slice in y-direction + if pbc_y is not None: + dy = np.abs(oxygen_position[:, 1] - y_com) + dy = np.minimum(dy, pbc_y - dy) + mask = dy <= 3 + else: + mask = np.abs(oxygen_position[:, 1] - y_com) <= 3 + oxygen_selected = oxygen_position[mask] + # Recenter if needed. ``oxygen_selected`` is already a fresh copy + # from the boolean mask; ``wall_coords`` is the caller's array and + # must be copied before in-place shifting so the plotter remains + # side-effect-free. + if self.center: + z_shift = np.mean(wall_coords[:, 2]) + wall_coords = wall_coords.copy() + wall_coords[:, 2] -= z_shift + oxygen_selected = oxygen_selected.copy() + oxygen_selected[:, 2] -= z_shift + surface_data = [ + np.column_stack([surf[:, 0], surf[:, 1] - z_shift]) + for surf in surface_data + ] + x_center, z_center, radius, _ = popt + z_center -= z_shift + else: + x_center, z_center, radius, _ = popt + fig = go.Figure() + # --- Wall --- + if show_wall: + fig.add_trace( + go.Scatter( + x=wall_coords[:, 0], + y=wall_coords[:, 2], + mode="markers", + name="Wall", + marker=dict(color=self.wall_color, size=3), + opacity=0.7, + visible=True, + showlegend=True, + ) + ) + # --- Water molecules --- + if show_water: + fig.add_trace( + go.Scatter( + x=oxygen_selected[:, 0], + y=oxygen_selected[:, 2], + mode="markers", + name="Water", + marker=dict(color=self.oxygen_color, size=5), + opacity=0.8, + visible=True, + showlegend=True, + ) + ) + # --- Surface contour --- + if show_surface: + for surf in surface_data: + # Append the first point to the end to close the contour + closed_surf = np.vstack([surf, surf[0]]) + fig.add_trace( + go.Scatter( + x=closed_surf[:, 0], + y=closed_surf[:, 1], + mode="lines", + name="Surface contour", + line=dict(color=self.surface_color, width=3), + visible=True, + showlegend=True, + ) + ) + # --- Fitted circle --- + if show_circle: + theta = np.linspace(0, 2 * np.pi, 200) + circle_x = x_center + radius * np.cos(theta) + circle_z = z_center + radius * np.sin(theta) + fig.add_trace( + go.Scatter( + x=circle_x, + y=circle_z, + mode="lines", + name="Fitted Circle", + line=dict(color=self.circle_color, width=3, dash="dash"), + visible=True, + showlegend=True, + ) + ) + # --- Tangent + α arc --- + if show_tangent and alpha is not None: + z_line = min([np.min(surf[:, 1]) for surf in surface_data]) + delta_z = z_line - z_center + discriminant = radius**2 - delta_z**2 + if discriminant > 0: + x_contact = x_center + np.sqrt(discriminant) # Right side + z_contact = z_line + z_top = z_center + radius * 1.1 + # When the contact point sits at the circle's equator + # (``z_contact == z_center``) the tangent is vertical and + # the closed-form slope diverges; draw a vertical segment + # of the same z-extent instead so the overlay still renders. + if np.isclose(z_contact, z_center): + x_line = np.full(100, x_contact) + z_line_tan = np.linspace(z_contact, z_top, 100) + else: + m_tangent = -(x_contact - x_center) / (z_contact - z_center) + x_top = x_contact + (z_top - z_contact) / m_tangent + x_line = np.linspace(x_contact, x_top, 100) + z_line_tan = m_tangent * (x_line - x_contact) + z_contact + fig.add_trace( + go.Scatter( + x=x_line, + y=z_line_tan, + mode="lines", + name=f"{alpha:.1f}°", + line=dict(color=self.tangent_color, width=3), + visible=True, + showlegend=True, + ) + ) + # α arc (left side) + alpha_rad = np.radians(alpha) + arc_radius = radius * 0.25 + theta_arc = np.linspace(np.pi - alpha_rad, np.pi, 100) + arc_x = x_contact + arc_radius * np.cos(theta_arc) + arc_z = z_contact + arc_radius * np.sin(theta_arc) + fig.add_trace( + go.Scatter( + x=arc_x, + y=arc_z, + mode="lines", + name=f"{alpha:.1f}° Arc", + line=dict(color="gray", width=2), + visible=True, + showlegend=False, + ) + ) + # Label α near mid-arc + mid_theta = alpha_rad / 2 + text_x = x_contact + 1.2 * arc_radius * np.cos(mid_theta) + text_z = z_contact + 1.2 * arc_radius * np.sin(mid_theta) + fig.add_annotation( + x=text_x, + y=text_z, + text=f"{alpha:.1f}°", + showarrow=False, + font=dict(size=12, color="black"), + ) + # --- Layout --- + fig.update_layout( + width=600, + height=450, + xaxis_title="x (Å)", + yaxis_title="z (Å)", + template="plotly_white", + showlegend=True, + legend=dict( + x=1.05, + y=1, + bgcolor="rgba(255, 255, 255, 0.8)", + bordercolor="gray", + borderwidth=1, + itemsizing="constant", + font=dict(size=10), + ), + yaxis=dict(scaleanchor="x", scaleratio=1), + ) + + return fig diff --git a/src/wetting_angle_kit/visualization/droplet_slice_plots.py b/src/wetting_angle_kit/visualization/droplet_slice_plots.py deleted file mode 100644 index 78139de..0000000 --- a/src/wetting_angle_kit/visualization/droplet_slice_plots.py +++ /dev/null @@ -1,758 +0,0 @@ -from collections.abc import Sequence -from typing import Any - -import matplotlib.pyplot as plt -import numpy as np -import plotly.graph_objects as go -from matplotlib.ticker import AutoMinorLocator - -from wetting_angle_kit.analysis.slicing import ContactAngleSlicing -from wetting_angle_kit.io_utils import recenter_droplet_pbc -from wetting_angle_kit.parsers import ( - LammpsDumpParser, - LammpsDumpWallParser, - LammpsDumpWaterFinder, -) - -plt.style.use("seaborn-v0_8-whitegrid") - - -class DropletSlicePlotter: - """Matplotlib-based plotter for droplet slices: surface contours, - fitted circle and tangent line.""" - - def __init__( - self, center: bool = True, show_wall: bool = True, molecule_view: bool = True - ): - """ - Parameters - ---------- - center : bool, default True - If True recentre z coordinates by subtracting mean wall z. - show_wall : bool, default True - Whether to draw wall particles. - molecule_view : bool, default True - If True draw fake hydrogens around each oxygen (schematic water view). - """ - self.center = center - self.show_wall = show_wall - self.molecule_view = molecule_view - - # Colors - self.oxygen_color = "#d62828" - self.hydrogen_color = "white" - self.surface_color = "black" - self.circle_color = "#0A9396" - self.wall_color = "black" - self.tangent_color = "#bb3e03" - - def plot_surface_points( - self, - oxygen_position: np.ndarray, - surface_data: list[np.ndarray], - popt: Sequence[float], - wall_coords: np.ndarray | None = None, - output_filename: Any | None = None, - y_com: float | None = None, - pbc_y: float | None = None, - alpha: float | None = None, - ) -> None: - """Render slice figure and save to file. - - Parameters - ---------- - oxygen_position : ndarray (N, 3) - Cartesian coordinates of oxygen atoms for the frame. - surface_data : list[array] - List of arrays with surface line coordinates (x, z) for each slice. - popt : sequence - Fitted circle parameters (x_center, z_center, radius, extra) - for chosen slice. - wall_coords : ndarray (M, 3) - Wall particle coordinates. - output_filename : str or Path - Path to save the PNG figure. - y_com : float, optional - Y centre used to select atoms in a thin slice. If None computed. - pbc_y : float, optional - Box length in y for PBC wrapping; if provided shortest-distance used. - alpha : float, optional - Contact angle in degrees; if given draw tangent line and arc. - - Returns - ------- - None - Saves figure to ``output_filename`` and closes it. - """ - - if y_com is None: - y_com = np.mean(oxygen_position[:, 1]) - - # Select atoms near the Y center (±3 Å) - if pbc_y is not None: - dy = np.abs(oxygen_position[:, 1] - y_com) - dy = np.minimum(dy, pbc_y - dy) - mask = dy <= 3 - else: - mask = np.abs(oxygen_position[:, 1] - y_com) <= 3 - oxygen_selected = oxygen_position[mask] - - # --- Subsample for clarity --- - rng = np.random.default_rng(42) - keep_fraction = 0.70 - sample_idx = rng.choice( - len(oxygen_selected), - size=int(len(oxygen_selected) * keep_fraction), - replace=False, - ) - oxygen_selected = oxygen_selected[sample_idx] - - # --- Limit wall region under droplet (±5 Å margin) --- - x_min, x_max = ( - np.min(oxygen_selected[:, 0]) - 5, - np.max(oxygen_selected[:, 0]) + 5, - ) - - # Only process wall_coords if needed - if self.show_wall and wall_coords is not None: - wall_mask = (wall_coords[:, 0] >= x_min) & (wall_coords[:, 0] <= x_max) - wall_coords = wall_coords[wall_mask] - - # --- Optional recentring --- - if self.center and wall_coords is not None: - z_shift = np.mean(wall_coords[:, 2]) - oxygen_selected[:, 2] -= z_shift - wall_coords[:, 2] -= z_shift - surface_data = [ - np.column_stack([surf[:, 0], surf[:, 1] - z_shift]) - for surf in surface_data - ] - x_center, z_center, radius, limit_med = popt - z_center -= z_shift - else: - x_center, z_center, radius, limit_med = popt - - # --- Plot setup --- - fig, ax = plt.subplots(figsize=(4.0, 3.0), dpi=300) - - # --- Wall atoms --- - if self.show_wall and wall_coords is not None: - ax.scatter( - wall_coords[:, 0], - wall_coords[:, 2], - color=self.wall_color, - s=3, - alpha=0.7, - zorder=0, - ) - - # --- Water representation --- - if self.molecule_view: - h_dist = 1.0 - for ox, _oy, oz in oxygen_selected: - ax.scatter( - ox, - oz, - color=self.oxygen_color, - s=8, - alpha=0.9, - edgecolors="none", - linewidths=0.15, - zorder=1, - ) - for _ in range(2): - angle = rng.uniform(0, 2 * np.pi) - dx, dz = h_dist * np.cos(angle), h_dist * np.sin(angle) - ax.scatter( - ox + dx, - oz + dz, - color=self.hydrogen_color, - s=4, - alpha=0.8, - edgecolors="black", - linewidths=0.15, - zorder=1, - ) - else: - ax.scatter( - oxygen_selected[:, 0], - oxygen_selected[:, 2], - color=self.oxygen_color, - s=6, - alpha=0.9, - zorder=1, - ) - - # --- Surface line --- - for surf in surface_data: - x_data, z_data = surf[:, 0], surf[:, 1] - if not np.allclose([x_data[0], z_data[0]], [x_data[-1], z_data[-1]]): - x_data = np.append(x_data, x_data[0]) - z_data = np.append(z_data, z_data[0]) - ax.plot(x_data, z_data, color=self.surface_color, lw=1.5, zorder=3) - - # --- Fitted circle --- - circle = plt.Circle( - (x_center, z_center), - radius, - color=self.circle_color, - fill=False, - ls="--", - lw=2.5, - zorder=4, - ) - ax.add_artist(circle) - # --- Tangent line (based on circle–surface intersection) --- - if alpha is not None: - alpha_rad = np.radians(alpha) - - # --- Determine the contact point from the surface bottom --- - z_baseline = min(np.min(surf[:, 1]) for surf in surface_data) - # Use the (possibly z-shifted) circle parameters set above. - delta_z = z_baseline - z_center - discriminant = radius**2 - delta_z**2 - if discriminant <= 0: - plt.close(fig) - return - - dx = np.sqrt(discriminant) - - # Choose correct side (right if α > 90°, left if α < 90°) - if alpha > 90: - x_contact = x_center + dx - else: - x_contact = x_center - dx - z_contact = z_baseline - - # --- Tangent slope at the intersection point --- - m_tangent = -(x_contact - x_center) / (z_contact - z_center) - - # --- Extend tangent line upwards to top of circle --- - z_top = z_center + radius * 1.1 # extend slightly above for visibility - if abs(m_tangent) > 1e-6: - x_top = x_contact + (z_top - z_contact) / m_tangent - else: - x_top = x_contact - x_tangent = np.linspace(x_contact, x_top, 100) - z_tangent = m_tangent * (x_tangent - x_contact) + z_contact - - # Draw tangent line - ax.plot( - x_tangent, - z_tangent, - color=self.tangent_color, - lw=2.0, - ls="-", - label=f"Tangent (α={alpha:.1f}°)", - zorder=5, - ) - - # --- Draw arc centered at contact point --- - arc_radius = radius * 0.25 - theta = np.linspace( - np.pi - alpha_rad, np.pi, 100 - ) # from horizontal (0) to tangent (α) - arc_x = x_contact + arc_radius * np.cos(theta) - arc_z = z_contact + arc_radius * np.sin(theta) - ax.plot(arc_x, arc_z, color="gray", lw=1.5, zorder=6) - - # --- Label α value near the middle of the arc --- - mid_theta = alpha_rad / 2 - text_x = x_contact + 1.2 * arc_radius * np.cos(mid_theta) - text_z = z_contact + 1.2 * arc_radius * np.sin(mid_theta) - ax.text( - text_x, - text_z, - f"{alpha:.1f}°", - fontsize=9, - color="black", - ha="center", - va="center", - zorder=7, - ) - - # --- Axes --- - ax.set_xlabel("x (Å)", fontsize=9) - ax.set_ylabel("z (Å)", fontsize=9) - ax.tick_params(axis="both", which="major", labelsize=8) - ax.xaxis.set_minor_locator(AutoMinorLocator()) - ax.yaxis.set_minor_locator(AutoMinorLocator()) - ax.set_aspect("equal", adjustable="box") - ax.grid(False) - ax.set_xlim(x_min - 5, x_max + 5) - - # --- Legend --- - ax.legend( - handles=[ - plt.Line2D( - [], [], color=self.surface_color, lw=1.5, label="Surface contour" - ), - plt.Line2D( - [], - [], - color=self.circle_color, - ls="--", - lw=1.5, - label="Fitted circle", - ), - plt.Line2D( - [], [], color=self.tangent_color, lw=1.5, label="Tangent line" - ), - ], - loc="upper left", - frameon=False, - fontsize=7, - ) - - plt.tight_layout(pad=0.1) - plt.savefig(output_filename, dpi=300, bbox_inches="tight") - plt.close() - - -class DropletSlicePlotlyPlotter: - """Interactive Plotly slice visualization with toggleable layers.""" - - def __init__(self, center: bool = True): - """ - Parameters - ---------- - center : bool, default True - If True recentre z coordinates by subtracting mean wall z. - """ - self.center = center - # Colors - self.oxygen_color = "#d62828" - self.hydrogen_color = "#FFFFFF" - self.surface_color = "#000000" - self.circle_color = "#0A9396" - self.wall_color = "#000000" - self.tangent_color = "#bb3e03" - - def plot_surface_points( - self, - oxygen_position: np.ndarray, - surface_data: list[np.ndarray], - popt: Sequence[float], - wall_coords: np.ndarray, - alpha: float | None = None, - y_com: float | None = None, - pbc_y: float | None = None, - show_water: bool = True, - show_surface: bool = True, - show_circle: bool = True, - show_tangent: bool = True, - show_wall: bool = True, - ) -> Any: - """Create interactive Plotly figure for a single frame slice. - - Parameters - ---------- - oxygen_position : ndarray (N, 3) - Oxygen atom coordinates. - surface_data : list[array] - List of surface contours for selected slice. - popt : sequence - Fitted circle parameters (x_center, z_center, radius, extra). - wall_coords : ndarray (M, 3) - Wall particle coordinates. - alpha : float, optional - Contact angle for tangent construction. - y_com : float, optional - Mean y used for slicing; computed if None. - pbc_y : float, optional - Y box length for periodic slicing. - show_water, show_surface, show_circle, show_tangent, show_wall : bool - Layer visibility toggles. - - Returns - ------- - plotly.graph_objects.Figure - Configured figure object (not saved). - """ - if y_com is None: - y_com = np.mean(oxygen_position[:, 1]) - # Select slice in y-direction - if pbc_y is not None: - dy = np.abs(oxygen_position[:, 1] - y_com) - dy = np.minimum(dy, pbc_y - dy) - mask = dy <= 3 - else: - mask = np.abs(oxygen_position[:, 1] - y_com) <= 3 - oxygen_selected = oxygen_position[mask] - # Recenter if needed - if self.center: - z_shift = np.mean(wall_coords[:, 2]) - oxygen_selected[:, 2] -= z_shift - wall_coords[:, 2] -= z_shift - surface_data = [ - np.column_stack([surf[:, 0], surf[:, 1] - z_shift]) - for surf in surface_data - ] - x_center, z_center, radius, _ = popt - z_center -= z_shift - else: - x_center, z_center, radius, _ = popt - fig = go.Figure() - # --- Wall --- - if show_wall: - fig.add_trace( - go.Scatter( - x=wall_coords[:, 0], - y=wall_coords[:, 2], - mode="markers", - name="Wall", - marker=dict(color=self.wall_color, size=3), - opacity=0.7, - visible=True, - showlegend=True, - ) - ) - # --- Water molecules --- - if show_water: - fig.add_trace( - go.Scatter( - x=oxygen_selected[:, 0], - y=oxygen_selected[:, 2], - mode="markers", - name="Water", - marker=dict(color=self.oxygen_color, size=5), - opacity=0.8, - visible=True, - showlegend=True, - ) - ) - # --- Surface contour --- - if show_surface: - for surf in surface_data: - # Append the first point to the end to close the contour - closed_surf = np.vstack([surf, surf[0]]) - fig.add_trace( - go.Scatter( - x=closed_surf[:, 0], - y=closed_surf[:, 1], - mode="lines", - name="Surface contour", - line=dict(color=self.surface_color, width=3), # Thicker line - visible=True, - showlegend=True, - ) - ) - # --- Fitted circle --- - if show_circle: - theta = np.linspace(0, 2 * np.pi, 200) - circle_x = x_center + radius * np.cos(theta) - circle_z = z_center + radius * np.sin(theta) - fig.add_trace( - go.Scatter( - x=circle_x, - y=circle_z, - mode="lines", - name="Fitted Circle", - line=dict( - color=self.circle_color, width=3, dash="dash" - ), # Thicker line - visible=True, - showlegend=True, - ) - ) - # --- Tangent + α arc --- - if show_tangent and alpha is not None: - z_line = min([np.min(surf[:, 1]) for surf in surface_data]) - delta_z = z_line - z_center - discriminant = radius**2 - delta_z**2 - if discriminant > 0: - x_contact = x_center + np.sqrt(discriminant) # Right side - z_contact = z_line - m_tangent = -(x_contact - x_center) / (z_contact - z_center) - # Tangent line - z_top = z_center + radius * 1.1 - x_top = x_contact + (z_top - z_contact) / m_tangent - x_line = np.linspace(x_contact, x_top, 100) - z_line_tan = m_tangent * (x_line - x_contact) + z_contact - fig.add_trace( - go.Scatter( - x=x_line, - y=z_line_tan, - mode="lines", - name=f"{alpha:.1f}°", # Only show angle value - line=dict(color=self.tangent_color, width=3), # Thicker line - visible=True, - showlegend=True, - ) - ) - # α arc (left side) - alpha_rad = np.radians(alpha) - arc_radius = radius * 0.25 - theta_arc = np.linspace(np.pi - alpha_rad, np.pi, 100) - arc_x = x_contact + arc_radius * np.cos(theta_arc) - arc_z = z_contact + arc_radius * np.sin(theta_arc) - fig.add_trace( - go.Scatter( - x=arc_x, - y=arc_z, - mode="lines", - name=f"{alpha:.1f}° Arc", # Only show angle value - line=dict(color="gray", width=2), - visible=True, - showlegend=False, - ) - ) - - # Label α near mid-arc - mid_theta = alpha_rad / 2 - text_x = x_contact + 1.2 * arc_radius * np.cos(mid_theta) - text_z = z_contact + 1.2 * arc_radius * np.sin(mid_theta) - fig.add_annotation( - x=text_x, - y=text_z, - text=f"{alpha:.1f}°", - showarrow=False, - font=dict(size=12, color="black"), - ) - # --- Layout --- - fig.update_layout( - width=600, - height=450, - xaxis_title="x (Å)", - yaxis_title="z (Å)", - template="plotly_white", - showlegend=True, - legend=dict( - x=1.05, - y=1, # Position legend outside the plot - bgcolor="rgba(255, 255, 255, 0.8)", - bordercolor="gray", - borderwidth=1, - itemsizing="constant", # Ensures checkboxes are clearly visible - font=dict(size=10), - ), - yaxis=dict(scaleanchor="x", scaleratio=1), - ) - - return fig - - -class ContactAngleAnimator: - """Generate interactive Plotly slider animation of median slice angle per frame.""" - - def __init__( - self, - filename: str, - particle_type_wall: set, - oxygen_type: int, - hydrogen_type: int, - liquid_particle_types: set, - n_frames: int = 10, - droplet_geometry: str = "cylinder_y", - delta_cylinder: int = 5, - max_dist: int = 100, - width_cylinder: int = 21, - precentered: bool = False, - ): - """ - Parameters - ---------- - filename : str - Path to LAMMPS dump trajectory file. - particle_type_wall : set - LAMMPS particle type IDs for wall atoms. - oxygen_type : int - LAMMPS particle type ID for oxygen atoms. - hydrogen_type : int - LAMMPS particle type ID for hydrogen atoms. - liquid_particle_types : set - LAMMPS particle type IDs for all liquid atoms (used to mask wall parser). - n_frames : int, default 10 - Number of frames to include in the animation. - droplet_geometry : str, default "cylinder_y" - Droplet geometry passed to ContactAngleSlicing. - delta_cylinder : int, default 5 - Step size along the slicing axis (Å). - max_dist : int, default 100 - Maximum radial distance for line sampling (Å). - width_cylinder : int, default 21 - Box extent along the cylinder axis (Å). - precentered : bool, default False - Set True if the trajectory already recenters the droplet at - every frame and atoms are not wrapped across periodic - boundaries; the per-frame circular-mean recentering is then - skipped. Setting this on a trajectory that does NOT satisfy the - precondition will misplace the contact-angle overlay. - """ - self.filename = filename - self.particle_type_wall = particle_type_wall - self.oxygen_type = oxygen_type - self.hydrogen_type = hydrogen_type - self.liquid_particle_types = liquid_particle_types - self.n_frames = n_frames - self.droplet_geometry = droplet_geometry - self.delta_cylinder = delta_cylinder - self.max_dist = max_dist - self.width_cylinder = width_cylinder - self.precentered = precentered - - # Initialize objects - self.wat_find = LammpsDumpWaterFinder( - self.filename, - particle_type_wall=self.particle_type_wall, - oxygen_type=self.oxygen_type, - hydrogen_type=self.hydrogen_type, - ) - self.oxygen_indices = self.wat_find.get_water_oxygen_ids(frame_index=0) - self.coord_wall = LammpsDumpWallParser( - self.filename, liquid_particle_types=list(self.liquid_particle_types) - ) - self.wall_coords = self.coord_wall.parse(frame_index=0) - self.parser = LammpsDumpParser(filepath=self.filename) - self.plotter = DropletSlicePlotlyPlotter(center=True) - - def generate_animation( - self, output_filename: str = "ContactAngle_Median_PerFrame_Slider.html" - ) -> None: - """Build and write HTML with slider of median contact angles over frames. - Parameters - ---------- - output_filename : str, default "ContactAngle_Median_PerFrame_Slider.html" - Output HTML file path. - Returns - ------- - None - Writes HTML file and prints path. - """ - fig = go.Figure() - frames_list = [] - frame_labels = [] - median_angles = [] - for frame_idx in range(self.n_frames): - oxygen_position = self.parser.parse( - frame_index=frame_idx, indices=self.oxygen_indices - ) - if self.precentered: - liquid_geom_center = np.mean(oxygen_position, axis=0) - else: - box_size_xy = ( - self.parser.box_size_x(frame_index=frame_idx), - self.parser.box_size_y(frame_index=frame_idx), - ) - oxygen_position, liquid_geom_center = recenter_droplet_pbc( - oxygen_position, self.droplet_geometry, box_size=box_size_xy - ) - processor = ContactAngleSlicing( - liquid_coordinates=oxygen_position, - liquid_geom_center=liquid_geom_center, - droplet_geometry=self.droplet_geometry, - delta_cylinder=self.delta_cylinder, - max_dist=self.max_dist, - width_cylinder=self.width_cylinder, - ) - angles, surfaces, popt_arrays = processor.predict_contact_angle() - median_idx = np.argsort(angles)[len(angles) // 2] - alpha = angles[median_idx] - popt = popt_arrays[median_idx] - surface = [surfaces[median_idx]] - median_angles.append(alpha) - fig_frame = self.plotter.plot_surface_points( - oxygen_position=oxygen_position, - surface_data=surface, - popt=popt, - wall_coords=self.wall_coords.copy(), - y_com=np.mean(oxygen_position[:, 1]), - pbc_y=None, - alpha=alpha, - show_water=True, - show_surface=True, - show_circle=True, - show_tangent=True, - show_wall=True, - ) - frame = go.Frame( - data=fig_frame.data, - name=f"Frame {frame_idx}", - layout=go.Layout( - title_text=( - f"Frame {frame_idx} | Median contact angle = {alpha:.2f}\u00b0" - ) - ), - ) - frames_list.append(frame) - frame_labels.append(f"Frame {frame_idx}") - fig.frames = frames_list - fig.add_traces(frames_list[0].data) - fig.update_layout( - title=("Interactive Contact Angle Evolution (Median Slice per Frame)"), - width=800, - height=600, - margin=dict(l=80, r=200, t=80, b=100), - xaxis_title="x (\u00c5)", - yaxis_title="z (\u00c5)", - template="simple_white", - showlegend=True, - legend=dict( - x=1.05, - y=0.95, - bgcolor="rgba(255,255,255,0.8)", - bordercolor="lightgray", - borderwidth=1, - font=dict(size=11), - ), - xaxis=dict( - mirror=True, - showline=True, - linecolor="black", - ticks="outside", - showgrid=True, - gridcolor="lightgray", - zeroline=False, - ), - yaxis=dict( - mirror=True, - showline=True, - linecolor="black", - ticks="outside", - showgrid=True, - gridcolor="lightgray", - zeroline=False, - scaleanchor="x", - scaleratio=1, - ), - sliders=[ - { - "active": 0, - "pad": {"b": 60, "t": 40}, - "x": 0.2, - "len": 0.6, - "y": -0.1, - "yanchor": "top", - "steps": [ - { - "args": [ - [f"Frame {k}"], - { - "frame": {"duration": 0, "redraw": True}, - "mode": "immediate", - }, - ], - "label": f"{k}", - "method": "animate", - } - for k in range(len(frames_list)) - ], - } - ], - ) - fig.write_html(output_filename) - print(f"Interactive HTML saved: {output_filename}") - - -# Example usage -# if __name__ == "__main__": -# animator = ContactAngleAnimator( -# filename="../wetting_angle_kit/tests/trajectories/" -# "traj_10_3_330w_nve_4k_reajust.lammpstrj", -# particle_type_wall={3}, -# oxygen_type=1, -# hydrogen_type=2, -# liquid_particle_types={2, 1}, -# n_frames=10, -# ) -# animator.generate_animation() diff --git a/src/wetting_angle_kit/visualization/method_comparison.py b/src/wetting_angle_kit/visualization/method_comparison.py deleted file mode 100644 index 5ef0cdb..0000000 --- a/src/wetting_angle_kit/visualization/method_comparison.py +++ /dev/null @@ -1,314 +0,0 @@ -import os -from typing import Any - -import matplotlib.pyplot as plt -import numpy as np - - -class MethodComparison: - """Utility to compare contact angle statistics - from multiple trajectory analyzers.""" - - def __init__( - self, analyzers: list[Any], method_names: list[str] | None = None - ) -> None: - """ - Parameters - ---------- - analyzers : list - Analyzer instances exposing ``directories`` and required API methods. - method_names : list[str], optional - Custom display names. If None, uses each analyzer's ``get_method_name``. - """ - self.analyzers = analyzers - self.method_names = method_names or [a.get_method_name() for a in analyzers] - for analyzer in self.analyzers: - if not hasattr(analyzer, "data") or not analyzer.data: - analyzer.load_data() - - def _check_and_run_analysis(self, analyzer: Any) -> None: - """Run analyzer if expected output file is absent for any directory. - Parameters - ---------- - analyzer : BaseTrajectoryAnalyzer - Analyzer instance whose output will be checked. - """ - for directory in analyzer.directories: - output_file = f"{directory}/output_stats.txt" - if not os.path.exists(output_file): - raise FileNotFoundError( - f"No analysis found for {directory}. Please run the analysis first." - ) - - def _read_analysis_output( - self, analyzer: Any, directory: str - ) -> tuple[float, float]: - """Return mean surface area and angle parsed from stats file. - Parameters - ---------- - analyzer : BaseTrajectoryAnalyzer - Analyzer owning the directory. - directory : str - Path containing ``output_stats.txt``. - Returns - ------- - tuple(float, float) - (mean_surface_area, mean_contact_angle). - """ - output_file = f"{directory}/output_stats.txt" - with open(output_file, encoding="utf-8") as f: - lines = f.readlines() - mean_surface_area = float(lines[2].split(": ")[1].strip()) - mean_contact_angle = float(lines[3].split(": ")[1].strip().replace("°", "")) - return mean_surface_area, mean_contact_angle - - def plot_side_by_side_comparison( - self, - save_path: str | None = None, - figsize: tuple[int, int] = (14, 5), - color: str = "purple", - ) -> None: - """ - Produce side-by-side comparison of mean contact angle vs. surface area scaling. - Inspired by plot_mean_angle_vs_surface(). - """ - plt.rcParams.update( - { - "font.family": "serif", - "font.size": 13, - "axes.labelsize": 14, - "axes.titlesize": 15, - "legend.fontsize": 11, - "xtick.direction": "in", - "ytick.direction": "in", - "axes.linewidth": 1.0, - "errorbar.capsize": 3, - } - ) - fig, axes = plt.subplots(1, len(self.analyzers), figsize=figsize) - if len(self.analyzers) == 1: - axes = [axes] - - for ax, analyzer, method_name in zip( - axes, self.analyzers, self.method_names, strict=False - ): - # gather one point per directory - xvals, yvals = [], [] - for directory in analyzer.directories: - mean_sa, mean_angle = self._read_analysis_output(analyzer, directory) - - x = 1.0 / np.sqrt(mean_sa) # same as example - y = mean_angle - - ax.errorbar(x, y, yerr=0.5, fmt="o", color=color) - ax.annotate( - analyzer.get_clean_label(directory), - xy=(x, y), - xytext=(4, 4), - textcoords="offset points", - fontsize=7, - ) - - xvals.append(x) - yvals.append(y) - - # linear fit if we have ≥2 points - xvals_arr, yvals_arr = np.array(xvals), np.array(yvals) - if len(xvals_arr) >= 2: - coeffs = np.polyfit(xvals_arr, yvals_arr, 1) - fit_line = np.poly1d(coeffs) - x_fit = np.linspace(0, xvals_arr.max() * 1.1, 100) - ax.plot( - x_fit, - fit_line(x_fit), - "--", - color="gray", - label=f"Fit: y={coeffs[0]:.2f}x+{coeffs[1]:.2f}", - ) - - ax.set_xlabel(r"$1 / \sqrt{\text{Surface Area}}$") - ax.set_ylabel("Mean Angle (°)") - ax.set_title(method_name) - ax.legend(frameon=False) - ax.set_xlim(left=-0.001) - - if yvals_arr.size > 0: - ax.set_ylim(min(yvals_arr) - 2, max(yvals_arr) + 2) - - plt.tight_layout() - if save_path: - plt.savefig(save_path, dpi=400, bbox_inches="tight") - plt.close() - - def plot_overlay_comparison( - self, - save_path: str | None = None, - figsize: tuple[int, int] = (7, 5), - colors: list[str] | None = None, - point_labels: list[list[str]] | None = None, - ) -> None: - """Overlay Modified Young's equation plot across all analyzers. - - Plots ``cos(θ)`` vs ``1/√A`` with linear fits and extrapolated - ``θ∞`` (contact angle at infinite surface area) for each analyzer. - - Parameters - ---------- - save_path : str, optional - Path to save the figure. - figsize : tuple[int, int], default (7, 5) - Figure size in inches. - colors : list[str], optional - One color per analyzer. If None a default palette is used. - point_labels : list[list[str]] or None, optional - Custom labels for each data point. Outer list corresponds to - analyzers, inner list to directories (same order as - ``analyzer.directories``). If None, directory names are used. - """ - if colors is None: - colors = ["#0A9396", "#bb3e03", "#9b5de5", "#f15bb5", "#00bbf9"] - - plt.rcParams.update( - { - "font.family": "serif", - "font.size": 13, - "axes.labelsize": 14, - "axes.titlesize": 15, - "legend.fontsize": 11, - "xtick.direction": "in", - "ytick.direction": "in", - "axes.linewidth": 1.0, - "errorbar.capsize": 3, - } - ) - - fig, ax = plt.subplots(figsize=figsize) - - for idx, (analyzer, method_name) in enumerate( - zip(self.analyzers, self.method_names, strict=False) - ): - color = colors[idx % len(colors)] - xvals, yvals = [], [] - - for dir_idx, directory in enumerate(analyzer.directories): - mean_sa, mean_angle = self._read_analysis_output(analyzer, directory) - # Read std from output_stats.txt (line 4) - output_file = f"{directory}/output_stats.txt" - with open(output_file, encoding="utf-8") as f: - lines = f.readlines() - std_angle = float(lines[4].split(": ")[1].strip().replace("°", "")) - - x = 1.0 / np.sqrt(mean_sa) - y = np.cos(np.radians(mean_angle)) - - # Error propagation: d(cos θ) = |sin θ| · dθ - yerr = ( - np.abs(np.sin(np.radians(mean_angle))) * np.radians(std_angle) / 5 - ) - - ax.errorbar( - x, - y, - yerr=yerr, - fmt="o", - color=color, - markersize=6, - capsize=3, - lw=1.2, - ) - - # Annotation - if point_labels is not None: - text = point_labels[idx][dir_idx] - else: - text = analyzer.get_clean_label(directory) - ax.annotate( - text, - xy=(x, y), - xytext=(5, 5), - textcoords="offset points", - fontsize=6, - color="black", - ) - - xvals.append(x) - yvals.append(y) - - # Linear fit with θ∞ extrapolation - if len(xvals) >= 2: - xarr, yarr = np.array(xvals), np.array(yvals) - coeffs = np.polyfit(xarr, yarr, 1) - fit_line = np.poly1d(coeffs) - intercept = np.clip(coeffs[1], -1.0, 1.0) - theta_inf = np.degrees(np.arccos(intercept)) - - x_fit = np.linspace(0, xarr.max() * 1.1, 100) - ax.plot( - x_fit, - fit_line(x_fit), - "--", - color=color, - lw=1.5, - label=( - f"{method_name}: " - rf"$\theta_{{\infty}} = {theta_inf:.1f}^\circ$" - ), - ) - - ax.set_xlabel(r"$1 / \sqrt{A} \; (\mathrm{\AA^{-1}})$") - ax.set_ylabel(r"$\cos(\theta)$") - ax.set_title("Modified Young's Eq – Comparison") - ax.legend(frameon=False, loc="best") - ax.set_xlim(left=-0.001) - plt.tight_layout() - if save_path: - plt.savefig(save_path, dpi=400, bbox_inches="tight") - plt.close() - - def compare_statistics(self) -> str: - """Build per-directory and overall mean/std statistics for each method. - - Returns - ------- - str - The full formatted report. The caller is responsible for displaying - it (e.g. ``print(comparator.compare_statistics())``). Returning a - string instead of printing makes the output capturable from - notebooks and tests. - """ - lines: list[str] = [] - lines.append("=" * 70) - lines.append("METHOD COMPARISON STATISTICS") - lines.append("=" * 70) - for method_name, analyzer in zip( - self.method_names, self.analyzers, strict=False - ): - lines.append(f"\n{method_name}:") - lines.append("-" * 70) - all_angles = [] - all_surfaces = [] - for directory in analyzer.directories: - try: - mean_surface_area, mean_contact_angle = self._read_analysis_output( - analyzer, directory - ) - angles = analyzer.get_contact_angles(directory) - surfaces = analyzer.get_surface_areas(directory) - except FileNotFoundError: - angles = analyzer.get_contact_angles(directory) - surfaces = analyzer.get_surface_areas(directory) - mean_surface_area = float(np.mean(surfaces)) - mean_contact_angle = float(np.mean(angles)) - all_angles.extend(angles) - all_surfaces.extend(surfaces) - lines.append(f" {analyzer.get_clean_label(directory)}:") - lines.append(f" Mean Surface Area: {mean_surface_area:.4f}") - lines.append(f" Mean Angle: {mean_contact_angle:.4f}°") - if all_angles: - lines.append("\n Overall Statistics:") - lines.append(f" Total samples: {len(all_angles)}") - lines.append(f" Mean Surface Area: {np.mean(all_surfaces):.4f}") - lines.append(f" Mean Angle: {np.mean(all_angles):.4f}°") - lines.append(f" Std Angle: {np.std(all_angles):.4f}°") - lines.append("\n" + "=" * 70) - return "\n".join(lines) diff --git a/src/wetting_angle_kit/visualization/slicing_trajectory_analyzer.py b/src/wetting_angle_kit/visualization/slicing_trajectory_analyzer.py deleted file mode 100644 index d427296..0000000 --- a/src/wetting_angle_kit/visualization/slicing_trajectory_analyzer.py +++ /dev/null @@ -1,234 +0,0 @@ -import os - -import matplotlib.pyplot as plt -import numpy as np - -from wetting_angle_kit.visualization.base_trajectory_analyzer import ( - BaseTrajectoryAnalyzer, -) - - -class SlicingTrajectoryAnalyzer(BaseTrajectoryAnalyzer): - """BaseTrajectoryAnalyzer implementation for the slicing contact angle method.""" - - def __init__( - self, - directories: list[str], - time_steps: dict[str, float] | None = None, - time_unit: str = "ps", - ) -> None: - """ - Initialize the analyzer with a list of directory paths. - - Parameters - ---------- - directories : list of str - List of directory paths containing analysis results. - time_steps : dict, optional - Dictionary mapping directory to its time step. - If None, defaults to 1.0 for all directories. - time_unit : str, optional - Time unit for the x-axis (e.g., "ps", "ns", "fs"). - """ - self.time_steps = time_steps if time_steps else {d: 1.0 for d in directories} - self.time_unit = time_unit - super().__init__(directories, time_unit=time_unit) - - def _initialize_data_structure(self) -> None: - """Initialize data structure for slicing analysis.""" - for directory in self.directories: - self.data[directory] = { - "surfaces_files": [], - "popts_files": [], - "angles_files": [], - "mean_surface_areas": [], - "all_angles": [], - "median_angles": [], - "mean_angles": [], - "std_angles": [], - "time_step": self.time_steps.get(directory, 1.0), - } - - def get_method_name(self) -> str: - """Return method name.""" - return "Slicing Analysis" - - def load_data(self) -> None: - """Load combined .npy files (angles, surfaces, popts) - from all directories and compute mean surface areas per frame.""" - for directory in self.directories: - all_angles = np.load( - os.path.join(directory, "all_angles.npy"), allow_pickle=True - ) - all_surfaces = np.load( - os.path.join(directory, "all_surfaces.npy"), allow_pickle=True - ) - all_popts = np.load( - os.path.join(directory, "all_popts.npy"), allow_pickle=True - ) - - # Calculate mean surface area for each frame - mean_surface_areas = [] - for frame_data in all_surfaces: - surfaces = frame_data[1] - all_surf = [ - self.calculate_polygon_area(surface) for surface in surfaces - ] - mean_area = np.mean(np.array(all_surf)) - mean_surface_areas.append(mean_area) - - self.data[directory] = { - "all_angles": all_angles, - "all_surfaces": all_surfaces, - "all_popts": all_popts, - "frame_numbers": [item[0] for item in all_angles], - "mean_surface_areas": mean_surface_areas, - "median_angles": [(item[0], np.median(item[1])) for item in all_angles], - "mean_angles": [(item[0], np.mean(item[1])) for item in all_angles], - "std_angles": [(item[0], np.std(item[1])) for item in all_angles], - "time_step": self.time_steps.get(directory, 1.0), - } - - @staticmethod - def calculate_polygon_area(points: np.ndarray) -> float: - """ - Calculate the area of a polygon given its vertices using the Shoelace formula. - - Parameters - ---------- - points : numpy.ndarray - Array of shape (N, 2) containing polygon vertices. - - Returns - ------- - float - Area of the polygon. - """ - x = points[:, 0] - y = points[:, 1] - area = 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1))) - return area - - def mean_surface_frame(self, surfaces: list[np.ndarray]) -> float: - """Return the mean polygon area across all surfaces in a frame. - - Parameters - ---------- - surfaces : list[ndarray] - List of surface polygon vertex arrays, each of shape (N, 2). - - Returns - ------- - float - Mean surface area across all surfaces in the frame. - """ - all_surf = [self.calculate_polygon_area(surface) for surface in surfaces] - return np.mean(np.array(all_surf)) - - def get_surface_areas(self, directory: str) -> np.ndarray: - """Get surface areas for a directory.""" - return np.array(self.data[directory]["mean_surface_areas"]) - - def get_contact_angles(self, directory: str) -> np.ndarray: - """Get contact angles (median angles) for a directory.""" - data = np.array(self.data[directory]["median_angles"]) - if data.ndim == 2 and data.shape[1] >= 2: - return data[:, 1] - return data - - def plot_median_angles_evolution( - self, - save_path: str, - labels: dict[str, str] | None = None, - plot_std: bool = True, - ) -> None: - """Plot evolution of median contact angle with standard deviation. - - Align trajectories by truncating to shortest. - """ - if not self.data[self.directories[0]]["median_angles"]: - self.load_data() - - plot_labels = ( - labels if labels else {d: os.path.basename(d) for d in self.directories} - ) - - plt.figure(figsize=(10, 6)) - colors = plt.cm.tab20(np.linspace(0, 1, len(self.directories))) - - for i, directory in enumerate(self.directories): - median_angles = self.data[directory]["median_angles"] - std_angles = self.data[directory]["std_angles"] - frame_numbers = [item[0] for item in median_angles] - median_values = [item[1] for item in median_angles] - std_values = [item[1] for item in std_angles] - time_step = self.data[directory]["time_step"] - time_values = np.array(frame_numbers) * time_step - label = plot_labels.get(directory, os.path.basename(directory)) - - plt.plot( - time_values, - median_values, - linestyle="-", - color=colors[i], - label=f"{label}", - ) - if plot_std: - plt.fill_between( - time_values, - np.array(median_values) - np.array(std_values), - np.array(median_values) + np.array(std_values), - color=colors[i], - alpha=0.2, - ) - - plt.title("Evolution of the Median Angle") - plt.xlabel(f"Time ({self.time_unit})") - plt.ylabel("Angle (°)") - plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left") - plt.grid(False) - plt.tight_layout() - plt.savefig(save_path, dpi=400, bbox_inches="tight") - plt.close() - - def plot_mean_angles_evolution( - self, - save_path: str, - labels: dict[str, str] | None = None, - ) -> None: - """Plot evolution of mean contact angle with standard deviation.""" - plot_labels = ( - labels if labels else {d: os.path.basename(d) for d in self.directories} - ) - plt.figure(figsize=(10, 6)) - colors = plt.cm.tab20(np.linspace(0, 1, len(self.directories))) - - for i, directory in enumerate(self.directories): - mean_angles = self.data[directory]["mean_angles"] - std_angles = self.data[directory]["std_angles"] - frame_numbers = [item[0] for item in mean_angles] - mean_values = [item[1] for item in mean_angles] - std_values = [item[1] for item in std_angles] - time_step = self.data[directory]["time_step"] - time_values = np.array(frame_numbers) * time_step - label = plot_labels.get(directory, os.path.basename(directory)) - - plt.plot( - time_values, mean_values, linestyle="-", color=colors[i], label=label - ) - plt.fill_between( - time_values, - np.array(mean_values) - np.array(std_values), - np.array(mean_values) + np.array(std_values), - color=colors[i], - alpha=0.2, - ) - - plt.title("Evolution of the Mean Angle with Standard Deviation") - plt.xlabel(f"Time ({self.time_unit})") - plt.ylabel("Angle (°)") - plt.legend(bbox_to_anchor=(1.05, 1), loc="upper left") - plt.grid(False) - plt.tight_layout() - plt.savefig(save_path, dpi=400, bbox_inches="tight") - plt.close() diff --git a/src/wetting_angle_kit/visualization/slicing_trajectory_plotter.py b/src/wetting_angle_kit/visualization/slicing_trajectory_plotter.py new file mode 100644 index 0000000..bba6ca4 --- /dev/null +++ b/src/wetting_angle_kit/visualization/slicing_trajectory_plotter.py @@ -0,0 +1,215 @@ +from collections.abc import Iterable + +import numpy as np +import plotly.colors as pc +import plotly.graph_objects as go + +from wetting_angle_kit.analysis.slicing.results import SlicingResults +from wetting_angle_kit.visualization.base_trajectory_plotter import ( + BaseTrajectoryPlotter, +) +from wetting_angle_kit.visualization.stats import TrajectoryStats + + +def _shoelace_area(points: np.ndarray) -> float: + """Polygon area via the shoelace formula.""" + x = points[:, 0] + y = points[:, 1] + return float(0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))) + + +def _hex_to_rgba(hex_color: str, alpha: float) -> str: + """Return a CSS ``rgba(...)`` string from a ``#rrggbb`` hex color.""" + h = hex_color.lstrip("#") + r, g, b = int(h[0:2], 16), int(h[2:4], 16), int(h[4:6], 16) + return f"rgba({r},{g},{b},{alpha})" + + +class SlicingTrajectoryPlotter(BaseTrajectoryPlotter): + """Plot statistics derived from one or more :class:`SlicingResults`.""" + + def __init__( + self, + results: SlicingResults | Iterable[SlicingResults], + labels: list[str] | None = None, + time_steps: list[float] | None = None, + time_unit: str = "ps", + ) -> None: + """ + Parameters + ---------- + results : SlicingResults or iterable of SlicingResults + One results container per trajectory. + labels : list of str, optional + Display labels (one per results container). Defaults to + ``["trajectory_0", ...]``. + time_steps : list of float, optional + Per-trajectory time step applied to ``frames`` for the time + axis of evolution plots. Defaults to ``1.0`` for each. + time_unit : str, optional + Time unit shown on x-axis labels. + """ + if isinstance(results, SlicingResults): + results = [results] + else: + results = list(results) + self.results = results + self.labels = labels or [f"trajectory_{i}" for i in range(len(results))] + self.time_steps = time_steps or [1.0] * len(results) + self.time_unit = time_unit + + def _mean_surface_areas(self, result: SlicingResults) -> np.ndarray: + """Per-frame mean polygon area (shoelace over the frame's slices).""" + return np.array( + [ + float(np.mean([_shoelace_area(s) for s in frame_surfaces])) + for frame_surfaces in result.surfaces + ] + ) + + def summary(self) -> list[TrajectoryStats]: + stats: list[TrajectoryStats] = [] + for label, result in zip(self.labels, self.results, strict=False): + surfaces = self._mean_surface_areas(result) + stats.append( + TrajectoryStats( + method_name="Slicing Analysis", + label=label, + mean_surface_area=float(np.mean(surfaces)), + mean_contact_angle=result.mean_angle, + std_contact_angle=result.std_angle, + n_samples=len(result), + ) + ) + return stats + + def plot_angle_evolution( + self, + stat: str = "median", + per_frame_std: bool = True, + running_mean: bool = True, + timestep: float | None = None, + time_unit: str | None = None, + save_path: str | None = None, + ) -> go.Figure: + """Plot per-frame contact angle as a function of time. + + Parameters + ---------- + stat : str, default "median" + Per-frame aggregation across slices; one of ``"median"`` or ``"mean"``. + per_frame_std : bool, default True + If True, draw a transparent ±σ band around the per-frame curve + using the inter-slice spread within each frame — shows how noisy + the contact angle estimate is at each instant. + running_mean : bool, default True + If True, overlay the cumulative running mean of the per-frame + central tendency as a dashed line, plus a transparent ±σ band + of that cumulative series — shows how the time-averaged contact + angle converges as more frames are accumulated. + timestep : float, optional + Time between two consecutive frames *in the trajectory file* + (i.e. dump interval × MD integration timestep). Applied + uniformly to all trajectories, overriding the per-trajectory + ``time_steps`` passed at construction. This is **not** the MD + integration timestep — it is the spacing between frames as + they appear in the dump. + time_unit : str, optional + Override for the x-axis time unit label. Defaults to the + ``time_unit`` passed at construction. + save_path : str, optional + If provided, write the figure as standalone HTML. + + Returns + ------- + plotly.graph_objects.Figure + Figure with one per-frame line per trajectory, optionally with + an inter-slice ±σ band and/or a running mean line with its + cumulative ±σ band. + """ + if stat not in ("median", "mean"): + raise ValueError(f"stat must be 'median' or 'mean', got {stat!r}") + agg = np.median if stat == "median" else np.mean + palette = pc.qualitative.Plotly + band_traces: list[go.Scatter] = [] + line_traces: list[go.Scatter] = [] + effective_unit = time_unit if time_unit is not None else self.time_unit + for idx, (label, result, default_dt) in enumerate( + zip(self.labels, self.results, self.time_steps, strict=False) + ): + dt = timestep if timestep is not None else default_dt + color = palette[idx % len(palette)] + band_color = _hex_to_rgba(color, 0.2) + times = np.array(result.frames) * dt + per_frame = np.array([float(agg(a)) for a in result.angles]) + per_frame_group = label + running_group = f"{label} running mean" + line_traces.append( + go.Scatter( + x=times, + y=per_frame, + mode="lines", + name=label, + line=dict(width=2, color=color), + legendgroup=per_frame_group, + ) + ) + if per_frame_std: + std = np.array([float(np.std(a)) for a in result.angles]) + band_traces.append( + go.Scatter( + x=np.concatenate([times, times[::-1]]), + y=np.concatenate([per_frame + std, (per_frame - std)[::-1]]), + fill="toself", + fillcolor=band_color, + line=dict(width=0), + name=f"{label} ±σ", + legendgroup=per_frame_group, + showlegend=False, + hoverinfo="skip", + ) + ) + if running_mean: + counts = np.arange(1, len(per_frame) + 1) + cum_mean = np.cumsum(per_frame) / counts + sq_mean = np.cumsum(per_frame**2) / counts + cum_std = np.sqrt(np.maximum(sq_mean - cum_mean**2, 0.0)) + band_traces.append( + go.Scatter( + x=np.concatenate([times, times[::-1]]), + y=np.concatenate( + [cum_mean + cum_std, (cum_mean - cum_std)[::-1]] + ), + fill="toself", + fillcolor=band_color, + line=dict(width=0), + name=f"{label} running ±σ", + legendgroup=running_group, + showlegend=False, + hoverinfo="skip", + ) + ) + line_traces.append( + go.Scatter( + x=times, + y=cum_mean, + mode="lines", + name=running_group, + line=dict(width=2, color=color, dash="dash"), + legendgroup=running_group, + ) + ) + fig = go.Figure() + for trace in band_traces: + fig.add_trace(trace) + for trace in line_traces: + fig.add_trace(trace) + fig.update_layout( + title=f"Contact angle evolution ({stat})", + xaxis_title=f"Time ({effective_unit})", + yaxis_title="Contact angle (°)", + template="plotly_white", + ) + if save_path: + fig.write_html(save_path) + return fig diff --git a/src/wetting_angle_kit/visualization/stats.py b/src/wetting_angle_kit/visualization/stats.py new file mode 100644 index 0000000..549f539 --- /dev/null +++ b/src/wetting_angle_kit/visualization/stats.py @@ -0,0 +1,44 @@ +from dataclasses import dataclass + + +@dataclass +class TrajectoryStats: + """Summary statistics for a single contact-angle trajectory. + + Replaces the legacy ``output_stats.txt`` file: instead of writing to + disk, the plotter returns this dataclass so callers can both display + the block (``print(stats)``) and reuse the underlying numbers + programmatically. + + Attributes + ---------- + method_name : str + Name of the analysis method (e.g. ``"Slicing Analysis"``). + label : str + Display label identifying the trajectory. + mean_surface_area : float + Mean droplet/cap surface area in Ų. + mean_contact_angle : float + Mean contact angle in degrees. + std_contact_angle : float + Standard deviation of the contact angle in degrees. + n_samples : int + Number of samples (frames or batches) contributing to the means. + """ + + method_name: str + label: str + mean_surface_area: float + mean_contact_angle: float + std_contact_angle: float + n_samples: int + + def __str__(self) -> str: + return ( + f"Label: {self.label}\n" + f"Method: {self.method_name}\n" + f"Mean Surface Area: {self.mean_surface_area:.4f}\n" + f"Mean Contact Angle: {self.mean_contact_angle:.4f}°\n" + f"Std Contact Angle: {self.std_contact_angle:.4f}°\n" + f"N samples: {self.n_samples}" + ) diff --git a/src/wetting_angle_kit/visualization/surface_plots.py b/src/wetting_angle_kit/visualization/surface_plots.py deleted file mode 100644 index 15844c5..0000000 --- a/src/wetting_angle_kit/visualization/surface_plots.py +++ /dev/null @@ -1,143 +0,0 @@ -from typing import Any - -import matplotlib.pyplot as plt -import numpy as np - - -def plot_surface_file(file_path: str) -> tuple[np.ndarray, np.ndarray]: - """Return x,y columns from surface text file. - - Parameters - ---------- - file_path : str - Path to whitespace-delimited file with at least two columns. - - Returns - ------- - tuple(ndarray, ndarray) - (x, y) coordinate arrays. - """ - data = np.loadtxt(file_path) - x = data[:, 0] - y = data[:, 1] - return x, y - - -def plot_slice(x: np.ndarray, y: np.ndarray) -> None: - """Plot a 2D surface contour line from x and y coordinate arrays.""" - plt.figure() - plt.plot(x, y, label="Surface Slice") - plt.xlabel("X-axis") - plt.ylabel("Y-axis") - plt.title("2D Slice of Fitted Surface") - plt.legend() - plt.grid() - plt.show() - - -def read_surface_file(file_path: str) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Load surface file returning x,y,z arrays (z zeros if absent). - - Parameters - ---------- - file_path : str - Path to surface file with 2 or 3 columns. - - Returns - ------- - tuple(ndarray, ndarray, ndarray) - (x, y, z) arrays; z is zeros if file has only two columns. - """ - data = np.loadtxt(file_path) - if data.shape[1] == 2: - x, y = data[:, 0], data[:, 1] - z = np.zeros_like(x) - else: - x, y, z = data[:, 0], data[:, 1], data[:, 2] - return x, y, z - - -def plot_surface_and_points( - x_surf: np.ndarray, - y_surf: np.ndarray, - z_surf: np.ndarray, - x_points: np.ndarray, - y_points: np.ndarray, - z_points: np.ndarray, -) -> None: - """Render 3D plot of surface curve and point cloud. - - Parameters - ---------- - x_surf, y_surf, z_surf : ndarray - Surface coordinates. - x_points, y_points, z_points : ndarray - Point cloud coordinates. - """ - fig = plt.figure() - ax = fig.add_subplot(111, projection="3d") - ax.plot(x_surf, y_surf, z_surf, label="Surface", color="black") - ax.scatter( - x_points, y_points, z_points, s=10, alpha=0.7, label="Points", color="tab:blue" - ) - ax.set_xlabel("X") - ax.set_ylabel("Y") - ax.set_zlabel("Z") - ax.legend() - plt.tight_layout() - plt.show() - - -def visualize_surface_with_points(surface_file: str, points: np.ndarray) -> None: - """Convenience wrapper: load surface and overlay points. - - Parameters - ---------- - surface_file : str - Path to surface file. - points : ndarray, shape (N, 3) - XYZ coordinates of points to overlay. - """ - x_surf, y_surf, z_surf = read_surface_file(surface_file) - x_points, y_points, z_points = points[:, 0], points[:, 1], points[:, 2] - plot_surface_and_points(x_surf, y_surf, z_surf, x_points, y_points, z_points) - - -def plot_liquid_particles( - positions: np.ndarray, - ax: Any | None = None, - color: str = "tab:blue", - subsample: int | None = None, -) -> Any: - """Scatter plot 3D particle positions with optional subsampling. - - Parameters - ---------- - positions : ndarray, shape (N, 3) - Particle coordinates. - ax : mpl_toolkits.mplot3d.Axes3D, optional - Existing axes to plot on; new figure created if None. - color : str, default "tab:blue" - Marker color. - subsample : int, optional - If provided and smaller than N, random subset size to plot. - - Returns - ------- - mpl_toolkits.mplot3d.Axes3D - Axes object used for plotting. - """ - if subsample is not None and subsample < len(positions): - rng = np.random.default_rng(42) - idx = rng.choice(len(positions), size=subsample, replace=False) - positions = positions[idx] - if ax is None: - fig = plt.figure() - ax = fig.add_subplot(111, projection="3d") - ax.scatter( - positions[:, 0], positions[:, 1], positions[:, 2], s=8, alpha=0.8, color=color - ) - ax.set_xlabel("X") - ax.set_ylabel("Y") - ax.set_zlabel("Z") - return ax diff --git a/tests/README b/tests/README index 7209f45..2cefdd9 100644 --- a/tests/README +++ b/tests/README @@ -14,14 +14,20 @@ Layout │ (covers spherical / cylinder_x / cylinder_y) ├── test_edge_cases.py Validation errors, deprecation paths, │ NaN guards, factory rejections - ├── test_visualization.py Smoke tests for the plotting helpers + ├── test_visualization/ Smoke tests for the plotting helpers + │ ├── test_droplet_slice_plot.py + │ └── test_trajectory_plotters.py ├── test_parser/ Per-format parser tests (LAMMPS dump, │ ├── test_parser_dump.py XYZ, ASE) │ ├── test_parser_xyz.py - │ └── test_parser_ase.py - ├── test_contact_angle_methods/ Integration tests for the sliced and - │ ├── test_sliced_method.py binning analyzers on real fixtures - │ └── test_binning_method.py + │ ├── test_parser_ase.py + │ ├── test_water_finders.py + │ └── test_parser_factory.py + ├── test_analysis/ Integration tests for the sliced and + │ ├── test_slicing_method.py binning analyzers on real fixtures + │ ├── test_slicing_edge_cases.py + │ ├── test_binning_method.py + │ ├── test_binning_surface_definition.py └── trajectories/ Fixture trajectories used by integration tests (LAMMPS dump and XYZ/ASE samples) diff --git a/docs/tutorials/Animated_slicing_droplet.py b/tests/__init__.py similarity index 100% rename from docs/tutorials/Animated_slicing_droplet.py rename to tests/__init__.py diff --git a/tests/test_analysis/test_binning_method.py b/tests/test_analysis/test_binning_method.py index 1665c2f..5d98050 100644 --- a/tests/test_analysis/test_binning_method.py +++ b/tests/test_analysis/test_binning_method.py @@ -3,8 +3,16 @@ import numpy as np import pytest -from wetting_angle_kit.analysis import contact_angle_analyzer -from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder +# The binning integration tests run on a LAMMPS dump fixture parsed through +# OVITO; skip the whole module when the optional dependency is unavailable +# (typically on macOS CI). +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import BinningTrajectoryAnalyzer # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) # --- Fixtures --- @@ -20,9 +28,7 @@ def filename(): @pytest.fixture def wat_find(filename): - return LammpsDumpWaterFinder( - filename, particle_type_wall={3}, oxygen_type=1, hydrogen_type=2 - ) + return LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) @pytest.fixture @@ -47,66 +53,47 @@ def binning_params(): } -# --- Unit Test for BinningContactAngleAnalyzer --- +# --- Unit Test for BinningTrajectoryAnalyzer --- @pytest.mark.integration def test_binning_contact_angle_analyzer_with_real_data( - filename, oxygen_indices, binning_params, tmp_path + filename, oxygen_indices, binning_params ): - # Use a temporary directory for output - output_dir = tmp_path / "result_dump_cylinder_noplot" - - # Create the analyzer - analyzer = contact_angle_analyzer( - method="binning", + analyzer = BinningTrajectoryAnalyzer( parser=LammpsDumpParser(filename), - output_dir=output_dir, atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - width_cylinder=21, binning_params=binning_params, - plot_graphs=False, ) - # Run analysis for frame 1 results = analyzer.analyze([1]) - # Assert results - assert "mean_angle" in results - assert "std_angle" in results - assert "angles" in results - assert len(results["angles"]) == 1 + assert len(results) == 1 # Cylindrical droplet on a graphene-like surface gives a contact angle # around 90-100° here. Use a moderate band so the test catches gross # regressions but tolerates the inherent noise of a single-frame fit. - assert 80.0 <= results["mean_angle"] <= 115.0 - assert np.isfinite(results["std_angle"]) + assert 80.0 <= results.mean_angle <= 115.0 + assert np.isfinite(results.std_angle) # --- Multi-batch test: with split_factor=1 each frame produces its own # angle, so we should get one angle per frame, not a single collapsed value. @pytest.mark.integration def test_binning_contact_angle_analyzer_per_frame_with_split_factor( - filename, oxygen_indices, binning_params, tmp_path + filename, oxygen_indices, binning_params ): - output_dir = tmp_path / "result_dump_per_frame" - - analyzer = contact_angle_analyzer( - method="binning", + analyzer = BinningTrajectoryAnalyzer( parser=LammpsDumpParser(filename), - output_dir=output_dir, atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - width_cylinder=21, binning_params=binning_params, - plot_graphs=False, ) # split_factor=1 → one batch per frame → 3 batch-level angles. results = analyzer.analyze([1, 2, 3], split_factor=1) - assert results["method_metadata"] == {"frames_per_trajectory": 1} - assert results["angles"].shape == (3,) + assert results.method_metadata == {"frames_per_trajectory": 1} + assert results.angles_per_batch.shape == (3,) # Each batch can either converge to a physically-plausible angle in # [0, 180] or return NaN (signaling fit failure on a single frame). - for angle in results["angles"]: + for angle in results.angles_per_batch: assert np.isnan(angle) or (0.0 <= angle <= 180.0) diff --git a/tests/test_analysis/test_binning_surface_definition.py b/tests/test_analysis/test_binning_surface_definition.py index 3fd344b..00d335d 100644 --- a/tests/test_analysis/test_binning_surface_definition.py +++ b/tests/test_analysis/test_binning_surface_definition.py @@ -1,19 +1,212 @@ +import warnings + import numpy as np +import pytest from wetting_angle_kit.analysis.binning.surface_definition import ( HyperbolicTangentModel, ) +# Reference parameter set used across the analytic checks below. +# Wall at z=0 sits inside a sphere of radius 10 centered at z=8. +_REF_PARAMS = [1.0, 0.0, 10.0, 8.0, 0.0, 1.0, 1.0] +_PARAM_NAMES = ["rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2"] -def test_hyperbolic_tangent_compute_isoline_well_formed(): - """Wall inside the fitted sphere should yield finite isoline arrays.""" + +def _fitted_model(params=_REF_PARAMS) -> HyperbolicTangentModel: model = HyperbolicTangentModel() - # Wall at z=0, droplet center at z=8, radius 10 → wall is inside the - # sphere (|0 - 8| = 8 < 10). Densities and thicknesses are positive. - model.params = [1.0, 0.0, 10.0, 8.0, 0.0, 1.0, 1.0] + model.params = list(params) + return model + + +# --- compute_isoline ----------------------------------------------------- + + +def test_hyperbolic_tangent_compute_isoline_well_formed(): + """Wall inside the fitted sphere should yield finite isoline arrays + whose points exactly satisfy the scaled-sphere and wall equations.""" + model = _fitted_model() circle_xi, circle_zi, wall_xi, wall_zi = model.compute_isoline() assert circle_xi.size == 100 - assert np.all(np.isfinite(circle_xi)) - assert np.all(np.isfinite(circle_zi)) - assert np.all(np.isfinite(wall_xi)) - np.testing.assert_allclose(wall_zi, 0.0) + assert wall_xi.size == 100 + + # Circle points sit on the visualization sphere of radius + # scale_factor * R_eq centered at (0, z_center). + r = 0.95 * _REF_PARAMS[2] # scale_factor * R_eq + z_center = _REF_PARAMS[3] + np.testing.assert_allclose(circle_xi**2 + (circle_zi - z_center) ** 2, r**2) + # The contact point closes the arc at xi = sqrt(r^2 - (z_wall - z_c)^2), + # z = z_wall; the arc ends at the sphere apex (xi=0, z=z_c+r). + z_wall = _REF_PARAMS[4] + xi_contact = np.sqrt(r**2 - (z_wall - z_center) ** 2) + assert circle_xi[0] == pytest.approx(xi_contact) + assert circle_zi[0] == pytest.approx(z_wall) + assert circle_xi[-1] == pytest.approx(0.0, abs=1e-12) + assert circle_zi[-1] == pytest.approx(z_center + r) + + # Wall line spans [0, xi_contact] at constant z = z_wall. + np.testing.assert_allclose(wall_zi, z_wall) + assert wall_xi[0] == pytest.approx(0.0) + assert wall_xi[-1] == pytest.approx(xi_contact) + + +def test_compute_isoline_raises_when_wall_outside_sphere(): + # |z_wall - z_center| = 12 > R_eq = 10 → no intersection → ValueError. + model = _fitted_model([1.0, 0.0, 10.0, 0.0, 12.0, 1.0, 1.0]) + with pytest.raises(ValueError, match="outside the fitted droplet radius"): + model.compute_isoline() + + +def test_compute_isoline_requires_fit_first(): + model = HyperbolicTangentModel() + model.params = None + with pytest.raises(ValueError, match="must be fitted"): + model.compute_isoline() + + +# --- compute_contact_angle ------------------------------------------------ + + +def test_compute_contact_angle_wall_at_equator_is_ninety_degrees(): + # Sphere center on the wall (zi_c = zi_0) → tangent at intersection is + # vertical → contact angle is 90°. + model = _fitted_model([1.0, 0.0, 10.0, 0.0, 0.0, 1.0, 1.0]) + assert model.compute_contact_angle() == pytest.approx(90.0) + + +def test_compute_contact_angle_wall_above_center_gives_acute_angle(): + # zi_0 - zi_c = +5, R_eq = 10 → xi_cross = sqrt(75); contact angle 60°. + model = _fitted_model([1.0, 0.0, 10.0, 0.0, 5.0, 1.0, 1.0]) + assert model.compute_contact_angle() == pytest.approx(60.0) + + +def test_compute_contact_angle_wall_below_center_gives_obtuse_angle(): + # zi_0 - zi_c = -5, R_eq = 10 → droplet sits past its equator on the + # wall → contact angle 120°. + model = _fitted_model([1.0, 0.0, 10.0, 5.0, 0.0, 1.0, 1.0]) + assert model.compute_contact_angle() == pytest.approx(120.0) + + +def test_compute_contact_angle_returns_nan_when_wall_outside_sphere(): + model = _fitted_model([1.0, 0.0, 10.0, 0.0, 12.0, 1.0, 1.0]) + with pytest.warns(RuntimeWarning, match="outside the fitted droplet sphere"): + angle = model.compute_contact_angle() + assert np.isnan(angle) + + +def test_compute_contact_angle_requires_fit_first(): + model = HyperbolicTangentModel() + model.params = None + with pytest.raises(ValueError, match="must be fitted"): + model.compute_contact_angle() + + +# --- evaluate / evaluate_on_grid ----------------------------------------- + + +def test_evaluate_matches_fitting_function(): + model = _fitted_model() + xi, zi = 3.0, 4.0 + rho1, rho2, R_eq, zi_c, zi_0, t1, t2 = _REF_PARAMS + r = np.sqrt(xi**2 + (zi - zi_c) ** 2) + z = zi - zi_0 + expected = ( + 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) + ) * (0.5 * (1 + np.tanh(2 * z / t2))) + assert model.evaluate((xi, zi)) == pytest.approx(expected) + + +def test_evaluate_requires_fit_first(): + model = HyperbolicTangentModel() + model.params = None + with pytest.raises(ValueError, match="must be fitted"): + model.evaluate((0.0, 0.0)) + + +def test_evaluate_on_grid_shape_and_values(): + model = _fitted_model() + xi_grid = np.array([0.0, 1.0, 2.0, 3.0]) + zi_grid = np.array([4.0, 5.0]) + grid = model.evaluate_on_grid(xi_grid, zi_grid) + assert grid.shape == (len(xi_grid), len(zi_grid)) + # Spot-check entries against scalar evaluate calls (indexing='ij'). + for i, xi in enumerate(xi_grid): + for j, zi in enumerate(zi_grid): + assert grid[i, j] == pytest.approx(model.evaluate((xi, zi))) + + +# --- get_parameters / get_parameter_strings ------------------------------ + + +def test_get_parameters_maps_names_to_values(): + model = _fitted_model() + params = model.get_parameters() + assert list(params.keys()) == _PARAM_NAMES + assert list(params.values()) == _REF_PARAMS + + +def test_get_parameters_requires_fit_first(): + model = HyperbolicTangentModel() + model.params = None + with pytest.raises(ValueError, match="must be fitted"): + model.get_parameters() + + +def test_get_parameter_strings_format(): + model = _fitted_model() + strings = model.get_parameter_strings() + assert len(strings) == len(_PARAM_NAMES) + for name, value, line in zip(_PARAM_NAMES, _REF_PARAMS, strings, strict=True): + assert line == f"{name}:{value}\n" + + +def test_get_parameter_strings_requires_fit_first(): + model = HyperbolicTangentModel() + model.params = None + with pytest.raises(ValueError, match="must be fitted"): + model.get_parameter_strings() + + +# --- fit (round-trip on a synthetic density field) ----------------------- + + +def test_fit_recovers_synthetic_parameters(): + # Matches the call style used by BinningBatchFitter: flattened + # (xi, zi) coordinates and a flattened density vector. + true_params = [0.02, 0.001, 12.0, 6.0, 0.0, 1.5, 1.2] + xi_grid = np.linspace(0.1, 25.0, 30) + zi_grid = np.linspace(-5.0, 25.0, 35) + xi_mesh, zi_mesh = np.meshgrid(xi_grid, zi_grid, indexing="ij") + xi_flat = xi_mesh.ravel() + zi_flat = zi_mesh.ravel() + + seed_model = HyperbolicTangentModel(initial_params=list(true_params)) + truth = seed_model._fitting_function((xi_flat, zi_flat), *true_params) + + # Start from a perturbed initial guess to make the recovery non-trivial. + perturbed = [p * 1.1 for p in true_params] + model = HyperbolicTangentModel(initial_params=perturbed) + fitted = model.fit((xi_flat, zi_flat), truth) + assert fitted is model + np.testing.assert_allclose(model.params, true_params, rtol=1e-4, atol=1e-4) + + +def test_warn_if_at_bounds_fires_when_parameter_pinned(): + # Drive ``_warn_if_at_bounds`` directly: the TRF solver inside ``fit`` + # keeps iterates strictly feasible, so it's hard to land exactly on a + # bound through curve_fit. The warning logic itself is what matters. + model = HyperbolicTangentModel() + # t1 sits at its lower bound of 1e-6. + model.params = np.array([1e-3, 1e-3, 10.0, 0.0, 0.0, 1e-6, 1.0]) + with pytest.warns(RuntimeWarning, match="at the physical bound"): + model._warn_if_at_bounds() + + +def test_warn_if_at_bounds_silent_when_parameters_interior(): + # Interior values across all seven parameters; _REF_PARAMS itself has + # rho2=0 sitting on its lower bound and would (correctly) warn. + model = HyperbolicTangentModel() + model.params = np.array([1e-3, 1e-3, 10.0, 0.0, 0.0, 1.0, 1.0]) + with warnings.catch_warnings(): + warnings.simplefilter("error") # any warning would fail the test + model._warn_if_at_bounds() diff --git a/tests/test_analysis/test_slicing_edge_cases.py b/tests/test_analysis/test_slicing_edge_cases.py index 17cb94b..7df2f27 100644 --- a/tests/test_analysis/test_slicing_edge_cases.py +++ b/tests/test_analysis/test_slicing_edge_cases.py @@ -1,21 +1,25 @@ -import os - import numpy as np import pytest -from wetting_angle_kit.analysis.slicing.angle_fitting import ( - ContactAngleSlicing, +from wetting_angle_kit.analysis.slicing.analyzer import ( + SlicingTrajectoryAnalyzer, + _SlicingFrameResult, ) -from wetting_angle_kit.analysis.slicing.parallel import ( - ContactAngleSlicingParallel, - SlicingFrameResult, +from wetting_angle_kit.analysis.slicing.angle_fitting import ( + SlicingFrameFitter, ) -def _simple_predictor(droplet_geometry="cylinder_y", **kwargs): - """Return a minimally-initialised ContactAngleSlicing with required attrs.""" - return ContactAngleSlicing( - liquid_coordinates=np.zeros((10, 3)), +def _simple_predictor( + droplet_geometry="cylinder_y", + liquid_coordinates=None, + **kwargs, +): + """Return a minimally-initialised SlicingFrameFitter with required attrs.""" + if liquid_coordinates is None: + liquid_coordinates = np.zeros((10, 3)) + return SlicingFrameFitter( + liquid_coordinates=liquid_coordinates, max_dist=20, liquid_geom_center=np.array([0.0, 0.0, 0.0]), droplet_geometry=droplet_geometry, @@ -23,76 +27,47 @@ def _simple_predictor(droplet_geometry="cylinder_y", **kwargs): ) -def test_calculate_y_axis_requires_cylinder_widths(): - predictor = _simple_predictor( - droplet_geometry="cylinder_y", width_cylinder=10.0, delta_cylinder=2.0 - ) - # Now break them to force the validation branch in calculate_y_axis_list. - predictor.width_cylinder = None - with pytest.raises(ValueError, match="width_cylinder and delta_cylinder"): - predictor.calculate_y_axis_list() - - -def test_calculate_gammas_requires_cylinder_widths(): - predictor = _simple_predictor( - droplet_geometry="cylinder_y", width_cylinder=10.0, delta_cylinder=2.0 - ) - predictor.delta_cylinder = None - with pytest.raises(ValueError, match="width_cylinder and delta_cylinder"): - predictor.calculate_gammas_list() - - -def test_spherical_calculations_require_delta_gamma(): - predictor = _simple_predictor(droplet_geometry="spherical", delta_gamma=10.0) - predictor.delta_gamma = None - with pytest.raises(ValueError, match="delta_gamma is required"): - predictor.calculate_y_axis_list() - with pytest.raises(ValueError, match="delta_gamma is required"): - predictor.calculate_gammas_list() - - def test_spherical_constructor_requires_delta_gamma(): with pytest.raises(ValueError, match="delta_gamma must be provided"): _simple_predictor(droplet_geometry="spherical") -def test_cylinder_constructor_warns_without_widths(): - with pytest.warns(UserWarning, match="recommended"): +def test_cylinder_constructor_requires_delta_cylinder(): + with pytest.raises(ValueError, match="delta_cylinder must be provided"): _simple_predictor(droplet_geometry="cylinder_y") def test_find_intersection_returns_none_when_circle_does_not_intersect_baseline(): - predictor = _simple_predictor( - droplet_geometry="cylinder_y", width_cylinder=10.0, delta_cylinder=2.0 - ) + predictor = _simple_predictor(droplet_geometry="cylinder_y", delta_cylinder=2.0) # Circle center far below the baseline → no intersection popt = (0.0, -10.0, 1.0) assert predictor.find_intersection(popt, y_line=5.0) is None def test_find_intersection_returns_angle_for_intersecting_circle(): - predictor = _simple_predictor( - droplet_geometry="cylinder_y", width_cylinder=10.0, delta_cylinder=2.0 - ) + predictor = _simple_predictor(droplet_geometry="cylinder_y", delta_cylinder=2.0) # Circle of radius 5 at z=0, baseline at z=0 → contact angle = 90°. popt = (0.0, 0.0, 5.0) angle = predictor.find_intersection(popt, y_line=0.0) assert angle == pytest.approx(90.0) -def test_calculate_y_axis_cylinder(): +def test_calculate_y_axis_cylinder_spans_liquid_extent(): + # Liquid y-extent runs 0..10; with delta=2.5 expect 4 slices. + liquid = np.column_stack( + [np.zeros(5), np.array([0.0, 2.5, 5.0, 7.5, 10.0]), np.zeros(5)] + ) predictor = _simple_predictor( - droplet_geometry="cylinder_y", width_cylinder=10.0, delta_cylinder=2.5 + droplet_geometry="cylinder_y", + liquid_coordinates=liquid, + delta_cylinder=2.5, ) assert predictor.calculate_y_axis_list() == [0.0, 2.5, 5.0, 7.5] assert predictor.calculate_gammas_list() == [0.0, 0.0, 0.0, 0.0] def test_calculate_y_axis_spherical(): - predictor = _simple_predictor( - droplet_geometry="spherical", - delta_gamma=90.0, - ) + predictor = _simple_predictor(droplet_geometry="spherical", delta_gamma=90.0) # 180 / 90 = 2 entries; y_axis_list mirrors liquid_geom_center[1] each entry. y_axis = predictor.calculate_y_axis_list() gammas = predictor.calculate_gammas_list() @@ -101,62 +76,49 @@ def test_calculate_y_axis_spherical(): assert all(g >= 0 for g in gammas) -# --- ContactAngleSlicingParallel internals --- +# --- SlicingTrajectoryAnalyzer worker internals --- -def test_create_batches_few_frames(tmp_path): - parallel = ContactAngleSlicingParallel(filename="ignored", output_dir=str(tmp_path)) - # num_batches >= len(frames) → one frame per batch - assert parallel._create_batches([1, 2, 3], num_batches=4) == [[1], [2], [3]] +def test_run_one_frame_invokes_pipeline_on_real_lammps(): + """Drive ``_run_one_frame`` on a real LAMMPS fixture in the current process. - -def test_create_batches_many_frames(tmp_path): - parallel = ContactAngleSlicingParallel(filename="ignored", output_dir=str(tmp_path)) - batches = parallel._create_batches(list(range(10)), num_batches=3) - flat = [f for batch in batches for f in batch] - assert flat == list(range(10)) - # Approximately equal split; each batch is ≤ ceil(10/3) = 4. - assert all(len(b) <= 4 for b in batches) - - -def test_process_batch_worker_invokes_pipeline_on_real_lammps(tmp_path): - """Run _process_batch_worker on a real LAMMPS fixture in the current process. - - Goes through detect_parser_type → LammpsDumpParser → predict_contact_angle, - so it exercises the worker code paths that subprocess execution otherwise - hides from coverage. + The worker static methods normally run inside child processes, so this + test initialises ``_WORKER_STATE`` manually and then calls + ``_run_one_frame`` to exercise the parser → ``predict_contact_angle`` + path that subprocess execution otherwise hides from coverage. """ + pytest.importorskip("ovito") from tests.conftest import trajectory_path - parallel = ContactAngleSlicingParallel( + SlicingTrajectoryAnalyzer._init_worker( filename=trajectory_path("traj_spherical_drop_4k.lammpstrj"), - output_dir=str(tmp_path), droplet_geometry="spherical", + atom_indices=np.array([]), delta_gamma=20.0, + delta_cylinder=None, + points_per_angstrom=1.0, + precentered=False, ) - results = parallel._process_batch_worker(batch_frames=[0]) - assert len(results) == 1 - assert isinstance(results[0], SlicingFrameResult) - assert results[0].frame_num == 0 + try: + result = SlicingTrajectoryAnalyzer._run_one_frame(0) + finally: + SlicingTrajectoryAnalyzer._WORKER_STATE.clear() + assert isinstance(result, _SlicingFrameResult) + assert result.frame_num == 0 -def test_process_batch_worker_unsupported_extension(tmp_path): - """Unknown trajectory extension → worker returns failed results.""" +def test_unsupported_extension_raises_at_construction(tmp_path): + """Unknown trajectory extension must fail fast at construction, not later in + subprocesses where the error would be silently swallowed.""" fake = tmp_path / "trajectory.bogus" fake.write_text("not a real trajectory\n") - parallel = ContactAngleSlicingParallel( - filename=str(fake), - output_dir=str(tmp_path), - droplet_geometry="spherical", - delta_gamma=20.0, - ) - results = parallel._process_batch_worker(batch_frames=[0, 1]) - assert len(results) == 2 - assert all(r.mean_angle is None for r in results) + class _FakeParser: + filepath = str(fake) -def test_output_dir_is_created(tmp_path): - target = tmp_path / "nested" / "out" - parallel = ContactAngleSlicingParallel(filename="ignored", output_dir=str(target)) - assert os.path.isdir(target) - assert parallel.output_dir == str(target) + with pytest.raises(ValueError, match="Unsupported trajectory file format"): + SlicingTrajectoryAnalyzer( + parser=_FakeParser(), + droplet_geometry="spherical", + delta_gamma=20.0, + ) diff --git a/tests/test_analysis/test_slicing_method.py b/tests/test_analysis/test_slicing_method.py index 573f934..cb9a2cb 100644 --- a/tests/test_analysis/test_slicing_method.py +++ b/tests/test_analysis/test_slicing_method.py @@ -3,8 +3,16 @@ import numpy as np import pytest -from wetting_angle_kit.analysis import contact_angle_analyzer -from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder +# The slicing integration tests run on a LAMMPS dump fixture parsed through +# OVITO; skip the whole module when the optional dependency is unavailable +# (typically on macOS CI). +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import SlicingTrajectoryAnalyzer # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) # --- Fixtures --- @@ -20,9 +28,7 @@ def filename(): @pytest.fixture def wat_find(filename): - return LammpsDumpWaterFinder( - filename, particle_type_wall={3}, oxygen_type=1, hydrogen_type=2 - ) + return LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) @pytest.fixture @@ -35,7 +41,7 @@ def parser(filename): return LammpsDumpParser(filename) -# --- Unit Tests for ContactAngleSlicing --- +# --- Unit Tests for SlicingFrameFitter --- @pytest.mark.integration @pytest.mark.slow def test_contact_angle_slicing_with_real_data(parser, oxygen_indices): @@ -51,12 +57,12 @@ def test_contact_angle_slicing_with_real_data(parser, oxygen_indices): ) mean_liquid_position = np.mean(liquid_positions, axis=0) - # Initialize ContactAngleSlicing + # Initialize SlicingFrameFitter from wetting_angle_kit.analysis.slicing import ( - ContactAngleSlicing, + SlicingFrameFitter, ) - predictor = ContactAngleSlicing( + predictor = SlicingFrameFitter( liquid_coordinates=liquid_positions, liquid_geom_center=mean_liquid_position, droplet_geometry="spherical", @@ -72,19 +78,12 @@ def test_contact_angle_slicing_with_real_data(parser, oxygen_indices): assert len(angles) > 0 -# --- Integration Test for SlicingContactAngleAnalyzer --- +# --- Integration Test for SlicingTrajectoryAnalyzer --- @pytest.mark.integration @pytest.mark.slow -def test_slicing_contact_angle_analyzer_with_real_data( - filename, oxygen_indices, tmp_path -): - # Use a temporary directory for output - output_dir = tmp_path / "result_dump_spherical_slicing" - - analyzer = contact_angle_analyzer( - method="slicing", +def test_slicing_contact_angle_analyzer_with_real_data(filename, oxygen_indices): + analyzer = SlicingTrajectoryAnalyzer( parser=LammpsDumpParser(filename), - output_dir=output_dir, atom_indices=oxygen_indices, droplet_geometry="spherical", delta_gamma=20, @@ -92,14 +91,12 @@ def test_slicing_contact_angle_analyzer_with_real_data( results = analyzer.analyze([1]) - # Assert results - assert "mean_angle" in results - assert "std_angle" in results - assert "angles" in results - assert len(results["angles"]) == 1 + assert len(results) == 1 + assert results.frames == [1] # The fixture is a water droplet on a graphene-like substrate, which # gives a contact angle around 90-100° (literature: ~93° for graphene). # Assert a tight physically-plausible band so regressions in the # slicing pipeline are caught. - assert 80.0 <= results["mean_angle"] <= 110.0 - assert np.isfinite(results["std_angle"]) + mean_angle = float(np.mean(results.angles[0])) + assert 80.0 <= mean_angle <= 110.0 + assert np.isfinite(np.std(results.angles[0])) diff --git a/tests/test_analysis/test_slicing_surface_definition.py b/tests/test_analysis/test_slicing_surface_definition.py new file mode 100644 index 0000000..f04cadd --- /dev/null +++ b/tests/test_analysis/test_slicing_surface_definition.py @@ -0,0 +1,192 @@ +import numpy as np +import pytest + +from wetting_angle_kit.analysis.slicing.surface_definition import ( + SurfaceDefinition, +) + + +def _bare_surface(**overrides) -> SurfaceDefinition: + """Build a SurfaceDefinition with defaults that test setup can override.""" + kwargs = dict( + atom_coords=np.zeros((1, 3)), + delta_angle=10.0, + max_dist=20.0, + center_geom=np.zeros(3), + gamma=0.0, + ) + kwargs.update(overrides) + return SurfaceDefinition(**kwargs) + + +# --- density_profile (static tanh model) --------------------------------- + + +def test_density_profile_at_interface_equals_offset(): + # tanh(0) = 0, so rho(zd) = h regardless of d. + z = np.array([5.0]) + rho = SurfaceDefinition.density_profile(z, zd=5.0, d=0.5, h=0.3) + assert rho == pytest.approx(0.3) + + +def test_density_profile_saturates_far_from_interface(): + # tanh(+inf) = 1 (liquid side), tanh(-inf) = -1 (vapor side). + z = np.array([-50.0, 50.0]) + rho = SurfaceDefinition.density_profile(z, zd=5.0, d=0.5, h=0.3) + np.testing.assert_allclose(rho, [0.8, -0.2], atol=1e-10) + + +# --- density_contribution (Gaussian smoothing on a KD-tree) -------------- + + +def test_density_contribution_empty_atom_set_returns_zeros(): + surf = _bare_surface(atom_coords=np.empty((0, 3))) + positions = np.random.default_rng(0).normal(size=(7, 3)) + result = surf.density_contribution(positions) + assert result.shape == (7,) + np.testing.assert_array_equal(result, np.zeros(7)) + + +def test_density_contribution_zero_samples_returns_zeros(): + surf = _bare_surface(atom_coords=np.zeros((3, 3))) + result = surf.density_contribution(np.empty((0, 3))) + assert result.shape == (0,) + + +def test_density_contribution_distant_atoms_short_circuit(): + # Single atom 173 Å from origin; 5 sigma cutoff at default sigma=3 is 15 Å. + surf = _bare_surface(atom_coords=np.array([[100.0, 100.0, 100.0]])) + result = surf.density_contribution(np.zeros((4, 3))) + np.testing.assert_array_equal(result, np.zeros(4)) + + +def test_density_contribution_peaks_at_atom_position(): + sigma = 3.0 + surf = _bare_surface( + atom_coords=np.array([[0.0, 0.0, 0.0]]), + density_sigma=sigma, + ) + samples = np.array([[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]]) + result = surf.density_contribution(samples) + peak = 1.0 / (2 * np.pi * sigma**2) ** 1.5 + assert result[0] == pytest.approx(peak) + # 10 Å lies inside the 15 Å default cutoff but is heavily Gaussian-suppressed. + expected_far = peak * np.exp(-(10.0**2) / (2 * sigma**2)) + assert result[1] == pytest.approx(expected_far) + + +def test_density_contribution_density_conversion_unused_in_contribution(): + # density_conversion is applied in analyze_lines, not in + # density_contribution itself: setting it must not change this raw + # output, which equals the bare Gaussian kernel at the sample. + sigma = 3.0 + common = dict( + atom_coords=np.array([[0.0, 0.0, 0.0]]), + density_sigma=sigma, + ) + samples = np.array([[1.0, 0.0, 0.0]]) + expected = (1.0 / (2 * np.pi * sigma**2) ** 1.5) * np.exp(-1.0 / (2 * sigma**2)) + baseline = _bare_surface(density_conversion=1.0, **common).density_contribution( + samples + ) + scaled = _bare_surface(density_conversion=12.5, **common).density_contribution( + samples + ) + assert baseline[0] == pytest.approx(expected) + np.testing.assert_allclose(scaled, baseline) + + +# --- _fit_density_profiles_batched (Gauss-Newton tanh fit) --------------- + + +def test_fit_density_profiles_batched_recovers_known_zd(): + surf = _bare_surface(max_dist=30.0) + z = np.linspace(0.0, 30.0, 80) + true_zd = np.array([10.0, 15.0, 22.0]) + d, h = 0.6, 0.2 + densities = np.stack([d * np.tanh(zd - z) + h for zd in true_zd]) + fitted = surf._fit_density_profiles_batched(z, densities) + np.testing.assert_allclose(fitted, true_zd, atol=1e-3) + + +def test_fit_density_profiles_batched_constant_input_falls_back_to_zero(): + # Constant density: rho_max==rho_min so d0=0 and the data midpoint + # crossing zd0=z[argmin(0)]=z[0]=0. The first GN iteration then has a + # singular normal matrix (j_zd = d*(1-u^2) = 0), the solver breaks, + # and the final clip returns the seed value 0.0 exactly. + surf = _bare_surface(max_dist=20.0) + z = np.linspace(0.0, 20.0, 40) + densities = np.full((2, 40), 0.5) + fitted = surf._fit_density_profiles_batched(z, densities) + np.testing.assert_array_equal(fitted, np.zeros(2)) + + +# --- analyze_lines (end-to-end on a synthetic 2D droplet) ---------------- + + +def _disk_atoms_in_xz(radius: float, n_atoms: int, seed: int) -> np.ndarray: + """Uniform 2D disk of atoms in the y=0 slice plane.""" + rng = np.random.default_rng(seed) + r = radius * np.sqrt(rng.uniform(0.0, 1.0, n_atoms)) + theta = rng.uniform(0.0, 2 * np.pi, n_atoms) + return np.column_stack([r * np.cos(theta), np.zeros(n_atoms), r * np.sin(theta)]) + + +def test_analyze_lines_recovers_disk_radius(): + radius = 15.0 + atoms = _disk_atoms_in_xz(radius, n_atoms=4000, seed=42) + surf = SurfaceDefinition( + atom_coords=atoms, + delta_angle=30.0, + max_dist=25.0, + center_geom=np.zeros(3), + gamma=0.0, + points_per_angstrom=2.0, + ) + rr, xz = surf.analyze_lines() + n_rays = int(360 / 30) + assert len(rr) == n_rays + assert len(xz) == n_rays + assert all(len(row) == 2 for row in rr) + assert all(len(row) == 2 for row in xz) + # The fit pulls the apparent interface ~0.5 Å inside the geometric + # boundary because the model uses a fixed-width tanh while the data + # is a Gaussian-smoothed (sigma=3) step; the mismatch biases zd + # toward the liquid side. Per-ray scatter from finite atom count is + # ~0.3 Å on top of that. + interface_distances = np.array([row[0] for row in rr]) + assert np.max(np.abs(interface_distances - radius)) < 1.0 + assert abs(interface_distances.mean() - radius) < 0.7 + + +def test_analyze_lines_returns_consistent_xz_projection(): + center = np.array([5.0, 0.0, -2.0]) + atoms = _disk_atoms_in_xz(radius=10.0, n_atoms=2000, seed=0) + center + surf = SurfaceDefinition( + atom_coords=atoms, + delta_angle=60.0, + max_dist=20.0, + center_geom=center, + gamma=0.0, + points_per_angstrom=2.0, + ) + rr, xz = surf.analyze_lines() + # Projection contract: xz[i] = center + interface_re * (cos(beta), 0, sin(beta)). + for (re, beta), (x_proj, z_proj) in zip(rr, xz, strict=True): + beta_rad = np.deg2rad(beta) + assert x_proj == pytest.approx(np.cos(beta_rad) * re + center[0]) + assert z_proj == pytest.approx(np.sin(beta_rad) * re + center[2]) + + +def test_analyze_lines_ray_count_matches_delta_angle(): + surf = _bare_surface( + atom_coords=_disk_atoms_in_xz(radius=8.0, n_atoms=500, seed=1), + delta_angle=45.0, + max_dist=15.0, + ) + rr, xz = surf.analyze_lines() + assert len(rr) == 8 + assert len(xz) == 8 + # Each ray records its own azimuth angle in degrees, evenly spaced. + betas = [row[1] for row in rr] + np.testing.assert_allclose(betas, np.arange(0.0, 360.0, 45.0)) diff --git a/tests/test_edge_cases.py b/tests/test_edge_cases.py index 90d7ebf..35e2b7b 100644 --- a/tests/test_edge_cases.py +++ b/tests/test_edge_cases.py @@ -7,7 +7,7 @@ HyperbolicTangentModel, ) from wetting_angle_kit.analysis.slicing.angle_fitting import ( - ContactAngleSlicing, + SlicingFrameFitter, ) # --- Invalid droplet_geometry should be rejected by both analyzers --- @@ -16,7 +16,7 @@ def test_contact_angle_slicing_rejects_invalid_geometry(): coords = np.array([[0.0, 0.0, 0.0]]) with pytest.raises(ValueError, match="Unknown droplet_geometry"): - ContactAngleSlicing( + SlicingFrameFitter( liquid_coordinates=coords, max_dist=10, liquid_geom_center=np.zeros(3), @@ -33,7 +33,7 @@ def test_predict_contact_angle_returns_aligned_lists(): length. This guards against the historical bug where median_idx into angles would address a different slice in popt_arrays/surfaces.""" coords = np.array([[0.0, 0.0, 10.0]]) # single atom = no tanh interface - predictor = ContactAngleSlicing( + predictor = SlicingFrameFitter( liquid_coordinates=coords, max_dist=10, liquid_geom_center=np.zeros(3), @@ -47,7 +47,7 @@ def test_predict_contact_angle_returns_aligned_lists(): def test_contact_angle_slicing_copies_geometric_center(): """Constructor must not retain a reference to the caller's array.""" center = np.array([1.0, 2.0, 3.0]) - predictor = ContactAngleSlicing( + predictor = SlicingFrameFitter( liquid_coordinates=np.zeros((1, 3)), max_dist=10, liquid_geom_center=center, @@ -59,12 +59,12 @@ def test_contact_angle_slicing_copies_geometric_center(): np.testing.assert_array_equal(center, np.array([1.0, 2.0, 3.0])) -# --- Cylindrical mode without delta_cylinder/width_cylinder warns --- +# --- Cylindrical mode without delta_cylinder raises --- -def test_slicing_cylinder_without_width_warns(): - with pytest.warns(UserWarning, match="width_cylinder and delta_cylinder"): - ContactAngleSlicing( +def test_slicing_cylinder_without_delta_cylinder_raises(): + with pytest.raises(ValueError, match="delta_cylinder"): + SlicingFrameFitter( liquid_coordinates=np.zeros((3, 3)), max_dist=10, liquid_geom_center=np.zeros(3), @@ -74,7 +74,7 @@ def test_slicing_cylinder_without_width_warns(): def test_slicing_spherical_requires_delta_gamma(): with pytest.raises(ValueError, match="delta_gamma must be provided"): - ContactAngleSlicing( + SlicingFrameFitter( liquid_coordinates=np.zeros((3, 3)), max_dist=10, liquid_geom_center=np.zeros(3), @@ -117,27 +117,13 @@ def test_hyperbolic_tangent_compute_isoline_raises_for_unphysical_fit(): model.compute_isoline() -# --- Factory rejects unknown methods --- +# --- BinningBatchFitter.get_profile_coordinates --- -def test_contact_angle_analyzer_factory_rejects_unknown_method(tmp_path): - from wetting_angle_kit.analysis import contact_angle_analyzer +def _make_binning_analyzer(parser): + from wetting_angle_kit.analysis.binning import BinningBatchFitter - with pytest.raises(ValueError, match="Unknown method"): - contact_angle_analyzer( - method="not-a-method", - parser=object(), - output_dir=str(tmp_path), - ) - - -# --- ContactAngleBinning.get_profile_coordinates --- - - -def _make_binning_analyzer(parser, tmp_path): - from wetting_angle_kit.analysis.binning import ContactAngleBinning - - return ContactAngleBinning( + return BinningBatchFitter( parser=parser, atom_indices=None, droplet_geometry="spherical", @@ -149,44 +135,64 @@ def _make_binning_analyzer(parser, tmp_path): "zi_f": 10.0, "nbins_zi": 5, }, - output_dir=str(tmp_path), - plot_graphs=False, ) -def test_binning_get_profile_coordinates_empty_frame_list(tmp_path): +class _BoxedStubParser: + """Helper that supplies the abstract box-size methods of ``BaseParser``. + + Subclasses only need to set ``frames`` (a list of ``(N, 3)`` arrays) and + use the defaults below for a 100x100x100 orthogonal cell. + """ + + box: tuple[float, float, float] = (100.0, 100.0, 100.0) + + def box_size_x(self, frame_index): + return self.box[0] + + def box_size_y(self, frame_index): + return self.box[1] + + def box_length_max(self, frame_index): + return max(self.box) + + +def test_binning_get_profile_coordinates_empty_frame_list(): """Empty frame_indices must return empty arrays and zero frames.""" from wetting_angle_kit.parsers.base import BaseParser - class _StubParser(BaseParser): + class _StubParser(_BoxedStubParser, BaseParser): def parse(self, frame_index, indices=None): return np.zeros((0, 3)) def frame_count(self): return 0 - analyzer = _make_binning_analyzer(_StubParser(), tmp_path) + analyzer = _make_binning_analyzer(_StubParser()) r, z, n = analyzer.get_profile_coordinates(frame_indices=[]) assert r.shape == (0,) assert z.shape == (0,) assert n == 0 -def test_binning_get_profile_coordinates_concatenates_frames(tmp_path): +def test_binning_get_profile_coordinates_concatenates_frames(): """r and z arrays are concatenated across requested frames; z stays in lab frame.""" from wetting_angle_kit.parsers.base import BaseParser frame0 = np.array([[1.0, 0.0, 5.0], [-1.0, 0.0, 6.0], [0.0, 0.0, 7.0]]) frame1 = np.array([[2.0, 0.0, 8.0], [-2.0, 0.0, 9.0], [0.0, 0.0, 10.0]]) - class _StubParser(BaseParser): + class _StubParser(_BoxedStubParser, BaseParser): + # A large box so the per-frame circular mean coincides with the + # arithmetic mean and the asserted radii do not depend on PBC + # wrapping. def parse(self, frame_index, indices=None): return [frame0, frame1][frame_index] def frame_count(self): return 2 - analyzer = _make_binning_analyzer(_StubParser(), tmp_path) + analyzer = _make_binning_analyzer(_StubParser()) r, z, n = analyzer.get_profile_coordinates(frame_indices=[0, 1]) assert n == 2 # Spherical r is non-negative and the per-frame center-of-mass projection @@ -196,51 +202,35 @@ def frame_count(self): np.testing.assert_array_equal(z, np.array([5.0, 6.0, 7.0, 8.0, 9.0, 10.0])) -def test_binning_warns_and_falls_back_when_parser_has_no_box(tmp_path): - """Parsers that don't expose box_size_x/y (plain XYZ without a Lattice= - line, custom stubs) must trigger the fallback warning and still produce - results via the legacy arithmetic-mean centering.""" +def test_binning_precentered_skips_box_probe(): + """``precentered=True`` must bypass the box probe entirely so the + box-size accessors are never invoked, even by a parser that would raise + if asked for box info.""" + from wetting_angle_kit.analysis.binning import BinningBatchFitter from wetting_angle_kit.parsers.base import BaseParser frame = np.array([[1.0, 0.0, 5.0], [-1.0, 0.0, 6.0], [0.0, 0.0, 7.0]]) - class _StubParser(BaseParser): + class _NoBoxParser(BaseParser): def parse(self, frame_index, indices=None): return frame def frame_count(self): return 1 - # box_size_x / box_size_y inherited from BaseParser raise NotImplementedError. - - analyzer = _make_binning_analyzer(_StubParser(), tmp_path) - with pytest.warns(UserWarning, match="does not expose lateral box sizes"): - r, z, n = analyzer.get_profile_coordinates(frame_indices=[0]) - assert n == 1 - np.testing.assert_allclose(r, np.array([1.0, 1.0, 0.0])) - np.testing.assert_array_equal(z, np.array([5.0, 6.0, 7.0])) - - -def test_binning_precentered_skips_box_probe_and_warning(tmp_path): - """precentered=True must bypass the box probe entirely so a parser - that lacks box_size_x/y is accepted silently, no warning is issued, - and the result matches the legacy arithmetic-mean path.""" - import warnings - - from wetting_angle_kit.analysis.binning import ContactAngleBinning - from wetting_angle_kit.parsers.base import BaseParser - - frame = np.array([[1.0, 0.0, 5.0], [-1.0, 0.0, 6.0], [0.0, 0.0, 7.0]]) + def box_size_x(self, frame_index): + raise AssertionError("box_size_x must not be called when precentered=True") - class _StubParser(BaseParser): - def parse(self, frame_index, indices=None): - return frame + def box_size_y(self, frame_index): + raise AssertionError("box_size_y must not be called when precentered=True") - def frame_count(self): - return 1 + def box_length_max(self, frame_index): + raise AssertionError( + "box_length_max must not be called when precentered=True" + ) - analyzer = ContactAngleBinning( - parser=_StubParser(), + analyzer = BinningBatchFitter( + parser=_NoBoxParser(), atom_indices=None, droplet_geometry="spherical", binning_params={ @@ -251,40 +241,8 @@ def frame_count(self): "zi_f": 10.0, "nbins_zi": 5, }, - output_dir=str(tmp_path), - plot_graphs=False, precentered=True, ) - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - r, z, n = analyzer.get_profile_coordinates(frame_indices=[0]) + r, z, n = analyzer.get_profile_coordinates(frame_indices=[0]) assert n == 1 np.testing.assert_allclose(r, np.array([1.0, 1.0, 0.0])) - - -def test_binning_no_warning_when_parser_exposes_box(tmp_path): - """The fallback warning must NOT fire when the parser exposes box info; - otherwise it would spam every real run.""" - import warnings - - from wetting_angle_kit.parsers.base import BaseParser - - frame = np.array([[1.0, 0.0, 5.0], [-1.0, 0.0, 6.0], [0.0, 0.0, 7.0]]) - - class _StubParserWithBox(BaseParser): - def parse(self, frame_index, indices=None): - return frame - - def frame_count(self): - return 1 - - def box_size_x(self, frame_index): - return 100.0 - - def box_size_y(self, frame_index): - return 100.0 - - analyzer = _make_binning_analyzer(_StubParserWithBox(), tmp_path) - with warnings.catch_warnings(): - warnings.simplefilter("error", UserWarning) - analyzer.get_profile_coordinates(frame_indices=[0]) diff --git a/tests/test_io_utils.py b/tests/test_io_utils.py index 6ef4101..5c7e7ba 100644 --- a/tests/test_io_utils.py +++ b/tests/test_io_utils.py @@ -1,8 +1,6 @@ """Unit tests for :mod:`wetting_angle_kit.io_utils`.""" import os -import sys -from unittest import mock import numpy as np import pytest @@ -11,9 +9,7 @@ VALID_DROPLET_GEOMETRIES, assert_orthogonal_cell, detect_parser_type, - geometric_center, recenter_droplet_pbc, - save_array_as_txt, validate_droplet_geometry, ) @@ -41,33 +37,6 @@ def test_detect_parser_type_rejects_unknown(filename): detect_parser_type(filename) -# --- geometric_center --- - - -def test_geometric_center_simple(): - points = np.array([[0.0, 0.0, 0.0], [2.0, 4.0, 6.0]]) - center = geometric_center(points) - np.testing.assert_array_equal(center, np.array([1.0, 2.0, 3.0])) - - -def test_geometric_center_single_point(): - points = np.array([[1.5, -2.0, 3.7]]) - center = geometric_center(points) - np.testing.assert_array_equal(center, np.array([1.5, -2.0, 3.7])) - - -# --- save_array_as_txt --- - - -def test_save_array_as_txt_roundtrip(tmp_path): - target = tmp_path / "values.txt" - data = np.array([[1.0, 2.0], [3.5, 4.25]]) - save_array_as_txt(data, str(target)) - assert target.exists() - loaded = np.loadtxt(target) - np.testing.assert_allclose(loaded, data) - - # --- validate_droplet_geometry --- @@ -83,19 +52,6 @@ def test_validate_droplet_geometry_rejects_invalid(bad): validate_droplet_geometry(bad) -# --- load_dump_ovito (only test the ImportError path; calling it for real -# requires ovito and a trajectory, which the other test modules cover) --- - - -def test_load_dump_ovito_raises_when_ovito_missing(): - from wetting_angle_kit import io_utils - - # Block ovito imports for the duration of this test. - with mock.patch.dict(sys.modules, {"ovito": None, "ovito.io": None}): - with pytest.raises(ImportError, match="ovito"): - io_utils.load_dump_ovito("/nonexistent.lammpstrj") - - def test_valid_droplet_geometries_constant_is_a_tuple(): # Constant should be a frozen tuple-like sequence so callers cannot # mutate the package-level whitelist accidentally. diff --git a/tests/test_parser/test_parser_dump.py b/tests/test_parser/test_parser_dump.py index dc6fbdc..3be2a8e 100644 --- a/tests/test_parser/test_parser_dump.py +++ b/tests/test_parser/test_parser_dump.py @@ -4,7 +4,11 @@ import numpy as np import pytest -from wetting_angle_kit.parsers.lammps_dump import LammpsDumpParser +# LAMMPS dump parsing goes through OVITO; skip the whole module when the +# optional dependency is unavailable (typically on macOS CI). +pytest.importorskip("ovito") + +from wetting_angle_kit.parsers.lammps_dump import LammpsDumpParser # noqa: E402 # Path to the test trajectory file (LAMMPS dump format) TRAJECTORY_PATH = os.path.join( @@ -75,13 +79,6 @@ def test_frame_count(dump_parser): assert total_frames > 0 -# --- frame_tot is a deprecated alias for frame_count --- -def test_frame_tot_emits_deprecation_warning(dump_parser): - with pytest.warns(DeprecationWarning, match="frame_tot is deprecated"): - total = dump_parser.frame_tot() - assert total == dump_parser.frame_count() - - # --- Test non-orthogonal cell rejection --- def _write_triclinic_dump(path): path.write_text( diff --git a/tests/test_parser/test_parser_factory.py b/tests/test_parser/test_parser_factory.py index fa74aba..e751cbc 100644 --- a/tests/test_parser/test_parser_factory.py +++ b/tests/test_parser/test_parser_factory.py @@ -11,7 +11,6 @@ def test_get_water_finder_lammpstrj(): pytest.importorskip("ovito") finder = get_water_finder( trajectory_path("traj_spherical_drop_4k.lammpstrj"), - particle_type_wall={1}, oxygen_type=3, hydrogen_type=2, ) @@ -21,7 +20,6 @@ def test_get_water_finder_lammpstrj(): def test_get_water_finder_ase_traj(): finder = get_water_finder( trajectory_path("slice_10_mace_mlips_cylindrical_2_5.traj"), - particle_type_wall=["C"], oxygen_type="O", hydrogen_type="H", ) @@ -31,7 +29,6 @@ def test_get_water_finder_ase_traj(): def test_get_water_finder_xyz(): finder = get_water_finder( trajectory_path("slice_10_mace_mlips_cylindrical_2_5.xyz"), - particle_type_wall=["C"], oxygen_type="O", hydrogen_type="H", ) @@ -42,7 +39,6 @@ def test_get_water_finder_unsupported_extension(): with pytest.raises(ValueError, match="Unsupported file format"): get_water_finder( "/tmp/does_not_matter.pdb", - particle_type_wall=set(), oxygen_type="O", hydrogen_type="H", ) diff --git a/tests/test_parser/test_water_finders.py b/tests/test_parser/test_water_finders.py index c772be7..43d2428 100644 --- a/tests/test_parser/test_water_finders.py +++ b/tests/test_parser/test_water_finders.py @@ -43,16 +43,14 @@ def water_xyz(tmp_path): def test_xyz_water_finder_identifies_oxygens(water_xyz): - finder = XYZWaterFinder( - water_xyz, particle_type_wall=["C"], oxygen_type="O", hydrogen_type="H" - ) + finder = XYZWaterFinder(water_xyz, oxygen_type="O", hydrogen_type="H") indices = finder.get_water_oxygen_indices(0) # Two oxygens are at indices 0 and 3 in the input order. assert sorted(indices.tolist()) == [0, 3] def test_xyz_water_finder_positions(water_xyz): - finder = XYZWaterFinder(water_xyz, particle_type_wall=["C"]) + finder = XYZWaterFinder(water_xyz) positions = finder.get_water_oxygen_positions(0) assert positions.shape == (2, 3) # Centers of the two waters. @@ -66,27 +64,27 @@ def test_xyz_water_finder_no_water_returns_empty(tmp_path): '1\nLattice="10.0 0.0 0.0 0.0 10.0 0.0 0.0 0.0 10.0" ' "Properties=species:S:1:pos:R:3\nO 0.0 0.0 0.0\n" ) - finder = XYZWaterFinder(str(p), particle_type_wall=["C"]) + finder = XYZWaterFinder(str(p)) positions = finder.get_water_oxygen_positions(0) assert positions.shape == (0, 3) def test_xyz_water_finder_parse_filters_liquid(water_xyz): - finder = XYZWaterFinder(water_xyz, particle_type_wall=["C"]) + finder = XYZWaterFinder(water_xyz) positions = finder.parse(["O", "H"], 0) # Six liquid atoms (2 O + 4 H), wall (C) excluded. assert positions.shape == (6, 3) def test_xyz_water_finder_box_length_max(water_xyz): - finder = XYZWaterFinder(water_xyz, particle_type_wall=["C"]) + finder = XYZWaterFinder(water_xyz) assert finder.box_length_max(0) == pytest.approx(20.0) def test_xyz_water_finder_box_length_max_without_lattice_raises(tmp_path): p = tmp_path / "no_lattice.xyz" _write_water_xyz(p, with_lattice=False) - finder = XYZWaterFinder(str(p), particle_type_wall=["C"]) + finder = XYZWaterFinder(str(p)) with pytest.raises(ValueError, match="No Lattice="): finder.box_length_max(0) @@ -117,7 +115,6 @@ def water_extxyz(tmp_path): def test_ase_water_finder_identifies_oxygens(water_extxyz): finder = AseWaterFinder( water_extxyz, - particle_type_wall=["C"], oxygen_type="O", hydrogen_type="H", ) @@ -126,7 +123,7 @@ def test_ase_water_finder_identifies_oxygens(water_extxyz): def test_ase_water_finder_positions(water_extxyz): - finder = AseWaterFinder(water_extxyz, particle_type_wall=["C"]) + finder = AseWaterFinder(water_extxyz) positions = finder.get_water_oxygen_positions(0) assert positions.shape == (2, 3) diff --git a/tests/test_visualization/test_droplet_slice_plot.py b/tests/test_visualization/test_droplet_slice_plot.py new file mode 100644 index 0000000..c185548 --- /dev/null +++ b/tests/test_visualization/test_droplet_slice_plot.py @@ -0,0 +1,93 @@ +"""Smoke tests for the plotly droplet-slice plotter.""" + +import numpy as np +import plotly.graph_objects as go + +from wetting_angle_kit.visualization import DropletSlicePlotter + + +def _synthetic_droplet(seed=0): + rng = np.random.default_rng(seed) + theta = rng.uniform(0, np.pi, 400) + r = rng.uniform(0.0, 15.0, 400) + x = r * np.cos(theta) + 50.0 + z = r * np.sin(theta) + 10.0 + y = rng.uniform(0.0, 20.0, 400) + oxygen = np.column_stack([x, y, z]) + + wx = rng.uniform(20.0, 80.0, 150) + wy = rng.uniform(0.0, 20.0, 150) + wz = np.zeros(150) + wall = np.column_stack([wx, wy, wz]) + + arc = np.linspace(0, np.pi, 60) + surface = np.column_stack([50.0 + 14.0 * np.cos(arc), 10.0 + 14.0 * np.sin(arc)]) + return oxygen, wall, [surface], np.array([50.0, 10.0, 14.0, 0.0]) + + +# --- DropletSlicePlotter (plotly) --- + + +def test_droplet_slice_plotter_returns_figure(): + """Default code path builds a plotly figure with the expected layers.""" + oxygen, wall, surface_data, popt = _synthetic_droplet() + plotter = DropletSlicePlotter(center=False) + fig = plotter.plot_surface_points( + oxygen_position=oxygen, + surface_data=surface_data, + popt=popt, + wall_coords=wall, + alpha=90.0, + ) + assert isinstance(fig, go.Figure) + # At least the wall, water, surface, circle, tangent, and arc traces. + assert len(fig.data) >= 5 + + +def test_droplet_slice_plotter_center_path(): + """center=True triggers the recentering branch.""" + oxygen, wall, surface_data, popt = _synthetic_droplet() + plotter = DropletSlicePlotter(center=True) + fig = plotter.plot_surface_points( + oxygen_position=oxygen, + surface_data=surface_data, + popt=popt, + wall_coords=wall, + alpha=85.0, + ) + assert isinstance(fig, go.Figure) + assert len(fig.data) >= 5 + + +def test_droplet_slice_plotter_with_pbc_y(): + """pbc_y wrapping branch.""" + oxygen, wall, surface_data, popt = _synthetic_droplet() + plotter = DropletSlicePlotter(center=False) + fig = plotter.plot_surface_points( + oxygen_position=oxygen, + surface_data=surface_data, + popt=popt, + wall_coords=wall, + alpha=85.0, + pbc_y=20.0, + ) + assert len(fig.data) >= 3 + + +def test_droplet_slice_plotter_layers_can_be_disabled(): + """All show_* flags off → figure has zero data traces.""" + oxygen, wall, surface_data, popt = _synthetic_droplet() + plotter = DropletSlicePlotter(center=False) + fig = plotter.plot_surface_points( + oxygen_position=oxygen, + surface_data=surface_data, + popt=popt, + wall_coords=wall, + alpha=None, + show_water=False, + show_surface=False, + show_circle=False, + show_tangent=False, + show_wall=False, + ) + assert len(fig.data) == 0 diff --git a/tests/test_visualization/test_droplet_slice_plots.py b/tests/test_visualization/test_droplet_slice_plots.py deleted file mode 100644 index 43989c7..0000000 --- a/tests/test_visualization/test_droplet_slice_plots.py +++ /dev/null @@ -1,223 +0,0 @@ -"""End-to-end and branch tests for droplet_slice_plots.py. - -Smoke tests confirm the default code paths produce PNG/figure outputs; -the branch tests exercise center=True, molecule_view=True, pbc_y wrapping, -the no-intersection early return, and the ContactAngleAnimator. -""" - -import matplotlib - -matplotlib.use("Agg", force=False) - -import matplotlib.pyplot as plt -import numpy as np -import plotly.graph_objects as go -import pytest - -from tests.conftest import trajectory_path -from wetting_angle_kit.visualization.droplet_slice_plots import ( - ContactAngleAnimator, - DropletSlicePlotlyPlotter, - DropletSlicePlotter, -) - - -def _synthetic_droplet(seed=0): - rng = np.random.default_rng(seed) - theta = rng.uniform(0, np.pi, 400) - r = rng.uniform(0.0, 15.0, 400) - x = r * np.cos(theta) + 50.0 - z = r * np.sin(theta) + 10.0 - y = rng.uniform(0.0, 20.0, 400) - oxygen = np.column_stack([x, y, z]) - - wx = rng.uniform(20.0, 80.0, 150) - wy = rng.uniform(0.0, 20.0, 150) - wz = np.zeros(150) - wall = np.column_stack([wx, wy, wz]) - - arc = np.linspace(0, np.pi, 60) - surface = np.column_stack([50.0 + 14.0 * np.cos(arc), 10.0 + 14.0 * np.sin(arc)]) - return oxygen, wall, [surface], np.array([50.0, 10.0, 14.0, 0.0]) - - -def test_droplet_slicing_plotter_writes_png(tmp_path): - oxygen, wall, surface_data, popt = _synthetic_droplet() - output = tmp_path / "droplet.png" - plotter = DropletSlicePlotter(center=False, show_wall=True, molecule_view=False) - plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=surface_data, - popt=popt, - wall_coords=wall, - output_filename=str(output), - alpha=90.0, - ) - assert output.exists() - assert output.stat().st_size > 0 - plt.close("all") - - -def test_droplet_slicing_plotter_plotly_returns_figure(): - """The Plotly version should build a figure with the requested layers.""" - oxygen, wall, surface_data, popt = _synthetic_droplet() - plotter = DropletSlicePlotlyPlotter(center=False) - fig = plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=surface_data, - popt=popt, - wall_coords=wall, - alpha=90.0, - ) - assert isinstance(fig, go.Figure) - # At least the wall, water, surface, circle, tangent, and arc traces. - assert len(fig.data) >= 5 - - -def test_droplet_slicing_plotter_center_and_molecule_view(tmp_path): - """center=True + molecule_view=True exercises the recentering and - water-molecule branches.""" - oxygen, wall, surface_data, popt = _synthetic_droplet() - output = tmp_path / "centered_molview.png" - plotter = DropletSlicePlotter(center=True, show_wall=True, molecule_view=True) - plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=surface_data, - popt=popt, - wall_coords=wall, - output_filename=str(output), - alpha=90.0, - ) - assert output.exists() and output.stat().st_size > 0 - - -def test_droplet_slicing_plotter_with_pbc_y(tmp_path): - """pbc_y wrapping branch.""" - oxygen, wall, surface_data, popt = _synthetic_droplet() - output = tmp_path / "pbc.png" - plotter = DropletSlicePlotter(center=False, show_wall=True, molecule_view=False) - plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=surface_data, - popt=popt, - wall_coords=wall, - output_filename=str(output), - alpha=85.0, - pbc_y=20.0, - ) - assert output.exists() - - -def test_droplet_slicing_plotter_no_intersection_returns_early(tmp_path): - """When discriminant <= 0 the function should close the figure and bail out.""" - oxygen, wall, _, _ = _synthetic_droplet() - # Surface arc placed high above the wall so z_baseline = min(surf z) is far - # from z_center → discriminant = radius² - delta_z² < 0. - arc = np.linspace(0, np.pi, 60) - high_surface = np.column_stack( - [50.0 + 14.0 * np.cos(arc), 100.0 + 14.0 * np.sin(arc)] - ) - popt = np.array([50.0, 10.0, 0.5, 0.0]) # tiny radius, center far below surface - output = tmp_path / "no_intersect.png" - plotter = DropletSlicePlotter(center=False, show_wall=True, molecule_view=False) - plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=[high_surface], - popt=popt, - wall_coords=wall, - output_filename=str(output), - alpha=120.0, - ) - # The function bails out before saving — the file should not exist. - assert not output.exists() - - -def test_plotly_plotter_center_path(): - """DropletSlicePlotlyPlotter with center=True triggers the recentering branch.""" - oxygen, wall, surface_data, popt = _synthetic_droplet() - plotter = DropletSlicePlotlyPlotter(center=True) - fig = plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=surface_data, - popt=popt, - wall_coords=wall, - alpha=85.0, - ) - assert fig is not None - assert len(fig.data) >= 5 - - -def test_plotly_plotter_with_pbc_y(): - oxygen, wall, surface_data, popt = _synthetic_droplet() - plotter = DropletSlicePlotlyPlotter(center=False) - fig = plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=surface_data, - popt=popt, - wall_coords=wall, - alpha=85.0, - pbc_y=20.0, - ) - assert len(fig.data) >= 3 - - -def test_plotly_plotter_layers_can_be_disabled(): - """All show_* flags off → figure has zero data traces.""" - oxygen, wall, surface_data, popt = _synthetic_droplet() - plotter = DropletSlicePlotlyPlotter(center=False) - fig = plotter.plot_surface_points( - oxygen_position=oxygen, - surface_data=surface_data, - popt=popt, - wall_coords=wall, - alpha=None, - show_water=False, - show_surface=False, - show_circle=False, - show_tangent=False, - show_wall=False, - ) - assert len(fig.data) == 0 - - -def test_contact_angle_animator_init_loads_fixture(tmp_path): - """ContactAngleAnimator.__init__ wires up parsers and finders for a real fixture.""" - pytest.importorskip("ovito") - animator = ContactAngleAnimator( - filename=trajectory_path("traj_spherical_drop_4k.lammpstrj"), - particle_type_wall={3}, - oxygen_type=1, - hydrogen_type=2, - liquid_particle_types={1, 2}, - n_frames=1, - droplet_geometry="cylinder_y", - delta_cylinder=20, - max_dist=50, - width_cylinder=20, - ) - assert animator.wall_coords.shape[1] == 3 - assert animator.oxygen_indices.size > 0 - assert animator.parser is not None - assert animator.plotter is not None - - -@pytest.mark.slow -def test_contact_angle_animator_generates_html(tmp_path): - """Smoke-test ContactAngleAnimator on the LAMMPS fixture via cylinder_y geometry.""" - pytest.importorskip("ovito") - output = tmp_path / "animation.html" - animator = ContactAngleAnimator( - filename=trajectory_path("traj_spherical_drop_4k.lammpstrj"), - particle_type_wall={3}, - oxygen_type=1, - hydrogen_type=2, - liquid_particle_types={1, 2}, - n_frames=1, - droplet_geometry="cylinder_y", - delta_cylinder=20, - max_dist=50, - width_cylinder=20, - ) - animator.generate_animation(output_filename=str(output)) - assert output.exists() - assert output.stat().st_size > 0 diff --git a/tests/test_visualization/test_method_comparison.py b/tests/test_visualization/test_method_comparison.py deleted file mode 100644 index 3fd3078..0000000 --- a/tests/test_visualization/test_method_comparison.py +++ /dev/null @@ -1,106 +0,0 @@ -import os - -import pytest - -from wetting_angle_kit.visualization.binning_trajectory_analyzer import ( - BinningTrajectoryAnalyzer, -) -from wetting_angle_kit.visualization.method_comparison import MethodComparison - - -def _write_binning_dir(directory, samples): - directory.mkdir() - for idx, (r_eq, zi_c, zi_0, angle) in enumerate(samples, start=1): - (directory / f"log_data_batch_{idx}.txt").write_text( - f"R_eq:{r_eq}\nzi_c:{zi_c}\nzi_0:{zi_0}\nContact angle:{angle}\n" - ) - - -@pytest.fixture -def two_binning_analyzers(tmp_path): - d1 = tmp_path / "result_dump_runA" - d2 = tmp_path / "result_dump_runB" - _write_binning_dir(d1, [(15.0, 8.0, 6.0, 95.0), (14.5, 7.8, 6.1, 96.5)]) - _write_binning_dir(d2, [(16.0, 8.2, 6.0, 92.0), (15.5, 8.0, 6.1, 93.5)]) - - analyzer1 = BinningTrajectoryAnalyzer([str(d1)]) - analyzer2 = BinningTrajectoryAnalyzer([str(d2)]) - analyzer1.load_data() - analyzer2.load_data() - # Generate output_stats.txt for both - analyzer1.analyze() - analyzer2.analyze() - return analyzer1, analyzer2 - - -def test_method_comparison_compare_statistics(two_binning_analyzers): - a1, a2 = two_binning_analyzers - comparator = MethodComparison([a1, a2], method_names=["Binning A", "Binning B"]) - report = comparator.compare_statistics() - assert "METHOD COMPARISON STATISTICS" in report - assert "Binning A" in report - assert "Binning B" in report - assert "Mean Angle" in report - assert "Std Angle" in report - - -def test_method_comparison_default_method_names(two_binning_analyzers): - a1, a2 = two_binning_analyzers - comparator = MethodComparison([a1, a2]) - assert comparator.method_names == ["Binning Analysis", "Binning Analysis"] - - -def test_method_comparison_side_by_side_plot(two_binning_analyzers, tmp_path): - a1, a2 = two_binning_analyzers - comparator = MethodComparison([a1, a2], method_names=["A", "B"]) - save_path = tmp_path / "side_by_side.png" - comparator.plot_side_by_side_comparison(save_path=str(save_path)) - assert save_path.exists() - - -def test_method_comparison_overlay_plot(two_binning_analyzers, tmp_path): - a1, a2 = two_binning_analyzers - comparator = MethodComparison([a1, a2], method_names=["A", "B"]) - save_path = tmp_path / "overlay.png" - comparator.plot_overlay_comparison(save_path=str(save_path)) - assert save_path.exists() - - -def test_method_comparison_side_by_side_single_analyzer( - two_binning_analyzers, tmp_path -): - # Cover the `len(self.analyzers) == 1` branch - a1, _ = two_binning_analyzers - comparator = MethodComparison([a1]) - save_path = tmp_path / "single.png" - comparator.plot_side_by_side_comparison(save_path=str(save_path)) - assert save_path.exists() - - -def test_method_comparison_check_and_run_raises_when_missing(tmp_path): - """_check_and_run_analysis raises if output_stats.txt is absent.""" - d = tmp_path / "result_dump_empty" - d.mkdir() - (d / "log_data_batch_1.txt").write_text( - "R_eq:15.0\nzi_c:8.0\nzi_0:6.0\nContact angle:90.0\n" - ) - analyzer = BinningTrajectoryAnalyzer([str(d)]) - analyzer.load_data() - # do not call analyze(), so output_stats.txt is missing - comparator = MethodComparison([analyzer]) - with pytest.raises(FileNotFoundError, match="No analysis found"): - comparator._check_and_run_analysis(analyzer) - - -def test_method_comparison_compare_statistics_falls_back_when_stats_missing(tmp_path): - """compare_statistics() recovers via in-memory data - if output_stats.txt is missing.""" - d = tmp_path / "result_dump_no_stats" - _write_binning_dir(d, [(15.0, 8.0, 6.0, 95.0), (14.5, 7.8, 6.1, 96.5)]) - analyzer = BinningTrajectoryAnalyzer([str(d)]) - analyzer.load_data() - # Remove output_stats.txt path is never created (analyze() not called) - assert not os.path.exists(os.path.join(str(d), "output_stats.txt")) - comparator = MethodComparison([analyzer]) - report = comparator.compare_statistics() - assert "Mean Angle" in report diff --git a/tests/test_visualization/test_surface_plots.py b/tests/test_visualization/test_surface_plots.py deleted file mode 100644 index 3759bf4..0000000 --- a/tests/test_visualization/test_surface_plots.py +++ /dev/null @@ -1,84 +0,0 @@ -import matplotlib - -matplotlib.use("Agg", force=False) - -import numpy as np - -from wetting_angle_kit.visualization.surface_plots import ( - plot_liquid_particles, - plot_slice, - plot_surface_and_points, - plot_surface_file, - read_surface_file, - visualize_surface_with_points, -) - - -def _write_surface(tmp_path, columns): - path = tmp_path / "surface.dat" - np.savetxt(path, columns) - return str(path) - - -def test_plot_surface_file_returns_xy(tmp_path): - data = np.array([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]]) - path = _write_surface(tmp_path, data) - x, y = plot_surface_file(path) - assert np.allclose(x, [1.0, 3.0, 5.0]) - assert np.allclose(y, [2.0, 4.0, 6.0]) - - -def test_read_surface_file_two_columns_pads_z(tmp_path): - data = np.array([[1.0, 2.0], [3.0, 4.0]]) - path = _write_surface(tmp_path, data) - x, y, z = read_surface_file(path) - assert np.allclose(x, [1.0, 3.0]) - assert np.allclose(y, [2.0, 4.0]) - assert np.allclose(z, [0.0, 0.0]) - - -def test_read_surface_file_three_columns(tmp_path): - data = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) - path = _write_surface(tmp_path, data) - x, y, z = read_surface_file(path) - assert np.allclose(x, [1.0, 4.0]) - assert np.allclose(y, [2.0, 5.0]) - assert np.allclose(z, [3.0, 6.0]) - - -def test_plot_slice_runs(): - plot_slice(np.array([0.0, 1.0, 2.0]), np.array([0.0, 1.0, 0.0])) - - -def test_plot_surface_and_points_runs(): - x = np.linspace(0, 1, 5) - plot_surface_and_points(x, x, x, x + 0.1, x + 0.2, x + 0.3) - - -def test_visualize_surface_with_points_runs(tmp_path): - data = np.array([[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]]) - path = _write_surface(tmp_path, data) - points = np.array([[0.5, 0.5, 0.5], [0.2, 0.2, 0.2]]) - visualize_surface_with_points(path, points) - - -def test_plot_liquid_particles_creates_axes(): - positions = np.random.default_rng(0).uniform(size=(50, 3)) - ax = plot_liquid_particles(positions) - assert ax is not None - - -def test_plot_liquid_particles_subsample(): - positions = np.random.default_rng(0).uniform(size=(100, 3)) - ax = plot_liquid_particles(positions, subsample=10) - assert ax is not None - - -def test_plot_liquid_particles_uses_given_ax(): - import matplotlib.pyplot as plt - - fig = plt.figure() - ax = fig.add_subplot(111, projection="3d") - positions = np.random.default_rng(0).uniform(size=(20, 3)) - returned = plot_liquid_particles(positions, ax=ax) - assert returned is ax diff --git a/tests/test_visualization/test_trajectory_analyzers.py b/tests/test_visualization/test_trajectory_analyzers.py deleted file mode 100644 index cfdfd5c..0000000 --- a/tests/test_visualization/test_trajectory_analyzers.py +++ /dev/null @@ -1,185 +0,0 @@ -import os -import pathlib - -import numpy as np -import pytest - -from wetting_angle_kit.visualization.binning_trajectory_analyzer import ( - BinningTrajectoryAnalyzer, -) -from wetting_angle_kit.visualization.slicing_trajectory_analyzer import ( - SlicingTrajectoryAnalyzer, -) - - -def _square_polygon(side: float = 2.0) -> np.ndarray: - half = side / 2.0 - return np.array( - [ - [-half, -half], - [half, -half], - [half, half], - [-half, half], - ] - ) - - -@pytest.fixture -def slicing_result_dir(tmp_path): - """Create a directory with the .npy files expected by SlicingTrajectoryAnalyzer.""" - directory = tmp_path / "slicing_result" - directory.mkdir() - polygon = _square_polygon(side=4.0) - all_angles = np.array( - [ - (0, np.array([85.0, 90.0, 95.0])), - (1, np.array([87.0, 92.0, 96.0])), - ], - dtype=object, - ) - all_surfaces = np.array( - [ - (0, [polygon, polygon * 1.1]), - (1, [polygon * 1.05, polygon * 1.15]), - ], - dtype=object, - ) - all_popts = np.array( - [ - (0, np.array([1.0, 2.0, 3.0, 4.0])), - (1, np.array([1.1, 2.1, 3.1, 4.1])), - ], - dtype=object, - ) - np.save(directory / "all_angles.npy", all_angles, allow_pickle=True) - np.save(directory / "all_surfaces.npy", all_surfaces, allow_pickle=True) - np.save(directory / "all_popts.npy", all_popts, allow_pickle=True) - return str(directory) - - -@pytest.fixture -def binning_result_dir(tmp_path): - """Create a directory with the log_data_batch_*.txt - expected by BinningTrajectoryAnalyzer.""" - directory = tmp_path / "binning_result" - directory.mkdir() - for idx, (r_eq, zi_c, zi_0, angle) in enumerate( - [(15.0, 8.0, 6.0, 95.0), (14.5, 7.8, 6.1, 96.5)], start=1 - ): - path = directory / f"log_data_batch_{idx}.txt" - path.write_text( - f"R_eq:{r_eq}\nzi_c:{zi_c}\nzi_0:{zi_0}\nContact angle:{angle}\n" - ) - return str(directory) - - -def test_slicing_trajectory_analyzer_load_and_stats(slicing_result_dir): - analyzer = SlicingTrajectoryAnalyzer( - [slicing_result_dir], time_steps={slicing_result_dir: 0.5} - ) - analyzer.load_data() - - surfaces = analyzer.get_surface_areas(slicing_result_dir) - angles = analyzer.get_contact_angles(slicing_result_dir) - assert surfaces.shape == (2,) - assert np.all(surfaces > 0) - assert angles.shape == (2,) - assert analyzer.get_method_name() == "Slicing Analysis" - - -def test_slicing_trajectory_analyzer_plot_evolution(slicing_result_dir, tmp_path): - analyzer = SlicingTrajectoryAnalyzer([slicing_result_dir]) - analyzer.load_data() - - median_path = tmp_path / "median.png" - mean_path = tmp_path / "mean.png" - analyzer.plot_median_angles_evolution(str(median_path)) - analyzer.plot_mean_angles_evolution(str(mean_path)) - - assert median_path.exists() - assert mean_path.exists() - - -def test_binning_trajectory_analyzer_load(binning_result_dir): - analyzer = BinningTrajectoryAnalyzer([binning_result_dir]) - analyzer.read_data() # alias for load_data - angles = analyzer.get_contact_angles(binning_result_dir) - surfaces = analyzer.get_surface_areas(binning_result_dir) - assert angles.shape == (2,) - assert np.allclose(angles, [95.0, 96.5]) - assert surfaces.shape == (2,) - assert np.all(surfaces > 0) - assert analyzer.get_method_name() == "Binning Analysis" - - -@pytest.mark.parametrize( - "R,z_center,z_cut,expected", - [ - (1.0, 0.0, 5.0, 0.0), # cap entirely above cut - (1.0, 0.0, -5.0, np.pi), # cap covers full disk (π·R²) - ], -) -def test_circular_segment_area_edge_cases(R, z_center, z_cut, expected): - area = BinningTrajectoryAnalyzer.circular_segment_area(R, z_center, z_cut) - assert area == pytest.approx(expected, rel=1e-6) - - -def test_circular_segment_area_partial(): - area = BinningTrajectoryAnalyzer.circular_segment_area(1.0, 0.0, 0.0) - # Cut at midplane → half disk area - assert area == pytest.approx(np.pi / 2, rel=1e-6) - - -def test_circular_segment_area_upper_half(): - # h > R but < 2R: between half and full disk - area = BinningTrajectoryAnalyzer.circular_segment_area(1.0, 0.0, -0.5) - full = np.pi - assert np.pi / 2 < area < full - - -def test_base_analyze_writes_output_stats(binning_result_dir): - analyzer = BinningTrajectoryAnalyzer([binning_result_dir]) - analyzer.analyze() - output_file = os.path.join(binning_result_dir, "output_stats.txt") - assert os.path.exists(output_file) - with open(output_file, encoding="utf-8") as f: - text = f.read() - assert "Mean Contact Angle:" in text - assert "Std Contact Angle:" in text - assert "Mean Surface Area:" in text - - -def test_base_compute_statistics_and_clean_label(binning_result_dir): - analyzer = BinningTrajectoryAnalyzer([binning_result_dir]) - analyzer.load_data() - x, y, yerr = analyzer.compute_statistics(binning_result_dir) - assert x > 0 - assert y == pytest.approx(np.mean([95.0, 96.5])) - assert yerr >= 0 - - label = analyzer.get_clean_label("result_dump_my_run_reduce_binning") - assert label == "my_run" - - -def test_base_plot_mean_angle_vs_surface(binning_result_dir, tmp_path): - # Two directories with the same fixture content so the linear-fit branch executes. - second = tmp_path / "binning_result_2" - second.mkdir() - for src in os.listdir(binning_result_dir): - (second / src).write_text( - (pathlib.Path(binning_result_dir) / src).read_text(encoding="utf-8"), - encoding="utf-8", - ) - - analyzer = BinningTrajectoryAnalyzer([binning_result_dir, str(second)]) - save_path = tmp_path / "scaling.png" - analyzer.plot_mean_angle_vs_surface(save_path=str(save_path)) - assert save_path.exists() - - -def test_binning_load_files_raises_on_empty(tmp_path): - empty = tmp_path / "empty" - empty.mkdir() - analyzer = BinningTrajectoryAnalyzer([str(empty)]) - with pytest.raises(ValueError, match="No log_data_batch"): - analyzer.load_files() diff --git a/tests/test_visualization/test_trajectory_plotters.py b/tests/test_visualization/test_trajectory_plotters.py new file mode 100644 index 0000000..03f3ea0 --- /dev/null +++ b/tests/test_visualization/test_trajectory_plotters.py @@ -0,0 +1,180 @@ +import numpy as np +import plotly.graph_objects as go +import pytest + +from wetting_angle_kit.analysis.binning.results import BinningBatch, BinningResults +from wetting_angle_kit.analysis.slicing.results import SlicingResults +from wetting_angle_kit.visualization.binning_trajectory_plotter import ( + BinningTrajectoryPlotter, +) +from wetting_angle_kit.visualization.slicing_trajectory_plotter import ( + SlicingTrajectoryPlotter, +) + + +def _square_polygon(side: float = 2.0) -> np.ndarray: + half = side / 2.0 + return np.array( + [ + [-half, -half], + [half, -half], + [half, half], + [-half, half], + ] + ) + + +@pytest.fixture +def slicing_results(): + polygon = _square_polygon(side=4.0) + return SlicingResults( + frames=[0, 1], + angles=[ + np.array([85.0, 90.0, 95.0]), + np.array([87.0, 92.0, 96.0]), + ], + surfaces=[ + [polygon, polygon * 1.1], + [polygon * 1.05, polygon * 1.15], + ], + popts=[ + np.array([1.0, 2.0, 3.0, 4.0]), + np.array([1.1, 2.1, 3.1, 4.1]), + ], + ) + + +@pytest.fixture +def binning_results(): + return BinningResults( + batches=[ + BinningBatch( + batch_index=1, + angle=95.0, + n_particles=100.0, + xi_cc=np.linspace(0.0, 10.0, 5), + zi_cc=np.linspace(0.0, 10.0, 5), + rho_cc=np.ones((5, 5)), + circle_xi=np.array([0.0, 1.0, 2.0]), + circle_zi=np.array([5.0, 6.0, 7.0]), + wall_line_xi=np.array([0.0, 1.0, 2.0]), + wall_line_zi=np.array([6.0, 6.0, 6.0]), + fitted_params={"R_eq": 15.0, "zi_c": 8.0, "zi_0": 6.0}, + ), + BinningBatch( + batch_index=2, + angle=96.5, + n_particles=110.0, + xi_cc=np.linspace(0.0, 10.0, 5), + zi_cc=np.linspace(0.0, 10.0, 5), + rho_cc=np.ones((5, 5)), + circle_xi=None, + circle_zi=None, + wall_line_xi=None, + wall_line_zi=None, + fitted_params={"R_eq": 14.5, "zi_c": 7.8, "zi_0": 6.1}, + ), + ] + ) + + +# --- SlicingTrajectoryPlotter --- + + +def test_slicing_plotter_summary(slicing_results): + plotter = SlicingTrajectoryPlotter(slicing_results, labels=["A"]) + [stats] = plotter.summary() + assert stats.method_name == "Slicing Analysis" + assert stats.label == "A" + assert stats.n_samples == 2 + # mean of per-frame means: mean([90.0, 91.667]) ≈ 90.83 + assert 80.0 < stats.mean_contact_angle < 100.0 + assert stats.mean_surface_area > 0 + + +def test_slicing_plotter_plot_angle_evolution_returns_figure(slicing_results): + plotter = SlicingTrajectoryPlotter(slicing_results, time_steps=[0.5]) + fig = plotter.plot_angle_evolution(stat="median") + assert isinstance(fig, go.Figure) + fig_mean = plotter.plot_angle_evolution(stat="mean") + assert isinstance(fig_mean, go.Figure) + + +def test_slicing_plotter_rejects_unknown_stat(slicing_results): + plotter = SlicingTrajectoryPlotter(slicing_results) + with pytest.raises(ValueError, match="stat must be"): + plotter.plot_angle_evolution(stat="bogus") + + +# --- BinningTrajectoryPlotter --- + + +def test_binning_plotter_summary(binning_results): + plotter = BinningTrajectoryPlotter(binning_results, labels=["A"]) + [stats] = plotter.summary() + assert stats.method_name == "Binning Analysis" + assert stats.label == "A" + assert stats.n_samples == 2 + assert stats.mean_contact_angle == pytest.approx(np.mean([95.0, 96.5])) + assert stats.std_contact_angle == pytest.approx(np.std([95.0, 96.5])) + assert stats.mean_surface_area > 0 + + +def test_binning_plotter_summary_str_block(binning_results): + plotter = BinningTrajectoryPlotter(binning_results) + [stats] = plotter.summary() + text = str(stats) + assert "Mean Contact Angle:" in text + assert "Std Contact Angle:" in text + assert "Mean Surface Area:" in text + + +def test_binning_plotter_plot_angle_evolution_returns_figure(binning_results): + plotter = BinningTrajectoryPlotter(binning_results, time_steps=[2.0]) + fig = plotter.plot_angle_evolution() + assert isinstance(fig, go.Figure) + + +def test_binning_plotter_density_contour_with_isoline(binning_results): + plotter = BinningTrajectoryPlotter(binning_results) + fig = plotter.plot_density_contour(batch_index=0) + assert isinstance(fig, go.Figure) + # contour + circle + wall = 3 traces + assert len(fig.data) == 3 + + +def test_binning_plotter_density_contour_without_isoline(binning_results): + plotter = BinningTrajectoryPlotter(binning_results) + # second batch has circle/wall = None + fig = plotter.plot_density_contour(batch_index=1) + assert isinstance(fig, go.Figure) + # only the contour trace when isoline is missing + assert len(fig.data) == 1 + + +# --- circular_segment_area static method --- + + +@pytest.mark.parametrize( + "R,z_center,z_cut,expected", + [ + (1.0, 0.0, 5.0, 0.0), # cap entirely above cut + (1.0, 0.0, -5.0, np.pi), # cap covers full disk (π·R²) + ], +) +def test_circular_segment_area_edge_cases(R, z_center, z_cut, expected): + area = BinningTrajectoryPlotter.circular_segment_area(R, z_center, z_cut) + assert area == pytest.approx(expected, rel=1e-6) + + +def test_circular_segment_area_partial(): + area = BinningTrajectoryPlotter.circular_segment_area(1.0, 0.0, 0.0) + # Cut at midplane → half disk area + assert area == pytest.approx(np.pi / 2, rel=1e-6) + + +def test_circular_segment_area_upper_half(): + # h > R but < 2R: between half and full disk + area = BinningTrajectoryPlotter.circular_segment_area(1.0, 0.0, -0.5) + full = np.pi + assert np.pi / 2 < area < full diff --git a/wetting_angle_kit_JOSS/mean_cos_angle_vs_surface_graphite.pdf b/wetting_angle_kit_JOSS/mean_cos_angle_vs_surface_graphite.pdf deleted file mode 100644 index 96b2914..0000000 Binary files a/wetting_angle_kit_JOSS/mean_cos_angle_vs_surface_graphite.pdf and /dev/null differ diff --git a/wetting_angle_kit_JOSS/mean_cos_angle_vs_surface_ptfe.pdf b/wetting_angle_kit_JOSS/mean_cos_angle_vs_surface_ptfe.pdf deleted file mode 100644 index 02708c4..0000000 Binary files a/wetting_angle_kit_JOSS/mean_cos_angle_vs_surface_ptfe.pdf and /dev/null differ diff --git a/wetting_angle_kit_JOSS/paper.md b/wetting_angle_kit_JOSS/paper.md index 8caba55..7d7c897 100644 --- a/wetting_angle_kit_JOSS/paper.md +++ b/wetting_angle_kit_JOSS/paper.md @@ -23,7 +23,7 @@ authors: affiliation: "2" - name: Gian-Marco Rignanese orcid: 0000-0002-1422-1205 - affiliation: "1 ,3" + affiliation: "1, 3" - name: David Waroquiers orcid: 0000-0001-8943-9762 affiliation: "1" @@ -49,8 +49,7 @@ bibliography: paper.bib Wetting-angle-kit is a Python toolkit designed to extract wettability properties, specifically the contact angle of a droplet on a surface, -from molecular dynamics (MD) simulations. -The software is designed for researchers working in MD simulation of interfaces +from molecular dynamics (MD) simulations of interfaces between liquids and solid surfaces. It supports a variety of standard file formats including extended XYZ, LAMMPS, @@ -63,19 +62,20 @@ reproducibility across different simulation setups. # Statement of need -Building upon foundational work ([@Hautman1997]), the methodologies for computating +Building upon foundational work [@Hautman1997], the methodologies for computing contact angles from MD simulations have progressed through several key milestones -[@Rafiee2012; @Vega2016; @Carlson2024].Despite these advancements, the field currently +[@Rafiee2012; @Vega2016; @Carlson2024]. +Despite these advancements, the field currently lacks a standardized, unified tool for comparing and validating the diverse methods used to derive contact angles. Such fragmentation undermines collaborative research and reproducibility, as many implementations remain inaccessible or poorly documented. In addition, the lack of a standardized framework makes it difficult to benchmark different approaches or assess the impact of methodological choices. -Wetting-angle-kit addresses this critical gap by providing a flexible, -open-source packa It enables the implementation of novel post-processing algorithms -for the extraction and calculation of contact angle, compare them against accepted techniques, -and establish a standardize benchmark for MD wettability analysis. +Wetting-angle-kit addresses this critical gap by providing a flexible, open-source package. +It enables the implementation of novel post-processing algorithms +for the extraction and calculation of contact angle, to compare them against accepted techniques, +and to establish a standardized benchmark for MD wettability analysis. # State of the field @@ -87,7 +87,7 @@ However, they do not include a standardized implementation of contact angle extraction methods, which are typically developed as custom scripts tailored to specific systems. -Existing approaches to contact angle estimation range from geometric fitting techniques +Existing approaches to contact angle computation range from geometric fitting techniques based on spherical or cylindrical cap approximations [@Hautman1997] to density-based interface analysis [@Vega2016] and pressure-tensor approaches derived from planar equilibrium simulations [@Carlson2024], making direct comparison across @@ -99,17 +99,13 @@ the development and/or implementation of other methods. # Software design Wetting-angle-kit is organized into three main components: -parsers, contact angle computation methods, and visualization, Fig. \ref{package_overview}. +parsers, analysis, and visualization, +as represented in Figure 1. This modular organization separates data handling, analysis, and visualization, allowing components to evolve independently while simplifying the integration of new features. -\begin{figure}[h!] -\centering -\includegraphics[width=0.9\textwidth, trim=100 480 100 200, clip]{package_overviewDiagram.drawio.pdf} -\caption{Wetting-angle-kit, package structure.} -\label{package_overview} -\end{figure} +![Wetting-angle-kit package structure. The package is composed of three main modules: parsers, analysis, and visualization.](package_overviewDiagram.drawio.pdf){width=90%} The parser module provides a unified interface for reading trajectory data from multiple formats, ensuring consistent handling of atomic coordinates, @@ -120,52 +116,43 @@ established trajectory-reading tools when available, while extended XYZ parsing implemented directly within the package. The parser also consistently handles periodic boundary conditions, ensuring that droplet shapes are correctly reconstructed across simulation boundaries and avoiding artifacts in interface detection. - -This consistency facilitates seamless integration with downstream analysis methods -and ensures the system's scalability, enabling researchers to easily +This consistency facilitates seamless integration with downstream analysis methods, enabling researchers to easily incorporate support for additional file formats or simulation engines. -The contact angle computation methods (analysis) module implements -two complementary approaches for contact angle estimation (Fig. \ref{analysis_methods}). - -\begin{figure}[h!] -\centering -\includegraphics[width=0.8\textwidth, trim=1.5cm 6cm 2.5cm 1cm, clip ]{schema_methods_analysis.pdf} -\caption{Schema of the two contact angle analysis methods.} -\label{analysis_methods} -\end{figure} - -The slicing method performs frame-by-frame geometric analysis, -enabling detailed temporal resolution at the cost of higher computational expense. +The analysis module implements +two complementary approaches for contact angle computation that are illustrated in Figure 2. +The slicing method consists in a frame-by-frame geometric analysis, +which enables a detailed temporal resolution. In practice, this approach provides a local characterization of the liquid–vapor interface, allowing the detection of asymmetries and transient deformations of the droplet shape. It is particularly well suited for non-equilibrium simulations or systems where the droplet deviates from an ideal spherical cap. In contrast, the binning method constructs time-averaged density fields, -providing a computationally efficient approach suitable for large datasets -and symmetric systems. By averaging particle positions over time, -this method reduces thermal fluctuations and produces a smoother -and more stable interface, making it suitable for extracting +reducing thermal fluctuations and producing a smoother +and more stable interface. This makes this approach suitable for extracting equilibrium contact angles from noisy datasets. However, this temporal averaging may obscure short-lived fluctuations and local deviations from ideal geometries. +The binning method is also more suited to symmetric systems, since atoms are folded into a single quadrant. +Due to the finer analysis it provides, the slicing method is one order of magnitude more +expensive computationnally than its binning counterpart. These two approaches reflect a trade-off between temporal resolution and statistical robustness, allowing users to select the method best suited to their system. +![Schematic representation of the two methods developed in wetting-angle-kit to compute contact angle from a MD trajectory. In the slicing method (left), all trajectory frames are analyzed and a circle is fitted on each of those, providing a time evolution of the contact angle. In the binning method (right), all frames are concatenated to fictitiously increase the molecular density of the droplet, allowing for smoother statistics at the cost of losing the time dependence of the contact angle.](schema_methods_analysis.pdf){width=80%} + Additionally, wetting-angle-kit supports two geometric models commonly used -in the literature: spherical and cylindrical [@Scocchi2011] (Fig. \ref{geometries}). -While spherical droplets provide a more direct representation of droplet curvature, -cylindrical geometries reduce curvature effects and computational cost, +in the literature for droplets: spherical and cylindrical [@Scocchi2011] (see Figure 3). +While the spherical case provides a more direct representation of the droplet curvature, +a cylindrical geometry reduces curvature effects and computational cost by relying on periodic boundary conditions along the cylinder axis, at the expense of relying on an idealized geometry. -\begin{figure}[h!] -\centering -\includegraphics[width=0.48\textwidth]{wetting_angle_kit_3d_droplet.pdf} -\hfill -\includegraphics[width=0.48\textwidth]{wetting_angle_kit_cylinder.pdf} -\caption{Geometric representations of droplets used in the analysis: spherical droplet (left) and cylindrical droplet (right).} -\label{geometries} -\end{figure} +![Geometric representations of droplets used in the analysis: spherical droplet (left) and cylindrical droplet (right).](wetting_angle_kit_sphere_vs_cylinder.pdf){width=90%} + +The visualization module includes tools to support interpretation and validation +of analysis results without requiring external post-processing tools. +These tools consists in (1) a contact-angle vs. trajectory timeframe for the slicing analysis, +and (2) a density heatmap based on the binning analysis. The software architecture relies on abstract base classes to enforce consistent interfaces and facilitate extensibility. @@ -174,50 +161,32 @@ compatibility with existing workflows, promoting reuse and method comparison. It also facilitates the integration of newly developed methods into an existing and standardized analysis pipeline. -Visualization tools are included to support interpretation and validation -of analysis results without requiring external post-processing tools. -These tools provide representations of droplet geometries, enabling users to -directly inspect the quality of interface detection and fitting procedures. -By facilitating visual verification of intermediate and final results, -they help identify potential artifacts or inconsistencies in the analysis and improve -the reliability of extracted contact angles. - # Research impact statement Wetting-angle-kit provides a reproducible framework for contact angle analysis in MD simulations, addressing a common need in studies of nanoscale wetting. The package has been validated using MD simulations of water droplets on graphene and polymer substrates, yielding contact angle values consistent -with literature results (e.g., ~93° for graphene, ~110° for PTFE), see Fig. \ref{results}. -The reported contact angles were obtained by analyzing droplets of increasing size -and extrapolating to the macroscopic limit using the Modified Young’s relation, -where the contact angle is related to droplet size through a line-tension correction -term, enabling estimation of the infinite-droplet contact angle. -These results are consistent with literature values obtained using -similar carbon-oxygen LJ parameters [@Jorgensen1996]. - -\begin{figure}[h!] -\centering -\includegraphics[width=0.8\textwidth]{mean_cos_angle_vs_surface_graphite_ptfe.pdf} -\caption{Size-dependent contact angle analysis for water droplets on graphite - and PTFE substrates. Values of $\cos(\theta)$ are plotted as a function of the inverse square - root of the droplet surface area for droplets containing between 500 and 6000 water molecules. - Linear extrapolation following the Modified Young’s relation is used - to estimate the macroscopic (infinite-droplet) contact angle.} -\label{results} -\end{figure} +with literature results (e.g., ~93° for graphene, ~110° for PTFE), see Figure 4. +The reported contact angles were obtained by analyzing droplets of increasing sizes +and extrapolating to the macroscopic limit using the modified Young’s equation **ref**, +where the contact angle is related to droplet size, enabling the estimation of the infinite-droplet contact angle through linear extrapolation. +These results are consistent with values reported in the literature, obtained using +similar interatomic potential parameters [@Jorgensen1996] for the MD simulation. + +![Size-dependent contact angle analysis for water droplets on graphite and PTFE substrates. Values of $\cos(\theta)$ are plotted as a function of the inverse square root of the droplet surface area for droplets containing between 500 and 6000 water molecules. A linear extrapolation following the modified Young’s equation is used to estimate the macroscopic (infinite-droplet) contact angle.](mean_cos_angle_vs_surface_graphite_ptfe.pdf) By enabling systematic comparison of analysis methods -and providing standardized workflows, the software supports more robust and +and providing standardized workflows, wetting-angle-kit supports more robust and reproducible wettability studies. Its modular design also facilitates integration into existing simulation pipelines and encourages community-driven extensions. The package is expected to be particularly -useful for researchers using various types of force fields (classical and MLIPs) +useful for researchers using various types of force fields (classical, ab initio, and machine learned) or investigating nanoscale interfacial phenomena. -# AI usage disclosure +# AI usage disclosure -Generative AI tools were used in the development of the software, +Generative AI tools (Claude Code with Sonnet 4.6 and Opus 4.7, **XXX Gabriel add yours**) were used in the development of the software, for drafting and assisting debugging. Generative AI was used to assist in refining the language, translation and clarity of the manuscript and docstring. diff --git a/wetting_angle_kit_JOSS/paper.pdf b/wetting_angle_kit_JOSS/paper.pdf index 3fcafb9..4beb1c8 100644 Binary files a/wetting_angle_kit_JOSS/paper.pdf and b/wetting_angle_kit_JOSS/paper.pdf differ diff --git a/wetting_angle_kit_JOSS/paper_old.pdf b/wetting_angle_kit_JOSS/paper_old.pdf deleted file mode 100644 index 47dcd92..0000000 Binary files a/wetting_angle_kit_JOSS/paper_old.pdf and /dev/null differ