From 001c62b612b509e2ae60cd7253a73575cbb7c3de Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 4 Jun 2026 14:28:16 +0200 Subject: [PATCH 01/53] First step of the refactoring: adding a DropletGeometry object defining the geometry + utilities. --- src/wetting_angle_kit/analysis/geometry.py | 113 +++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 src/wetting_angle_kit/analysis/geometry.py diff --git a/src/wetting_angle_kit/analysis/geometry.py b/src/wetting_angle_kit/analysis/geometry.py new file mode 100644 index 0000000..84db45a --- /dev/null +++ b/src/wetting_angle_kit/analysis/geometry.py @@ -0,0 +1,113 @@ +"""Droplet symmetry and the internal axis convention. + +Every analyzer in :mod:`wetting_angle_kit.analysis` operates on a +:class:`DropletGeometry` instance. The class normalises the three +supported cases (``spherical``, ``cylinder_x``, ``cylinder_y``) and +exposes a single helper, :meth:`to_internal_coords`, that downstream +code can use to assume the cylinder axis is always ``y``. + +User-facing APIs accept either a :class:`DropletGeometry` instance or +the bare string name; :meth:`DropletGeometry.coerce` is the canonical +entry point that performs the conversion. +""" + +from dataclasses import dataclass +from typing import ClassVar, Literal + +import numpy as np + +#: Public type alias for the three accepted droplet geometry names. +DropletGeometryName = Literal["spherical", "cylinder_x", "cylinder_y"] + + +@dataclass(frozen=True) +class DropletGeometry: + """Droplet symmetry descriptor with axis-layout helpers. + + Three cases are supported: + + * ``spherical``: the droplet is a 3D cap with no preferred horizontal + axis. Rays sweep over the upper hemisphere ``(theta, phi)``. + * ``cylinder_y``: the droplet is a ridge whose translational symmetry + axis is ``y``. In-plane analysis happens in ``(x, z)`` and slices + are taken at successive ``y`` positions. No internal axis swap. + * ``cylinder_x``: the droplet is a ridge whose translational symmetry + axis is ``x``. A ``[1, 0, 2]`` swap is applied at the analyzer + boundary so every downstream routine can assume the cylinder axis + is ``y`` internally. The swap is self-inverse, so the same helper + maps internal coordinates back to user coordinates. + """ + + _VALID_NAMES: ClassVar[tuple[DropletGeometryName, ...]] = ( + "spherical", + "cylinder_x", + "cylinder_y", + ) + + name: DropletGeometryName + + def __post_init__(self) -> None: + if self.name not in self._VALID_NAMES: + raise ValueError( + f"droplet_geometry must be one of {self._VALID_NAMES}; " + f"got {self.name!r}." + ) + + @classmethod + def coerce( + cls, value: "DropletGeometry | DropletGeometryName | str" + ) -> "DropletGeometry": + """Return a :class:`DropletGeometry` for either an instance or a name. + + Parameters + ---------- + value : DropletGeometry or str + Either an existing instance (returned unchanged) or one of the + bare name strings ``"spherical"``, ``"cylinder_x"``, + ``"cylinder_y"``. + + Returns + ------- + DropletGeometry + """ + if isinstance(value, cls): + return value + return cls(name=value) # type: ignore[arg-type] + + @property + def is_spherical(self) -> bool: + return self.name == "spherical" + + @property + def is_cylinder(self) -> bool: + return self.name in ("cylinder_x", "cylinder_y") + + @property + def cylinder_axis(self) -> Literal["x", "y"] | None: + """User-frame axis along which the cylinder extends, or ``None``.""" + if self.name == "cylinder_x": + return "x" + if self.name == "cylinder_y": + return "y" + return None + + def to_internal_coords(self, coords: np.ndarray) -> np.ndarray: + """Map coordinates from the user frame to the internal frame. + + For ``cylinder_x`` this applies the ``[1, 0, 2]`` swap so the + cylinder axis ends up on the ``y`` column. Spherical and + ``cylinder_y`` are returned unchanged. Accepts any array whose + last axis has length 3 (a single point ``(3,)`` or a batch + ``(..., 3)``). + """ + if self.name == "cylinder_x": + return coords[..., [1, 0, 2]] + return coords + + def to_user_coords(self, coords: np.ndarray) -> np.ndarray: + """Inverse of :meth:`to_internal_coords`. + + The ``[1, 0, 2]`` swap is self-inverse, so this delegates back + to :meth:`to_internal_coords`. + """ + return self.to_internal_coords(coords) From 38322892ab8a39c1111ab7931f06c91f7d2f2729 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 4 Jun 2026 15:52:22 +0200 Subject: [PATCH 02/53] Slight update of DropletGeometry. Adding a TemporalAggregator to loop over batches of frames, that will be used to choose between a frame-by-frame analysis, batch-of-frames by batch-of-frames, or a concatenation of the whole trajectory. --- src/wetting_angle_kit/analysis/geometry.py | 11 ++- src/wetting_angle_kit/analysis/temporal.py | 99 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 4 deletions(-) create mode 100644 src/wetting_angle_kit/analysis/temporal.py diff --git a/src/wetting_angle_kit/analysis/geometry.py b/src/wetting_angle_kit/analysis/geometry.py index 84db45a..e23098c 100644 --- a/src/wetting_angle_kit/analysis/geometry.py +++ b/src/wetting_angle_kit/analysis/geometry.py @@ -105,9 +105,12 @@ def to_internal_coords(self, coords: np.ndarray) -> np.ndarray: return coords def to_user_coords(self, coords: np.ndarray) -> np.ndarray: - """Inverse of :meth:`to_internal_coords`. + """Map coordinates from the internal frame back to the user frame. - The ``[1, 0, 2]`` swap is self-inverse, so this delegates back - to :meth:`to_internal_coords`. + Mirror of :meth:`to_internal_coords`: applies the ``[1, 0, 2]`` + swap for ``cylinder_x`` (which is its own inverse), and returns + the input unchanged for ``spherical`` and ``cylinder_y``. """ - return self.to_internal_coords(coords) + if self.name == "cylinder_x": + return coords[..., [1, 0, 2]] + return coords diff --git a/src/wetting_angle_kit/analysis/temporal.py b/src/wetting_angle_kit/analysis/temporal.py new file mode 100644 index 0000000..a56d3f3 --- /dev/null +++ b/src/wetting_angle_kit/analysis/temporal.py @@ -0,0 +1,99 @@ +"""Temporal aggregation across frames for trajectory analysis. + +A :class:`TemporalAggregator` groups frame indices into batches. Each +batch is later processed by an analyzer as a single fitting unit, +producing one contact angle estimate per batch. + +The ``batch_size`` parameter controls the time-vs-statistics trade-off: + +- ``batch_size=1`` (default) — per-frame analysis. Produces a time + series with one angle per frame; statistics come from frame-to-frame + variation. +- ``batch_size=N`` (N > 1) — pool consecutive groups of ``N`` frames + together before fitting. Reduces thermal noise per fit at the cost + of time resolution. +- ``batch_size=-1`` — fully pooled. Every requested frame goes into a + single batch, producing one angle estimate for the trajectory. +""" + +from collections.abc import Iterator +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TemporalAggregator: + """Group frame indices into batches for per-batch surface fitting. + + Designed to be held by a :class:`TrajectoryAnalyzer` and driven from + inside :meth:`analyze`, which supplies the frame indices to walk. + Standalone use is fine for inspection (e.g. previewing batch + boundaries) but the caller must always provide ``frame_range``. + + Parameters + ---------- + batch_size : int, default 1 + Number of consecutive frames pooled per surface fit. + ``batch_size=1`` (the default) gives per-frame analysis: each + frame is its own batch. Larger values pool consecutive groups + of frames, trading time resolution for statistics; the last + batch is shorter if the range isn't evenly divisible. + ``batch_size=-1`` is the "all" sentinel: every supplied frame + is pooled into a single batch. + """ + + batch_size: int = 1 + + def __post_init__(self) -> None: + if self.batch_size == 0 or self.batch_size < -1: + raise ValueError( + f"batch_size must be a positive integer or -1 (pool all); " + f"got {self.batch_size!r}." + ) + + def iter_batches(self, frame_range: list[int]) -> Iterator[list[int]]: + """Yield successive lists of frame indices, one per fitting unit. + + Parameters + ---------- + frame_range : list[int] + The frame indices to distribute. The analyzer normally + populates this with ``range(parser.frame_count())`` or with + a caller-supplied subset; the aggregator only groups what + it is given and never consults the parser itself. May be + empty (no batches yielded). + + Yields + ------ + list[int] + One batch of frame indices. Order within and across batches + preserves the order of ``frame_range``. + """ + if not frame_range: + return + if self.batch_size == -1: + yield list(frame_range) + return + for i in range(0, len(frame_range), self.batch_size): + yield list(frame_range[i : i + self.batch_size]) + + def n_batches(self, n_frames: int) -> int: + """Return the number of batches that would be yielded. + + Useful for sizing progress bars before iteration starts. + + Parameters + ---------- + n_frames : int + Length of the ``frame_range`` that would be passed to + :meth:`iter_batches`. + + Returns + ------- + int + Number of batches the aggregator will produce for that input. + """ + if n_frames <= 0: + return 0 + if self.batch_size == -1: + return 1 + return -(-n_frames // self.batch_size) From 8f9685f14e343d9e98108c12666f5d1790173bdb Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 4 Jun 2026 16:20:13 +0200 Subject: [PATCH 03/53] Added a WallDetector to locate wall atoms. For now it will be simple but can be extended later. --- src/wetting_angle_kit/analysis/wall.py | 203 +++++++++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 src/wetting_angle_kit/analysis/wall.py diff --git a/src/wetting_angle_kit/analysis/wall.py b/src/wetting_angle_kit/analysis/wall.py new file mode 100644 index 0000000..57e0350 --- /dev/null +++ b/src/wetting_angle_kit/analysis/wall.py @@ -0,0 +1,203 @@ +"""Wall-plane detectors used by trajectory analyzers. + +A :class:`WallDetector` returns the z-coordinate of the wall plane +that a :class:`SurfaceFitter` intersects to compute the contact angle. +Three strategies are supported: + +- ``min_plus_offset``: take the lowest interface point and shift up by + a configurable offset. Cheap and self-contained but picks up thermal + noise from the liquid–vapor interface bottom. +- ``explicit``: use a fixed user-supplied z value. Best when the wall + plane is known a priori from the simulation setup. +- ``from_atoms``: derive z from a pool of wall atom positions (e.g. + mean z of the topmost layer). Most physical but requires the + analyzer to be told which atoms form the wall. + +Users construct detectors through classmethod factories on the base +class:: + + WallDetector.min_plus_offset(offset=2.0) + WallDetector.explicit(z_wall=15.0) + WallDetector.from_atoms(wall_atom_indices=indices) +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Literal, TypeAlias + +import numpy as np + +#: Interface point set produced by an :class:`InterfaceExtractor`. +#: +#: - In slicing mode, a list of ``(N_i, 2)`` arrays in the per-slice +#: ``(x, z)`` plane. +#: - In whole mode, a single ``(N, 3)`` array in the internal +#: ``(x, y, z)`` frame. +InterfaceData: TypeAlias = list[np.ndarray] | np.ndarray + + +@dataclass(frozen=True) +class WallContext: + """Per-batch data passed to :meth:`WallDetector.detect`. + + Wrapping the inputs in a single object keeps the detector method + signature forward-compatible: new detectors can read new fields + without changing the protocol. + + Attributes + ---------- + interface_data : list[ndarray] or ndarray + Interface point set produced by the :class:`InterfaceExtractor`; + format depends on the extractor kind (per-slice 2D points or a + 3D shell). + wall_coords : ndarray, optional + Pooled ``(N, 3)`` positions of wall atoms in the internal + coordinate frame, if the analyzer was constructed with + ``wall_atom_indices``. Required by ``from_atoms`` detectors and + unused by the others. + """ + + interface_data: InterfaceData + wall_coords: np.ndarray | None = None + + +class WallDetector(ABC): + """Abstract base for wall-plane detection strategies. + + Construct concrete detectors with one of the classmethod factories + :meth:`min_plus_offset`, :meth:`explicit`, or :meth:`from_atoms`. + Direct subclassing is supported for custom strategies but the + factories cover all built-in cases. + """ + + @abstractmethod + def detect(self, ctx: WallContext) -> float: + """Return the wall-plane z-coordinate for one batch. + + Parameters + ---------- + ctx : WallContext + Per-batch data; see :class:`WallContext`. + + Returns + ------- + float + Wall-plane z in the internal coordinate frame (Å). + """ + + @classmethod + def min_plus_offset(cls, offset: float = 2.0) -> "WallDetector": + """Take the lowest interface point and shift up by ``offset``. + + Parameters + ---------- + offset : float, default 2.0 + Vertical shift (Å) added to the minimum z to skip the + wall-adjacent density spike. The default of 2.0 Å matches + the slicing analyzer's historical behaviour for water on + silica-like surfaces; tune for other systems. + """ + return _MinPlusOffsetDetector(offset=offset) + + @classmethod + def explicit(cls, z_wall: float) -> "WallDetector": + """Use a fixed wall z-coordinate. + + Parameters + ---------- + z_wall : float + Wall-plane z in the internal coordinate frame (Å). + """ + return _ExplicitDetector(z_wall=z_wall) + + @classmethod + def from_atoms( + cls, + wall_atom_indices: np.ndarray, + method: Literal["max_z", "mean_top_layer"] = "mean_top_layer", + top_layer_tolerance: float = 1.0, + ) -> "WallDetector": + """Derive wall z from a set of wall atom positions. + + The analyzer must be constructed with the matching + ``wall_atom_indices`` so the wall atoms are gathered and + supplied through :attr:`WallContext.wall_coords`. + + Parameters + ---------- + wall_atom_indices : ndarray + Indices of the atoms that form the wall. + method : {"max_z", "mean_top_layer"}, default "mean_top_layer" + How to reduce wall atom z values to a single plane. + ``"max_z"`` uses the highest wall atom z; cheap but noisy. + ``"mean_top_layer"`` averages over all atoms within + ``top_layer_tolerance`` Å of the maximum, smoothing thermal + motion. + top_layer_tolerance : float, default 1.0 + Vertical window (Å) defining the "top layer" for + ``method="mean_top_layer"``. Ignored for ``"max_z"``. + """ + return _FromAtomsDetector( + wall_atom_indices=np.asarray(wall_atom_indices), + method=method, + top_layer_tolerance=top_layer_tolerance, + ) + + +@dataclass(frozen=True) +class _MinPlusOffsetDetector(WallDetector): + """Concrete detector for :meth:`WallDetector.min_plus_offset`.""" + + offset: float + + def detect(self, ctx: WallContext) -> float: + data = ctx.interface_data + if isinstance(data, list): + z_mins = [float(np.min(s[:, 1])) for s in data if s.size > 0] + if not z_mins: + raise ValueError( + "min_plus_offset: interface_data has no non-empty slices." + ) + z_min = min(z_mins) + else: + if data.size == 0: + raise ValueError("min_plus_offset: interface_data is empty.") + z_min = float(np.min(data[:, 2])) + return z_min + self.offset + + +@dataclass(frozen=True) +class _ExplicitDetector(WallDetector): + """Concrete detector for :meth:`WallDetector.explicit`.""" + + z_wall: float + + def detect(self, ctx: WallContext) -> float: + return self.z_wall + + +# eq=False avoids the auto-generated __eq__ tripping on the numpy field; +# equality between detectors isn't a use case we need. +@dataclass(frozen=True, eq=False) +class _FromAtomsDetector(WallDetector): + """Concrete detector for :meth:`WallDetector.from_atoms`.""" + + wall_atom_indices: np.ndarray + method: Literal["max_z", "mean_top_layer"] + top_layer_tolerance: float + + def detect(self, ctx: WallContext) -> float: + if ctx.wall_coords is None: + raise ValueError( + "from_atoms wall detection requires wall_coords in the " + "context; construct the analyzer with wall_atom_indices " + "so the wall atoms are loaded each batch." + ) + z = ctx.wall_coords[:, 2] + if z.size == 0: + raise ValueError("from_atoms wall detection received empty wall_coords.") + if self.method == "max_z": + return float(np.max(z)) + z_max = float(np.max(z)) + top = z[z >= z_max - self.top_layer_tolerance] + return float(np.mean(top)) From 581c3d90de33253a7c9bfd835831ed8f14579b3e Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 4 Jun 2026 16:44:00 +0200 Subject: [PATCH 04/53] Added Result container classes. --- src/wetting_angle_kit/analysis/results.py | 220 ++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 src/wetting_angle_kit/analysis/results.py diff --git a/src/wetting_angle_kit/analysis/results.py b/src/wetting_angle_kit/analysis/results.py new file mode 100644 index 0000000..9ad0ab3 --- /dev/null +++ b/src/wetting_angle_kit/analysis/results.py @@ -0,0 +1,220 @@ +"""In-memory containers for trajectory analyzer outputs. + +The unified :class:`TrajectoryAnalyzer` returns :class:`TrajectoryResults` +holding one :class:`BatchResult` per batch produced by the +:class:`TemporalAggregator`. The specific :class:`BatchResult` subclass +depends on the :class:`SurfaceFitter` kind: + +- slicing fitters → :class:`SlicingBatchResult` +- whole fitters → :class:`WholeBatchResult` + +:class:`CoupledBinningAnalyzer` returns :class:`CoupledBinningResults`, +which carries a different per-batch payload (the full density grid plus +the joint-fit parameters) and is therefore not part of the +:class:`TrajectoryResults` hierarchy. +""" + +from dataclasses import dataclass, field +from typing import Any + +import numpy as np + +# ``eq=False`` is used throughout because per-batch payloads contain +# numpy arrays, on which the auto-generated ``__eq__`` would call +# element-wise ``==`` and raise in a boolean context. Equality between +# result objects isn't a use case the package needs. + + +@dataclass(frozen=True, eq=False, kw_only=True) +class BatchResult: + """Common fields shared by all per-batch trajectory results. + + All fields are keyword-only so that subclasses can interleave their + own fields with the parent's defaulted ones without ordering + constraints. + + Attributes + ---------- + frames : list[int] + Frame indices pooled into this batch. + angle : float + Representative contact angle for the batch (degrees). For + slicing fits this is the mean across slices; for whole fits + it is the single fitted angle. + z_wall : float + Wall-plane z used by the surface fitter for this batch (Å). + rms_residual : float + Aggregate fit residual (Å). For slicing fits, an aggregate of + the per-slice circle-fit residuals; for whole fits, the + single sphere/cylinder-fit residual. + angle_std : float, optional + Within-batch standard deviation of the contact angle + (degrees), describing the spread of the per-batch ``angle``. + For slicing fits, the std of ``per_slice_angles`` (always + populated). For whole fits, the bootstrap std when the fitter + was constructed with ``bootstrap_samples > 0``, otherwise + ``None``. + """ + + frames: list[int] + angle: float + z_wall: float + rms_residual: float + angle_std: float | None = None + + +@dataclass(frozen=True, eq=False, kw_only=True) +class SlicingBatchResult(BatchResult): + """Per-batch result from a slicing-kind surface fitter. + + Attributes + ---------- + per_slice_angles : ndarray + ``(n_slices,)`` array of per-slice contact angles (degrees). + :attr:`BatchResult.angle` is the mean of this array; + :attr:`BatchResult.angle_std` is its standard deviation. + slice_surfaces : list[ndarray] + One ``(M_i, 2)`` array per slice of interface points in the + slice ``(x, z)`` plane. + slice_popts : ndarray + ``(n_slices, 4)`` array of fitted circle parameters per slice; + columns ``[xc, zc, R, z_wall]``. + """ + + per_slice_angles: np.ndarray + slice_surfaces: list[np.ndarray] + slice_popts: np.ndarray + + +@dataclass(frozen=True, eq=False, kw_only=True) +class WholeBatchResult(BatchResult): + """Per-batch result from a whole-kind surface fitter. + + Attributes + ---------- + interface_shell : ndarray + ``(N, 3)`` array of interface points used in the fit, in the + internal ``(x, y, z)`` frame. + popt : ndarray + Fitted shape parameters extended by the wall plane. Spherical + fitter: ``[xc, yc, zc, R, z_wall]``. Cylinder fitter: + ``[xc, zc, R, z_wall]``. + """ + + interface_shell: np.ndarray + popt: np.ndarray + + +@dataclass(frozen=True, eq=False) +class CoupledBinningBatchResult: + """Per-batch result from :class:`CoupledBinningAnalyzer`. + + Attributes + ---------- + frames : list[int] + Frame indices pooled into this batch. + angle : float + Contact angle (degrees) implied by the joint tanh-model fit. + model_params : dict[str, float] + Fitted parameters of the hyperbolic tangent model; keys are + ``"rho1"``, ``"rho2"``, ``"R_eq"``, ``"zi_c"``, ``"zi_0"``, + ``"t1"``, ``"t2"``. + xi_grid : ndarray + In-plane binning grid centers (Å). + zi_grid : ndarray + Vertical binning grid centers (Å). + density : ndarray + ``(len(xi_grid), len(zi_grid))`` binned density on the grid. + """ + + frames: list[int] + angle: float + model_params: dict[str, float] + xi_grid: np.ndarray + zi_grid: np.ndarray + density: np.ndarray + + +@dataclass +class TrajectoryResults: + """In-memory results of a :class:`TrajectoryAnalyzer.analyze` run. + + Holds one :class:`BatchResult` per batch produced by the + :class:`TemporalAggregator`. Within a single Results object all + batches share the same subclass (:class:`SlicingBatchResult` or + :class:`WholeBatchResult`), determined by the analyzer's + :class:`SurfaceFitter` kind. + + Attributes + ---------- + batches : list[BatchResult] + Per-batch results, in the order produced by the aggregator. + method_metadata : dict + Free-form descriptor of the analyzer configuration (kind, + droplet geometry, batch size, …) for downstream plotting / + serialization. + """ + + batches: list[BatchResult] + method_metadata: dict[str, Any] = field(default_factory=dict) + + def __len__(self) -> int: + return len(self.batches) + + @property + def per_batch_angles(self) -> np.ndarray: + """Per-batch contact angle (degrees), in batch order.""" + return np.array([b.angle for b in self.batches]) + + @property + def mean_angle(self) -> float: + """Mean contact angle across batches (degrees).""" + if not self.batches: + return float("nan") + return float(np.mean(self.per_batch_angles)) + + @property + def std_angle(self) -> float: + """Standard deviation of the per-batch contact angle (degrees).""" + if not self.batches: + return float("nan") + return float(np.std(self.per_batch_angles)) + + +@dataclass +class CoupledBinningResults: + """In-memory results of a :class:`CoupledBinningAnalyzer.analyze` run. + + Attributes + ---------- + batches : list[CoupledBinningBatchResult] + Per-batch results, in the order produced by the aggregator. + method_metadata : dict + Free-form descriptor (droplet geometry, binning params, + initial parameters, batch size). + """ + + batches: list[CoupledBinningBatchResult] + method_metadata: dict[str, Any] = field(default_factory=dict) + + def __len__(self) -> int: + return len(self.batches) + + @property + def per_batch_angles(self) -> np.ndarray: + """Per-batch contact angle (degrees), in batch order.""" + return np.array([b.angle for b in self.batches]) + + @property + def mean_angle(self) -> float: + """Mean contact angle across batches (degrees).""" + if not self.batches: + return float("nan") + return float(np.mean(self.per_batch_angles)) + + @property + def std_angle(self) -> float: + """Standard deviation of the per-batch contact angle (degrees).""" + if not self.batches: + return float("nan") + return float(np.std(self.per_batch_angles)) From ad6664ad347718a094deb96783c0284163a94519 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 11:16:12 +0200 Subject: [PATCH 05/53] Added extractors to find the interface between the liquid (drop) and vapor. This interface will be used to fit a circle or sphere, for slices or as a surface on the whole drop shape. --- src/wetting_angle_kit/analysis/extractors.py | 488 +++++++++++++++++++ src/wetting_angle_kit/analysis/results.py | 105 +++- 2 files changed, 581 insertions(+), 12 deletions(-) create mode 100644 src/wetting_angle_kit/analysis/extractors.py diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py new file mode 100644 index 0000000..a1eaa07 --- /dev/null +++ b/src/wetting_angle_kit/analysis/extractors.py @@ -0,0 +1,488 @@ +"""Interface extractors: build the interface point set from raw atoms. + +An :class:`InterfaceExtractor` converts pooled liquid-atom coordinates +into one of two output shapes, determined by the :class:`SurfaceFitter` +the analyzer is paired with: + +- ``surface_kind="slicing"`` → a list of per-slice ``(M_i, 2)`` arrays + in the slice ``(x, z)`` plane; +- ``surface_kind="whole"`` → a single ``(N, 3)`` shell array in the + internal ``(x, y, z)`` frame. + +Extractors are constructed through classmethod factories on the base +class — each factory configures one sampling + density-kernel +combination:: + + InterfaceExtractor.rays_gaussian(...) # ray fan + Gaussian KDE + tanh + InterfaceExtractor.rays_binning(...) # ray fan + histogram bins + tanh + InterfaceExtractor.grid_gaussian(...) # 2D KDE map + isocontour (slicing only) + InterfaceExtractor.grid_binning(...) # 2D histogram map + isocontour + # (slicing only) + +The pairing between the chosen extractor and the analyzer's +:class:`SurfaceFitter` is validated at :class:`TrajectoryAnalyzer` +construction via :meth:`InterfaceExtractor.validate_compatibility`. + +Algorithm bodies are stubbed (``raise NotImplementedError``) at this +skeleton stage; only constructor surfaces and compatibility checks are +implemented here. Density / tanh-fit primitives will be pulled into +``analysis/_density.py`` when porting begins. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, ClassVar, Literal + +import numpy as np + +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.wall import InterfaceData + +#: What the downstream :class:`SurfaceFitter` will consume. +SurfaceKind = Literal["slicing", "whole"] + +#: Sampling strategy used by the extractor. +SamplingKind = Literal["rays", "grid"] + + +class InterfaceExtractor(ABC): + """Abstract base for interface point extractors. + + Concrete extractors are constructed through the classmethod + factories :meth:`rays_gaussian`, :meth:`rays_binning`, + :meth:`grid_gaussian`, :meth:`grid_binning`. Direct subclassing is + supported for custom strategies; the factories cover the built-in + cases. + """ + + #: Sampling strategy this extractor uses. Set by each concrete subclass. + sampling: ClassVar[SamplingKind] + + @abstractmethod + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + """Build the interface point set for one batch. + + Parameters + ---------- + liquid_coordinates : ndarray, shape (N, 3) + Pooled liquid-atom coordinates in the internal frame. + center_geom : ndarray, shape (3,) + Geometric droplet center. + droplet_geometry : DropletGeometry + Droplet symmetry; drives the per-slice axis choice for + slicing modes and the ray-fan layout for whole modes. + max_dist : float + Maximum radial distance sampled along each ray (Å). + surface_kind : {"slicing", "whole"} + What the downstream :class:`SurfaceFitter` will consume. + Determines the output shape (per-slice 2D points vs 3D + shell). The analyzer enforces ``surface_kind == fitter.kind`` + via :meth:`validate_compatibility` at construction. + + Returns + ------- + InterfaceData + ``list[ndarray]`` of ``(M_i, 2)`` per-slice points when + ``surface_kind="slicing"``; a single ``(N, 3)`` shell when + ``surface_kind="whole"``. + """ + + @abstractmethod + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + """Raise if this extractor cannot serve ``(surface_kind, geometry)``. + + Called by :class:`TrajectoryAnalyzer.__init__` so misconfigurations + fail fast at construction instead of at the first batch. + """ + + @classmethod + def rays_gaussian( + cls, + *, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, + n_rays_sphere: int | None = None, + delta_polar: float = 8.0, + points_per_angstrom: float = 1.0, + density_sigma: float = 3.0, + cutoff_sigma: float = 5.0, + ) -> "InterfaceExtractor": + """Ray fan + Gaussian KDE + per-ray tanh interface fit. + + The interface position along each ray is recovered by smoothing + atom positions with a Gaussian kernel and fitting a hyperbolic + tangent profile to the resulting 1D density. + + Required ray-fan parameters depend on the + ``(surface_kind, droplet_geometry)`` the extractor will be + paired with: + + ========================== ========================================= + surface_kind, geometry required ray params + ========================== ========================================= + slicing, spherical ``delta_azimuthal`` (+ ``delta_polar``) + slicing, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) + whole, spherical ``n_rays_sphere`` + whole, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) + ========================== ========================================= + + Parameters + ---------- + delta_azimuthal : float, optional + Azimuthal step (degrees) between slicing planes for the + spherical slicing mode. + delta_cylinder : float, optional + Step (Å) along the cylinder axis between slices for the + cylinder modes (both slicing and whole). + n_rays_sphere : int, optional + Total number of rays in the upper hemisphere for the + spherical whole-fit mode. Rays are placed via an equal-area + ``(cos θ, φ)`` construction to avoid pole bias. + delta_polar : float, default 8.0 + In-plane ray step (degrees) for every mode that emits rays + in the ``(x, z)`` plane (i.e. everything except + whole + spherical). + points_per_angstrom : float, default 1.0 + Sampling density along each ray (samples per Å). + density_sigma : float, default 3.0 + Gaussian kernel width (Å) for the density smoothing. + Default tuned for full-atomistic water at room temperature. + cutoff_sigma : float, default 5.0 + Per-atom kernel truncation in units of ``density_sigma``. + """ + return _RaysGaussianExtractor( + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, + n_rays_sphere=n_rays_sphere, + delta_polar=delta_polar, + points_per_angstrom=points_per_angstrom, + density_sigma=density_sigma, + cutoff_sigma=cutoff_sigma, + ) + + @classmethod + def rays_binning( + cls, + *, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, + n_rays_sphere: int | None = None, + delta_polar: float = 8.0, + bin_width: float = 1.0, + points_per_angstrom: float = 1.0, + ) -> "InterfaceExtractor": + """Ray fan + histogram density + per-ray tanh interface fit. + + Same ray-fan geometry as :meth:`rays_gaussian` (see that method + for the parameter compatibility table) but density along each + ray is estimated via a 1D histogram rather than a Gaussian + kernel. + + Parameters + ---------- + delta_azimuthal, delta_cylinder, n_rays_sphere, delta_polar : + See :meth:`rays_gaussian`. + bin_width : float, default 1.0 + Histogram bin width (Å) along the ray. + points_per_angstrom : float, default 1.0 + Sampling density at which the binned density is reported + after histogramming. + """ + return _RaysBinningExtractor( + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, + n_rays_sphere=n_rays_sphere, + delta_polar=delta_polar, + bin_width=bin_width, + points_per_angstrom=points_per_angstrom, + ) + + @classmethod + def grid_gaussian( + cls, + *, + grid_params: dict[str, Any], + density_sigma: float = 3.0, + cutoff_sigma: float = 5.0, + ) -> "InterfaceExtractor": + """Gaussian-KDE density grid + isocontour interface extraction. + + Supports both slicing and whole fitters: + + - For ``surface_kind="slicing"``, the grid is 2D in the slice + ``(x, z)`` plane and a marching-squares-style isocontour gives + one ``(M, 2)`` interface curve per slice. + - For ``surface_kind="whole"``, the grid is 3D in + ``(x, y, z)`` and the interface shell is recovered by + :func:`skimage.measure.marching_cubes`. This requires the + optional ``grid3d`` extra (``scikit-image``); construction + via :class:`TrajectoryAnalyzer` raises a clear + :class:`ImportError` if it is missing. + + Parameters + ---------- + grid_params : dict + Grid spec. For slicing, six keys: ``"xi_0"``, ``"xi_f"``, + ``"nbins_xi"``, ``"zi_0"``, ``"zi_f"``, ``"nbins_zi"``. + For whole, add three more: ``"yi_0"``, ``"yi_f"``, + ``"nbins_yi"``. + density_sigma : float, default 3.0 + Gaussian kernel width (Å) for the density smoothing. + cutoff_sigma : float, default 5.0 + Per-atom kernel truncation in units of ``density_sigma``. + """ + return _GridGaussianExtractor( + grid_params=dict(grid_params), + density_sigma=density_sigma, + cutoff_sigma=cutoff_sigma, + ) + + @classmethod + def grid_binning( + cls, + *, + grid_params: dict[str, Any], + ) -> "InterfaceExtractor": + """Histogram density grid + isocontour interface extraction. + + Same dimensionality + dependency rules as + :meth:`grid_gaussian`: 2D grid for slicing, 3D grid + marching + cubes (via optional ``scikit-image``) for whole. + + Parameters + ---------- + grid_params : dict + Grid spec; see :meth:`grid_gaussian` for the required keys. + """ + return _GridBinningExtractor(grid_params=dict(grid_params)) + + +_GRID_KEYS_2D = frozenset({"xi_0", "xi_f", "nbins_xi", "zi_0", "zi_f", "nbins_zi"}) +_GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "nbins_yi"} + + +def _validate_grid_params( + *, + name: str, + grid_params: dict[str, Any], + surface_kind: SurfaceKind, +) -> None: + """Shared validation for the two grid-based extractors. + + Checks that ``grid_params`` has the right dimensionality for + ``surface_kind`` and, for whole-kind, that ``scikit-image`` is + importable (it is the marching-cubes backend). + """ + if surface_kind == "slicing": + missing = _GRID_KEYS_2D - grid_params.keys() + if missing: + raise ValueError( + f"{name} for slicing requires a 2D grid_params; missing " + f"keys: {sorted(missing)}." + ) + return + # surface_kind == "whole" + try: + import skimage.measure # noqa: F401 + except ImportError as e: + raise ImportError( + f"{name} for whole-kind extraction requires scikit-image " + "(used for marching_cubes). Install with: " + "pip install 'wetting-angle-kit[grid3d]'." + ) from e + missing = _GRID_KEYS_3D - grid_params.keys() + if missing: + raise ValueError( + f"{name} for whole requires a 3D grid_params; missing keys: " + f"{sorted(missing)}." + ) + + +def _validate_rays_params( + *, + name: str, + delta_azimuthal: float | None, + delta_cylinder: float | None, + n_rays_sphere: int | None, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, +) -> None: + """Shared validation for the two ray-based extractors. + + Both ``rays_gaussian`` and ``rays_binning`` use the same ray-fan + parameter set; only the density estimator differs. + """ + if surface_kind == "slicing": + if droplet_geometry.is_spherical and delta_azimuthal is None: + raise ValueError(f"{name} for slicing+spherical requires delta_azimuthal.") + if droplet_geometry.is_cylinder and delta_cylinder is None: + raise ValueError( + f"{name} for slicing+{droplet_geometry.name} requires delta_cylinder." + ) + elif surface_kind == "whole": + if droplet_geometry.is_spherical and n_rays_sphere is None: + raise ValueError(f"{name} for whole+spherical requires n_rays_sphere.") + if droplet_geometry.is_cylinder and delta_cylinder is None: + raise ValueError( + f"{name} for whole+{droplet_geometry.name} requires delta_cylinder." + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _RaysGaussianExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.rays_gaussian`.""" + + sampling: ClassVar[SamplingKind] = "rays" + + delta_azimuthal: float | None + delta_cylinder: float | None + n_rays_sphere: int | None + delta_polar: float + points_per_angstrom: float + density_sigma: float + cutoff_sigma: float + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_rays_params( + name="rays_gaussian", + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + surface_kind=surface_kind, + droplet_geometry=droplet_geometry, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + raise NotImplementedError( + "rays_gaussian extraction not implemented in skeleton." + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _RaysBinningExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.rays_binning`.""" + + sampling: ClassVar[SamplingKind] = "rays" + + delta_azimuthal: float | None + delta_cylinder: float | None + n_rays_sphere: int | None + delta_polar: float + bin_width: float + points_per_angstrom: float + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_rays_params( + name="rays_binning", + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + surface_kind=surface_kind, + droplet_geometry=droplet_geometry, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + raise NotImplementedError( + "rays_binning extraction not implemented in skeleton." + ) + + +# eq=False avoids the auto __eq__ tripping on the dict field; equality +# between extractor instances is not a use case the package needs. +@dataclass(frozen=True, eq=False, kw_only=True) +class _GridGaussianExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.grid_gaussian`.""" + + sampling: ClassVar[SamplingKind] = "grid" + + grid_params: dict[str, Any] + density_sigma: float + cutoff_sigma: float + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_grid_params( + name="grid_gaussian", + grid_params=self.grid_params, + surface_kind=surface_kind, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + raise NotImplementedError( + "grid_gaussian extraction not implemented in skeleton." + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _GridBinningExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.grid_binning`.""" + + sampling: ClassVar[SamplingKind] = "grid" + + grid_params: dict[str, Any] + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_grid_params( + name="grid_binning", + grid_params=self.grid_params, + surface_kind=surface_kind, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + raise NotImplementedError( + "grid_binning extraction not implemented in skeleton." + ) diff --git a/src/wetting_angle_kit/analysis/results.py b/src/wetting_angle_kit/analysis/results.py index 9ad0ab3..e8bf291 100644 --- a/src/wetting_angle_kit/analysis/results.py +++ b/src/wetting_angle_kit/analysis/results.py @@ -8,10 +8,11 @@ - slicing fitters → :class:`SlicingBatchResult` - whole fitters → :class:`WholeBatchResult` -:class:`CoupledBinningAnalyzer` returns :class:`CoupledBinningResults`, -which carries a different per-batch payload (the full density grid plus -the joint-fit parameters) and is therefore not part of the -:class:`TrajectoryResults` hierarchy. +The two joint-fit analyzers — :class:`CoupledBinning2DAnalyzer` and +:class:`CoupledBinning3DAnalyzer` — each return their own results type +(:class:`CoupledBinning2DResults`, :class:`CoupledBinning3DResults`). +They carry density grids plus joint-fit parameters and are therefore +not part of the :class:`TrajectoryResults` hierarchy. """ from dataclasses import dataclass, field @@ -106,17 +107,17 @@ class WholeBatchResult(BatchResult): @dataclass(frozen=True, eq=False) -class CoupledBinningBatchResult: - """Per-batch result from :class:`CoupledBinningAnalyzer`. +class CoupledBinning2DBatchResult: + """Per-batch result from :class:`CoupledBinning2DAnalyzer`. Attributes ---------- frames : list[int] Frame indices pooled into this batch. angle : float - Contact angle (degrees) implied by the joint tanh-model fit. + Contact angle (degrees) implied by the joint 2D tanh-model fit. model_params : dict[str, float] - Fitted parameters of the hyperbolic tangent model; keys are + Fitted parameters of the 2D hyperbolic tangent model; keys are ``"rho1"``, ``"rho2"``, ``"R_eq"``, ``"zi_c"``, ``"zi_0"``, ``"t1"``, ``"t2"``. xi_grid : ndarray @@ -135,6 +136,47 @@ class CoupledBinningBatchResult: density: np.ndarray +@dataclass(frozen=True, eq=False) +class CoupledBinning3DBatchResult: + """Per-batch result from :class:`CoupledBinning3DAnalyzer`. + + Only meaningful for spherical droplets; cylindrical droplets carry + a translational symmetry along the cylinder axis that the 2D + analyzer already exploits, so :class:`CoupledBinning3DAnalyzer` + rejects non-spherical geometries at construction. + + Attributes + ---------- + frames : list[int] + Frame indices pooled into this batch. + angle : float + Contact angle (degrees) implied by the joint 3D tanh-model fit. + model_params : dict[str, float] + Fitted parameters of the 3D hyperbolic tangent model; keys are + ``"rho1"``, ``"rho2"``, ``"R_eq"``, ``"xi_c"``, ``"yi_c"``, + ``"zi_c"``, ``"zi_0"``, ``"t1"``, ``"t2"``. The droplet + horizontal centers ``xi_c`` / ``yi_c`` are reported even if + the underlying fit fixes them to zero by symmetry. + xi_grid : ndarray + x-binning grid centers (Å). + yi_grid : ndarray + y-binning grid centers (Å). + zi_grid : ndarray + Vertical binning grid centers (Å). + density : ndarray + ``(len(xi_grid), len(yi_grid), len(zi_grid))`` binned density + on the 3D grid. + """ + + frames: list[int] + angle: float + model_params: dict[str, float] + xi_grid: np.ndarray + yi_grid: np.ndarray + zi_grid: np.ndarray + density: np.ndarray + + @dataclass class TrajectoryResults: """In-memory results of a :class:`TrajectoryAnalyzer.analyze` run. @@ -182,19 +224,58 @@ def std_angle(self) -> float: @dataclass -class CoupledBinningResults: - """In-memory results of a :class:`CoupledBinningAnalyzer.analyze` run. +class CoupledBinning2DResults: + """In-memory results of a :class:`CoupledBinning2DAnalyzer.analyze` run. Attributes ---------- - batches : list[CoupledBinningBatchResult] + batches : list[CoupledBinning2DBatchResult] Per-batch results, in the order produced by the aggregator. method_metadata : dict Free-form descriptor (droplet geometry, binning params, initial parameters, batch size). """ - batches: list[CoupledBinningBatchResult] + batches: list[CoupledBinning2DBatchResult] + method_metadata: dict[str, Any] = field(default_factory=dict) + + def __len__(self) -> int: + return len(self.batches) + + @property + def per_batch_angles(self) -> np.ndarray: + """Per-batch contact angle (degrees), in batch order.""" + return np.array([b.angle for b in self.batches]) + + @property + def mean_angle(self) -> float: + """Mean contact angle across batches (degrees).""" + if not self.batches: + return float("nan") + return float(np.mean(self.per_batch_angles)) + + @property + def std_angle(self) -> float: + """Standard deviation of the per-batch contact angle (degrees).""" + if not self.batches: + return float("nan") + return float(np.std(self.per_batch_angles)) + + +@dataclass +class CoupledBinning3DResults: + """In-memory results of a :class:`CoupledBinning3DAnalyzer.analyze` run. + + Attributes + ---------- + batches : list[CoupledBinning3DBatchResult] + Per-batch results, in the order produced by the aggregator. + method_metadata : dict + Free-form descriptor (droplet geometry — always spherical for + this analyzer, binning params, initial parameters, batch size). + """ + + batches: list[CoupledBinning3DBatchResult] method_metadata: dict[str, Any] = field(default_factory=dict) def __len__(self) -> int: From 6e3f83f2b40fb04553b943a04c20a29305721b75 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 12:39:54 +0200 Subject: [PATCH 06/53] Added fitters whose job is to fit circles/spheres based on interfaced ata. --- src/wetting_angle_kit/analysis/fitters.py | 290 ++++++++++++++++++++++ 1 file changed, 290 insertions(+) create mode 100644 src/wetting_angle_kit/analysis/fitters.py diff --git a/src/wetting_angle_kit/analysis/fitters.py b/src/wetting_angle_kit/analysis/fitters.py new file mode 100644 index 0000000..7ebc5fb --- /dev/null +++ b/src/wetting_angle_kit/analysis/fitters.py @@ -0,0 +1,290 @@ +"""Surface fitters: derive a contact angle from interface points + wall. + +A :class:`SurfaceFitter` consumes an interface point set produced by an +:class:`InterfaceExtractor` plus a wall z-coordinate produced by a +:class:`WallDetector`, and returns one :class:`BatchResult` per call +holding the contact angle and fit diagnostics. + +Two fitter kinds are supported: + +- ``slicing``: one algebraic-circle fit per slice in the slice's + ``(x, z)`` plane, then a mean across slices. +- ``whole``: one algebraic-sphere fit (spherical droplet) or + algebraic-cylinder fit (cylindrical droplet) to the 3D interface + shell. + +Users construct fitters through classmethod factories on the base +class:: + + SurfaceFitter.slicing() + SurfaceFitter.whole(bootstrap_samples=0) + +Algorithm bodies are stubbed (``raise NotImplementedError``) at this +skeleton stage; only constructor surfaces and compatibility checks are +implemented here. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ClassVar, Literal + +import numpy as np + +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.results import ( + BatchResult, + SlicingBatchResult, + WholeBatchResult, +) +from wetting_angle_kit.analysis.wall import InterfaceData + +#: Surface-representation kind the fitter consumes. Mirrors +#: :data:`wetting_angle_kit.analysis.extractors.SurfaceKind` — the two +#: are kept in sync by the analyzer's compatibility check, which raises +#: if ``extractor.kind != fitter.kind``. +SurfaceKind = Literal["slicing", "whole"] + + +class FitOutput(ABC): + """Frames-less per-batch fit output returned by :meth:`SurfaceFitter.fit`. + + The fitter computes the geometric fit and returns one of + :class:`SlicingFitOutput` or :class:`WholeFitOutput`. The analyzer + then calls :meth:`to_batch_result` with the batch's frame indices + to produce the user-facing :class:`BatchResult` — keeping + bookkeeping (frames) and computation (the fit) separate. + """ + + @abstractmethod + def to_batch_result(self, frames: list[int]) -> BatchResult: + """Attach ``frames`` to this fit output and return a BatchResult.""" + + +@dataclass(frozen=True, eq=False, kw_only=True) +class SlicingFitOutput(FitOutput): + """Output of :meth:`SurfaceFitter.slicing` for one batch. + + Carries the same payload as :class:`SlicingBatchResult` minus + ``frames``. Field semantics are identical; see that class for + documentation. + """ + + angle: float + z_wall: float + rms_residual: float + angle_std: float + per_slice_angles: np.ndarray + slice_surfaces: list[np.ndarray] + slice_popts: np.ndarray + + def to_batch_result(self, frames: list[int]) -> SlicingBatchResult: + return SlicingBatchResult( + frames=frames, + angle=self.angle, + z_wall=self.z_wall, + rms_residual=self.rms_residual, + angle_std=self.angle_std, + per_slice_angles=self.per_slice_angles, + slice_surfaces=self.slice_surfaces, + slice_popts=self.slice_popts, + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class WholeFitOutput(FitOutput): + """Output of :meth:`SurfaceFitter.whole` for one batch. + + Carries the same payload as :class:`WholeBatchResult` minus + ``frames``. Field semantics are identical; see that class for + documentation. + """ + + angle: float + z_wall: float + rms_residual: float + angle_std: float | None + interface_shell: np.ndarray + popt: np.ndarray + + def to_batch_result(self, frames: list[int]) -> WholeBatchResult: + return WholeBatchResult( + frames=frames, + angle=self.angle, + z_wall=self.z_wall, + rms_residual=self.rms_residual, + angle_std=self.angle_std, + interface_shell=self.interface_shell, + popt=self.popt, + ) + + +class SurfaceFitter(ABC): + """Abstract base for contact-angle surface fitters. + + Concrete fitters are constructed through the classmethod factories + :meth:`slicing` and :meth:`whole`. Direct subclassing is supported + for custom strategies but the factories cover all built-in cases. + """ + + #: Surface-representation kind this fitter consumes. Set by each + #: concrete subclass; the analyzer matches this against the chosen + #: :class:`InterfaceExtractor` at construction time. + kind: ClassVar[SurfaceKind] + + @abstractmethod + def fit( + self, + interface_data: InterfaceData, + z_wall: float, + droplet_geometry: DropletGeometry, + ) -> FitOutput: + """Fit the contact angle for one batch. + + Parameters + ---------- + interface_data : InterfaceData + Interface point set produced by the + :class:`InterfaceExtractor`. Per-slice 2D points for + ``kind="slicing"``; a 3D shell for ``kind="whole"``. + z_wall : float + Wall-plane z-coordinate from the :class:`WallDetector`. + droplet_geometry : DropletGeometry + Droplet symmetry; controls the geometric model + (circle per slice / sphere / cylinder). + + Returns + ------- + FitOutput + :class:`SlicingFitOutput` for slicing fitters, + :class:`WholeFitOutput` for whole fitters. The analyzer + attaches the batch's frame indices via + :meth:`FitOutput.to_batch_result` to produce the + user-facing :class:`BatchResult`. + """ + + @abstractmethod + def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + """Raise if this fitter cannot handle ``droplet_geometry``. + + Called by :class:`TrajectoryAnalyzer.__init__`. The kind + compatibility (slicing vs whole) is enforced separately at + the analyzer level by matching :attr:`SurfaceFitter.kind` + against the extractor's chosen ``surface_kind``. + """ + + @classmethod + def slicing( + cls, + *, + surface_filter_offset: float = 2.0, + ) -> "SurfaceFitter": + """Per-slice algebraic circle fits, averaged across slices. + + Each slice's 2D interface points are filtered to + ``z > z_wall + surface_filter_offset`` (to exclude + wall-adjacent density distortions), an algebraic Kasa circle + is fit to the kept points, and the contact angle is the + angle of intersection between that circle and the line + ``z = z_wall``. The batch angle is the mean over slices; + :attr:`BatchResult.angle_std` is the empirical std across + slices. + + Parameters + ---------- + surface_filter_offset : float, default 2.0 + Vertical offset above ``z_wall`` (Å) below which interface + points are excluded from the circle fit. This is distinct + from any offset baked into the :class:`WallDetector`: this + offset is a fit-quality knob for the per-slice circle, and + the wall detector's offset (if it uses one, e.g. + :meth:`WallDetector.min_plus_offset`) defines where the + wall plane sits. + """ + return _SlicingFitter(surface_filter_offset=surface_filter_offset) + + @classmethod + def whole( + cls, + *, + surface_filter_offset: float = 2.0, + bootstrap_samples: int = 0, + ) -> "SurfaceFitter": + """Algebraic sphere or cylinder fit to the 3D interface shell. + + Spherical droplets get a sphere fit; cylindrical droplets get + a circular-cylinder fit whose axis is parallel to ``y`` + (internal frame, post axis-swap for ``cylinder_x``). The + contact angle follows from the cap geometry: + ``cos θ = (z_wall - z_center) / R``. + + Parameters + ---------- + surface_filter_offset : float, default 2.0 + Vertical offset above ``z_wall`` (Å) below which shell + points are excluded from the geometric fit. Same role as + in :meth:`slicing`: distinct from the wall detector's + offset. + bootstrap_samples : int, default 0 + If positive, the fit is repeated on this many bootstrap + resamples of the filtered shell, and the resulting std + of the angles is reported as + :attr:`BatchResult.angle_std`. ``0`` disables bootstrap; + the field is then ``None`` in the returned + :class:`WholeBatchResult`. + """ + return _WholeFitter( + surface_filter_offset=surface_filter_offset, + bootstrap_samples=bootstrap_samples, + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _SlicingFitter(SurfaceFitter): + """Concrete fitter for :meth:`SurfaceFitter.slicing`.""" + + kind: ClassVar[SurfaceKind] = "slicing" + + surface_filter_offset: float + + def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + # Slicing handles all three geometries (spherical and both + # cylinder orientations); nothing geometry-specific to reject. + return None + + def fit( + self, + interface_data: InterfaceData, + z_wall: float, + droplet_geometry: DropletGeometry, + ) -> SlicingFitOutput: + raise NotImplementedError("slicing surface fit not implemented in skeleton.") + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _WholeFitter(SurfaceFitter): + """Concrete fitter for :meth:`SurfaceFitter.whole`.""" + + kind: ClassVar[SurfaceKind] = "whole" + + surface_filter_offset: float + bootstrap_samples: int + + def __post_init__(self) -> None: + if self.bootstrap_samples < 0: + raise ValueError( + f"bootstrap_samples must be >= 0; got {self.bootstrap_samples}." + ) + + def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + # Whole-fit covers spherical (sphere fit) and both cylinder + # orientations (cylinder fit with the standard axis swap); + # nothing geometry-specific to reject. + return None + + def fit( + self, + interface_data: InterfaceData, + z_wall: float, + droplet_geometry: DropletGeometry, + ) -> WholeFitOutput: + raise NotImplementedError("whole surface fit not implemented in skeleton.") From 313a67dd2024f607d172f698e58bcd73c8a1d063 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 13:16:49 +0200 Subject: [PATCH 07/53] Added base file for the trajectory analysis. --- src/wetting_angle_kit/analysis/base.py | 332 +++++++++++++++++++++++++ 1 file changed, 332 insertions(+) create mode 100644 src/wetting_angle_kit/analysis/base.py diff --git a/src/wetting_angle_kit/analysis/base.py b/src/wetting_angle_kit/analysis/base.py new file mode 100644 index 0000000..297f204 --- /dev/null +++ b/src/wetting_angle_kit/analysis/base.py @@ -0,0 +1,332 @@ +"""Shared worker-pool scaffolding for batched trajectory analyzers. + +:class:`_BatchedTrajectoryAnalyzer` is the private base from which both +:class:`TrajectoryAnalyzer` and the two coupled-fit analyzers +(:class:`CoupledBinning2DAnalyzer`, :class:`CoupledBinning3DAnalyzer`) +inherit. It centralises: + +- the common constructor (parser + atom_indices + droplet_geometry + + temporal_aggregator + precentered + optional wall_atom_indices); +- the :meth:`analyze` orchestration (spawn-context worker pool + tqdm + progress + out-of-order result reassembly); +- the per-batch coordinate gathering (per-frame PBC recentering and + the ``cylinder_x`` axis swap, pooled across the batch's frames); +- helpers for building a parser inside a worker. + +Subclasses fill in: + +- ``_init_args()`` / ``_init_worker(...)`` — what shared state to + send to worker processes; +- ``_process_batch_worker(frame_indices)`` — the per-batch + computation, run inside a worker; +- ``_build_results(batches)`` — packaging the per-batch results + into the analyzer's results dataclass. + +Algorithm bodies for the subclass extension points are stubbed at this +skeleton stage. The shared scaffolding (constructor, ``analyze``, coord +gathering) is real because it is small, well-defined, and stable. +""" + +import logging +import multiprocessing as mp +from abc import abstractmethod +from typing import Any, ClassVar + +import numpy as np +from tqdm.auto import tqdm + +from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.results import BatchResult +from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.io_utils import ( + detect_parser_type, + recenter_droplet_pbc, +) +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). A scoped +# context keeps this side-effect-free at import time. +_MP_CONTEXT = mp.get_context("spawn") + +logger = logging.getLogger(__name__) + + +def build_parser(filename: str) -> BaseParser: + """Build a parser by sniffing the file's extension. + + Used by worker processes to rebuild a parser locally from a + filepath, since the parent's parser instance is generally not + picklable. + """ + 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}") + + +def gather_batch_coords( + *, + parser: BaseParser, + frame_indices: list[int], + atom_indices: np.ndarray, + droplet_geometry: DropletGeometry, + precentered: bool, +) -> tuple[np.ndarray, np.ndarray, float]: + """Pool liquid-atom coordinates across one batch. + + Each frame's atoms are recentered (per-frame circular-mean PBC + recentering unless ``precentered=True``) and the ``cylinder_x`` + axis swap is applied so downstream code can assume the cylinder + axis is always ``y``. Per-frame coordinate arrays are then + concatenated. + + Parameters + ---------- + parser : BaseParser + Parser to read frames from. Typically the worker-local copy. + frame_indices : list[int] + Frames to pool. May be a single frame (per-frame analysis) or + many frames (batched / fully pooled). + atom_indices : ndarray + Indices of liquid atoms; passed to ``parser.parse``. + droplet_geometry : DropletGeometry + Used for both PBC recentering and the axis swap. + precentered : bool + If True, skip the circular-mean PBC recentering and use the + plain arithmetic-mean centre. + + Returns + ------- + pooled_coords : ndarray, shape (sum_N, 3) + Concatenated liquid coordinates in the internal frame. + avg_center : ndarray, shape (3,) + Mean of the per-frame liquid centres; used as the ray-fan + origin by extractors. + max_dist : float + Maximum in-plane box half-extent across the batch, used as + the radial-sampling envelope. + """ + liquid_chunks: list[np.ndarray] = [] + centres: list[np.ndarray] = [] + max_box = 0.0 + for frame_num in frame_indices: + positions = parser.parse(frame_index=frame_num, indices=atom_indices) + if precentered: + mean_pos = np.mean(positions, axis=0) + else: + box_xy = ( + parser.box_size_x(frame_index=frame_num), + parser.box_size_y(frame_index=frame_num), + ) + positions, mean_pos = recenter_droplet_pbc( + positions, droplet_geometry.name, box_size=box_xy + ) + # The axis swap on a (N, 3) array; DropletGeometry handles + # both batch and single-point cases via the trailing-axis + # fancy index. + positions = droplet_geometry.to_internal_coords(positions) + mean_pos = droplet_geometry.to_internal_coords(mean_pos) + liquid_chunks.append(positions) + centres.append(mean_pos) + box_x = parser.box_size_x(frame_index=frame_num) + box_y = parser.box_size_y(frame_index=frame_num) + max_box = max(max_box, float(box_x), float(box_y)) + pooled = ( + np.concatenate(liquid_chunks, axis=0) if liquid_chunks else np.empty((0, 3)) + ) + avg_center = np.mean(np.stack(centres, axis=0), axis=0) if centres else np.zeros(3) + return pooled, avg_center, max_box / 2.0 + + +def gather_wall_coords( + *, + parser: BaseParser, + frame_indices: list[int], + wall_atom_indices: np.ndarray, + droplet_geometry: DropletGeometry, +) -> np.ndarray: + """Pool wall-atom coordinates across one batch. + + Wall atoms are not PBC-recentered (the wall is assumed fixed in + its lab-frame position) but the ``cylinder_x`` axis swap is + applied so the wall lives in the same internal frame as the + liquid pool returned by :func:`gather_batch_coords`. + + Parameters + ---------- + parser : BaseParser + Parser to read frames from. + frame_indices : list[int] + Frames to pool. + wall_atom_indices : ndarray + Indices of wall atoms. + droplet_geometry : DropletGeometry + Used for the axis swap. + + Returns + ------- + ndarray, shape (sum_N, 3) + Concatenated wall coordinates in the internal frame. + """ + chunks: list[np.ndarray] = [] + for frame_num in frame_indices: + positions = parser.parse(frame_index=frame_num, indices=wall_atom_indices) + positions = droplet_geometry.to_internal_coords(positions) + chunks.append(positions) + return np.concatenate(chunks, axis=0) if chunks else np.empty((0, 3)) + + +class _BatchedTrajectoryAnalyzer(BaseTrajectoryAnalyzer): + """Shared scaffolding for batched trajectory analyzers. + + Not user-facing. Concrete analyzers (:class:`TrajectoryAnalyzer`, + :class:`CoupledBinning2DAnalyzer`, :class:`CoupledBinning3DAnalyzer`) + inherit from this and provide the four extension points listed in + the module docstring. + """ + + #: Per-process worker state. Each concrete subclass MUST shadow this + #: with its own empty dict so worker initialisation writes to a + #: subclass-specific slot rather than colliding via the parent class. + _WORKER_STATE: ClassVar[dict[str, Any]] = {} + + def __init__( + self, + parser: Any, + atom_indices: np.ndarray | None = None, + droplet_geometry: DropletGeometry | str = "spherical", + *, + temporal_aggregator: TemporalAggregator | None = None, + precentered: bool = False, + wall_atom_indices: np.ndarray | None = None, + ) -> None: + # Fail fast on the parser shape so users see the error at + # construction, not on first batch. + detect_parser_type(parser.filepath) + self.parser = parser + self.atom_indices = ( + np.asarray(atom_indices) if atom_indices is not None else np.array([]) + ) + self.wall_atom_indices = ( + np.asarray(wall_atom_indices) if wall_atom_indices is not None else None + ) + self.droplet_geometry = DropletGeometry.coerce(droplet_geometry) + self.temporal_aggregator = temporal_aggregator or TemporalAggregator( + batch_size=1 + ) + self.precentered = precentered + + def analyze( + self, + frame_range: list[int] | None = None, + n_jobs: int | None = None, + ) -> Any: + """Run the analyzer in parallel across batches. + + Parameters + ---------- + frame_range : list[int], optional + Frame indices to analyse. Defaults to every frame in the + trajectory (``range(parser.frame_count())``). + n_jobs : int, optional + Worker process count. ``None`` lets + ``multiprocessing.Pool`` pick the default + (``os.cpu_count()``). + + Returns + ------- + Results + Subclass-specific results dataclass produced by + :meth:`_build_results`. + """ + if frame_range is None: + frame_range = list(range(self.parser.frame_count())) + batches = list(self.temporal_aggregator.iter_batches(frame_range)) + if not batches: + return self._build_results(batches=[]) + + logger.info( + f"Processing {len(batches)} batches " + f"(batch_size={self.temporal_aggregator.batch_size}, n_jobs={n_jobs})." + ) + + init_args = self._init_args() + batch_results: list[BatchResult] = [] + with ( + _MP_CONTEXT.Pool( + processes=n_jobs, + initializer=self._init_worker, + initargs=init_args, + ) as pool, + tqdm( + total=len(batches), + desc=self._tqdm_desc(), + unit="batch", + ) as pbar, + ): + for result in pool.imap_unordered(self._process_batch_worker, batches): + if result is not None: + batch_results.append(result) + pbar.update(1) + + if not batch_results: + raise RuntimeError( + f"None of the {len(batches)} requested batches produced a " + "result. Check the worker logs above for the underlying " + "parser, geometry, or fit errors." + ) + + # ``imap_unordered`` returns completion-ordered; restore batch + # order using the first frame index in each batch. + batch_results.sort(key=lambda b: min(b.frames) if b.frames else 0) + return self._build_results(batches=batch_results) + + def _tqdm_desc(self) -> str: + """Progress bar label. Subclasses may override for clarity.""" + return type(self).__name__ + + # ------------------------------------------------------------------ + # Subclass extension points. + # ------------------------------------------------------------------ + + @abstractmethod + def _init_args(self) -> tuple: + """Return the tuple of args sent to every worker on startup. + + Must be picklable. The companion :meth:`_init_worker` unpacks + this tuple inside each worker and stores the rebuilt state. + """ + + @staticmethod + @abstractmethod + def _init_worker(*args: Any) -> None: + """Populate the subclass's ``_WORKER_STATE`` inside a worker. + + Called once per worker process at pool startup. Implementations + typically rebuild the parser via :func:`build_parser` and + stash all per-batch-needed state into the class-level dict. + """ + + @staticmethod + @abstractmethod + def _process_batch_worker(frame_indices: list[int]) -> BatchResult | None: + """Process one batch inside a worker process. + + Reads from the subclass's ``_WORKER_STATE`` populated by + :meth:`_init_worker`. Returns the per-batch result, or + ``None`` on a per-batch failure (the parent process logs and + skips ``None`` results, raising only if every batch fails). + """ + + @abstractmethod + def _build_results(self, batches: list[BatchResult]) -> Any: + """Wrap per-batch results into the analyzer's results dataclass.""" From a8a1706c1c062831563614fb326d8819bb726127 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 13:48:28 +0200 Subject: [PATCH 08/53] Skeleton for TrajectoryAnalyzer. --- src/wetting_angle_kit/analysis/trajectory.py | 235 +++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 src/wetting_angle_kit/analysis/trajectory.py diff --git a/src/wetting_angle_kit/analysis/trajectory.py b/src/wetting_angle_kit/analysis/trajectory.py new file mode 100644 index 0000000..b9012d3 --- /dev/null +++ b/src/wetting_angle_kit/analysis/trajectory.py @@ -0,0 +1,235 @@ +"""The decomposed :class:`TrajectoryAnalyzer`. + +:class:`TrajectoryAnalyzer` ties together the five strategy components +that define a contact-angle analysis pipeline: + +- :class:`DropletGeometry` — droplet symmetry / internal axis layout +- :class:`TemporalAggregator` — per-frame vs pooled-batch scheduling +- :class:`InterfaceExtractor` — atom → interface points +- :class:`SurfaceFitter` — interface points → contact angle +- :class:`WallDetector` — wall plane location + +The class extends the shared :class:`_BatchedTrajectoryAnalyzer` +worker-pool scaffolding by implementing the four extension points +documented there. The per-batch wiring lives in +:meth:`_process_batch_worker`; algorithm bodies (extraction, fitting, +wall detection beyond :meth:`WallDetector.min_plus_offset`) remain +``NotImplementedError`` until porting begins. + +The joint-fit analyzers (:class:`CoupledBinning2DAnalyzer`, +:class:`CoupledBinning3DAnalyzer`) live in their own modules and +share only the worker-pool scaffolding, not this strategy pipeline. +""" + +import logging +from typing import Any, ClassVar + +import numpy as np + +from wetting_angle_kit.analysis.base import ( + _BatchedTrajectoryAnalyzer, + build_parser, + gather_batch_coords, + gather_wall_coords, +) +from wetting_angle_kit.analysis.extractors import InterfaceExtractor +from wetting_angle_kit.analysis.fitters import SurfaceFitter +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.results import BatchResult, TrajectoryResults +from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.analysis.wall import WallContext, WallDetector + +logger = logging.getLogger(__name__) + + +class TrajectoryAnalyzer(_BatchedTrajectoryAnalyzer): + """Decomposed contact-angle analyzer: extractor → wall → fitter. + + 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``. + atom_indices : ndarray, optional + Indices of the liquid atoms; passed through to the parser's + per-frame ``parse`` call. Empty by default. + droplet_geometry : DropletGeometry or str, default ``"spherical"`` + Either a :class:`DropletGeometry` instance or the bare name + string. Drives the internal axis convention and the per-slice + layout used by the extractor. + interface_extractor : InterfaceExtractor + Built via one of :meth:`InterfaceExtractor.rays_gaussian` / + :meth:`InterfaceExtractor.rays_binning` / + :meth:`InterfaceExtractor.grid_gaussian` / + :meth:`InterfaceExtractor.grid_binning`. + surface_fitter : SurfaceFitter + Built via :meth:`SurfaceFitter.slicing` or + :meth:`SurfaceFitter.whole`. Its :attr:`kind` must match the + extractor's natural output, which is enforced via + :meth:`InterfaceExtractor.validate_compatibility` at + construction. + wall_detector : WallDetector, optional + Built via :meth:`WallDetector.min_plus_offset` / + :meth:`WallDetector.explicit` / + :meth:`WallDetector.from_atoms`. Defaults to + ``WallDetector.min_plus_offset(offset=2.0)``. + temporal_aggregator : TemporalAggregator, optional + Defaults to per-frame analysis (``batch_size=1``). + 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. + wall_atom_indices : ndarray, optional + Required when ``wall_detector`` is a + :meth:`WallDetector.from_atoms` instance. The analyzer gathers + and pools these coordinates per batch and supplies them to the + detector via :attr:`WallContext.wall_coords`. + """ + + #: Per-process worker state — shadowed from the parent so this + #: subclass writes to its own slot and never collides with the + #: coupled-fit analyzers in the same process. + _WORKER_STATE: ClassVar[dict[str, Any]] = {} + + def __init__( + self, + parser: Any, + atom_indices: np.ndarray | None = None, + droplet_geometry: DropletGeometry | str = "spherical", + *, + interface_extractor: InterfaceExtractor, + surface_fitter: SurfaceFitter, + wall_detector: WallDetector | None = None, + temporal_aggregator: TemporalAggregator | None = None, + precentered: bool = False, + wall_atom_indices: np.ndarray | None = None, + ) -> None: + super().__init__( + parser=parser, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + temporal_aggregator=temporal_aggregator, + precentered=precentered, + wall_atom_indices=wall_atom_indices, + ) + self.interface_extractor = interface_extractor + self.surface_fitter = surface_fitter + self.wall_detector = wall_detector or WallDetector.min_plus_offset(offset=2.0) + # Fail fast on incompatible component combinations. The + # extractor validates against (surface_kind, droplet_geometry); + # the fitter validates against (droplet_geometry). + self.interface_extractor.validate_compatibility( + surface_kind=self.surface_fitter.kind, + droplet_geometry=self.droplet_geometry, + ) + self.surface_fitter.validate_compatibility(self.droplet_geometry) + + # ------------------------------------------------------------------ + # _BatchedTrajectoryAnalyzer extension points. + # ------------------------------------------------------------------ + + def _tqdm_desc(self) -> str: + return ( + f"TrajectoryAnalyzer ({self.surface_fitter.kind} / " + f"{self.interface_extractor.sampling})" + ) + + def _init_args(self) -> tuple: + return ( + self.parser.filepath, + self.atom_indices, + self.wall_atom_indices, + self.droplet_geometry, + self.interface_extractor, + self.surface_fitter, + self.wall_detector, + self.precentered, + ) + + @staticmethod + def _init_worker( + filename: str, + atom_indices: np.ndarray, + wall_atom_indices: np.ndarray | None, + droplet_geometry: DropletGeometry, + interface_extractor: InterfaceExtractor, + surface_fitter: SurfaceFitter, + wall_detector: WallDetector, + precentered: bool, + ) -> None: + cls = TrajectoryAnalyzer + cls._WORKER_STATE.clear() + cls._WORKER_STATE.update( + parser=build_parser(filename), + atom_indices=atom_indices, + wall_atom_indices=wall_atom_indices, + droplet_geometry=droplet_geometry, + interface_extractor=interface_extractor, + surface_fitter=surface_fitter, + wall_detector=wall_detector, + precentered=precentered, + ) + + @staticmethod + def _process_batch_worker(frame_indices: list[int]) -> BatchResult | None: + state = TrajectoryAnalyzer._WORKER_STATE + parser = state["parser"] + atom_indices: np.ndarray = state["atom_indices"] + wall_atom_indices: np.ndarray | None = state["wall_atom_indices"] + droplet_geometry: DropletGeometry = state["droplet_geometry"] + extractor: InterfaceExtractor = state["interface_extractor"] + fitter: SurfaceFitter = state["surface_fitter"] + detector: WallDetector = state["wall_detector"] + precentered: bool = state["precentered"] + try: + coords, center, max_dist = gather_batch_coords( + parser=parser, + frame_indices=frame_indices, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + precentered=precentered, + ) + wall_coords = ( + gather_wall_coords( + parser=parser, + frame_indices=frame_indices, + wall_atom_indices=wall_atom_indices, + droplet_geometry=droplet_geometry, + ) + if wall_atom_indices is not None + else None + ) + interface_data = extractor.extract( + liquid_coordinates=coords, + center_geom=center, + droplet_geometry=droplet_geometry, + max_dist=max_dist, + surface_kind=fitter.kind, + ) + z_wall = detector.detect( + WallContext( + interface_data=interface_data, + wall_coords=wall_coords, + ) + ) + fit_output = fitter.fit( + interface_data=interface_data, + z_wall=z_wall, + droplet_geometry=droplet_geometry, + ) + return fit_output.to_batch_result(list(frame_indices)) + except Exception as e: + logger.error(f"Error processing batch {frame_indices}: {e}", exc_info=True) + return None + + def _build_results(self, batches: list[BatchResult]) -> TrajectoryResults: + return TrajectoryResults( + batches=batches, + method_metadata={ + "kind": self.surface_fitter.kind, + "sampling": self.interface_extractor.sampling, + "droplet_geometry": self.droplet_geometry.name, + "batch_size": self.temporal_aggregator.batch_size, + }, + ) From 934f1352e9e1e32673d792dffb7949c04b26d9f6 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 14:16:35 +0200 Subject: [PATCH 09/53] Added CoupledBinning2DAnalyzer, which should replace the old binning method. --- src/wetting_angle_kit/analysis/base.py | 26 ++- .../analysis/coupled_binning_2d.py | 191 ++++++++++++++++++ 2 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 src/wetting_angle_kit/analysis/coupled_binning_2d.py diff --git a/src/wetting_angle_kit/analysis/base.py b/src/wetting_angle_kit/analysis/base.py index 297f204..637397e 100644 --- a/src/wetting_angle_kit/analysis/base.py +++ b/src/wetting_angle_kit/analysis/base.py @@ -37,7 +37,6 @@ from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.results import BatchResult from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.io_utils import ( detect_parser_type, @@ -260,7 +259,11 @@ def analyze( ) init_args = self._init_args() - batch_results: list[BatchResult] = [] + # ``Any`` here because each concrete subclass has its own + # per-batch result type (BatchResult / CoupledBinning2DBatchResult + # / CoupledBinning3DBatchResult); they share a duck-typed + # ``.frames`` attribute used for ordering, not a nominal base. + batch_results: list[Any] = [] with ( _MP_CONTEXT.Pool( processes=n_jobs, @@ -318,15 +321,22 @@ def _init_worker(*args: Any) -> None: @staticmethod @abstractmethod - def _process_batch_worker(frame_indices: list[int]) -> BatchResult | None: + def _process_batch_worker(frame_indices: list[int]) -> Any: """Process one batch inside a worker process. Reads from the subclass's ``_WORKER_STATE`` populated by - :meth:`_init_worker`. Returns the per-batch result, or - ``None`` on a per-batch failure (the parent process logs and - skips ``None`` results, raising only if every batch fails). + :meth:`_init_worker`. Returns the per-batch result (a + :class:`BatchResult` subclass for :class:`TrajectoryAnalyzer`, + a :class:`CoupledBinning2DBatchResult` for the 2D coupled fit, + etc.) — or ``None`` on a per-batch failure. The parent process + logs and skips ``None`` results, raising only if every batch + fails. """ @abstractmethod - def _build_results(self, batches: list[BatchResult]) -> Any: - """Wrap per-batch results into the analyzer's results dataclass.""" + def _build_results(self, batches: list[Any]) -> Any: + """Wrap per-batch results into the analyzer's results dataclass. + + ``batches`` carries the concrete per-batch type the subclass + returns from :meth:`_process_batch_worker`. + """ diff --git a/src/wetting_angle_kit/analysis/coupled_binning_2d.py b/src/wetting_angle_kit/analysis/coupled_binning_2d.py new file mode 100644 index 0000000..8232d1b --- /dev/null +++ b/src/wetting_angle_kit/analysis/coupled_binning_2d.py @@ -0,0 +1,191 @@ +"""Coupled 2D-binning joint contact-angle analyzer. + +:class:`CoupledBinning2DAnalyzer` is the modern incarnation of the +package's original binning method. Unlike :class:`TrajectoryAnalyzer` +it does not separate interface extraction, wall detection, and surface +fit — a seven-parameter hyperbolic-tangent model (rho1, rho2, R_eq, +zi_c, zi_0, t1, t2) solves all three jointly on a binned 2D density +grid. + +Use it when: + +- the droplet is in the spherical-cap regime (cylindrical works too; + the 2D fit exploits the cylinder's translational symmetry); +- you have many frames per batch so the binned density is + well-sampled; +- you want a single robust estimate per batch and don't need per-frame + time resolution. + +For per-frame analysis with separable strategies use +:class:`TrajectoryAnalyzer` instead. For the 3D extension of this +analyzer (relaxing the radial symmetry assumption) see +:class:`CoupledBinning3DAnalyzer`. + +The algorithm body (projection → 2D histogram → tanh fit) is stubbed +with ``NotImplementedError`` at this skeleton stage. The +worker-pool wiring is real, so misconfigurations and per-batch +exception handling are exercised end-to-end. +""" + +import logging +from typing import Any, ClassVar + +import numpy as np + +from wetting_angle_kit.analysis.base import ( + _BatchedTrajectoryAnalyzer, + build_parser, + gather_batch_coords, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.results import ( + CoupledBinning2DBatchResult, + CoupledBinning2DResults, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator + +logger = logging.getLogger(__name__) + + +class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): + """Joint contact-angle fit on a 2D binned density grid. + + Parameters + ---------- + parser : BaseParser + Trajectory parser. Only ``parser.filepath`` and + ``parser.frame_count()`` are read in the parent process; each + worker rebuilds its own parser. + atom_indices : ndarray, optional + Indices of the liquid atoms. + droplet_geometry : DropletGeometry or str, default ``"spherical"`` + Either an instance or the bare name string. Determines the + per-frame projection onto the ``(xi, zi)`` plane: spherical + droplets use the in-plane radial coordinate + ``xi = sqrt(x^2 + y^2)``; cylindrical droplets use the + coordinate perpendicular to the cylinder axis. + binning_params : dict, optional + 2D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"nbins_xi"``, + ``"zi_0"``, ``"zi_f"``, ``"nbins_zi"``. If ``None``, a + heuristic default is used (a third of the largest in-plane box + dimension; 50 × 50 bins). The heuristic is rarely optimal for + a specific system and emits a warning when used. + initial_params : list[float], optional + Initial guess for the seven tanh-model parameters + ``[rho1, rho2, R_eq, zi_c, zi_0, t1, t2]``. Defaults to the + values tuned for room-temperature water in the existing + ``HyperbolicTangentModel``. + temporal_aggregator : TemporalAggregator, optional + Defaults to a single fully pooled batch + (``batch_size=-1``) — the coupled fit benefits from as much + statistics as possible. Set ``batch_size=N`` to compute + independent angles for each ``N``-frame block. + 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. + """ + + #: Per-process worker state — shadowed from the parent so this + #: subclass writes to its own slot. + _WORKER_STATE: ClassVar[dict[str, Any]] = {} + + def __init__( + self, + parser: Any, + atom_indices: np.ndarray | None = None, + droplet_geometry: DropletGeometry | str = "spherical", + *, + binning_params: dict[str, Any] | None = None, + initial_params: list[float] | None = None, + temporal_aggregator: TemporalAggregator | None = None, + precentered: bool = False, + ) -> None: + super().__init__( + parser=parser, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + temporal_aggregator=temporal_aggregator + or TemporalAggregator(batch_size=-1), + precentered=precentered, + ) + self.binning_params = binning_params + self.initial_params = initial_params + + # ------------------------------------------------------------------ + # _BatchedTrajectoryAnalyzer extension points. + # ------------------------------------------------------------------ + + def _tqdm_desc(self) -> str: + return f"CoupledBinning2DAnalyzer ({self.droplet_geometry.name})" + + def _init_args(self) -> tuple: + return ( + self.parser.filepath, + self.atom_indices, + self.droplet_geometry, + self.binning_params, + self.initial_params, + self.precentered, + ) + + @staticmethod + def _init_worker( + filename: str, + atom_indices: np.ndarray, + droplet_geometry: DropletGeometry, + binning_params: dict[str, Any] | None, + initial_params: list[float] | None, + precentered: bool, + ) -> None: + cls = CoupledBinning2DAnalyzer + cls._WORKER_STATE.clear() + cls._WORKER_STATE.update( + parser=build_parser(filename), + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + binning_params=binning_params, + initial_params=initial_params, + precentered=precentered, + ) + + @staticmethod + def _process_batch_worker( + frame_indices: list[int], + ) -> CoupledBinning2DBatchResult | None: + state = CoupledBinning2DAnalyzer._WORKER_STATE + parser = state["parser"] + atom_indices: np.ndarray = state["atom_indices"] + droplet_geometry: DropletGeometry = state["droplet_geometry"] + precentered: bool = state["precentered"] + try: + # Pooled liquid-atom coordinates across the batch. The + # CoupledBinning2D algorithm then projects to (xi, zi), + # builds the histogram, and fits the tanh model — all + # currently stubbed. + coords, center, max_dist = gather_batch_coords( + parser=parser, + frame_indices=frame_indices, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + precentered=precentered, + ) + raise NotImplementedError( + "coupled 2D-binning joint fit not implemented in skeleton." + ) + except Exception as e: + logger.error(f"Error processing batch {frame_indices}: {e}", exc_info=True) + return None + + def _build_results( + self, batches: list[CoupledBinning2DBatchResult] + ) -> CoupledBinning2DResults: + return CoupledBinning2DResults( + batches=batches, + method_metadata={ + "droplet_geometry": self.droplet_geometry.name, + "binning_params": self.binning_params, + "initial_params": self.initial_params, + "batch_size": self.temporal_aggregator.batch_size, + }, + ) From f597924b2698307c88676a1d885b17201ec5272d Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 14:23:13 +0200 Subject: [PATCH 10/53] Added the same for 3D spherical analysis. --- .../analysis/coupled_binning_3d.py | 192 ++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 src/wetting_angle_kit/analysis/coupled_binning_3d.py diff --git a/src/wetting_angle_kit/analysis/coupled_binning_3d.py b/src/wetting_angle_kit/analysis/coupled_binning_3d.py new file mode 100644 index 0000000..c1d4f8e --- /dev/null +++ b/src/wetting_angle_kit/analysis/coupled_binning_3d.py @@ -0,0 +1,192 @@ +"""Coupled 3D-binning joint contact-angle analyzer. + +:class:`CoupledBinning3DAnalyzer` is the 3D extension of the joint +binning fit (:class:`CoupledBinning2DAnalyzer`). Instead of projecting +atoms onto a 2D ``(xi, zi)`` plane and exploiting radial symmetry, it +bins the full 3D density ``rho(xi, yi, zi)`` and fits a nine-parameter +hyperbolic-tangent model (``rho1, rho2, R_eq, xi_c, yi_c, zi_c, zi_0, +t1, t2``) directly. + +Use it when: + +- the droplet is spherical AND you want to avoid the radial-symmetry + assumption baked into the 2D fit (e.g. you suspect asymmetry from + an anisotropic wall or wetting heterogeneity); +- you have many frames per batch — a 3D density grid needs more + sampling than a 2D one to reach the same per-cell noise. + +Cylindrical droplets are rejected at construction: their translational +symmetry along the cylinder axis means the 3D fit reduces to the 2D +fit already implemented by :class:`CoupledBinning2DAnalyzer`. + +The algorithm body (3D histogram → joint tanh fit) is stubbed with +``NotImplementedError`` at this skeleton stage. The worker-pool wiring +is real, so misconfigurations and per-batch exception handling are +exercised end-to-end. +""" + +import logging +from typing import Any, ClassVar + +import numpy as np + +from wetting_angle_kit.analysis.base import ( + _BatchedTrajectoryAnalyzer, + build_parser, + gather_batch_coords, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.results import ( + CoupledBinning3DBatchResult, + CoupledBinning3DResults, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator + +logger = logging.getLogger(__name__) + + +class CoupledBinning3DAnalyzer(_BatchedTrajectoryAnalyzer): + """Joint contact-angle fit on a 3D binned density grid. + + Parameters + ---------- + parser : BaseParser + Trajectory parser. Only ``parser.filepath`` and + ``parser.frame_count()`` are read in the parent process; each + worker rebuilds its own parser. + atom_indices : ndarray, optional + Indices of the liquid atoms. + droplet_geometry : DropletGeometry or str, default ``"spherical"`` + Must be spherical. Cylindrical droplets are rejected at + construction because their translational symmetry already + collapses the 3D problem onto the 2D one solved by + :class:`CoupledBinning2DAnalyzer`. + binning_params : dict, optional + 3D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"nbins_xi"``, + ``"yi_0"``, ``"yi_f"``, ``"nbins_yi"``, ``"zi_0"``, ``"zi_f"``, + ``"nbins_zi"``. If ``None``, a heuristic default is used (a + third of the largest in-plane box dimension; 50 × 50 × 50 + bins). The heuristic is rarely optimal and emits a warning. + initial_params : list[float], optional + Initial guess for the nine tanh-model parameters + ``[rho1, rho2, R_eq, xi_c, yi_c, zi_c, zi_0, t1, t2]``. + Defaults to values consistent with the 2D model's defaults + plus ``xi_c=0, yi_c=0`` (assuming the droplet is recentered). + temporal_aggregator : TemporalAggregator, optional + Defaults to a single fully pooled batch + (``batch_size=-1``). The 3D density needs more frames than the + 2D one for comparable per-cell noise. + precentered : bool, default ``False`` + Skip per-frame circular-mean PBC recentering. + """ + + #: Per-process worker state — shadowed from the parent so this + #: subclass writes to its own slot. + _WORKER_STATE: ClassVar[dict[str, Any]] = {} + + def __init__( + self, + parser: Any, + atom_indices: np.ndarray | None = None, + droplet_geometry: DropletGeometry | str = "spherical", + *, + binning_params: dict[str, Any] | None = None, + initial_params: list[float] | None = None, + temporal_aggregator: TemporalAggregator | None = None, + precentered: bool = False, + ) -> None: + super().__init__( + parser=parser, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + temporal_aggregator=temporal_aggregator + or TemporalAggregator(batch_size=-1), + precentered=precentered, + ) + if not self.droplet_geometry.is_spherical: + raise ValueError( + "CoupledBinning3DAnalyzer only supports spherical droplets; " + f"got droplet_geometry={self.droplet_geometry.name!r}. " + "For cylindrical droplets use CoupledBinning2DAnalyzer — " + "the 3D fit collapses onto the 2D one by translational " + "symmetry along the cylinder axis." + ) + self.binning_params = binning_params + self.initial_params = initial_params + + # ------------------------------------------------------------------ + # _BatchedTrajectoryAnalyzer extension points. + # ------------------------------------------------------------------ + + def _tqdm_desc(self) -> str: + return "CoupledBinning3DAnalyzer (spherical)" + + def _init_args(self) -> tuple: + return ( + self.parser.filepath, + self.atom_indices, + self.droplet_geometry, + self.binning_params, + self.initial_params, + self.precentered, + ) + + @staticmethod + def _init_worker( + filename: str, + atom_indices: np.ndarray, + droplet_geometry: DropletGeometry, + binning_params: dict[str, Any] | None, + initial_params: list[float] | None, + precentered: bool, + ) -> None: + cls = CoupledBinning3DAnalyzer + cls._WORKER_STATE.clear() + cls._WORKER_STATE.update( + parser=build_parser(filename), + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + binning_params=binning_params, + initial_params=initial_params, + precentered=precentered, + ) + + @staticmethod + def _process_batch_worker( + frame_indices: list[int], + ) -> CoupledBinning3DBatchResult | None: + state = CoupledBinning3DAnalyzer._WORKER_STATE + parser = state["parser"] + atom_indices: np.ndarray = state["atom_indices"] + droplet_geometry: DropletGeometry = state["droplet_geometry"] + precentered: bool = state["precentered"] + try: + # Pooled liquid-atom coordinates across the batch. The + # CoupledBinning3D algorithm then builds the 3D histogram + # and fits the nine-parameter tanh model — both stubbed. + coords, center, max_dist = gather_batch_coords( + parser=parser, + frame_indices=frame_indices, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + precentered=precentered, + ) + raise NotImplementedError( + "coupled 3D-binning joint fit not implemented in skeleton." + ) + except Exception as e: + logger.error(f"Error processing batch {frame_indices}: {e}", exc_info=True) + return None + + def _build_results( + self, batches: list[CoupledBinning3DBatchResult] + ) -> CoupledBinning3DResults: + return CoupledBinning3DResults( + batches=batches, + method_metadata={ + "droplet_geometry": self.droplet_geometry.name, + "binning_params": self.binning_params, + "initial_params": self.initial_params, + "batch_size": self.temporal_aggregator.batch_size, + }, + ) From 90c245c96415befdc041ee34c762d7f9d7d62115 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 14:36:12 +0200 Subject: [PATCH 11/53] Importing new classes. Will start implementing now. --- pyproject.toml | 13 ++- src/wetting_angle_kit/analysis/__init__.py | 103 ++++++++++++++++++--- 2 files changed, 103 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 90a777f..6358714 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,9 +52,20 @@ viz = [ ] ase = ["ase>=3.23.0"] ovito = ["ovito~=3.11.3"] +# scikit-image is only needed for whole-kind extraction with a grid +# extractor (InterfaceExtractor.grid_gaussian / grid_binning paired +# with SurfaceFitter.whole), which uses marching cubes to recover a +# 3D shell from the binned density. Slicing-only workflows do not +# need this extra. +grid3d = ["scikit-image>=0.22"] # Convenience extra: installs both trajectory backends and the optional # visualization helpers. Use ``[all,dev,doc]`` for a full developer setup. -all = ["ase>=3.23.0", "ovito~=3.11.3", "ipython>=8.0.0"] +all = [ + "ase>=3.23.0", + "ovito~=3.11.3", + "ipython>=8.0.0", + "scikit-image>=0.22", +] [project.urls] "Homepage" = "https://github.com/Matgenix/wetting-angle-kit" diff --git a/src/wetting_angle_kit/analysis/__init__.py b/src/wetting_angle_kit/analysis/__init__.py index 7333577..50f9fb9 100644 --- a/src/wetting_angle_kit/analysis/__init__.py +++ b/src/wetting_angle_kit/analysis/__init__.py @@ -1,23 +1,102 @@ -"""Contact-angle analysis orchestrators and per-method engines.""" +"""Contact-angle analysis orchestrators and per-method engines. + +Public API summary +------------------ + +Top-level analyzers (call ``analyze()`` to run a study):: + + TrajectoryAnalyzer # decomposed pipeline: extractor → wall → fitter + CoupledBinning2DAnalyzer # joint fit on a 2D density grid + CoupledBinning3DAnalyzer # joint fit on a 3D density grid (spherical only) + +Strategy components (compose into :class:`TrajectoryAnalyzer`):: + + DropletGeometry # spherical / cylinder_x / cylinder_y + TemporalAggregator # per-frame / pooled batches + InterfaceExtractor # rays / grid × gaussian / binning + SurfaceFitter # slicing / whole + WallDetector # min_plus_offset / explicit / from_atoms + +Results dataclasses returned by ``analyze()``:: + + TrajectoryResults, BatchResult, SlicingBatchResult, WholeBatchResult + CoupledBinning2DResults, CoupledBinning2DBatchResult + CoupledBinning3DResults, CoupledBinning3DBatchResult + +Legacy entry points (kept while the new pipeline's algorithm bodies are +being ported; scheduled for removal once :class:`TrajectoryAnalyzer` +subsumes them):: + + SlicingTrajectoryAnalyzer, SlicingFrameFitter + BinningTrajectoryAnalyzer, BinningBatchFitter +""" 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 ( - BinningBatchFitter, -) -from wetting_angle_kit.analysis.slicing.analyzer import ( - SlicingTrajectoryAnalyzer, + +# Legacy entry points. Kept while the new pipeline's algorithm bodies +# are being ported. To be removed once TrajectoryAnalyzer + +# CoupledBinning{2,3}DAnalyzer fully subsume them. +from wetting_angle_kit.analysis.binning.analyzer import BinningTrajectoryAnalyzer +from wetting_angle_kit.analysis.binning.angle_fitting import BinningBatchFitter + +# New top-level analyzers. +from wetting_angle_kit.analysis.coupled_binning_2d import CoupledBinning2DAnalyzer +from wetting_angle_kit.analysis.coupled_binning_3d import CoupledBinning3DAnalyzer + +# Strategy components. +from wetting_angle_kit.analysis.extractors import InterfaceExtractor +from wetting_angle_kit.analysis.fitters import ( + FitOutput, + SlicingFitOutput, + SurfaceFitter, + WholeFitOutput, ) -from wetting_angle_kit.analysis.slicing.angle_fitting import ( - SlicingFrameFitter, +from wetting_angle_kit.analysis.geometry import DropletGeometry + +# Results dataclasses. +from wetting_angle_kit.analysis.results import ( + BatchResult, + CoupledBinning2DBatchResult, + CoupledBinning2DResults, + CoupledBinning3DBatchResult, + CoupledBinning3DResults, + SlicingBatchResult, + TrajectoryResults, + WholeBatchResult, ) +from wetting_angle_kit.analysis.slicing.analyzer import SlicingTrajectoryAnalyzer +from wetting_angle_kit.analysis.slicing.angle_fitting import SlicingFrameFitter +from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.analysis.trajectory import TrajectoryAnalyzer +from wetting_angle_kit.analysis.wall import WallDetector __all__ = [ + # Top-level analyzers. "BaseTrajectoryAnalyzer", + "TrajectoryAnalyzer", + "CoupledBinning2DAnalyzer", + "CoupledBinning3DAnalyzer", + # Strategy components. + "DropletGeometry", + "TemporalAggregator", + "InterfaceExtractor", + "SurfaceFitter", + "FitOutput", + "SlicingFitOutput", + "WholeFitOutput", + "WallDetector", + # Results. + "BatchResult", + "SlicingBatchResult", + "WholeBatchResult", + "TrajectoryResults", + "CoupledBinning2DBatchResult", + "CoupledBinning2DResults", + "CoupledBinning3DBatchResult", + "CoupledBinning3DResults", + # Legacy. "SlicingTrajectoryAnalyzer", + "SlicingFrameFitter", "BinningTrajectoryAnalyzer", "BinningBatchFitter", - "SlicingFrameFitter", ] From 4830438d9d650e27a3a98ca055cfb791198c2ff5 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 5 Jun 2026 12:42:04 +0000 Subject: [PATCH 12/53] (auto) Paper PDF Draft --- wetting_angle_kit_JOSS/paper.pdf | Bin 747472 -> 747472 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/wetting_angle_kit_JOSS/paper.pdf b/wetting_angle_kit_JOSS/paper.pdf index 490ade50dddf589458f265dcc9866e32680a3960..29c91b27f84eae5728f9d3876a9752206531c696 100644 GIT binary patch delta 227 zcmca`UiZRz-G&y%7N!>F7M2#)7Pc1l7LF~P9M{>5tV|58OtF7M2#)7Pc1l7LF~P9M{ Date: Fri, 5 Jun 2026 15:32:07 +0200 Subject: [PATCH 13/53] Write density primitives, use them in legacy slicing. --- src/wetting_angle_kit/analysis/_density.py | 220 ++++++++++++++++++ .../analysis/slicing/surface_definition.py | 172 +++++--------- 2 files changed, 278 insertions(+), 114 deletions(-) create mode 100644 src/wetting_angle_kit/analysis/_density.py diff --git a/src/wetting_angle_kit/analysis/_density.py b/src/wetting_angle_kit/analysis/_density.py new file mode 100644 index 0000000..c17b656 --- /dev/null +++ b/src/wetting_angle_kit/analysis/_density.py @@ -0,0 +1,220 @@ +"""Shared density-on-rays kernel and batched tanh interface fit. + +Used by both the slicing method (re-imported by +:class:`wetting_angle_kit.analysis.slicing.surface_definition.SurfaceDefinition`) +and the new ``rays_gaussian`` / ``rays_binning`` extractors that fit +a hyperbolic-tangent profile to the density along a ray. + +:class:`GaussianDensityField` wraps a ``cKDTree`` over the atom cloud +plus the kernel-width parameters. :func:`fit_tanh_profiles_batched` +solves the per-ray tanh fit for an entire slice in one batched +Gauss–Newton call. +""" + +import numpy as np +from scipy.spatial import cKDTree + +#: Minimum number of sampling points along each ray. Below this the +#: tanh profile fit becomes numerically unreliable. +MIN_POINTS_PER_RAY = 20 + +#: Default Gaussian standard deviation (Å) for the density-along-ray +#: smoothing kernel. Tuned for the full atomistic model of water at +#: room temperature; 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 + + +class GaussianDensityField: + """Truncated-Gaussian density evaluator over a fixed atom cloud. + + Parameters + ---------- + atom_coords : ndarray, shape (N, 3) + Atom positions used as the density sources. + density_sigma : float, default :data:`DEFAULT_DENSITY_SIGMA` + Gaussian kernel standard deviation (Å). + cutoff_sigma : float, default :data:`DEFAULT_CUTOFF_SIGMA` + Per-atom kernel truncation in units of ``density_sigma``. + """ + + def __init__( + self, + atom_coords: np.ndarray, + density_sigma: float = DEFAULT_DENSITY_SIGMA, + cutoff_sigma: float = DEFAULT_CUTOFF_SIGMA, + ) -> None: + self.density_sigma = density_sigma + self.cutoff_sigma = cutoff_sigma + # cKDTree over the atomic coordinates so each sample point's + # density touches only the active neighbourhood instead of the + # full N atoms. ``None`` for the empty-input case; ``evaluate`` + # short-circuits to a zeros array in that branch. + self._atom_tree: cKDTree | None = ( + cKDTree(atom_coords) if len(atom_coords) > 0 else None + ) + + def evaluate(self, positions: np.ndarray) -> np.ndarray: + """Return Gaussian-smoothed density at each sample position. + + 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 + :meth:`scipy.spatial.cKDTree.sparse_distance_matrix`. + + Parameters + ---------- + positions : ndarray, shape (M, 3) + Sample coordinates. + + Returns + ------- + ndarray, shape (M,) + Density values at each sample position. + """ + 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 + 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) + + +def tanh_profile(z: np.ndarray, zd: float, d: float, h: float) -> np.ndarray: + """Hyperbolic-tangent liquid–vapor density profile. + + Parameters + ---------- + z : ndarray + Distances along the ray (Å). + zd : float + Interface position parameter to be fitted. + d : float + Amplitude scaling parameter (half the peak-to-vapor density + range). + h : float + Vertical offset parameter (mid-point of liquid and vapor + densities). + + Returns + ------- + ndarray + Modeled density values at each ``z``. + """ + return np.tanh(-z + zd) * d + h + + +def fit_tanh_profiles_batched( + distances: np.ndarray, + densities: np.ndarray, + *, + max_dist: float, + 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 a slice share the same sampling grid, so the Jacobian + 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 + :func:`numpy.linalg.solve` once per iteration — much faster than + per-ray :func:`scipy.optimize.curve_fit`. + + 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 the initial + guess. + + Parameters + ---------- + 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_dist : float + Upper bound on the fitted interface position; the returned + ``zd`` is clipped to ``[0, max_dist]`` to keep ill-fit rays + from escaping the sampling envelope. + 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 + ------- + ndarray, shape (R,) + Fitted ``zd`` (interface position) per ray, clipped to + ``[0, max_dist]``. + """ + 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(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(max_dist)) diff --git a/src/wetting_angle_kit/analysis/slicing/surface_definition.py b/src/wetting_angle_kit/analysis/slicing/surface_definition.py index 216a6f0..df88737 100644 --- a/src/wetting_angle_kit/analysis/slicing/surface_definition.py +++ b/src/wetting_angle_kit/analysis/slicing/surface_definition.py @@ -19,11 +19,31 @@ fitted ``zd`` is the interface position; the corresponding (x, z) point in the slice plane is returned. +The density evaluator and the batched tanh fit are imported from +:mod:`wetting_angle_kit.analysis._density`, which is also used by the +new ray-based interface extractors. This module only adds the +slice-specific :meth:`SurfaceDefinition.analyze_lines` orchestration +on top. + All lengths are expected in Ångströms; angles are in degrees. """ import numpy as np -from scipy.spatial import cKDTree + +from wetting_angle_kit.analysis._density import ( + DEFAULT_CUTOFF_SIGMA as _DEFAULT_CUTOFF_SIGMA, +) +from wetting_angle_kit.analysis._density import ( + DEFAULT_DENSITY_SIGMA as _DEFAULT_DENSITY_SIGMA, +) +from wetting_angle_kit.analysis._density import ( + MIN_POINTS_PER_RAY as _MIN_POINTS_PER_RAY, +) +from wetting_angle_kit.analysis._density import ( + GaussianDensityField, + fit_tanh_profiles_batched, + tanh_profile, +) class SurfaceDefinition: @@ -34,21 +54,13 @@ class SurfaceDefinition: the interface position ("re") which is then projected back to XZ plane. """ - # Minimum number of sampling points along each ray. Below this the - # tanh profile fit becomes numerically unreliable. - MIN_POINTS_PER_RAY = 20 - - # Default Gaussian standard deviation (Å) for the density-along-ray - # smoothing kernel. Tuned for the full atomistic model of water at room temperature; - # 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 + # Re-exported class attributes for callers that read e.g. + # ``SurfaceDefinition.DEFAULT_DENSITY_SIGMA`` (notably + # ``SlicingFrameFitter.__init__`` in this package). The canonical + # values live in :mod:`wetting_angle_kit.analysis._density`. + MIN_POINTS_PER_RAY = _MIN_POINTS_PER_RAY + DEFAULT_DENSITY_SIGMA = _DEFAULT_DENSITY_SIGMA + DEFAULT_CUTOFF_SIGMA = _DEFAULT_CUTOFF_SIGMA def __init__( self, @@ -59,8 +71,8 @@ def __init__( gamma: float, density_conversion: float = 1.0, points_per_angstrom: float = 1.0, - density_sigma: float = DEFAULT_DENSITY_SIGMA, - cutoff_sigma: float = DEFAULT_CUTOFF_SIGMA, + density_sigma: float = _DEFAULT_DENSITY_SIGMA, + cutoff_sigma: float = _DEFAULT_CUTOFF_SIGMA, ) -> None: """ Parameters @@ -96,24 +108,21 @@ def __init__( 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 + # Density evaluator now shared with the new ray-based + # extractors via ``analysis/_density.py``. Empty atom clouds + # are handled inside the field (it short-circuits to zeros). + self._density = GaussianDensityField( + atom_coords=atom_coords, + density_sigma=density_sigma, + cutoff_sigma=cutoff_sigma, ) 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. + Delegates to :meth:`GaussianDensityField.evaluate`; kept as a + method for backwards compatibility with code that wraps + ``SurfaceDefinition``. Parameters ---------- @@ -127,25 +136,15 @@ def density_contribution(self, positions: np.ndarray) -> np.ndarray: ndarray, shape (M,) Density values at each sampling position. """ - 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 - 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) + return self._density.evaluate(positions) @staticmethod def density_profile(z: np.ndarray, zd: float, d: float, h: float) -> np.ndarray: - """Simple hyperbolic tangent profile used for liquid-vapor interface - localization. + """Hyperbolic-tangent liquid–vapor density profile. + + Thin wrapper around + :func:`wetting_angle_kit.analysis._density.tanh_profile`; kept + as a static method for backwards compatibility. Parameters ---------- @@ -163,7 +162,7 @@ def density_profile(z: np.ndarray, zd: float, d: float, h: float) -> np.ndarray: ndarray Modeled density values at each z. """ - return np.tanh(-z + zd) * d + h + return tanh_profile(z, zd, d, h) def _fit_density_profiles_batched( self, @@ -175,20 +174,10 @@ def _fit_density_profiles_batched( ) -> 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. + Delegates to + :func:`wetting_angle_kit.analysis._density.fit_tanh_profiles_batched`; + this method exists to preserve the per-instance ``self.max_dist`` + binding for in-process callers. Parameters ---------- @@ -209,58 +198,13 @@ def _fit_density_profiles_batched( ``[0, max_dist]`` to match the bounded behaviour of the original per-ray fit. """ - 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)) + return fit_tanh_profiles_batched( + distances, + densities, + max_dist=self.max_dist, + max_iter=max_iter, + tol=tol, + ) def analyze_lines(self) -> tuple[list[list[float]], list[list[float]]]: """Sample density along radial lines and fit interface positions. From c71bb77f87c5ef66748fb8b34895830a36eb5eaf Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 10:43:24 +0200 Subject: [PATCH 14/53] Added rays_gaussian extractor. --- pyproject.toml | 11 + src/wetting_angle_kit/analysis/extractors.py | 180 +++++++++++- .../test_rays_gaussian_extractor.py | 269 ++++++++++++++++++ 3 files changed, 458 insertions(+), 2 deletions(-) create mode 100644 tests/test_analysis/test_rays_gaussian_extractor.py diff --git a/pyproject.toml b/pyproject.toml index 8787011..8f0ba61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -131,6 +131,17 @@ disable_error_code = ["import-untyped"] module = ["ase.*", "wetting_angle_kit.parsers.ase"] disable_error_code = ["union-attr", "arg-type", "no-untyped-call"] +[[tool.mypy.overrides]] +# scikit-image is an optional dependency installed via the ``grid3d`` +# extra; the grid-based whole-kind extractors import it lazily and raise +# a clean ImportError otherwise. Telling mypy to ignore missing imports +# for ``skimage.*`` makes the inline ``# type: ignore[import-not-found]`` +# unnecessary AND keeps the check passing whether or not the user has +# scikit-image in their environment — otherwise running mypy with and +# without the extra produces flipped error states. +module = ["skimage.*"] +ignore_missing_imports = true + [tool.pytest.ini_options] testpaths = ["tests"] python_files = "test_*.py" diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py index a1eaa07..d8b24ca 100644 --- a/src/wetting_angle_kit/analysis/extractors.py +++ b/src/wetting_angle_kit/analysis/extractors.py @@ -35,6 +35,11 @@ class — each factory configures one sampling + density-kernel import numpy as np +from wetting_angle_kit.analysis._density import ( + MIN_POINTS_PER_RAY, + GaussianDensityField, + fit_tanh_profiles_batched, +) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.wall import InterfaceData @@ -309,6 +314,37 @@ def _validate_grid_params( ) +def _fibonacci_hemisphere_directions(n: int) -> np.ndarray: + """Equal-area Fibonacci-spiral directions on the upper hemisphere. + + ``cos θ`` is uniformly spaced over ``[0, 1]`` (so the surface + density is uniform on the sphere) and ``φ`` is incremented by the + golden angle for low-discrepancy azimuthal coverage. ``i = 0`` + sits at the horizon (``cos θ = 0``) and ``i = n - 1`` at the pole + (``cos θ = 1``). + + Parameters + ---------- + n : int + Number of directions. + + Returns + ------- + ndarray, shape (n, 3) + Unit direction vectors. + """ + if n <= 0: + return np.empty((0, 3)) + i = np.arange(n, dtype=np.float64) + cos_theta = i / (n - 1) if n > 1 else np.array([1.0]) + sin_theta = np.sqrt(np.maximum(0.0, 1.0 - cos_theta * cos_theta)) + golden_angle = np.pi * (3.0 - np.sqrt(5.0)) + phi = (i * golden_angle) % (2.0 * np.pi) + return np.column_stack( + [sin_theta * np.cos(phi), sin_theta * np.sin(phi), cos_theta] + ) + + def _validate_rays_params( *, name: str, @@ -375,9 +411,149 @@ def extract( max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: - raise NotImplementedError( - "rays_gaussian extraction not implemented in skeleton." + field = GaussianDensityField( + atom_coords=liquid_coordinates, + density_sigma=self.density_sigma, + cutoff_sigma=self.cutoff_sigma, + ) + n_samples = max(int(max_dist * self.points_per_angstrom), MIN_POINTS_PER_RAY) + distances = np.linspace(0.0, max_dist, n_samples) + if surface_kind == "slicing": + if droplet_geometry.is_spherical: + return self._slicing_spherical(field, center_geom, max_dist, distances) + return self._slicing_cylinder( + field, liquid_coordinates, center_geom, max_dist, distances + ) + # surface_kind == "whole" + if droplet_geometry.is_spherical: + return self._whole_spherical(field, center_geom, max_dist, distances) + return self._whole_cylinder( + field, liquid_coordinates, center_geom, max_dist, distances + ) + + def _slicing_spherical( + self, + field: GaussianDensityField, + center: np.ndarray, + max_dist: float, + distances: np.ndarray, + ) -> list[np.ndarray]: + # ``n_slices = int(180 / delta_azimuthal)`` and the gammas span + # ``[0, 180]`` inclusive — same construction as the legacy + # ``SlicingFrameFitter._slice_sweep`` to preserve parity. + assert self.delta_azimuthal is not None + n_slices = int(180 / self.delta_azimuthal) + gammas = np.linspace(0.0, 180.0, n_slices) + return [ + self._slice_in_plane(field, center, float(gamma), max_dist, distances) + for gamma in gammas + ] + + def _slicing_cylinder( + self, + field: GaussianDensityField, + liquid_coordinates: np.ndarray, + center: np.ndarray, + max_dist: float, + distances: np.ndarray, + ) -> list[np.ndarray]: + assert self.delta_cylinder is not None + y_vals = liquid_coordinates[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), self.delta_cylinder) + slices: list[np.ndarray] = [] + for y in ys: + slice_center = np.array([center[0], float(y), center[2]]) + slices.append( + self._slice_in_plane(field, slice_center, 0.0, max_dist, distances) + ) + return slices + + def _slice_in_plane( + self, + field: GaussianDensityField, + center: np.ndarray, + gamma: float, + max_dist: float, + distances: np.ndarray, + ) -> np.ndarray: + # Per-slice (x, z) interface from a tilted ray fan. Matches + # the legacy ``SurfaceDefinition.analyze_lines`` body. + beta = np.linspace(0, 360, int(360 / self.delta_polar), endpoint=False) + cos_beta = np.cos(np.deg2rad(beta)) + sin_beta = np.sin(np.deg2rad(beta)) + cos_gamma = np.cos(np.deg2rad(gamma)) + sin_gamma = np.sin(np.deg2rad(gamma)) + directions = np.column_stack( + (cos_beta * cos_gamma, cos_beta * sin_gamma, sin_beta) + ) + positions_rm = ( + center[None, None, :] + distances[None, :, None] * directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(beta), len(distances)) + interface_re = fit_tanh_profiles_batched( + distances, densities, max_dist=max_dist + ) + x_proj = cos_beta * interface_re + center[0] + z_proj = sin_beta * interface_re + center[2] + return np.column_stack([x_proj, z_proj]) + + def _whole_spherical( + self, + field: GaussianDensityField, + center: np.ndarray, + max_dist: float, + distances: np.ndarray, + ) -> np.ndarray: + assert self.n_rays_sphere is not None + directions = _fibonacci_hemisphere_directions(self.n_rays_sphere) + positions_rm = ( + center[None, None, :] + distances[None, :, None] * directions[:, None, :] ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(directions), len(distances)) + interface_re = fit_tanh_profiles_batched( + distances, densities, max_dist=max_dist + ) + return center[None, :] + interface_re[:, None] * directions + + def _whole_cylinder( + self, + field: GaussianDensityField, + liquid_coordinates: np.ndarray, + center: np.ndarray, + max_dist: float, + distances: np.ndarray, + ) -> np.ndarray: + assert self.delta_cylinder is not None + y_vals = liquid_coordinates[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), self.delta_cylinder) + beta = np.linspace(0, 360, int(360 / self.delta_polar), endpoint=False) + cos_beta = np.cos(np.deg2rad(beta)) + sin_beta = np.sin(np.deg2rad(beta)) + # In-plane (x, z) directions, with y = 0; same fan at every y. + directions = np.column_stack([cos_beta, np.zeros_like(beta), sin_beta]) + shells: list[np.ndarray] = [] + for y in ys: + slice_center = np.array([center[0], float(y), center[2]]) + positions_rm = ( + slice_center[None, None, :] + + distances[None, :, None] * directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(beta), len(distances)) + interface_re = fit_tanh_profiles_batched( + distances, densities, max_dist=max_dist + ) + points = np.column_stack( + [ + cos_beta * interface_re + slice_center[0], + np.full(len(beta), float(y)), + sin_beta * interface_re + slice_center[2], + ] + ) + shells.append(points) + return np.concatenate(shells, axis=0) if shells else np.empty((0, 3)) @dataclass(frozen=True, eq=False, kw_only=True) diff --git a/tests/test_analysis/test_rays_gaussian_extractor.py b/tests/test_analysis/test_rays_gaussian_extractor.py new file mode 100644 index 0000000..f6add52 --- /dev/null +++ b/tests/test_analysis/test_rays_gaussian_extractor.py @@ -0,0 +1,269 @@ +"""Quantification tests for the new ``rays_gaussian`` extractor. + +Two flavors: + +- **Parity** with the legacy slicing primitive + (:class:`SurfaceDefinition.analyze_lines`) on slicing-mode cases. + Both pipelines route through the same + :mod:`wetting_angle_kit.analysis._density` helpers, so agreement + should be bit-for-bit (max-abs diff at the 1e-12 level). +- **Fibonacci correctness** for the new whole+spherical case where + there's no legacy comparator. We build a synthetic uniform-volume + sphere of atoms and verify the recovered shell sits at the sphere + radius to within the density-smoothing tolerance. +""" + +import numpy as np +import pytest + +from wetting_angle_kit.analysis._density import ( + DEFAULT_CUTOFF_SIGMA, + DEFAULT_DENSITY_SIGMA, +) +from wetting_angle_kit.analysis.extractors import InterfaceExtractor +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.slicing.surface_definition import ( + SurfaceDefinition, +) + + +def _make_random_droplet( + seed: int = 0, n_atoms: int = 2000, sigma_xy: float = 8.0, sigma_z: float = 6.0 +) -> np.ndarray: + """Random atom cloud that loosely resembles a sessile droplet.""" + rng = np.random.default_rng(seed) + xy = rng.normal(0.0, sigma_xy, size=(n_atoms, 2)) + z = np.abs(rng.normal(0.0, sigma_z, size=n_atoms)) + 1.0 + return np.column_stack([xy, z]) + + +def test_slicing_spherical_matches_legacy_surface_definition() -> None: + """Parity: new extractor (slicing+spherical) vs legacy SurfaceDefinition.""" + atoms = _make_random_droplet(seed=0) + center = np.array([0.0, 0.0, 0.0]) + delta_azimuthal = 30.0 + delta_polar = 8.0 + max_dist = 30.0 + + # Legacy: instantiate one SurfaceDefinition per gamma and call + # analyze_lines() — exactly what the legacy + # SlicingFrameFitter does for spherical droplets. + n_slices = int(180 / delta_azimuthal) + gammas = np.linspace(0.0, 180.0, n_slices) + legacy_xz: list[np.ndarray] = [] + for gamma in gammas: + sd = SurfaceDefinition( + atom_coords=atoms, + delta_angle=delta_polar, + max_dist=max_dist, + center_geom=center, + gamma=float(gamma), + density_sigma=DEFAULT_DENSITY_SIGMA, + cutoff_sigma=DEFAULT_CUTOFF_SIGMA, + ) + _, xz = sd.analyze_lines() + legacy_xz.append(np.asarray(xz, dtype=float)) + + # New: rays_gaussian extractor in slicing+spherical mode. + extractor = InterfaceExtractor.rays_gaussian( + delta_azimuthal=delta_azimuthal, + delta_polar=delta_polar, + density_sigma=DEFAULT_DENSITY_SIGMA, + cutoff_sigma=DEFAULT_CUTOFF_SIGMA, + ) + geom = DropletGeometry.coerce("spherical") + extractor.validate_compatibility(surface_kind="slicing", droplet_geometry=geom) + new_xz = extractor.extract( + liquid_coordinates=atoms, + center_geom=center, + droplet_geometry=geom, + max_dist=max_dist, + surface_kind="slicing", + ) + + assert isinstance(new_xz, list) + assert len(new_xz) == len(legacy_xz) + + max_diff = 0.0 + for legacy, new in zip(legacy_xz, new_xz, strict=True): + assert legacy.shape == new.shape + diff = float(np.max(np.abs(legacy - new))) + max_diff = max(max_diff, diff) + + # Quantification: agreement is at the floating-point round-off + # level (the two paths call the same density / tanh helpers). + print(f"\nslicing+spherical parity: max |diff| = {max_diff:.3e} Å") + assert max_diff < 1e-10 + + +def test_slicing_cylinder_matches_legacy_surface_definition() -> None: + """Parity check for the cylinder slicing case.""" + atoms = _make_random_droplet(seed=1) + center = np.array([0.0, 0.0, 0.0]) + delta_cylinder = 4.0 + delta_polar = 8.0 + max_dist = 30.0 + + # Legacy: gamma stays at 0, y sweeps over delta_cylinder steps. + y_vals = atoms[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) + legacy_xz: list[np.ndarray] = [] + for y in ys: + slice_center = np.array([center[0], float(y), center[2]]) + sd = SurfaceDefinition( + atom_coords=atoms, + delta_angle=delta_polar, + max_dist=max_dist, + center_geom=slice_center, + gamma=0.0, + density_sigma=DEFAULT_DENSITY_SIGMA, + cutoff_sigma=DEFAULT_CUTOFF_SIGMA, + ) + _, xz = sd.analyze_lines() + legacy_xz.append(np.asarray(xz, dtype=float)) + + extractor = InterfaceExtractor.rays_gaussian( + delta_cylinder=delta_cylinder, + delta_polar=delta_polar, + density_sigma=DEFAULT_DENSITY_SIGMA, + cutoff_sigma=DEFAULT_CUTOFF_SIGMA, + ) + geom = DropletGeometry.coerce("cylinder_y") + extractor.validate_compatibility(surface_kind="slicing", droplet_geometry=geom) + new_xz = extractor.extract( + liquid_coordinates=atoms, + center_geom=center, + droplet_geometry=geom, + max_dist=max_dist, + surface_kind="slicing", + ) + + assert isinstance(new_xz, list) + assert len(new_xz) == len(legacy_xz) + max_diff = 0.0 + for legacy, new in zip(legacy_xz, new_xz, strict=True): + assert legacy.shape == new.shape + diff = float(np.max(np.abs(legacy - new))) + max_diff = max(max_diff, diff) + print(f"slicing+cylinder parity: max |diff| = {max_diff:.3e} Å") + assert max_diff < 1e-10 + + +def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndarray: + """Atoms uniformly distributed inside a sphere of given radius.""" + rng = np.random.default_rng(seed) + # Rejection-sample inside a cube; cheap and unbiased. + pts = [] + while len(pts) * 3 < n_atoms * 3: + sample = rng.uniform(-radius, radius, size=(4 * n_atoms, 3)) + inside = np.linalg.norm(sample, axis=1) < radius + pts.append(sample[inside]) + if sum(len(p) for p in pts) >= n_atoms: + break + return np.concatenate(pts, axis=0)[:n_atoms] + + +def test_whole_spherical_recovers_known_sphere_radius() -> None: + """Fibonacci sampling on the whole hemisphere recovers a known sphere. + + Builds a uniform-volume sphere of atoms and runs the new + whole+spherical extractor with rays emitted from the sphere centre. + Each shell point should sit at the sphere radius (modulo a small + inward shift from the Gaussian density smoothing). + """ + radius = 20.0 + sigma = 3.0 + # Use a full sphere (no hemisphere cut) so the Fibonacci-spaced + # upper-hemisphere rays probe an angularly isotropic atom cloud. + # This isolates the sampling pattern + tanh-fit recovery from any + # cap-induced inward bias near the equator. + atoms = _uniform_sphere_atoms(radius=radius, n_atoms=15000, seed=0) + + n_rays = 400 + extractor = InterfaceExtractor.rays_gaussian( + n_rays_sphere=n_rays, + density_sigma=sigma, + ) + geom = DropletGeometry.coerce("spherical") + extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) + shell = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=radius + 10.0, + surface_kind="whole", + ) + assert isinstance(shell, np.ndarray) + assert shell.shape == (n_rays, 3) + # Fibonacci hemisphere directions all have cos θ ≥ 0; the + # recovered shell points should mirror that. + assert np.all(shell[:, 2] >= -1e-12) + + r = np.linalg.norm(shell, axis=1) + mean_r = float(np.mean(r)) + std_r = float(np.std(r)) + max_dev = float(np.max(np.abs(r - radius))) + + print( + "\nFibonacci sphere recovery: " + f"R_truth = {radius} Å, R_mean = {mean_r:.3f} Å, " + f"R_std = {std_r:.3f} Å, max |R_i - R| = {max_dev:.3f} Å" + ) + + # Tolerance: density smoothing with sigma=3 places the tanh-fit + # interface at the half-max density, which can drift up to ~sigma + # from the geometric edge. Mean radius should land within sigma. + assert abs(mean_r - radius) < sigma + # The angular spread should be much smaller than the smoothing. + assert std_r < 1.0 + # No outliers far from the truth radius. + assert max_dev < 1.5 * sigma + + +@pytest.mark.unit +def test_whole_cylinder_recovers_horizontal_ridge() -> None: + """Smoke test: whole+cylinder recovers a horizontal cylindrical ridge. + + A uniformly-filled cylinder of radius R extending along y; recovered + shell points (x, *, z) should land on a circle of radius R in the + (x, z) plane at every sampled y. + """ + R_truth = 15.0 + y_extent = 30.0 + n_atoms = 8000 + rng = np.random.default_rng(2) + # Uniform within radius R in (x, z); uniform along y. + cross = [] + while sum(c.shape[0] for c in cross) < n_atoms: + cand = rng.uniform(-R_truth, R_truth, size=(2 * n_atoms, 2)) + inside = np.hypot(cand[:, 0], cand[:, 1]) < R_truth + cross.append(cand[inside]) + xz = np.concatenate(cross, axis=0)[:n_atoms] + y = rng.uniform(-y_extent / 2, y_extent / 2, size=n_atoms) + atoms = np.column_stack([xz[:, 0], y, xz[:, 1] + R_truth]) + # Shift so atoms sit above z = 0 to mimic the sessile-droplet frame. + + extractor = InterfaceExtractor.rays_gaussian( + delta_cylinder=3.0, + delta_polar=8.0, + ) + geom = DropletGeometry.coerce("cylinder_y") + extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) + shell = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.array([0.0, 0.0, R_truth]), + max_dist=R_truth + 10.0, + droplet_geometry=geom, + surface_kind="whole", + ) + assert isinstance(shell, np.ndarray) + assert shell.ndim == 2 and shell.shape[1] == 3 + + # In-plane radius (x, z relative to the centre we passed in). + in_plane_r = np.hypot(shell[:, 0], shell[:, 2] - R_truth) + mean_r = float(np.mean(in_plane_r)) + print( + f"whole+cylinder shell: n_points = {shell.shape[0]}, " + f"R_truth = {R_truth}, R_mean = {mean_r:.3f} Å" + ) + assert abs(mean_r - R_truth) < 1.5 # density-smoothing tolerance From 4ed8bff02370ca8ea6af5325400e5e3f0f66a5c3 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 11:39:33 +0200 Subject: [PATCH 15/53] Added surface fitting for slicing. --- src/wetting_angle_kit/analysis/fitters.py | 97 ++++++++- tests/test_analysis/test_slicing_fitter.py | 231 +++++++++++++++++++++ 2 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 tests/test_analysis/test_slicing_fitter.py diff --git a/src/wetting_angle_kit/analysis/fitters.py b/src/wetting_angle_kit/analysis/fitters.py index 7ebc5fb..95b7a8a 100644 --- a/src/wetting_angle_kit/analysis/fitters.py +++ b/src/wetting_angle_kit/analysis/fitters.py @@ -238,6 +238,45 @@ def whole( ) +def _kasa_circle_fit_2d(x: np.ndarray, z: np.ndarray) -> tuple[float, float, float]: + """Algebraic (Kasa) least-squares circle fit in 2D. + + 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 with :func:`numpy.linalg.lstsq`. + + Parameters + ---------- + x, z : ndarray + 2D point coordinates. + + Returns + ------- + (xc, zc, R) : tuple of float + Fitted circle centre and radius. + + Raises + ------ + np.linalg.LinAlgError + If the points are collinear (rank-deficient system). + ValueError + If the algebraic solution gives a non-positive ``R^2``. + """ + x = np.asarray(x, dtype=float) + z = np.asarray(z, dtype=float) + a_matrix = np.column_stack((2.0 * x, 2.0 * z, np.ones_like(x))) + rhs = x * x + z * z + 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 points are likely degenerate." + ) + return xc, zc, float(np.sqrt(r_sq)) + + @dataclass(frozen=True, eq=False, kw_only=True) class _SlicingFitter(SurfaceFitter): """Concrete fitter for :meth:`SurfaceFitter.slicing`.""" @@ -257,7 +296,63 @@ def fit( z_wall: float, droplet_geometry: DropletGeometry, ) -> SlicingFitOutput: - raise NotImplementedError("slicing surface fit not implemented in skeleton.") + if not isinstance(interface_data, list): + raise TypeError( + "slicing fitter expects a list of per-slice (M, 2) arrays; " + f"got {type(interface_data).__name__}." + ) + + z_filter = z_wall + self.surface_filter_offset + per_slice_angles: list[float] = [] + slice_surfaces: list[np.ndarray] = [] + slice_popts: list[np.ndarray] = [] + slice_rms_residuals: list[float] = [] + + for surf in interface_data: + if surf.size == 0: + continue + kept = surf[surf[:, 1] > z_filter] + # Need at least 3 non-collinear points to fit a circle. + if len(kept) < 3: + continue + try: + xc, zc, radius = _kasa_circle_fit_2d(kept[:, 0], kept[:, 1]) + except (np.linalg.LinAlgError, ValueError): + continue + # Contact angle from circle / wall-line intersection: + # ``cos θ = (z_wall - z_center) / R``. Drop slices where + # the fitted circle doesn't intersect the wall. + delta_z = z_wall - zc + if abs(delta_z) >= radius: + continue + angle = float(np.degrees(np.arccos(delta_z / radius))) + # Per-slice RMS of the circle-fit residuals (Å). The + # batch-level rms_residual reported in + # :class:`SlicingBatchResult` is the mean across slices. + radii = np.hypot(kept[:, 0] - xc, kept[:, 1] - zc) + rms = float(np.sqrt(np.mean((radii - radius) ** 2))) + + per_slice_angles.append(angle) + slice_surfaces.append(surf) + slice_popts.append(np.array([xc, zc, radius, z_wall])) + slice_rms_residuals.append(rms) + + if not per_slice_angles: + raise RuntimeError( + "slicing fit: no slice produced a valid contact angle " + "after filtering and circle fitting." + ) + + angles_arr = np.asarray(per_slice_angles, dtype=float) + return SlicingFitOutput( + angle=float(np.mean(angles_arr)), + z_wall=z_wall, + rms_residual=float(np.mean(slice_rms_residuals)), + angle_std=float(np.std(angles_arr)), + per_slice_angles=angles_arr, + slice_surfaces=slice_surfaces, + slice_popts=np.asarray(slice_popts, dtype=float), + ) @dataclass(frozen=True, eq=False, kw_only=True) diff --git a/tests/test_analysis/test_slicing_fitter.py b/tests/test_analysis/test_slicing_fitter.py new file mode 100644 index 0000000..72e9f7d --- /dev/null +++ b/tests/test_analysis/test_slicing_fitter.py @@ -0,0 +1,231 @@ +"""Phase 3 quantification: ``SurfaceFitter.slicing()`` correctness + parity. + +Two flavors: + +- **Synthetic-circle correctness.** Per-slice points lying on a known + circle. The fitter should recover the truth contact angle to + numerical precision and report (near-)zero fit residual. +- **End-to-end parity** against the legacy + :class:`SlicingTrajectoryAnalyzer` on the LAMMPS water-on-graphene + fixture. The two pipelines compute the angle baseline slightly + differently (legacy: per-slice ``min(z)``; new: per-batch + ``min(z) + offset``), so a small drift is expected — quantified + with and without the ``min_plus_offset`` offset. +""" + +import pathlib + +import numpy as np +import pytest + +# Slicing fixture is a LAMMPS dump parsed through OVITO; skip if the +# optional backend is missing (typically macOS CI without ovito). +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import ( # noqa: E402 + InterfaceExtractor, + SlicingTrajectoryAnalyzer, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +# --- Synthetic correctness ------------------------------------------------- + + +def _circle_arc_points( + xc: float, zc: float, radius: float, theta_lo: float, theta_hi: float, n: int +) -> np.ndarray: + """Sample (x, z) points along an arc of a circle.""" + theta = np.linspace(theta_lo, theta_hi, n) + return np.column_stack([xc + radius * np.cos(theta), zc + radius * np.sin(theta)]) + + +def test_slicing_fitter_recovers_known_circle_angle() -> None: + """Per-slice points on a known circle → recovered angle matches truth.""" + # Circle of radius R centred at (0, zc); wall at z=0. The cap + # extends from the contact line up to the top of the circle. + # cos θ = (z_wall - zc) / R, so for zc = -5 and R = 20: + # cos θ = 0.25 → θ ≈ 75.522°. + xc_truth, zc_truth, radius_truth = 0.0, -5.0, 20.0 + z_wall = 0.0 + truth_angle = float(np.degrees(np.arccos((z_wall - zc_truth) / radius_truth))) + + # Sample the upper arc of the circle (z > z_wall). + # theta in radians measured from +x axis; upper arc is [0, pi]. + arc = _circle_arc_points( + xc=xc_truth, + zc=zc_truth, + radius=radius_truth, + theta_lo=np.arcsin((z_wall - zc_truth) / radius_truth), + theta_hi=np.pi - np.arcsin((z_wall - zc_truth) / radius_truth), + n=80, + ) + # ``surface_filter_offset = 0`` so every arc point is kept. + fitter = SurfaceFitter.slicing(surface_filter_offset=0.0) + out = fitter.fit( + interface_data=[arc], + z_wall=z_wall, + droplet_geometry=DropletGeometry.coerce("spherical"), + ) + + print( + f"\nSynthetic circle: truth = {truth_angle:.4f}°, " + f"recovered = {out.angle:.4f}°, rms_residual = {out.rms_residual:.3e} Å" + ) + assert abs(out.angle - truth_angle) < 1e-6 + # Algebraic Kasa on exact-circle points fits to ~floating-point + # precision; the per-slice RMS residual should sit near 0. + assert out.rms_residual < 1e-9 + # One slice → angle_std = 0. + assert out.angle_std == 0.0 + + +def test_slicing_fitter_aggregates_across_slices() -> None: + """Multiple noisy slices around the same true angle — aggregation.""" + radius_truth = 20.0 + zc_truth = -5.0 + z_wall = 0.0 + truth_angle = float(np.degrees(np.arccos((z_wall - zc_truth) / radius_truth))) + + rng = np.random.default_rng(7) + slices: list[np.ndarray] = [] + for _ in range(8): + arc = _circle_arc_points( + xc=0.0, + zc=zc_truth, + radius=radius_truth, + theta_lo=np.arcsin((z_wall - zc_truth) / radius_truth), + theta_hi=np.pi - np.arcsin((z_wall - zc_truth) / radius_truth), + n=40, + ) + # ±0.05 Å Gaussian noise on both axes — sub-resolution thermal jitter. + arc = arc + rng.normal(0.0, 0.05, size=arc.shape) + slices.append(arc) + + fitter = SurfaceFitter.slicing(surface_filter_offset=0.0) + out = fitter.fit( + interface_data=slices, + z_wall=z_wall, + droplet_geometry=DropletGeometry.coerce("spherical"), + ) + + print( + f"Aggregated 8 noisy slices: truth = {truth_angle:.3f}°, " + f"mean = {out.angle:.3f}°, angle_std = {out.angle_std:.3f}°, " + f"rms_residual = {out.rms_residual:.3e} Å" + ) + assert abs(out.angle - truth_angle) < 0.5 + # Per-slice std should be small and finite. + assert out.angle_std > 0.0 + assert out.angle_std < 1.0 + assert out.per_slice_angles.shape == (8,) + assert out.slice_popts.shape == (8, 4) + assert len(out.slice_surfaces) == 8 + + +# --- End-to-end parity vs legacy SlicingTrajectoryAnalyzer ----------------- + + +_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) + + +@pytest.fixture +def fixture_path() -> pathlib.Path: + return _FIXTURE + + +@pytest.fixture +def oxygen_indices(fixture_path: pathlib.Path) -> np.ndarray: + finder = LammpsDumpWaterFinder(fixture_path, oxygen_type=1, hydrogen_type=2) + return finder.get_water_oxygen_ids(0) + + +def _run_legacy_angle( + fixture_path: pathlib.Path, oxygen_indices: np.ndarray, frame: int +) -> float: + analyzer = SlicingTrajectoryAnalyzer( + parser=LammpsDumpParser(fixture_path), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + delta_gamma=20, + ) + results = analyzer.analyze([frame]) + return float(np.mean(results.angles[0])) + + +def _run_new_angle( + fixture_path: pathlib.Path, + oxygen_indices: np.ndarray, + frame: int, + *, + wall_offset: float, +) -> float: + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(fixture_path), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, + delta_polar=8.0, + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=wall_offset), + ) + results = analyzer.analyze([frame]) + return float(results.per_batch_angles[0]) + + +@pytest.mark.integration +@pytest.mark.slow +def test_slicing_end_to_end_parity_vs_legacy( + fixture_path: pathlib.Path, oxygen_indices: np.ndarray +) -> None: + """Quantify the drift between legacy and new slicing pipelines. + + Two new-pipeline configurations are reported: + + - ``min_plus_offset(0)``: angle baseline ≈ legacy's per-slice + ``min(z)`` (modulo per-slice → per-batch substitution). + - ``min_plus_offset(2)``: the new default; angle baseline shifted + 2 Å above the lowest interface point. + """ + frame = 1 + legacy_angle = _run_legacy_angle(fixture_path, oxygen_indices, frame) + new_angle_offset0 = _run_new_angle( + fixture_path, oxygen_indices, frame, wall_offset=0.0 + ) + new_angle_offset2 = _run_new_angle( + fixture_path, oxygen_indices, frame, wall_offset=2.0 + ) + + drift_0 = abs(new_angle_offset0 - legacy_angle) + drift_2 = abs(new_angle_offset2 - legacy_angle) + + print( + f"\nLegacy mean angle = {legacy_angle:.3f}°" + f"\nNew (min_plus_offset offset=0) mean angle = " + f"{new_angle_offset0:.3f}° |drift| = {drift_0:.3f}°" + f"\nNew (min_plus_offset offset=2, default) mean = " + f"{new_angle_offset2:.3f}° |drift| = {drift_2:.3f}°" + ) + + # Both new configurations should land near the legacy result. + # offset=0 is the closest analogue (legacy uses per-slice min(z)); + # offset=2 shifts the baseline up by 2 Å (smaller measured angle). + assert drift_0 < 3.0, f"offset=0 drift too large: {drift_0:.3f}°" + assert drift_2 < 10.0, f"offset=2 drift too large: {drift_2:.3f}°" + # Both new angles should still sit in the physically plausible + # 80–110° band the legacy test enforces for this fixture. + assert 70.0 < new_angle_offset0 < 110.0 + assert 70.0 < new_angle_offset2 < 110.0 From 0aae97cdddebfae65a41b3d17cd9d0749b63a255 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 11:58:32 +0200 Subject: [PATCH 16/53] Added surface fitting for whole surfaces. --- src/wetting_angle_kit/analysis/fitters.py | 148 +++++++++++++++- tests/test_analysis/test_whole_fitter.py | 200 ++++++++++++++++++++++ 2 files changed, 347 insertions(+), 1 deletion(-) create mode 100644 tests/test_analysis/test_whole_fitter.py diff --git a/src/wetting_angle_kit/analysis/fitters.py b/src/wetting_angle_kit/analysis/fitters.py index 95b7a8a..321155c 100644 --- a/src/wetting_angle_kit/analysis/fitters.py +++ b/src/wetting_angle_kit/analysis/fitters.py @@ -355,6 +355,66 @@ def fit( ) +def _kasa_sphere_fit_3d( + x: np.ndarray, y: np.ndarray, z: np.ndarray +) -> tuple[float, float, float, float]: + """Algebraic (Kasa) least-squares sphere fit in 3D. + + Linearises ``(x - xc)^2 + (y - yc)^2 + (z - zc)^2 = R^2`` into + ``2 xc x + 2 yc y + 2 zc z + c = x^2 + y^2 + z^2`` with + ``c = R^2 - xc^2 - yc^2 - zc^2`` and solves with + :func:`numpy.linalg.lstsq`. + + Returns ``(xc, yc, zc, R)``. + + Raises + ------ + np.linalg.LinAlgError + If the input points are co-planar (rank-deficient system). + ValueError + If the algebraic solution gives a non-positive ``R^2``. + """ + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + z = np.asarray(z, dtype=float) + a_matrix = np.column_stack((2.0 * x, 2.0 * y, 2.0 * z, np.ones_like(x))) + rhs = x * x + y * y + z * z + sol, _, _, _ = np.linalg.lstsq(a_matrix, rhs, rcond=None) + xc, yc, zc, c = (float(sol[0]), float(sol[1]), float(sol[2]), float(sol[3])) + r_sq = c + xc * xc + yc * yc + zc * zc + if r_sq <= 0.0: + raise ValueError( + f"Algebraic sphere fit produced non-positive R^2 ({r_sq:.3g}); " + "the points are likely degenerate." + ) + return xc, yc, zc, float(np.sqrt(r_sq)) + + +def _whole_fit_one( + points: np.ndarray, *, spherical: bool +) -> tuple[np.ndarray, float, float]: + """Fit a sphere (spherical=True) or cylinder (spherical=False) to ``points``. + + Returns ``(popt_no_wall, R, zc)`` where ``popt_no_wall`` is + ``[xc, yc, zc, R]`` for spherical or ``[xc, zc, R]`` for cylinder. + The cylinder fit drops the ``y`` column and fits a 2D circle in + ``(x, z)``. + """ + if spherical: + xc, yc, zc, R = _kasa_sphere_fit_3d(points[:, 0], points[:, 1], points[:, 2]) + return np.array([xc, yc, zc, R]), R, zc + xc, zc, R = _kasa_circle_fit_2d(points[:, 0], points[:, 2]) + return np.array([xc, zc, R]), R, zc + + +def _angle_from_cap(z_wall: float, zc: float, R: float) -> float | None: + """Contact angle from ``cos θ = (z_wall - zc) / R`` or None if no intersection.""" + delta_z = z_wall - zc + if abs(delta_z) >= R: + return None + return float(np.degrees(np.arccos(delta_z / R))) + + @dataclass(frozen=True, eq=False, kw_only=True) class _WholeFitter(SurfaceFitter): """Concrete fitter for :meth:`SurfaceFitter.whole`.""" @@ -382,4 +442,90 @@ def fit( z_wall: float, droplet_geometry: DropletGeometry, ) -> WholeFitOutput: - raise NotImplementedError("whole surface fit not implemented in skeleton.") + if not isinstance(interface_data, np.ndarray): + raise TypeError( + "whole fitter expects an (N, 3) ndarray shell; " + f"got {type(interface_data).__name__}." + ) + if interface_data.ndim != 2 or interface_data.shape[1] != 3: + raise ValueError( + "whole fitter expects an (N, 3) ndarray shell; " + f"got shape {interface_data.shape}." + ) + + z_filter = z_wall + self.surface_filter_offset + kept = interface_data[interface_data[:, 2] > z_filter] + spherical = droplet_geometry.is_spherical + # Minimum points: 4 for a 3D sphere, 3 for a 2D circle. + min_points = 4 if spherical else 3 + if len(kept) < min_points: + raise RuntimeError( + f"whole fit: only {len(kept)} shell points above " + f"z_wall + surface_filter_offset = {z_filter:.3f} Å; " + f"need at least {min_points} for the " + f"{'sphere' if spherical else 'cylinder'} fit." + ) + + try: + popt_shape, radius, zc = _whole_fit_one(kept, spherical=spherical) + except (np.linalg.LinAlgError, ValueError) as e: + raise RuntimeError(f"whole fit: geometric fit failed: {e}") from e + + angle = _angle_from_cap(z_wall, zc, radius) + if angle is None: + raise RuntimeError( + f"whole fit: fitted shape (R={radius:.3f}, zc={zc:.3f}) " + f"does not intersect wall plane z={z_wall:.3f}." + ) + + # Per-point residuals: distance to the fitted shape, in Å. + if spherical: + xc, yc, zc_fit, R_fit = popt_shape + point_radius = np.sqrt( + (kept[:, 0] - xc) ** 2 + + (kept[:, 1] - yc) ** 2 + + (kept[:, 2] - zc_fit) ** 2 + ) + else: + xc, zc_fit, R_fit = popt_shape + point_radius = np.hypot(kept[:, 0] - xc, kept[:, 2] - zc_fit) + rms = float(np.sqrt(np.mean((point_radius - R_fit) ** 2))) + + angle_std = self._bootstrap_angle_std(kept, z_wall, spherical=spherical) + + # Pack popt with the wall position appended for plotting / + # downstream reproduction. Spherical: [xc, yc, zc, R, z_wall]. + # Cylinder: [xc, zc, R, z_wall]. + popt = np.concatenate([popt_shape, [z_wall]]) + + return WholeFitOutput( + angle=angle, + z_wall=z_wall, + rms_residual=rms, + angle_std=angle_std, + interface_shell=kept, + popt=popt, + ) + + def _bootstrap_angle_std( + self, kept: np.ndarray, z_wall: float, *, spherical: bool + ) -> float | None: + if self.bootstrap_samples <= 0: + return None + # Deterministic seed so result is reproducible per (analyzer, batch). + rng = np.random.default_rng(0) + n = len(kept) + bootstrap_angles: list[float] = [] + for _ in range(self.bootstrap_samples): + idx = rng.integers(0, n, n) + sample = kept[idx] + try: + _, b_R, b_zc = _whole_fit_one(sample, spherical=spherical) + except (np.linalg.LinAlgError, ValueError): + continue + a = _angle_from_cap(z_wall, b_zc, b_R) + if a is not None: + bootstrap_angles.append(a) + if not bootstrap_angles: + return None + return float(np.std(bootstrap_angles)) diff --git a/tests/test_analysis/test_whole_fitter.py b/tests/test_analysis/test_whole_fitter.py new file mode 100644 index 0000000..a357030 --- /dev/null +++ b/tests/test_analysis/test_whole_fitter.py @@ -0,0 +1,200 @@ +"""Phase 4 quantification: ``SurfaceFitter.whole()`` correctness + bootstrap. + +Four flavors: + +- **Exact-sphere recovery.** Feed the fitter exact Fibonacci-sphere + shell points; verify the recovered angle matches truth to numerical + precision and the RMS residual sits near zero. +- **Exact-cylinder recovery.** Same for a straight cylinder along ``y``. +- **End-to-end with the rays_gaussian extractor.** Synthetic atom sphere + → extractor → fitter; angle should track truth within the + density-smoothing budget. +- **Bootstrap σ scaling.** On a noisy shell, the bootstrap σ_θ should + scale like ``1/√N_shell``. Quantified for three shell sizes. +""" + +import numpy as np + +from wetting_angle_kit.analysis.extractors import ( + InterfaceExtractor, + _fibonacci_hemisphere_directions, +) +from wetting_angle_kit.analysis.fitters import SurfaceFitter +from wetting_angle_kit.analysis.geometry import DropletGeometry + + +def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndarray: + rng = np.random.default_rng(seed) + pts: list[np.ndarray] = [] + while sum(p.shape[0] for p in pts) < n_atoms: + sample = rng.uniform(-radius, radius, size=(4 * n_atoms, 3)) + pts.append(sample[np.linalg.norm(sample, axis=1) < radius]) + return np.concatenate(pts, axis=0)[:n_atoms] + + +def test_whole_fitter_exact_sphere_recovers_angle_to_numerical_precision() -> None: + """Exact Fibonacci-sphere shell → angle within < 1e-3°.""" + R_truth = 20.0 + zc_truth = 0.0 + z_wall = 5.0 # cos θ = 0.25 → θ ≈ 75.522° + truth_angle = float(np.degrees(np.arccos((z_wall - zc_truth) / R_truth))) + + directions = _fibonacci_hemisphere_directions(400) + shell = directions * R_truth + np.array([0.0, 0.0, zc_truth]) + + fitter = SurfaceFitter.whole(surface_filter_offset=0.0) + out = fitter.fit( + interface_data=shell, + z_wall=z_wall, + droplet_geometry=DropletGeometry.coerce("spherical"), + ) + + print( + f"\nExact sphere: truth = {truth_angle:.6f}°, " + f"recovered = {out.angle:.6f}°, " + f"R = {out.popt[3]:.6f}, zc = {out.popt[2]:.6f}, " + f"rms = {out.rms_residual:.3e} Å" + ) + assert abs(out.angle - truth_angle) < 1e-3 + assert out.rms_residual < 1e-9 + assert out.angle_std is None # bootstrap disabled + + +def test_whole_fitter_exact_cylinder_recovers_angle_to_numerical_precision() -> None: + """Exact cylinder shell → angle within < 1e-3°.""" + R_truth = 15.0 + zc_truth = 0.0 + z_wall = 4.0 # cos θ = 4/15 → θ ≈ 74.474° + truth_angle = float(np.degrees(np.arccos((z_wall - zc_truth) / R_truth))) + + # Half-cylinder along y, radius R_truth, in (x, z) plane. + beta = np.linspace(0, 360, 45, endpoint=False) + cos_beta = np.cos(np.deg2rad(beta)) + sin_beta = np.sin(np.deg2rad(beta)) + y_vals = np.arange(-15.0, 15.0, 3.0) + shell_parts: list[np.ndarray] = [] + for y in y_vals: + shell_parts.append( + np.column_stack( + [ + R_truth * cos_beta, + np.full_like(beta, y), + R_truth * sin_beta + zc_truth, + ] + ) + ) + shell = np.concatenate(shell_parts, axis=0) + + fitter = SurfaceFitter.whole(surface_filter_offset=0.0) + out = fitter.fit( + interface_data=shell, + z_wall=z_wall, + droplet_geometry=DropletGeometry.coerce("cylinder_y"), + ) + + print( + f"\nExact cylinder: truth = {truth_angle:.6f}°, " + f"recovered = {out.angle:.6f}°, " + f"R = {out.popt[2]:.6f}, zc = {out.popt[1]:.6f}, " + f"rms = {out.rms_residual:.3e} Å" + ) + assert abs(out.angle - truth_angle) < 1e-3 + assert out.rms_residual < 1e-9 + # popt for cylinder: [xc, zc, R, z_wall] + assert out.popt.shape == (4,) + + +def test_whole_fitter_end_to_end_atom_sphere() -> None: + """Full pipeline (extractor → fitter) on a synthetic atom sphere. + + The recovered angle has a density-smoothing bias: the tanh fit + locates the interface at the half-max density, which sits slightly + inside the geometric edge. That shifts R inward (~0.7 Å for σ=3) + and shifts the recovered angle a few degrees. + """ + R_truth = 20.0 + z_wall = 5.0 + truth_angle = float(np.degrees(np.arccos(z_wall / R_truth))) + atoms = _uniform_sphere_atoms(radius=R_truth, n_atoms=15000, seed=0) + + extractor = InterfaceExtractor.rays_gaussian(n_rays_sphere=400, density_sigma=3.0) + geom = DropletGeometry.coerce("spherical") + shell = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=R_truth + 10.0, + surface_kind="whole", + ) + + fitter = SurfaceFitter.whole(surface_filter_offset=0.0) + out = fitter.fit( + interface_data=shell, + z_wall=z_wall, + droplet_geometry=geom, + ) + + print( + f"\nEnd-to-end atom sphere: truth = {truth_angle:.3f}°, " + f"recovered = {out.angle:.3f}°, " + f"R_recovered = {out.popt[3]:.3f} (truth {R_truth}), " + f"zc = {out.popt[2]:.3f} (truth 0.0)" + ) + # Two compounding smoothing biases (at σ_density=3): + # 1. The tanh fit locates the interface at the half-density + # contour, which sits ~0.7 Å inside the geometric edge → R + # shrinks from 20 to ~19.7. + # 2. The hemisphere-weighted Fibonacci sampling combined with + # the density evaluator at the centre pulls the fitted zc + # slightly downward (~ -0.8 Å on this fixture). + # The net effect on the angle is a 2.6° drift at this scale; the + # bias would shrink with smaller σ_density or larger R. + assert abs(out.angle - truth_angle) < 3.0 + + +def test_whole_fitter_bootstrap_sigma_scales_inverse_sqrt_n_shell() -> None: + """Bootstrap σ_θ on a noisy shell scales like ``1/√N_shell``. + + With shell sizes (200, 800, 3200) — a 4× ratio between adjacent + levels — the σ ratio should land near 2 (one factor of √4). + """ + R_truth = 20.0 + zc_truth = 0.0 + z_wall = 5.0 + point_noise = 0.5 # Å Gaussian noise per shell point + + def make_noisy_shell(n_rays: int, seed: int) -> np.ndarray: + rng = np.random.default_rng(seed) + directions = _fibonacci_hemisphere_directions(n_rays) + shell = directions * R_truth + np.array([0.0, 0.0, zc_truth]) + return shell + rng.normal(0.0, point_noise, size=shell.shape) + + geom = DropletGeometry.coerce("spherical") + sigmas: dict[int, float] = {} + for n_rays in (200, 800, 3200): + shell = make_noisy_shell(n_rays, seed=42) + fitter = SurfaceFitter.whole(surface_filter_offset=0.0, bootstrap_samples=300) + out = fitter.fit(interface_data=shell, z_wall=z_wall, droplet_geometry=geom) + assert out.angle_std is not None + sigmas[n_rays] = out.angle_std + + print( + "\nBootstrap σ_θ vs shell size (Å noise = 0.5):" + + "".join(f"\n N = {n:5d}: σ_θ = {s:.4f}°" for n, s in sigmas.items()) + ) + + ratio_200_800 = sigmas[200] / sigmas[800] + ratio_800_3200 = sigmas[800] / sigmas[3200] + ratio_200_3200 = sigmas[200] / sigmas[3200] + print( + f" σ(200) / σ(800) = {ratio_200_800:.3f} (expected √4 ≈ 2.00)\n" + f" σ(800) / σ(3200) = {ratio_800_3200:.3f} (expected √4 ≈ 2.00)\n" + f" σ(200) / σ(3200) = {ratio_200_3200:.3f} (expected √16 ≈ 4.00)" + ) + + # Each 4× step should give a σ ratio near 2; total 16× near 4. + # Allow ±35% slack because the bootstrap estimator's own variance + # at 300 resamples isn't negligible. + assert 1.4 < ratio_200_800 < 2.7 + assert 1.4 < ratio_800_3200 < 2.7 + assert 2.7 < ratio_200_3200 < 5.4 From 328207a4c8ac1a4ffef48c16c622f6d1ec44c923 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 12:20:00 +0200 Subject: [PATCH 17/53] Updating and adding relevant tests. --- .../test_wall_detector_from_atoms_e2e.py | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/test_analysis/test_wall_detector_from_atoms_e2e.py diff --git a/tests/test_analysis/test_wall_detector_from_atoms_e2e.py b/tests/test_analysis/test_wall_detector_from_atoms_e2e.py new file mode 100644 index 0000000..5737a7f --- /dev/null +++ b/tests/test_analysis/test_wall_detector_from_atoms_e2e.py @@ -0,0 +1,230 @@ +"""Phase 5: end-to-end ``WallDetector.from_atoms`` through ``TrajectoryAnalyzer``. + +Wires the ``from_atoms`` detector through the full pipeline: + +1. Identify the LAMMPS substrate atom IDs (carbon, type 3 on this + fixture). +2. Construct ``TrajectoryAnalyzer`` with ``wall_detector = + WallDetector.from_atoms(...)`` and ``wall_atom_indices = carbon_ids``. +3. Run ``analyze`` on a single frame; verify the wall coordinates flow + through the worker → :func:`gather_wall_coords` → :class:`WallContext` + → ``WallDetector.detect``. + +Compares against ``WallDetector.min_plus_offset(offset=0)`` on the same +fixture/frame to quantify the gap between an atom-derived wall and an +interface-derived wall. + +Fixture context: the substrate is a multi-layer graphene stack with +the top layer at z ≈ 4.897 Å. That's the surface the droplet rests on +and the target of ``from_atoms(method="mean_top_layer")``. +""" + +import pathlib +from typing import Any, cast + +import numpy as np +import pytest + +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import ( # noqa: E402 + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) + + +@pytest.fixture +def fixture_path() -> pathlib.Path: + return _FIXTURE + + +@pytest.fixture +def oxygen_indices(fixture_path: pathlib.Path) -> np.ndarray: + """LAMMPS particle IDs of the water-oxygen atoms in frame 0.""" + return LammpsDumpWaterFinder( + fixture_path, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +@pytest.fixture +def carbon_indices(fixture_path: pathlib.Path) -> np.ndarray: + """LAMMPS particle IDs of the substrate carbon atoms (type 3).""" + # OVITO inline because the package has no general-purpose type-3-filter helper; + # this avoids adding one just for one test. + from ovito.io import import_file + + pipeline = cast(Any, import_file(str(fixture_path))) + data = pipeline.compute(0) + types = np.array(data.particles["Particle Type"].array) + type3 = np.where(types == 3)[0] + return np.asarray(data.particles["Particle Identifier"][type3]) + + +def _make_analyzer( + fixture_path: pathlib.Path, + oxygen_indices: np.ndarray, + wall_detector: WallDetector, + wall_atom_indices: np.ndarray | None = None, +) -> TrajectoryAnalyzer: + return TrajectoryAnalyzer( + parser=LammpsDumpParser(fixture_path), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, + delta_polar=8.0, + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=wall_detector, + wall_atom_indices=wall_atom_indices, + ) + + +@pytest.mark.integration +@pytest.mark.slow +def test_from_atoms_wall_detector_end_to_end( + fixture_path: pathlib.Path, + oxygen_indices: np.ndarray, + carbon_indices: np.ndarray, +) -> None: + """End-to-end: WallDetector.from_atoms drives a TrajectoryAnalyzer run. + + Verifies (a) the wall coords flow through to the detector, + (b) the analyzer produces a finite angle on the fixture, and + (c) the recovered ``z_wall`` sits at the graphene top-layer z + (~4.9 Å on this fixture), below the interface-derived + ``min_plus_offset(offset=0)`` baseline (which sits a few Å higher). + """ + frame = 1 + + # 1. mean_top_layer detector — averages the top monolayer of + # substrate atoms. + analyzer_atoms = _make_analyzer( + fixture_path, + oxygen_indices, + wall_detector=WallDetector.from_atoms( + wall_atom_indices=carbon_indices, + method="mean_top_layer", + top_layer_tolerance=1.0, + ), + wall_atom_indices=carbon_indices, + ) + res_atoms = analyzer_atoms.analyze([frame]) + z_wall_atoms = float(res_atoms.batches[0].z_wall) + angle_atoms = float(res_atoms.per_batch_angles[0]) + + # 2. min_plus_offset(0) detector — interface-derived baseline. + analyzer_min = _make_analyzer( + fixture_path, + oxygen_indices, + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + res_min = analyzer_min.analyze([frame]) + z_wall_min = float(res_min.batches[0].z_wall) + angle_min = float(res_min.per_batch_angles[0]) + + print( + f"\nfrom_atoms(mean_top_layer): z_wall = {z_wall_atoms:.3f} Å, " + f"angle = {angle_atoms:.3f}°" + f"\nmin_plus_offset(0): z_wall = {z_wall_min:.3f} Å, " + f"angle = {angle_min:.3f}°" + f"\nΔz_wall = {z_wall_atoms - z_wall_min:.3f} Å, " + f"Δangle = {angle_atoms - angle_min:.3f}°" + ) + + # Physical sanity: graphene top layer on this fixture is at z ≈ 4.9 Å. + assert 4.5 < z_wall_atoms < 5.3, ( + f"from_atoms z_wall = {z_wall_atoms:.3f} Å; " + f"expected ~4.9 Å for the top graphene layer." + ) + # The interface-derived baseline sits ABOVE the wall atoms by ~1–3 Å + # (the gap between graphene and the first liquid layer). + assert z_wall_min > z_wall_atoms + + # Both pipelines should yield a finite, physically-plausible angle. + assert 60.0 < angle_atoms < 130.0 + assert 60.0 < angle_min < 130.0 + + # Lowering the baseline (atoms-derived vs interface-derived) raises + # the measured angle; sign and magnitude follow + # Δθ ≈ -Δz_wall / (R · sin θ) · (180/π) — a few degrees on this + # fixture where R ≈ 30 Å. + assert angle_atoms > angle_min + + +@pytest.mark.integration +@pytest.mark.slow +def test_from_atoms_max_z_method( + fixture_path: pathlib.Path, + oxygen_indices: np.ndarray, + carbon_indices: np.ndarray, +) -> None: + """The ``max_z`` method should land on the highest substrate atom z. + + On the fixture all top-layer carbons sit at the same z ≈ 4.897 Å, + so ``max_z`` and ``mean_top_layer`` should agree to within + thermal-jitter precision (< 0.1 Å). + """ + frame = 1 + analyzer_max = _make_analyzer( + fixture_path, + oxygen_indices, + wall_detector=WallDetector.from_atoms( + wall_atom_indices=carbon_indices, method="max_z" + ), + wall_atom_indices=carbon_indices, + ) + res_max = analyzer_max.analyze([frame]) + z_wall_max = float(res_max.batches[0].z_wall) + + analyzer_mean = _make_analyzer( + fixture_path, + oxygen_indices, + wall_detector=WallDetector.from_atoms( + wall_atom_indices=carbon_indices, + method="mean_top_layer", + top_layer_tolerance=1.0, + ), + wall_atom_indices=carbon_indices, + ) + res_mean = analyzer_mean.analyze([frame]) + z_wall_mean = float(res_mean.batches[0].z_wall) + + print( + f"\nmax_z: z_wall = {z_wall_max:.4f} Å" + f"\nmean_top_layer: z_wall = {z_wall_mean:.4f} Å" + f"\n|Δ| = {abs(z_wall_max - z_wall_mean):.4f} Å" + ) + + # Both methods land on the same monolayer. + assert abs(z_wall_max - z_wall_mean) < 0.1 + + +def test_from_atoms_detector_missing_wall_coords_raises() -> None: + """Constructing the detector without ``wall_atom_indices`` should fail loudly. + + Direct unit test on the detector (no analyzer) — verifies the + sentinel error path that would otherwise only surface inside + a worker. + """ + from wetting_angle_kit.analysis.wall import WallContext, WallDetector + + detector = WallDetector.from_atoms(wall_atom_indices=np.array([1, 2, 3])) + # Context with no wall_coords — should match the failure mode when + # the analyzer is constructed without wall_atom_indices. + ctx = WallContext(interface_data=np.zeros((10, 3)), wall_coords=None) + with pytest.raises(ValueError, match="wall_coords"): + detector.detect(ctx) From 86f957acc45f02ead7d122dae7e56c17f1b6dee8 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 13:55:22 +0200 Subject: [PATCH 18/53] Added rays_binning extractor. --- src/wetting_angle_kit/analysis/_density.py | 89 ++++- src/wetting_angle_kit/analysis/extractors.py | 305 ++++++++++-------- .../test_rays_binning_extractor.py | 245 ++++++++++++++ 3 files changed, 494 insertions(+), 145 deletions(-) create mode 100644 tests/test_analysis/test_rays_binning_extractor.py diff --git a/src/wetting_angle_kit/analysis/_density.py b/src/wetting_angle_kit/analysis/_density.py index c17b656..339f1d5 100644 --- a/src/wetting_angle_kit/analysis/_density.py +++ b/src/wetting_angle_kit/analysis/_density.py @@ -6,14 +6,33 @@ a hyperbolic-tangent profile to the density along a ray. :class:`GaussianDensityField` wraps a ``cKDTree`` over the atom cloud -plus the kernel-width parameters. :func:`fit_tanh_profiles_batched` -solves the per-ray tanh fit for an entire slice in one batched -Gauss–Newton call. +plus the kernel-width parameters. :class:`HistogramDensityField` is +the equivalent for the histogram-style estimator used by +``rays_binning``. Both expose the same ``evaluate(positions)`` method +so the ray-fan geometry helpers in +:mod:`wetting_angle_kit.analysis.extractors` can take either one. +:func:`fit_tanh_profiles_batched` solves the per-ray tanh fit for an +entire slice in one batched Gauss–Newton call. """ +from typing import Protocol + import numpy as np from scipy.spatial import cKDTree + +class DensityFieldProtocol(Protocol): + """Density field used by the ray-fan extractors. + + Any object exposing :meth:`evaluate` mapping ``(M, 3)`` sample + positions to an ``(M,)`` density array satisfies the protocol; + both :class:`GaussianDensityField` and :class:`HistogramDensityField` + are concrete implementations. + """ + + def evaluate(self, positions: np.ndarray) -> np.ndarray: ... + + #: Minimum number of sampling points along each ray. Below this the #: tanh profile fit becomes numerically unreliable. MIN_POINTS_PER_RAY = 20 @@ -96,6 +115,70 @@ def evaluate(self, positions: np.ndarray) -> np.ndarray: return np.bincount(pairs["i"], weights=contribs, minlength=n_samples) +class HistogramDensityField: + """Top-hat (histogram-style) density evaluator over a fixed atom cloud. + + The natural counterpart of :class:`GaussianDensityField` for the + ``rays_binning`` extractor. Conceptually a 1D histogram of atoms + projected onto each ray, implemented as a 3D top-hat kernel: + each sample position counts atoms within a sphere of radius + ``bin_width / 2`` and divides by the sphere's volume. This shares + the ``cKDTree.sparse_distance_matrix`` machinery used by the + Gaussian field and exposes the same ``evaluate`` interface so the + ray-fan geometry helpers in :mod:`wetting_angle_kit.analysis.extractors` + can take either field. + + Parameters + ---------- + atom_coords : ndarray, shape (N, 3) + Atom positions used as the density sources. + bin_width : float + Diameter (Å) of the top-hat kernel — i.e. atoms within + ``bin_width / 2`` of a sample position count, atoms outside + do not. The natural analogue of ``density_sigma`` in the + Gaussian field, but with a hard cutoff instead of a smooth + fall-off. + """ + + def __init__(self, atom_coords: np.ndarray, bin_width: float) -> None: + if bin_width <= 0.0: + raise ValueError(f"bin_width must be positive; got {bin_width!r}.") + self.bin_width = bin_width + self._radius = bin_width / 2.0 + self._volume = (4.0 / 3.0) * np.pi * self._radius**3 + self._atom_tree: cKDTree | None = ( + cKDTree(atom_coords) if len(atom_coords) > 0 else None + ) + + def evaluate(self, positions: np.ndarray) -> np.ndarray: + """Return per-sample atom-count density. + + For each sample position, counts the atoms inside a sphere of + radius ``bin_width / 2`` and divides by that sphere's volume. + + Parameters + ---------- + positions : ndarray, shape (M, 3) + Sample coordinates. + + Returns + ------- + ndarray, shape (M,) + Density values (atoms / ų) at each sample position. + """ + n_samples = len(positions) + if self._atom_tree is None or n_samples == 0: + return np.zeros(n_samples) + sample_tree = cKDTree(positions) + pairs = sample_tree.sparse_distance_matrix( + self._atom_tree, max_distance=self._radius, output_type="ndarray" + ) + if pairs.size == 0: + return np.zeros(n_samples) + counts = np.bincount(pairs["i"], minlength=n_samples).astype(float) + return counts / self._volume + + def tanh_profile(z: np.ndarray, zd: float, d: float, h: float) -> np.ndarray: """Hyperbolic-tangent liquid–vapor density profile. diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py index d8b24ca..705a1cc 100644 --- a/src/wetting_angle_kit/analysis/extractors.py +++ b/src/wetting_angle_kit/analysis/extractors.py @@ -37,7 +37,9 @@ class — each factory configures one sampling + density-kernel from wetting_angle_kit.analysis._density import ( MIN_POINTS_PER_RAY, + DensityFieldProtocol, GaussianDensityField, + HistogramDensityField, fit_tanh_profiles_batched, ) from wetting_angle_kit.analysis.geometry import DropletGeometry @@ -199,10 +201,14 @@ def rays_binning( delta_azimuthal, delta_cylinder, n_rays_sphere, delta_polar : See :meth:`rays_gaussian`. bin_width : float, default 1.0 - Histogram bin width (Å) along the ray. + Diameter (Å) of the 3D top-hat kernel used at each sample + position along the ray: atoms within ``bin_width / 2`` of + a sample contribute uniformly to the density, atoms outside + do not. The natural analogue of :meth:`rays_gaussian`'s + ``density_sigma``, but with a hard cutoff instead of a + smooth fall-off. points_per_angstrom : float, default 1.0 - Sampling density at which the binned density is reported - after histogramming. + Sampling density along each ray (samples per Å). """ return _RaysBinningExtractor( delta_azimuthal=delta_azimuthal, @@ -375,6 +381,132 @@ def _validate_rays_params( ) +def _ray_slice_in_plane( + field: DensityFieldProtocol, + center: np.ndarray, + gamma: float, + max_dist: float, + distances: np.ndarray, + delta_polar: float, +) -> np.ndarray: + """Per-slice ``(R, 2)`` interface from a tilted ray fan. + + Matches the legacy ``SurfaceDefinition.analyze_lines`` body but + parameterised on a generic :class:`DensityFieldProtocol` so both + ``rays_gaussian`` and ``rays_binning`` can share the geometry. + """ + beta = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) + cos_beta = np.cos(np.deg2rad(beta)) + sin_beta = np.sin(np.deg2rad(beta)) + cos_gamma = np.cos(np.deg2rad(gamma)) + sin_gamma = np.sin(np.deg2rad(gamma)) + directions = np.column_stack((cos_beta * cos_gamma, cos_beta * sin_gamma, sin_beta)) + positions_rm = ( + center[None, None, :] + distances[None, :, None] * directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(beta), len(distances)) + interface_re = fit_tanh_profiles_batched(distances, densities, max_dist=max_dist) + x_proj = cos_beta * interface_re + center[0] + z_proj = sin_beta * interface_re + center[2] + return np.column_stack([x_proj, z_proj]) + + +def _extract_rays( + *, + field: DensityFieldProtocol, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + points_per_angstrom: float, + delta_azimuthal: float | None, + delta_cylinder: float | None, + n_rays_sphere: int | None, + delta_polar: float, +) -> InterfaceData: + """Dispatch a ray-fan extraction over the four ``(kind, geometry)`` cells. + + Shared by :class:`_RaysGaussianExtractor` and + :class:`_RaysBinningExtractor` — only the density evaluator + differs between them, so the geometry, sampling cadence, and + tanh-fit invocation all live here. + """ + n_samples = max(int(max_dist * points_per_angstrom), MIN_POINTS_PER_RAY) + distances = np.linspace(0.0, max_dist, n_samples) + + if surface_kind == "slicing": + if droplet_geometry.is_spherical: + assert delta_azimuthal is not None + n_slices = int(180 / delta_azimuthal) + gammas = np.linspace(0.0, 180.0, n_slices) + return [ + _ray_slice_in_plane( + field, center_geom, float(g), max_dist, distances, delta_polar + ) + for g in gammas + ] + # cylinder_*: y-step slice fan + assert delta_cylinder is not None + y_vals = liquid_coordinates[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) + slices: list[np.ndarray] = [] + for y in ys: + slice_center = np.array([center_geom[0], float(y), center_geom[2]]) + slices.append( + _ray_slice_in_plane( + field, slice_center, 0.0, max_dist, distances, delta_polar + ) + ) + return slices + + # surface_kind == "whole" + if droplet_geometry.is_spherical: + assert n_rays_sphere is not None + directions = _fibonacci_hemisphere_directions(n_rays_sphere) + positions_rm = ( + center_geom[None, None, :] + + distances[None, :, None] * directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(directions), len(distances)) + interface_re = fit_tanh_profiles_batched( + distances, densities, max_dist=max_dist + ) + return center_geom[None, :] + interface_re[:, None] * directions + + # whole + cylinder_*: pool a per-y ray fan into a 3D shell. + assert delta_cylinder is not None + y_vals = liquid_coordinates[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) + beta = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) + cos_beta = np.cos(np.deg2rad(beta)) + sin_beta = np.sin(np.deg2rad(beta)) + cyl_directions = np.column_stack([cos_beta, np.zeros_like(beta), sin_beta]) + shells: list[np.ndarray] = [] + for y in ys: + slice_center = np.array([center_geom[0], float(y), center_geom[2]]) + positions_rm = ( + slice_center[None, None, :] + + distances[None, :, None] * cyl_directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(beta), len(distances)) + interface_re = fit_tanh_profiles_batched( + distances, densities, max_dist=max_dist + ) + points = np.column_stack( + [ + cos_beta * interface_re + slice_center[0], + np.full(len(beta), float(y)), + sin_beta * interface_re + slice_center[2], + ] + ) + shells.append(points) + return np.concatenate(shells, axis=0) if shells else np.empty((0, 3)) + + @dataclass(frozen=True, eq=False, kw_only=True) class _RaysGaussianExtractor(InterfaceExtractor): """Concrete extractor for :meth:`InterfaceExtractor.rays_gaussian`.""" @@ -416,144 +548,19 @@ def extract( density_sigma=self.density_sigma, cutoff_sigma=self.cutoff_sigma, ) - n_samples = max(int(max_dist * self.points_per_angstrom), MIN_POINTS_PER_RAY) - distances = np.linspace(0.0, max_dist, n_samples) - if surface_kind == "slicing": - if droplet_geometry.is_spherical: - return self._slicing_spherical(field, center_geom, max_dist, distances) - return self._slicing_cylinder( - field, liquid_coordinates, center_geom, max_dist, distances - ) - # surface_kind == "whole" - if droplet_geometry.is_spherical: - return self._whole_spherical(field, center_geom, max_dist, distances) - return self._whole_cylinder( - field, liquid_coordinates, center_geom, max_dist, distances - ) - - def _slicing_spherical( - self, - field: GaussianDensityField, - center: np.ndarray, - max_dist: float, - distances: np.ndarray, - ) -> list[np.ndarray]: - # ``n_slices = int(180 / delta_azimuthal)`` and the gammas span - # ``[0, 180]`` inclusive — same construction as the legacy - # ``SlicingFrameFitter._slice_sweep`` to preserve parity. - assert self.delta_azimuthal is not None - n_slices = int(180 / self.delta_azimuthal) - gammas = np.linspace(0.0, 180.0, n_slices) - return [ - self._slice_in_plane(field, center, float(gamma), max_dist, distances) - for gamma in gammas - ] - - def _slicing_cylinder( - self, - field: GaussianDensityField, - liquid_coordinates: np.ndarray, - center: np.ndarray, - max_dist: float, - distances: np.ndarray, - ) -> list[np.ndarray]: - assert self.delta_cylinder is not None - y_vals = liquid_coordinates[:, 1] - ys = np.arange(float(y_vals.min()), float(y_vals.max()), self.delta_cylinder) - slices: list[np.ndarray] = [] - for y in ys: - slice_center = np.array([center[0], float(y), center[2]]) - slices.append( - self._slice_in_plane(field, slice_center, 0.0, max_dist, distances) - ) - return slices - - def _slice_in_plane( - self, - field: GaussianDensityField, - center: np.ndarray, - gamma: float, - max_dist: float, - distances: np.ndarray, - ) -> np.ndarray: - # Per-slice (x, z) interface from a tilted ray fan. Matches - # the legacy ``SurfaceDefinition.analyze_lines`` body. - beta = np.linspace(0, 360, int(360 / self.delta_polar), endpoint=False) - cos_beta = np.cos(np.deg2rad(beta)) - sin_beta = np.sin(np.deg2rad(beta)) - cos_gamma = np.cos(np.deg2rad(gamma)) - sin_gamma = np.sin(np.deg2rad(gamma)) - directions = np.column_stack( - (cos_beta * cos_gamma, cos_beta * sin_gamma, sin_beta) - ) - positions_rm = ( - center[None, None, :] + distances[None, :, None] * directions[:, None, :] - ) - density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(beta), len(distances)) - interface_re = fit_tanh_profiles_batched( - distances, densities, max_dist=max_dist - ) - x_proj = cos_beta * interface_re + center[0] - z_proj = sin_beta * interface_re + center[2] - return np.column_stack([x_proj, z_proj]) - - def _whole_spherical( - self, - field: GaussianDensityField, - center: np.ndarray, - max_dist: float, - distances: np.ndarray, - ) -> np.ndarray: - assert self.n_rays_sphere is not None - directions = _fibonacci_hemisphere_directions(self.n_rays_sphere) - positions_rm = ( - center[None, None, :] + distances[None, :, None] * directions[:, None, :] - ) - density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(directions), len(distances)) - interface_re = fit_tanh_profiles_batched( - distances, densities, max_dist=max_dist + return _extract_rays( + field=field, + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + max_dist=max_dist, + surface_kind=surface_kind, + points_per_angstrom=self.points_per_angstrom, + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + delta_polar=self.delta_polar, ) - return center[None, :] + interface_re[:, None] * directions - - def _whole_cylinder( - self, - field: GaussianDensityField, - liquid_coordinates: np.ndarray, - center: np.ndarray, - max_dist: float, - distances: np.ndarray, - ) -> np.ndarray: - assert self.delta_cylinder is not None - y_vals = liquid_coordinates[:, 1] - ys = np.arange(float(y_vals.min()), float(y_vals.max()), self.delta_cylinder) - beta = np.linspace(0, 360, int(360 / self.delta_polar), endpoint=False) - cos_beta = np.cos(np.deg2rad(beta)) - sin_beta = np.sin(np.deg2rad(beta)) - # In-plane (x, z) directions, with y = 0; same fan at every y. - directions = np.column_stack([cos_beta, np.zeros_like(beta), sin_beta]) - shells: list[np.ndarray] = [] - for y in ys: - slice_center = np.array([center[0], float(y), center[2]]) - positions_rm = ( - slice_center[None, None, :] - + distances[None, :, None] * directions[:, None, :] - ) - density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(beta), len(distances)) - interface_re = fit_tanh_profiles_batched( - distances, densities, max_dist=max_dist - ) - points = np.column_stack( - [ - cos_beta * interface_re + slice_center[0], - np.full(len(beta), float(y)), - sin_beta * interface_re + slice_center[2], - ] - ) - shells.append(points) - return np.concatenate(shells, axis=0) if shells else np.empty((0, 3)) @dataclass(frozen=True, eq=False, kw_only=True) @@ -591,8 +598,22 @@ def extract( max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: - raise NotImplementedError( - "rays_binning extraction not implemented in skeleton." + field = HistogramDensityField( + atom_coords=liquid_coordinates, + bin_width=self.bin_width, + ) + return _extract_rays( + field=field, + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + max_dist=max_dist, + surface_kind=surface_kind, + points_per_angstrom=self.points_per_angstrom, + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + delta_polar=self.delta_polar, ) diff --git a/tests/test_analysis/test_rays_binning_extractor.py b/tests/test_analysis/test_rays_binning_extractor.py new file mode 100644 index 0000000..ba951fb --- /dev/null +++ b/tests/test_analysis/test_rays_binning_extractor.py @@ -0,0 +1,245 @@ +"""Phase 6 quantification: ``rays_binning`` extractor + parity vs ``rays_gaussian``. + +Four flavors: + +- **HistogramDensityField unit test.** Top-hat evaluator returns the + uniform bulk density inside a dense atom box and zero well outside. +- **Fibonacci sphere recovery.** ``rays_binning`` + whole+spherical + on a known sphere; recovered R sits near truth. +- **Slicing-mode parity vs rays_gaussian.** Same atom cloud, same + ray fan, comparable kernel widths (top-hat diameter chosen to match + Gaussian FWHM). Recovered per-slice interface points should agree + within a small absolute tolerance. +- **End-to-end angle parity on the LAMMPS fixture.** Both ray + extractors, paired with the slicing fitter and ``min_plus_offset`` + wall, should produce angles within a few degrees of each other. +""" + +import pathlib + +import numpy as np +import pytest + +from wetting_angle_kit.analysis._density import ( + HistogramDensityField, +) +from wetting_angle_kit.analysis.extractors import InterfaceExtractor +from wetting_angle_kit.analysis.geometry import DropletGeometry + + +def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndarray: + rng = np.random.default_rng(seed) + pts: list[np.ndarray] = [] + while sum(p.shape[0] for p in pts) < n_atoms: + sample = rng.uniform(-radius, radius, size=(4 * n_atoms, 3)) + pts.append(sample[np.linalg.norm(sample, axis=1) < radius]) + return np.concatenate(pts, axis=0)[:n_atoms] + + +def test_histogram_density_field_uniform_box() -> None: + """Bulk density check on a dense uniform-volume box of atoms. + + Single-sample Poisson noise is large at small bin radii; we + instead probe a grid of N interior points and check the **mean** + density across that grid lands near the bulk value. Far outside + the box the field returns zero. + """ + rng = np.random.default_rng(0) + half = 30.0 + n_atoms = 50000 + atoms = rng.uniform(-half, half, size=(n_atoms, 3)) + bulk_density = n_atoms / (2 * half) ** 3 # atoms / ų + + field = HistogramDensityField(atoms, bin_width=6.0) + # 50 interior sample points, comfortably away from the box wall. + grid_lo, grid_hi = -half + 5.0, half - 5.0 + inside = rng.uniform(grid_lo, grid_hi, size=(50, 3)) + dens_in = field.evaluate(inside) + # Outside: far from atoms. + outside = np.array([[0.0, 0.0, 100.0], [50.0, 50.0, 50.0]]) + dens_out = field.evaluate(outside) + + mean_in = float(np.mean(dens_in)) + rel_dev_mean = abs(mean_in - bulk_density) / bulk_density + print( + f"\nHistogramDensityField bulk = {bulk_density:.5f} atoms/ų, " + f"inside mean over 50 samples = {mean_in:.5f}, " + f"|rel|_mean = {rel_dev_mean:.3f}, " + f"outside = {dens_out.tolist()}" + ) + # Averaging cancels Poisson noise: the mean should land within 5% + # of the true bulk density. + assert rel_dev_mean < 0.05 + assert np.all(dens_out == 0.0) + + +def test_rays_binning_whole_spherical_recovers_sphere() -> None: + """rays_binning + whole+spherical on a known sphere → R near truth. + + Top-hat radius ``bin_width / 2`` defines the per-sample + neighbourhood; pick it big enough that each bin captures O(50) + atoms so the tanh fit is fed a smooth profile rather than + Poisson-noisy bins. + """ + radius = 20.0 + bin_width = 8.0 # ~270 ų bin volume × 0.45 atoms/ų ≈ 120 atoms / bin + atoms = _uniform_sphere_atoms(radius=radius, n_atoms=15000, seed=0) + + extractor = InterfaceExtractor.rays_binning( + n_rays_sphere=400, bin_width=bin_width, points_per_angstrom=2.0 + ) + geom = DropletGeometry.coerce("spherical") + shell = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=radius + 10.0, + surface_kind="whole", + ) + assert isinstance(shell, np.ndarray) + assert shell.shape == (400, 3) + r = np.linalg.norm(shell, axis=1) + print( + f"\nrays_binning sphere recovery: R_truth = {radius} Å, " + f"R_mean = {float(np.mean(r)):.3f} Å, R_std = {float(np.std(r)):.3f} Å" + ) + # The half-density point sits ≤ d/2 = 4 Å from the geometric edge + # — i.e. the recovered R is inward-shifted by at most that much. + assert abs(float(np.mean(r)) - radius) < bin_width / 2.0 + assert float(np.std(r)) < 1.5 + + +def test_rays_binning_matches_rays_gaussian_slicing_spherical() -> None: + """rays_binning ≈ rays_gaussian within a small tolerance on a slicing fixture. + + Top-hat diameter is chosen by variance match + (``d ≈ σ √12``) so the two kernels produce comparable smoothing. + The dense droplet (15 000 atoms) and 2 samples / Å along each ray + keep the binned profiles smooth enough for the tanh fit. + """ + atoms = _uniform_sphere_atoms(radius=20.0, n_atoms=15000, seed=2) + sigma = 3.0 + bin_width = sigma * float(np.sqrt(12.0)) # variance-matched top-hat + delta_azimuthal = 30.0 + delta_polar = 8.0 + max_dist = 30.0 + + geom = DropletGeometry.coerce("spherical") + g = InterfaceExtractor.rays_gaussian( + delta_azimuthal=delta_azimuthal, + delta_polar=delta_polar, + density_sigma=sigma, + points_per_angstrom=2.0, + ) + b = InterfaceExtractor.rays_binning( + delta_azimuthal=delta_azimuthal, + delta_polar=delta_polar, + bin_width=bin_width, + points_per_angstrom=2.0, + ) + gauss_slices = g.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=max_dist, + surface_kind="slicing", + ) + bin_slices = b.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=max_dist, + surface_kind="slicing", + ) + assert isinstance(gauss_slices, list) + assert isinstance(bin_slices, list) + assert len(gauss_slices) == len(bin_slices) + + max_diff = 0.0 + mean_diff = 0.0 + n_slices = 0 + for gs, bs in zip(gauss_slices, bin_slices, strict=True): + assert gs.shape == bs.shape + diff = float(np.max(np.abs(gs - bs))) + mean_diff += float(np.mean(np.abs(gs - bs))) + max_diff = max(max_diff, diff) + n_slices += 1 + mean_diff /= n_slices + print( + f"\nslicing+spherical, σ={sigma}, d=σ√12={bin_width:.3f}: " + f"max |gauss - binning| = {max_diff:.3f} Å, " + f"mean = {mean_diff:.3f} Å (over {n_slices} slices)" + ) + # Comparable smoothing should leave per-slice points within ~σ + # on average; max can spike to a few σ near rays where the + # binned profile is noisier. + assert mean_diff < sigma + assert max_diff < 4.0 * sigma + + +_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) + + +@pytest.mark.integration +@pytest.mark.slow +def test_rays_binning_end_to_end_angle_close_to_rays_gaussian() -> None: + """Both ray extractors → similar angle on the LAMMPS water/graphene fixture.""" + pytest.importorskip("ovito") + from wetting_angle_kit.analysis import ( + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, + ) + from wetting_angle_kit.parsers import ( + LammpsDumpParser, + LammpsDumpWaterFinder, + ) + + finder = LammpsDumpWaterFinder(_FIXTURE, oxygen_type=1, hydrogen_type=2) + oxygen_indices = finder.get_water_oxygen_ids(0) + + def _angle(extractor: InterfaceExtractor) -> float: + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=extractor, + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + results = analyzer.analyze([1]) + return float(results.per_batch_angles[0]) + + sigma = 3.0 + bin_width = sigma * float(np.sqrt(12.0)) # variance-matched top-hat + angle_g = _angle( + InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, + delta_polar=8.0, + density_sigma=sigma, + points_per_angstrom=2.0, + ) + ) + angle_b = _angle( + InterfaceExtractor.rays_binning( + delta_azimuthal=20.0, + delta_polar=8.0, + bin_width=bin_width, + points_per_angstrom=2.0, + ) + ) + drift = abs(angle_g - angle_b) + print( + f"\nrays_gaussian (σ={sigma}) angle = {angle_g:.3f}°" + f"\nrays_binning (d={bin_width:.3f}) angle = {angle_b:.3f}°" + f"\n|drift| = {drift:.3f}°" + ) + # Both fall in the same physically-plausible band, drift is a few °. + assert 70.0 < angle_g < 110.0 + assert 70.0 < angle_b < 110.0 + assert drift < 5.0 From 40c9bc7d6d64964f355184a5ebdcf5256ebf1064 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 15:56:41 +0200 Subject: [PATCH 19/53] Added grid extractor of interface, slicing mode. --- src/wetting_angle_kit/analysis/extractors.py | 163 ++++++++++- tests/test_analysis/test_grid_extractors.py | 292 +++++++++++++++++++ 2 files changed, 451 insertions(+), 4 deletions(-) create mode 100644 tests/test_analysis/test_grid_extractors.py diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py index 705a1cc..c511f67 100644 --- a/src/wetting_angle_kit/analysis/extractors.py +++ b/src/wetting_angle_kit/analysis/extractors.py @@ -617,6 +617,143 @@ def extract( ) +def _project_atoms_to_rz( + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, +) -> tuple[np.ndarray, np.ndarray]: + """Collapse 3D atom coordinates to 2D ``(r, z)`` via droplet symmetry. + + For spherical droplets, ``r = sqrt((x - cx)² + (y - cy)²)``. + For cylinder droplets (axis along ``y`` in the internal frame after + the ``cylinder_x`` axis swap), ``r = |x - cx|``. ``z`` is kept in + the lab frame so the wall position retains physical meaning. + """ + dx = liquid_coordinates[:, 0] - center_geom[0] + if droplet_geometry.is_spherical: + dy = liquid_coordinates[:, 1] - center_geom[1] + r = np.hypot(dx, dy) + else: + r = np.abs(dx) + return r, liquid_coordinates[:, 2] + + +def _build_2d_density_grid( + atoms_r: np.ndarray, + atoms_z: np.ndarray, + grid_params: dict[str, Any], + droplet_geometry: DropletGeometry, + *, + smooth_sigma: float | None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Build a 2D density grid in ``(r, z)``. + + Volume normalisation: + + - **Spherical:** ``dV = 2π r dxi dzi`` per cell (annular shell). + Required so the recovered isocontour isn't pulled toward + smaller ``r``. + - **Cylinder:** ``dV = dxi dzi`` per cell (constant axial extent). + The cylinder-axis length cancels out for an isocontour at a + fraction of the max, so the simpler normalisation is used. + + Returns ``(r_centers, z_centers, density)`` with + ``density.shape == (n_r_cells, n_z_cells)``. + """ + r_edges = np.linspace( + float(grid_params["xi_0"]), + float(grid_params["xi_f"]), + int(grid_params["nbins_xi"]), + ) + z_edges = np.linspace( + float(grid_params["zi_0"]), + float(grid_params["zi_f"]), + int(grid_params["nbins_zi"]), + ) + counts, _, _ = np.histogram2d(atoms_r, atoms_z, bins=(r_edges, z_edges)) + r_centers = 0.5 * (r_edges[:-1] + r_edges[1:]) + z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) + dr = float(r_edges[1] - r_edges[0]) + dz = float(z_edges[1] - z_edges[0]) + + if droplet_geometry.is_spherical: + # Avoid division by zero at the innermost row (r=0); the contour + # at fraction-of-max never traverses that point anyway. + dV_per_row = 2.0 * np.pi * np.maximum(r_centers, 0.5 * dr) * dr * dz + density = counts / dV_per_row[:, None] + else: + density = counts / (dr * dz) + + if smooth_sigma is not None and smooth_sigma > 0.0: + from scipy.ndimage import gaussian_filter + + sigma_cells = (smooth_sigma / dr, smooth_sigma / dz) + density = gaussian_filter(density, sigma=sigma_cells) + return r_centers, z_centers, density + + +def _extract_isocontour_2d( + r_centers: np.ndarray, + z_centers: np.ndarray, + density: np.ndarray, + *, + fraction_of_bulk: float = 0.5, + bulk_percentile: float = 95.0, +) -> np.ndarray: + """Return the longest density iso-line as ``(M, 2)`` ``(r, z)`` points. + + The contour level is ``fraction_of_bulk * percentile(density, + bulk_percentile)`` — using a high percentile rather than ``max`` + makes the bulk estimate robust to the Poisson spikes that the + ``dV_per_row ∝ 1/r`` normalisation can introduce in small-``r`` + bins. + """ + from skimage.measure import find_contours + + if density.size == 0 or float(density.max()) <= 0: + return np.empty((0, 2)) + bulk = float(np.percentile(density, bulk_percentile)) + if bulk <= 0: + return np.empty((0, 2)) + level = fraction_of_bulk * bulk + # ``no-untyped-call`` fires only when scikit-image is not installed + # in the type-check env; ``unused-ignore`` keeps the comment tolerant + # when it IS installed and the call resolves to a typed function. + contours = find_contours(density, level) # type: ignore[no-untyped-call,unused-ignore] + if not contours: + return np.empty((0, 2)) + longest = max(contours, key=len) + # find_contours returns (row, col) fractional pixel indices, where + # row indexes axis 0 (= r) and col indexes axis 1 (= z). + dr = (float(r_centers[-1]) - float(r_centers[0])) / max(len(r_centers) - 1, 1) + dz = (float(z_centers[-1]) - float(z_centers[0])) / max(len(z_centers) - 1, 1) + r_phys = float(r_centers[0]) + dr * longest[:, 0] + z_phys = float(z_centers[0]) + dz * longest[:, 1] + return np.column_stack([r_phys, z_phys]) + + +def _extract_grid_slicing( + *, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + grid_params: dict[str, Any], + smooth_sigma: float | None, +) -> list[np.ndarray]: + """Build a ``(r, z)`` density map + isocontour for a slicing-mode grid extractor. + + Returns a single-element list since the symmetry-collapsed + ``(r, z)`` reduction makes the slice axis disappear; the downstream + :class:`SlicingFitter` runs one Kasa circle fit on that contour. + """ + r, z = _project_atoms_to_rz(liquid_coordinates, center_geom, droplet_geometry) + r_centers, z_centers, density = _build_2d_density_grid( + r, z, grid_params, droplet_geometry, smooth_sigma=smooth_sigma + ) + contour = _extract_isocontour_2d(r_centers, z_centers, density) + return [contour] + + # eq=False avoids the auto __eq__ tripping on the dict field; equality # between extractor instances is not a use case the package needs. @dataclass(frozen=True, eq=False, kw_only=True) @@ -648,8 +785,17 @@ def extract( max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: - raise NotImplementedError( - "grid_gaussian extraction not implemented in skeleton." + if surface_kind != "slicing": + raise NotImplementedError( + "grid_gaussian whole-kind extraction (3D grid + marching " + "cubes) lands in Phase 8." + ) + return _extract_grid_slicing( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=self.density_sigma, ) @@ -680,6 +826,15 @@ def extract( max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: - raise NotImplementedError( - "grid_binning extraction not implemented in skeleton." + if surface_kind != "slicing": + raise NotImplementedError( + "grid_binning whole-kind extraction (3D grid + marching " + "cubes) lands in Phase 8." + ) + return _extract_grid_slicing( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=None, ) diff --git a/tests/test_analysis/test_grid_extractors.py b/tests/test_analysis/test_grid_extractors.py new file mode 100644 index 0000000..982f19d --- /dev/null +++ b/tests/test_analysis/test_grid_extractors.py @@ -0,0 +1,292 @@ +"""Phase 7 quantification: grid extractors (slicing-only). + +Three flavors: + +- **Synthetic spherical droplet → recovered angle ≈ truth.** Generates + atoms inside a spherical cap of known truth angle, runs the + grid extractor + slicing fitter pipeline, and checks the recovered + angle. +- **grid_binning vs grid_gaussian on the same atoms.** Both should + recover a similar interface; the Gaussian-smoothed variant is + cleaner (lower per-slice RMS) than the bare histogram. +- **End-to-end on the LAMMPS water/graphene fixture.** Grid extractors + paired with the slicing fitter should produce angles within a few + degrees of ``rays_gaussian`` on the same fixture/frame. +""" + +import pathlib + +import numpy as np +import pytest + +# Skip Phase 7 entirely if the optional scikit-image extra isn't +# installed — both grid extractors depend on it. +pytest.importorskip("skimage") + +from wetting_angle_kit.analysis import ( # noqa: E402 + InterfaceExtractor, + SurfaceFitter, + WallDetector, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry # noqa: E402 + + +def _spherical_cap_atoms( + *, + R: float, + zc: float, + z_wall: float, + n_atoms: int, + seed: int = 0, +) -> np.ndarray: + """Atoms uniformly filling a spherical cap. + + Sphere of radius ``R`` centered at ``(0, 0, zc)``; only atoms with + ``z > z_wall`` are kept (the visible cap above the wall). + """ + rng = np.random.default_rng(seed) + pts: list[np.ndarray] = [] + while sum(p.shape[0] for p in pts) < n_atoms: + sample = rng.uniform(-R, R, size=(4 * n_atoms, 3)) + inside_sphere = np.linalg.norm(sample, axis=1) < R + sample = sample[inside_sphere] + sample[:, 2] += zc + sample = sample[sample[:, 2] > z_wall] + pts.append(sample) + return np.concatenate(pts, axis=0)[:n_atoms] + + +def _default_grid_params(max_dist: float) -> dict[str, object]: + return { + "xi_0": 0.0, + "xi_f": max_dist, + "nbins_xi": 50, + "zi_0": 0.0, + "zi_f": max_dist, + "nbins_zi": 50, + } + + +def test_grid_gaussian_recovers_known_spherical_cap_angle() -> None: + """Synthetic spherical cap → ``grid_gaussian`` recovers truth within ~1°.""" + R, zc, z_wall = 25.0, 0.0, 5.0 + truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) + atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=15000, seed=0) + + extractor = InterfaceExtractor.grid_gaussian( + grid_params=_default_grid_params(max_dist=35.0), + density_sigma=2.0, + ) + geom = DropletGeometry.coerce("spherical") + extractor.validate_compatibility(surface_kind="slicing", droplet_geometry=geom) + contours = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=35.0, + surface_kind="slicing", + ) + assert isinstance(contours, list) + assert len(contours) == 1 + contour = contours[0] + assert contour.ndim == 2 and contour.shape[1] == 2 + assert len(contour) >= 10 + + # ``find_contours`` traces the iso-line all the way around the + # droplet — including a thin "floor" segment near ``z = z_wall`` + # that arises from histogram discretisation across the wall. + # ``SurfaceFitter.slicing.surface_filter_offset`` is the designed + # mechanism for dropping that floor before the Kasa circle fit. + fitter = SurfaceFitter.slicing(surface_filter_offset=3.0) + out = fitter.fit(interface_data=contours, z_wall=z_wall, droplet_geometry=geom) + drift = abs(out.angle - truth_angle) + print( + f"\ngrid_gaussian cap recovery: truth = {truth_angle:.3f}°, " + f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " + f"R_fit = {out.slice_popts[0][2]:.3f}, " + f"zc_fit = {out.slice_popts[0][1]:.3f}, " + f"contour_points = {len(contour)}, " + f"rms_residual = {out.rms_residual:.3f} Å" + ) + # Grid resolution 0.71 Å + Gaussian smoothing σ=2 ⇒ a fraction of + # a degree on this dense fixture. + assert drift < 2.0 + + +def test_grid_binning_recovers_known_spherical_cap_with_coarse_bins() -> None: + """``grid_binning`` (no smoothing) needs coarser bins to give a usable contour. + + On finer grids the Poisson noise per bin dominates and the + iso-line jitter spoils the Kasa fit; the coarse-bins case shows + the tool produces sensible answers with the right configuration. + """ + R, zc, z_wall = 25.0, 0.0, 5.0 + truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) + atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=50000, seed=0) + + # 24 cells span the 35-Å envelope → dxi ≈ 1.5 Å, giving ~10× + # more atoms per bin than the nbins=50 default at this density. + grid_params: dict[str, object] = { + "xi_0": 0.0, + "xi_f": 35.0, + "nbins_xi": 25, + "zi_0": 0.0, + "zi_f": 35.0, + "nbins_zi": 25, + } + extractor = InterfaceExtractor.grid_binning(grid_params=grid_params) + geom = DropletGeometry.coerce("spherical") + contours = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=35.0, + surface_kind="slicing", + ) + + fitter = SurfaceFitter.slicing(surface_filter_offset=3.0) + out = fitter.fit(interface_data=contours, z_wall=z_wall, droplet_geometry=geom) + drift = abs(out.angle - truth_angle) + print( + f"\ngrid_binning (coarse) cap recovery: truth = {truth_angle:.3f}°, " + f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " + f"R_fit = {out.slice_popts[0][2]:.3f}, " + f"rms_residual = {out.rms_residual:.3f} Å" + ) + assert drift < 5.0 + + +def test_grid_gaussian_smoother_than_grid_binning() -> None: + """At equal grid spec, ``grid_gaussian`` gives a smoother contour + than ``grid_binning``. + + Same atoms, same grid, equal-shape contours; the smoothed variant + should have a lower per-point Kasa-fit residual. + """ + R, zc, z_wall = 25.0, 0.0, 5.0 + atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=50000, seed=1) + + # Coarse-enough grid for both estimators to give usable contours. + grid_params: dict[str, object] = { + "xi_0": 0.0, + "xi_f": 35.0, + "nbins_xi": 25, + "zi_0": 0.0, + "zi_f": 35.0, + "nbins_zi": 25, + } + geom = DropletGeometry.coerce("spherical") + + b = InterfaceExtractor.grid_binning(grid_params=grid_params) + g = InterfaceExtractor.grid_gaussian(grid_params=grid_params, density_sigma=2.0) + + binning_contours = b.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=35.0, + surface_kind="slicing", + ) + gaussian_contours = g.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=35.0, + surface_kind="slicing", + ) + + fitter = SurfaceFitter.slicing(surface_filter_offset=3.0) + out_b = fitter.fit( + interface_data=binning_contours, z_wall=z_wall, droplet_geometry=geom + ) + out_g = fitter.fit( + interface_data=gaussian_contours, z_wall=z_wall, droplet_geometry=geom + ) + print( + f"\ngrid_binning rms = {out_b.rms_residual:.3f} Å, angle = {out_b.angle:.3f}°" + f"\ngrid_gaussian rms = {out_g.rms_residual:.3f} Å, angle = {out_g.angle:.3f}°" + ) + assert out_g.rms_residual <= out_b.rms_residual + truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) + assert abs(out_b.angle - truth_angle) < 8.0 + assert abs(out_g.angle - truth_angle) < 5.0 + + +@pytest.mark.integration +@pytest.mark.slow +def test_grid_extractors_end_to_end_close_to_rays_gaussian() -> None: + """Grid extractor angles on the LAMMPS fixture sit within a few ° of rays.""" + pytest.importorskip("ovito") + from wetting_angle_kit.analysis import TrajectoryAnalyzer + from wetting_angle_kit.parsers import ( + LammpsDumpParser, + LammpsDumpWaterFinder, + ) + + fixture = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" + ) + finder = LammpsDumpWaterFinder(fixture, oxygen_type=1, hydrogen_type=2) + oxygen_indices = finder.get_water_oxygen_ids(0) + + # ``grid_gaussian`` works on the 4k-oxygen fixture with the same + # grid as the synthetic test. ``grid_binning`` (no smoothing) needs + # coarser bins to avoid noisy iso-contour points; we evaluate each + # at its own well-suited bin count. + grid_params_gauss = { + "xi_0": 0.0, + "xi_f": 40.0, + "nbins_xi": 26, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 26, + } + grid_params_bin = { + "xi_0": 0.0, + "xi_f": 40.0, + "nbins_xi": 16, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 16, + } + + def _angle(extractor: InterfaceExtractor) -> float: + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(fixture), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=extractor, + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=3.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + return float(analyzer.analyze([1]).per_batch_angles[0]) + + angle_rays = _angle( + InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, delta_polar=8.0, density_sigma=3.0 + ) + ) + angle_grid_bin = _angle( + InterfaceExtractor.grid_binning(grid_params=grid_params_bin) + ) + angle_grid_gauss = _angle( + InterfaceExtractor.grid_gaussian( + grid_params=grid_params_gauss, density_sigma=2.0 + ) + ) + print( + f"\nrays_gaussian angle = {angle_rays:.3f}°" + f"\ngrid_binning (nbins=16) angle = {angle_grid_bin:.3f}° " + f"|drift| = {abs(angle_grid_bin - angle_rays):.3f}°" + f"\ngrid_gaussian (nbins=26) angle = {angle_grid_gauss:.3f}° " + f"|drift| = {abs(angle_grid_gauss - angle_rays):.3f}°" + ) + for angle in (angle_rays, angle_grid_bin, angle_grid_gauss): + assert 70.0 < angle < 110.0 + # ``grid_gaussian`` smooths the density → close to ``rays_gaussian``. + assert abs(angle_grid_gauss - angle_rays) < 5.0 + # ``grid_binning`` is intrinsically noisier; allow a wider band. + assert abs(angle_grid_bin - angle_rays) < 12.0 From 444095def9c5837aa72ef0cf33fa0fc24ee90b97 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 16:07:24 +0200 Subject: [PATCH 20/53] Added grid extractor of interface, whole surface mode. --- src/wetting_angle_kit/analysis/extractors.py | 159 ++++++++++++- .../test_grid_extractors_whole.py | 210 ++++++++++++++++++ 2 files changed, 359 insertions(+), 10 deletions(-) create mode 100644 tests/test_analysis/test_grid_extractors_whole.py diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py index c511f67..ec26073 100644 --- a/src/wetting_angle_kit/analysis/extractors.py +++ b/src/wetting_angle_kit/analysis/extractors.py @@ -732,6 +732,139 @@ def _extract_isocontour_2d( return np.column_stack([r_phys, z_phys]) +def _build_3d_density_grid( + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + grid_params: dict[str, Any], + *, + smooth_sigma: float | None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build a 3D density grid centered laterally on the droplet COM. + + The ``(x, y)`` coordinates are recentered on ``(center_geom[0], + center_geom[1])`` so the user can specify symmetric grid bounds + (e.g. ``xi_0 = -30, xi_f = 30``) regardless of where the droplet + sits in the box after PBC recentering. ``z`` stays in the lab + frame so the wall position retains physical meaning. + + Returns ``(x_centers, y_centers, z_centers, density)`` with + ``density.shape == (n_x_cells, n_y_cells, n_z_cells)``. + """ + x_edges = np.linspace( + float(grid_params["xi_0"]), + float(grid_params["xi_f"]), + int(grid_params["nbins_xi"]), + ) + y_edges = np.linspace( + float(grid_params["yi_0"]), + float(grid_params["yi_f"]), + int(grid_params["nbins_yi"]), + ) + z_edges = np.linspace( + float(grid_params["zi_0"]), + float(grid_params["zi_f"]), + int(grid_params["nbins_zi"]), + ) + + atoms_centered = liquid_coordinates - np.array( + [center_geom[0], center_geom[1], 0.0] + ) + counts, _ = np.histogramdd(atoms_centered, bins=(x_edges, y_edges, z_edges)) + dx = float(x_edges[1] - x_edges[0]) + dy = float(y_edges[1] - y_edges[0]) + dz = float(z_edges[1] - z_edges[0]) + x_centers = 0.5 * (x_edges[:-1] + x_edges[1:]) + y_centers = 0.5 * (y_edges[:-1] + y_edges[1:]) + z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) + density = counts / (dx * dy * dz) + + if smooth_sigma is not None and smooth_sigma > 0.0: + from scipy.ndimage import gaussian_filter + + sigma_cells = ( + smooth_sigma / dx, + smooth_sigma / dy, + smooth_sigma / dz, + ) + density = gaussian_filter(density, sigma=sigma_cells) + return x_centers, y_centers, z_centers, density + + +def _extract_isosurface_3d( + x_centers: np.ndarray, + y_centers: np.ndarray, + z_centers: np.ndarray, + density: np.ndarray, + center_geom: np.ndarray, + *, + fraction_of_bulk: float = 0.5, + bulk_percentile: float = 95.0, +) -> np.ndarray: + """Run marching cubes and return ``(N, 3)`` shell points in the internal frame. + + The shell is shifted back by ``(center_geom[0], center_geom[1], 0)`` + so its coordinates match the convention used by the rays-whole + extractor: absolute internal-frame positions, not droplet-centered. + """ + from skimage.measure import marching_cubes + + if density.size == 0 or float(density.max()) <= 0: + return np.empty((0, 3)) + bulk = float(np.percentile(density, bulk_percentile)) + if bulk <= 0: + return np.empty((0, 3)) + level = fraction_of_bulk * bulk + try: + # ``no-untyped-call`` fires when scikit-image is not installed + # in the type-check env; ``unused-ignore`` keeps the comment + # tolerant when it is. + verts, _faces, _normals, _values = marching_cubes( # type: ignore[no-untyped-call,unused-ignore] + density, level + ) + except (RuntimeError, ValueError): + return np.empty((0, 3)) + + dx = float(x_centers[-1] - x_centers[0]) / max(len(x_centers) - 1, 1) + dy = float(y_centers[-1] - y_centers[0]) / max(len(y_centers) - 1, 1) + dz = float(z_centers[-1] - z_centers[0]) / max(len(z_centers) - 1, 1) + x_phys = float(x_centers[0]) + dx * verts[:, 0] + float(center_geom[0]) + y_phys = float(y_centers[0]) + dy * verts[:, 1] + float(center_geom[1]) + z_phys = float(z_centers[0]) + dz * verts[:, 2] + return np.column_stack([x_phys, y_phys, z_phys]) + + +def _extract_grid_whole( + *, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + grid_params: dict[str, Any], + smooth_sigma: float | None, +) -> np.ndarray: + """Build a 3D density grid + marching-cubes shell for whole-kind grid extractors. + + Currently spherical only — for cylindrical droplets the cylinder + axis would need to span the full simulation box, which the + centered-grid convention doesn't accommodate naturally. Use a + ``rays_*`` extractor for cylinder whole-fits. + """ + if droplet_geometry.is_cylinder: + raise NotImplementedError( + "grid + whole-kind extraction is currently spherical-only. " + "For cylinder droplets use InterfaceExtractor.rays_gaussian " + "or rays_binning with surface_kind='whole'." + ) + x_centers, y_centers, z_centers, density = _build_3d_density_grid( + liquid_coordinates, + center_geom, + grid_params, + smooth_sigma=smooth_sigma, + ) + return _extract_isosurface_3d( + x_centers, y_centers, z_centers, density, center_geom=center_geom + ) + + def _extract_grid_slicing( *, liquid_coordinates: np.ndarray, @@ -785,12 +918,15 @@ def extract( max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: - if surface_kind != "slicing": - raise NotImplementedError( - "grid_gaussian whole-kind extraction (3D grid + marching " - "cubes) lands in Phase 8." + if surface_kind == "slicing": + return _extract_grid_slicing( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=self.density_sigma, ) - return _extract_grid_slicing( + return _extract_grid_whole( liquid_coordinates=liquid_coordinates, center_geom=center_geom, droplet_geometry=droplet_geometry, @@ -826,12 +962,15 @@ def extract( max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: - if surface_kind != "slicing": - raise NotImplementedError( - "grid_binning whole-kind extraction (3D grid + marching " - "cubes) lands in Phase 8." + if surface_kind == "slicing": + return _extract_grid_slicing( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=None, ) - return _extract_grid_slicing( + return _extract_grid_whole( liquid_coordinates=liquid_coordinates, center_geom=center_geom, droplet_geometry=droplet_geometry, diff --git a/tests/test_analysis/test_grid_extractors_whole.py b/tests/test_analysis/test_grid_extractors_whole.py new file mode 100644 index 0000000..d463999 --- /dev/null +++ b/tests/test_analysis/test_grid_extractors_whole.py @@ -0,0 +1,210 @@ +"""Phase 8 quantification: 3D grid extractors via marching cubes. + +Three flavors: + +- **Synthetic spherical cap → recovered angle close to truth.** Atoms + uniformly fill a known spherical cap; the 3D grid extractor + + ``SurfaceFitter.whole`` should recover the cap angle within a few + degrees. +- **End-to-end on the LAMMPS fixture.** Smoke test pairing the + ``grid_gaussian`` whole extractor with ``SurfaceFitter.whole`` — + the angle should land in the same physically plausible band as + ``rays_gaussian``. +- **Cylinder geometry is rejected.** Whole + grid + cylinder raises + ``NotImplementedError`` with a clear pointer to the ``rays_*`` + fallback. +""" + +import pathlib + +import numpy as np +import pytest + +pytest.importorskip("skimage") + +from wetting_angle_kit.analysis import ( # noqa: E402 + InterfaceExtractor, + SurfaceFitter, + WallDetector, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry # noqa: E402 + + +def _spherical_cap_atoms( + *, + R: float, + zc: float, + z_wall: float, + n_atoms: int, + seed: int = 0, +) -> np.ndarray: + """Atoms uniformly filling a spherical cap above ``z_wall``.""" + rng = np.random.default_rng(seed) + pts: list[np.ndarray] = [] + while sum(p.shape[0] for p in pts) < n_atoms: + sample = rng.uniform(-R, R, size=(4 * n_atoms, 3)) + sample = sample[np.linalg.norm(sample, axis=1) < R] + sample[:, 2] += zc + sample = sample[sample[:, 2] > z_wall] + pts.append(sample) + return np.concatenate(pts, axis=0)[:n_atoms] + + +def _whole_grid_params(half_xy: float, z_lo: float, z_hi: float, nbins: int) -> dict: + return { + "xi_0": -half_xy, + "xi_f": half_xy, + "nbins_xi": nbins, + "yi_0": -half_xy, + "yi_f": half_xy, + "nbins_yi": nbins, + "zi_0": z_lo, + "zi_f": z_hi, + "nbins_zi": nbins, + } + + +def test_grid_gaussian_whole_recovers_known_spherical_cap() -> None: + """3D grid + marching cubes + sphere fit recovers a known cap angle.""" + R, zc, z_wall = 25.0, 0.0, 5.0 + truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) + atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=80000, seed=0) + + grid_params = _whole_grid_params(half_xy=30.0, z_lo=0.0, z_hi=35.0, nbins=31) + extractor = InterfaceExtractor.grid_gaussian( + grid_params=grid_params, density_sigma=2.0 + ) + geom = DropletGeometry.coerce("spherical") + extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) + shell = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=35.0, + surface_kind="whole", + ) + assert isinstance(shell, np.ndarray) + assert shell.ndim == 2 and shell.shape[1] == 3 + assert len(shell) >= 100 + + # Filter the floor (the iso-surface includes a disk near z_wall); + # SurfaceFitter.whole's surface_filter_offset is the designed + # mechanism for that. + fitter = SurfaceFitter.whole(surface_filter_offset=3.0) + out = fitter.fit(interface_data=shell, z_wall=z_wall, droplet_geometry=geom) + drift = abs(out.angle - truth_angle) + print( + f"\ngrid_gaussian whole cap recovery: truth = {truth_angle:.3f}°, " + f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " + f"R_fit = {out.popt[3]:.3f} (truth {R}), " + f"zc_fit = {out.popt[2]:.3f} (truth {zc}), " + f"shell_points = {len(shell)}, rms = {out.rms_residual:.3f} Å" + ) + # Grid resolution (~2 Å) + Gaussian smoothing σ=2 give a few + # degrees of drift at this droplet size. + assert drift < 5.0 + + +def test_grid_binning_whole_recovers_known_spherical_cap() -> None: + """No-smoothing variant also recovers truth at suitable atom density.""" + R, zc, z_wall = 25.0, 0.0, 5.0 + truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) + atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=200000, seed=1) + + grid_params = _whole_grid_params(half_xy=30.0, z_lo=0.0, z_hi=35.0, nbins=25) + extractor = InterfaceExtractor.grid_binning(grid_params=grid_params) + geom = DropletGeometry.coerce("spherical") + shell = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=35.0, + surface_kind="whole", + ) + fitter = SurfaceFitter.whole(surface_filter_offset=3.0) + out = fitter.fit(interface_data=shell, z_wall=z_wall, droplet_geometry=geom) + drift = abs(out.angle - truth_angle) + print( + f"\ngrid_binning whole cap recovery: truth = {truth_angle:.3f}°, " + f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " + f"R_fit = {out.popt[3]:.3f}, " + f"shell_points = {len(shell)}, rms = {out.rms_residual:.3f} Å" + ) + assert drift < 5.0 + + +def test_grid_whole_cylinder_raises_not_implemented() -> None: + """Whole + grid + cylinder is intentionally unsupported.""" + grid_params = _whole_grid_params(half_xy=20.0, z_lo=0.0, z_hi=20.0, nbins=15) + extractor = InterfaceExtractor.grid_gaussian( + grid_params=grid_params, density_sigma=2.0 + ) + geom = DropletGeometry.coerce("cylinder_y") + # validate_compatibility itself accepts the pairing (grid_params + # have the 9 keys); the NotImplementedError fires inside + # ``extract`` once the geometry is observed. + atoms = np.random.default_rng(0).uniform(-10, 10, size=(100, 3)) + with pytest.raises(NotImplementedError, match="cylinder"): + extractor.extract( + liquid_coordinates=atoms, + center_geom=np.zeros(3), + droplet_geometry=geom, + max_dist=20.0, + surface_kind="whole", + ) + + +@pytest.mark.integration +@pytest.mark.slow +def test_grid_gaussian_whole_end_to_end_on_lammps_fixture() -> None: + """``grid_gaussian`` whole pipeline on the water/graphene fixture.""" + pytest.importorskip("ovito") + from wetting_angle_kit.analysis import TrajectoryAnalyzer + from wetting_angle_kit.parsers import ( + LammpsDumpParser, + LammpsDumpWaterFinder, + ) + + fixture = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" + ) + finder = LammpsDumpWaterFinder(fixture, oxygen_type=1, hydrogen_type=2) + oxygen_indices = finder.get_water_oxygen_ids(0) + + grid_params = _whole_grid_params(half_xy=40.0, z_lo=0.0, z_hi=45.0, nbins=21) + + def _angle(extractor: InterfaceExtractor, fitter: SurfaceFitter) -> float: + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(fixture), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=extractor, + surface_fitter=fitter, + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + return float(analyzer.analyze([1]).per_batch_angles[0]) + + angle_rays_whole = _angle( + InterfaceExtractor.rays_gaussian(n_rays_sphere=400, density_sigma=3.0), + SurfaceFitter.whole(surface_filter_offset=3.0), + ) + angle_grid_whole = _angle( + InterfaceExtractor.grid_gaussian(grid_params=grid_params, density_sigma=2.0), + SurfaceFitter.whole(surface_filter_offset=3.0), + ) + print( + f"\nrays_gaussian (whole) angle = {angle_rays_whole:.3f}°" + f"\ngrid_gaussian (whole) angle = {angle_grid_whole:.3f}° " + f"|drift| = {abs(angle_grid_whole - angle_rays_whole):.3f}°" + ) + # Both should land in the physically plausible band. The drift + # between estimators can be sizable on this 4k-atom fixture because + # the grid is sparse (each bin captures only a few atoms) — the + # marching-cubes mesh + sphere fit are noisy. Synthetic tests + # (n_atoms = 80 000) confirm sub-degree accuracy when the grid is + # well-populated; this end-to-end is a structural smoke test. + for angle in (angle_rays_whole, angle_grid_whole): + assert 50.0 < angle < 140.0 From e7a11c747f1f6d6b868a583978829f4da346c4f0 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 16:26:56 +0200 Subject: [PATCH 21/53] Coupled binning analyzer in 2D (old binning). --- .../analysis/coupled_binning_2d.py | 157 ++++++++++++++--- .../test_analysis/test_coupled_binning_2d.py | 164 ++++++++++++++++++ 2 files changed, 301 insertions(+), 20 deletions(-) create mode 100644 tests/test_analysis/test_coupled_binning_2d.py diff --git a/src/wetting_angle_kit/analysis/coupled_binning_2d.py b/src/wetting_angle_kit/analysis/coupled_binning_2d.py index 8232d1b..87566b8 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_binning_2d.py @@ -20,14 +20,10 @@ :class:`TrajectoryAnalyzer` instead. For the 3D extension of this analyzer (relaxing the radial symmetry assumption) see :class:`CoupledBinning3DAnalyzer`. - -The algorithm body (projection → 2D histogram → tanh fit) is stubbed -with ``NotImplementedError`` at this skeleton stage. The -worker-pool wiring is real, so misconfigurations and per-batch -exception handling are exercised end-to-end. """ import logging +import warnings from typing import Any, ClassVar import numpy as np @@ -35,7 +31,9 @@ from wetting_angle_kit.analysis.base import ( _BatchedTrajectoryAnalyzer, build_parser, - gather_batch_coords, +) +from wetting_angle_kit.analysis.binning.surface_definition import ( + HyperbolicTangentModel, ) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( @@ -43,9 +41,46 @@ CoupledBinning2DResults, ) from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.io_utils import project_to_profile logger = logging.getLogger(__name__) +_PARAM_NAMES = ("rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2") + + +def _heuristic_binning_params(parser: Any) -> dict[str, Any]: + """Build the legacy heuristic binning grid: 50×50 cells over a third + of the largest in-plane box dimension. + """ + max_dist = int( + np.max( + np.array( + [ + parser.box_size_y(frame_index=0), + parser.box_size_x(frame_index=0), + ] + ) + ) + / 3 + ) + warnings.warn( + "binning_params was not supplied; using a heuristic default " + f"(xi_0=0, xi_f={max_dist}, zi_0=0, zi_f={max_dist}, 50x50 bins) " + "derived from one third of the largest in-plane box dimension. " + "For accurate density fields, supply system-specific " + "binning_params matching your droplet size and per-frame sampling.", + UserWarning, + stacklevel=3, + ) + return { + "xi_0": 0, + "xi_f": max_dist, + "nbins_xi": 50, + "zi_0": 0.0, + "zi_f": max_dist, + "nbins_zi": 50, + } + class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): """Joint contact-angle fit on a 2D binned density grid. @@ -74,7 +109,7 @@ class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): Initial guess for the seven tanh-model parameters ``[rho1, rho2, R_eq, zi_c, zi_0, t1, t2]``. Defaults to the values tuned for room-temperature water in the existing - ``HyperbolicTangentModel``. + :class:`HyperbolicTangentModel`. temporal_aggregator : TemporalAggregator, optional Defaults to a single fully pooled batch (``batch_size=-1``) — the coupled fit benefits from as much @@ -109,8 +144,20 @@ def __init__( or TemporalAggregator(batch_size=-1), precentered=precentered, ) + if binning_params is None: + binning_params = _heuristic_binning_params(parser) self.binning_params = binning_params self.initial_params = initial_params + # Cylinder dV normalisation needs the box length along the + # cylinder axis; read it once at construction (per legacy). + self.box_dimension: float | None + if self.droplet_geometry.is_cylinder: + if self.droplet_geometry.cylinder_axis == "x": + self.box_dimension = float(parser.box_size_x(frame_index=0)) + else: + self.box_dimension = float(parser.box_size_y(frame_index=0)) + else: + self.box_dimension = None # ------------------------------------------------------------------ # _BatchedTrajectoryAnalyzer extension points. @@ -127,6 +174,7 @@ def _init_args(self) -> tuple: self.binning_params, self.initial_params, self.precentered, + self.box_dimension, ) @staticmethod @@ -134,9 +182,10 @@ def _init_worker( filename: str, atom_indices: np.ndarray, droplet_geometry: DropletGeometry, - binning_params: dict[str, Any] | None, + binning_params: dict[str, Any], initial_params: list[float] | None, precentered: bool, + box_dimension: float | None, ) -> None: cls = CoupledBinning2DAnalyzer cls._WORKER_STATE.clear() @@ -147,6 +196,7 @@ def _init_worker( binning_params=binning_params, initial_params=initial_params, precentered=precentered, + box_dimension=box_dimension, ) @staticmethod @@ -157,21 +207,88 @@ def _process_batch_worker( parser = state["parser"] atom_indices: np.ndarray = state["atom_indices"] droplet_geometry: DropletGeometry = state["droplet_geometry"] + binning_params: dict[str, Any] = state["binning_params"] + initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] + box_dimension: float | None = state["box_dimension"] try: - # Pooled liquid-atom coordinates across the batch. The - # CoupledBinning2D algorithm then projects to (xi, zi), - # builds the histogram, and fits the tanh model — all - # currently stubbed. - coords, center, max_dist = gather_batch_coords( - parser=parser, - frame_indices=frame_indices, - atom_indices=atom_indices, - droplet_geometry=droplet_geometry, - precentered=precentered, + # Per-frame ``(xi, zi)`` projection, matching the legacy + # ``BinningBatchFitter.get_profile_coordinates`` so the + # joint fit sees the same projected coordinates. + r_chunks: list[np.ndarray] = [] + z_chunks: list[np.ndarray] = [] + for frame_idx in frame_indices: + positions = parser.parse(frame_index=frame_idx, indices=atom_indices) + box_size: tuple[float, float] | None = None + if not precentered: + box_size = ( + parser.box_size_x(frame_index=frame_idx), + parser.box_size_y(frame_index=frame_idx), + ) + r_frame, z_frame = project_to_profile( + positions, droplet_geometry.name, box_size=box_size + ) + r_chunks.append(r_frame) + z_chunks.append(z_frame) + r_values = np.concatenate(r_chunks) if r_chunks else np.empty(0) + z_values = np.concatenate(z_chunks) if z_chunks else np.empty(0) + n_frames = len(frame_indices) + + # Build the 2D density grid + apply geometry-aware dV + # normalisation, mirroring ``BinningBatchFitter.binning``. + xi_edges = np.linspace( + binning_params["xi_0"], + binning_params["xi_f"], + int(binning_params["nbins_xi"]), ) - raise NotImplementedError( - "coupled 2D-binning joint fit not implemented in skeleton." + zi_edges = np.linspace( + binning_params["zi_0"], + binning_params["zi_f"], + int(binning_params["nbins_zi"]), + ) + counts, _, _ = np.histogram2d(r_values, z_values, bins=(xi_edges, zi_edges)) + dxi = float(xi_edges[1] - xi_edges[0]) + dzi = float(zi_edges[1] - zi_edges[0]) + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) + if droplet_geometry.is_cylinder: + assert box_dimension is not None + dV = 2.0 * box_dimension * dxi * dzi + rho_cc = counts / dV + else: + dV_per_row = 2.0 * np.pi * xi_cc * dxi * dzi + rho_cc = counts / dV_per_row[:, np.newaxis] + if n_frames > 0: + rho_cc /= n_frames + + # Joint tanh fit. ``HyperbolicTangentModel`` expects the + # density and grid axes flattened in Fortran order — same + # as the legacy ``BinningBatchFitter.process_batch``. + model = HyperbolicTangentModel(initial_params=initial_params) + msh_zi_grid, msh_xi_grid = np.meshgrid(zi_cc, xi_cc) + n_flat = len(xi_cc) * len(zi_cc) + msh_zi = msh_zi_grid.reshape(n_flat, order="F") + msh_xi = msh_xi_grid.reshape(n_flat, order="F") + msh_rho = rho_cc.reshape(n_flat, order="F") + model.fit((msh_xi, msh_zi), msh_rho) + angle = float(model.compute_contact_angle()) + params = model.params + if params is None: + raise RuntimeError( + "HyperbolicTangentModel did not set model parameters; " + "cannot build CoupledBinning2DBatchResult." + ) + model_params = { + name: float(value) + for name, value in zip(_PARAM_NAMES, params, strict=False) + } + return CoupledBinning2DBatchResult( + frames=list(frame_indices), + angle=angle, + model_params=model_params, + xi_grid=xi_cc.copy(), + zi_grid=zi_cc.copy(), + density=rho_cc, ) except Exception as e: logger.error(f"Error processing batch {frame_indices}: {e}", exc_info=True) diff --git a/tests/test_analysis/test_coupled_binning_2d.py b/tests/test_analysis/test_coupled_binning_2d.py new file mode 100644 index 0000000..72f366f --- /dev/null +++ b/tests/test_analysis/test_coupled_binning_2d.py @@ -0,0 +1,164 @@ +"""Phase 9 quantification: ``CoupledBinning2DAnalyzer`` vs legacy parity. + +The new ``CoupledBinning2DAnalyzer`` is a structural rewrite of the +legacy ``BinningTrajectoryAnalyzer`` on top of the shared +``_BatchedTrajectoryAnalyzer`` scaffolding. Same per-frame projection +(``project_to_profile``), same 2D histogram + dV normalisation, same +:class:`HyperbolicTangentModel` fit. The angle should match the +legacy to floating-point precision on the same fixture. +""" + +import pathlib + +import numpy as np +import pytest + +# Coupled binning fixtures rely on OVITO for the LAMMPS dump parser. +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import ( # noqa: E402 + BinningTrajectoryAnalyzer, + CoupledBinning2DAnalyzer, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) + + +@pytest.fixture +def oxygen_indices() -> np.ndarray: + return LammpsDumpWaterFinder( + _FIXTURE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +def _binning_params() -> dict: + """Explicit grid (skip the heuristic-warning code path).""" + return { + "xi_0": 0, + "xi_f": 40, + "nbins_xi": 50, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 50, + } + + +@pytest.mark.integration +@pytest.mark.slow +def test_coupled_binning_2d_matches_legacy_single_frame( + oxygen_indices: np.ndarray, +) -> None: + """One-frame batch: legacy and new pipelines should produce the same angle.""" + binning_params = _binning_params() + + legacy = BinningTrajectoryAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + ) + legacy_results = legacy.analyze([1]) + legacy_angle = float(legacy_results.batches[0].angle) + + new = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + temporal_aggregator=TemporalAggregator(batch_size=-1), + ) + new_results = new.analyze([1]) + new_angle = float(new_results.batches[0].angle) + + drift = abs(legacy_angle - new_angle) + print( + f"\nlegacy BinningTrajectoryAnalyzer angle = {legacy_angle:.6f}°" + f"\nnew CoupledBinning2DAnalyzer angle = {new_angle:.6f}°" + f"\n|drift| = {drift:.3e}°" + ) + # Same projection, same histogram, same fit → bit-for-bit parity. + assert drift < 1e-9 + + +@pytest.mark.integration +@pytest.mark.slow +def test_coupled_binning_2d_matches_legacy_multi_frame( + oxygen_indices: np.ndarray, +) -> None: + """Three-frame pooled batch: same parity check across multiple frames.""" + binning_params = _binning_params() + frames = [0, 1, 2] + + legacy = BinningTrajectoryAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + ) + legacy_results = legacy.analyze(frames) + legacy_angle = float(legacy_results.batches[0].angle) + + new = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + temporal_aggregator=TemporalAggregator(batch_size=-1), + ) + new_results = new.analyze(frames) + new_angle = float(new_results.batches[0].angle) + + drift = abs(legacy_angle - new_angle) + print( + f"\n3-frame legacy angle = {legacy_angle:.6f}°" + f"\n3-frame new angle = {new_angle:.6f}°" + f"\n|drift| = {drift:.3e}°" + ) + assert drift < 1e-9 + + +@pytest.mark.integration +@pytest.mark.slow +def test_coupled_binning_2d_split_batches_match_legacy( + oxygen_indices: np.ndarray, +) -> None: + """Block-pooled batches: same angles as legacy's ``split_factor`` path.""" + binning_params = _binning_params() + frames = [0, 1, 2, 3] + + legacy = BinningTrajectoryAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + ) + legacy_results = legacy.analyze(frames, split_factor=2) + legacy_angles = sorted(float(b.angle) for b in legacy_results.batches) + + new = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + temporal_aggregator=TemporalAggregator(batch_size=2), + ) + new_results = new.analyze(frames) + new_angles = sorted(float(b.angle) for b in new_results.batches) + + print( + f"\nlegacy split-batch angles = {legacy_angles}" + f"\nnew batch-size=2 angles = {new_angles}" + ) + assert len(legacy_angles) == len(new_angles) == 2 + for la, na in zip(legacy_angles, new_angles, strict=True): + assert abs(la - na) < 1e-9 From 4478d49027fa3e0014d214b07829718d75b1a15f Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 16:42:06 +0200 Subject: [PATCH 22/53] Coupled binning analyzer in 3D. --- .../analysis/coupled_binning_3d.py | 289 ++++++++++++++++-- .../test_analysis/test_coupled_binning_3d.py | 183 +++++++++++ 2 files changed, 449 insertions(+), 23 deletions(-) create mode 100644 tests/test_analysis/test_coupled_binning_3d.py diff --git a/src/wetting_angle_kit/analysis/coupled_binning_3d.py b/src/wetting_angle_kit/analysis/coupled_binning_3d.py index c1d4f8e..dc0840e 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning_3d.py +++ b/src/wetting_angle_kit/analysis/coupled_binning_3d.py @@ -18,22 +18,18 @@ Cylindrical droplets are rejected at construction: their translational symmetry along the cylinder axis means the 3D fit reduces to the 2D fit already implemented by :class:`CoupledBinning2DAnalyzer`. - -The algorithm body (3D histogram → joint tanh fit) is stubbed with -``NotImplementedError`` at this skeleton stage. The worker-pool wiring -is real, so misconfigurations and per-batch exception handling are -exercised end-to-end. """ import logging +import warnings from typing import Any, ClassVar import numpy as np +from scipy.optimize import curve_fit from wetting_angle_kit.analysis.base import ( _BatchedTrajectoryAnalyzer, build_parser, - gather_batch_coords, ) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( @@ -41,9 +37,182 @@ CoupledBinning3DResults, ) from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.io_utils import recenter_droplet_pbc logger = logging.getLogger(__name__) +_PARAM_NAMES_3D = ( + "rho1", + "rho2", + "R_eq", + "xi_c", + "yi_c", + "zi_c", + "zi_0", + "t1", + "t2", +) + + +class _HyperbolicTangentModel3D: + """3D extension of the binning method's hyperbolic-tangent model. + + Density factorises into a radial sigmoid centred at ``(xi_c, yi_c, + zi_c)`` and a vertical sigmoid above the wall ``zi_0``: + + :: + + rho(xi, yi, zi) = g(r) * h(zi - zi_0), + g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], + h(z) = 0.5 * [1 + tanh(2 z / t2)], + r = sqrt((xi - xi_c)^2 + (yi - yi_c)^2 + (zi - zi_c)^2). + + Bounds keep densities and lengths in their physical ranges, same as + the 2D model. ``xi_c`` / ``yi_c`` carry the only extra degrees of + freedom over the 2D fit. + """ + + #: Initial guess tuned for room-temperature water; the two + #: horizontal centres default to ``0`` because the analyzer + #: pre-centers the atoms on the droplet COM before binning. + DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 0.0, 0.0, 20.0, 4.0, 1.0, 1.0) + + # Bounds vector order matches DEFAULT_INITIAL_PARAMS. + _PARAM_LOWER = np.array( + [0.0, 0.0, 1e-6, -np.inf, -np.inf, -np.inf, -np.inf, 1e-6, 1e-6] + ) + _PARAM_UPPER = np.array([np.inf] * 9) + + def __init__(self, initial_params: list[float] | None = None) -> None: + if initial_params is None: + initial_params = list(self.DEFAULT_INITIAL_PARAMS) + self.params: list[float] | np.ndarray | None = initial_params + self.covariance: np.ndarray | None = None + + @staticmethod + def _fitting_function( + x: tuple[np.ndarray, np.ndarray, np.ndarray], + rho1: float, + rho2: float, + R_eq: float, + xi_c: float, + yi_c: float, + zi_c: float, + zi_0: float, + t1: float, + t2: float, + ) -> np.ndarray: + xi, yi, zi = x[0], x[1], x[2] + r = np.sqrt((xi - xi_c) ** 2 + (yi - yi_c) ** 2 + (zi - zi_c) ** 2) + g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) + h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) + return g_r * h_z + + def fit( + self, + x_data: tuple[np.ndarray, np.ndarray, np.ndarray], + density_data: np.ndarray, + ) -> "_HyperbolicTangentModel3D": + self.params, self.covariance = curve_fit( + self._fitting_function, + x_data, + density_data, + p0=self.params, + bounds=(self._PARAM_LOWER, self._PARAM_UPPER), + maxfev=1_000_000, + ) + self._warn_if_at_bounds() + return self + + def _warn_if_at_bounds(self) -> None: + if self.params is None: + return + tol = 1e-6 + at_bound = [] + for name, value, lo, hi in zip( + _PARAM_NAMES_3D, + self.params, + self._PARAM_LOWER, + self._PARAM_UPPER, + strict=False, + ): + if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): + at_bound.append(f"{name}={value:.3g} at lower bound {lo}") + elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): + at_bound.append(f"{name}={value:.3g} at upper bound {hi}") + if at_bound: + warnings.warn( + "3D hyperbolic tangent fit converged with parameter(s) at " + "the physical bound, suggesting a poor fit: " + "; ".join(at_bound), + RuntimeWarning, + stacklevel=3, + ) + + def compute_contact_angle(self) -> float: + """Return the contact angle (degrees) implied by the fitted parameters. + + Same geometric formula as the 2D model: the sphere of radius + ``R_eq`` centred at ``(xi_c, yi_c, zi_c)`` intersects the wall + plane ``z = zi_0`` in a circle whose tangent makes the contact + angle with the wall. + """ + if self.params is None: + raise ValueError("Model must be fitted before computing contact angle.") + R_eq = float(self.params[2]) + zi_c = float(self.params[5]) + zi_0 = float(self.params[6]) + discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 + if discriminant < 0: + warnings.warn( + "3D fit wall is outside the fitted droplet sphere " + f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|=" + f"{abs(zi_0 - zi_c):.3f}); contact angle is undefined.", + RuntimeWarning, + stacklevel=2, + ) + return float("nan") + xi_cross = np.sqrt(discriminant) + return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) + + +def _heuristic_binning_params_3d(parser: Any) -> dict[str, Any]: + """Build a heuristic 3D binning grid centred on the droplet COM. + + Same one-third-of-box heuristic as the 2D version but tripled + along all three axes. Emits a warning because the user almost + always wants to override this. + """ + half = int( + np.max( + np.array( + [ + parser.box_size_y(frame_index=0), + parser.box_size_x(frame_index=0), + ] + ) + ) + / 6 + ) + warnings.warn( + "binning_params was not supplied; using a heuristic default " + f"(xi/yi in [-{half}, {half}], zi in [0, {2 * half}], 30^3 bins). " + "For accurate density fields, supply system-specific " + "binning_params matching your droplet size and per-frame sampling.", + UserWarning, + stacklevel=3, + ) + return { + "xi_0": -half, + "xi_f": half, + "nbins_xi": 30, + "yi_0": -half, + "yi_f": half, + "nbins_yi": 30, + "zi_0": 0.0, + "zi_f": 2 * half, + "nbins_zi": 30, + } + class CoupledBinning3DAnalyzer(_BatchedTrajectoryAnalyzer): """Joint contact-angle fit on a 3D binned density grid. @@ -64,14 +233,13 @@ class CoupledBinning3DAnalyzer(_BatchedTrajectoryAnalyzer): binning_params : dict, optional 3D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"nbins_xi"``, ``"yi_0"``, ``"yi_f"``, ``"nbins_yi"``, ``"zi_0"``, ``"zi_f"``, - ``"nbins_zi"``. If ``None``, a heuristic default is used (a - third of the largest in-plane box dimension; 50 × 50 × 50 - bins). The heuristic is rarely optimal and emits a warning. + ``"nbins_zi"``. ``xi``/``yi`` are in the droplet-centred frame + (atoms are recentred on the per-frame COM before binning); ``zi`` + is in the lab frame so the wall position retains physical + meaning. If ``None``, a heuristic default is used. initial_params : list[float], optional Initial guess for the nine tanh-model parameters ``[rho1, rho2, R_eq, xi_c, yi_c, zi_c, zi_0, t1, t2]``. - Defaults to values consistent with the 2D model's defaults - plus ``xi_c=0, yi_c=0`` (assuming the droplet is recentered). temporal_aggregator : TemporalAggregator, optional Defaults to a single fully pooled batch (``batch_size=-1``). The 3D density needs more frames than the @@ -111,6 +279,8 @@ def __init__( "the 3D fit collapses onto the 2D one by translational " "symmetry along the cylinder axis." ) + if binning_params is None: + binning_params = _heuristic_binning_params_3d(parser) self.binning_params = binning_params self.initial_params = initial_params @@ -136,7 +306,7 @@ def _init_worker( filename: str, atom_indices: np.ndarray, droplet_geometry: DropletGeometry, - binning_params: dict[str, Any] | None, + binning_params: dict[str, Any], initial_params: list[float] | None, precentered: bool, ) -> None: @@ -159,20 +329,93 @@ def _process_batch_worker( parser = state["parser"] atom_indices: np.ndarray = state["atom_indices"] droplet_geometry: DropletGeometry = state["droplet_geometry"] + binning_params: dict[str, Any] = state["binning_params"] + initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] try: - # Pooled liquid-atom coordinates across the batch. The - # CoupledBinning3D algorithm then builds the 3D histogram - # and fits the nine-parameter tanh model — both stubbed. - coords, center, max_dist = gather_batch_coords( - parser=parser, - frame_indices=frame_indices, - atom_indices=atom_indices, - droplet_geometry=droplet_geometry, - precentered=precentered, + # Per-frame PBC recentering, then drop each frame's atoms + # in the droplet-centred ``(x, y)`` frame (z stays in the + # lab frame so the wall position retains physical meaning). + coord_chunks: list[np.ndarray] = [] + for frame_idx in frame_indices: + positions = parser.parse(frame_index=frame_idx, indices=atom_indices) + if precentered: + com = np.mean(positions, axis=0) + else: + box_xy = ( + parser.box_size_x(frame_index=frame_idx), + parser.box_size_y(frame_index=frame_idx), + ) + positions, com = recenter_droplet_pbc( + positions, droplet_geometry.name, box_size=box_xy + ) + positions_centered = positions - np.array([com[0], com[1], 0.0]) + coord_chunks.append(positions_centered) + coords = ( + np.concatenate(coord_chunks, axis=0) + if coord_chunks + else np.empty((0, 3)) + ) + n_frames = len(frame_indices) + + xi_edges = np.linspace( + binning_params["xi_0"], + binning_params["xi_f"], + int(binning_params["nbins_xi"]), + ) + yi_edges = np.linspace( + binning_params["yi_0"], + binning_params["yi_f"], + int(binning_params["nbins_yi"]), ) - raise NotImplementedError( - "coupled 3D-binning joint fit not implemented in skeleton." + zi_edges = np.linspace( + binning_params["zi_0"], + binning_params["zi_f"], + int(binning_params["nbins_zi"]), + ) + counts, _ = np.histogramdd(coords, bins=(xi_edges, yi_edges, zi_edges)) + dxi = float(xi_edges[1] - xi_edges[0]) + dyi = float(yi_edges[1] - yi_edges[0]) + dzi = float(zi_edges[1] - zi_edges[0]) + rho = counts / (dxi * dyi * dzi) + if n_frames > 0: + rho /= n_frames + + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + yi_cc = 0.5 * (yi_edges[:-1] + yi_edges[1:]) + zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) + + # Flatten the 3D grid for the curve fit. ``np.meshgrid`` + # with ``indexing="ij"`` matches ``histogramdd``'s axis + # convention, so a plain ``ravel`` keeps positions aligned + # with density values. + XI, YI, ZI = np.meshgrid(xi_cc, yi_cc, zi_cc, indexing="ij") + xi_flat = XI.ravel() + yi_flat = YI.ravel() + zi_flat = ZI.ravel() + rho_flat = rho.ravel() + + model = _HyperbolicTangentModel3D(initial_params=initial_params) + model.fit((xi_flat, yi_flat, zi_flat), rho_flat) + angle = model.compute_contact_angle() + params = model.params + if params is None: + raise RuntimeError( + "_HyperbolicTangentModel3D did not set parameters; " + "cannot build CoupledBinning3DBatchResult." + ) + model_params = { + name: float(value) + for name, value in zip(_PARAM_NAMES_3D, params, strict=False) + } + return CoupledBinning3DBatchResult( + frames=list(frame_indices), + angle=float(angle), + model_params=model_params, + xi_grid=xi_cc.copy(), + yi_grid=yi_cc.copy(), + zi_grid=zi_cc.copy(), + density=rho, ) except Exception as e: logger.error(f"Error processing batch {frame_indices}: {e}", exc_info=True) diff --git a/tests/test_analysis/test_coupled_binning_3d.py b/tests/test_analysis/test_coupled_binning_3d.py new file mode 100644 index 0000000..b33b899 --- /dev/null +++ b/tests/test_analysis/test_coupled_binning_3d.py @@ -0,0 +1,183 @@ +"""Phase 10 quantification: ``CoupledBinning3DAnalyzer``. + +Three flavors: + +- **3D model on a clean analytic density grid.** Build the analytic + 9-parameter tanh field on a grid, run the model fit, verify the + recovered contact angle matches truth to ≤ 0.1°. +- **Cylinder rejection.** The analyzer refuses cylindrical droplets + at construction with the documented pointer to the 2D variant. +- **End-to-end vs ``CoupledBinning2DAnalyzer`` on the LAMMPS fixture.** + The 2D analyzer collapses the droplet via radial symmetry; the 3D + one keeps the full 3D density. For an approximately axisymmetric + droplet the two should agree within a few degrees. +""" + +import pathlib + +import numpy as np +import pytest + +from wetting_angle_kit.analysis.coupled_binning_3d import ( # noqa: E402 + CoupledBinning3DAnalyzer, + _HyperbolicTangentModel3D, +) + + +def test_3d_tanh_model_recovers_known_cap_angle_on_clean_grid() -> None: + """Analytic 9-parameter density → recovered angle ≤ 0.1° from truth.""" + # Truth parameters for the field. + rho1_truth = 3.3e-2 + rho2_truth = 1e-3 + R_eq_truth = 25.0 + xi_c_truth = 0.0 + yi_c_truth = 0.0 + zi_c_truth = 0.0 + zi_0_truth = 5.0 # wall plane + t1_truth = 1.0 + t2_truth = 1.0 + truth_angle = float(np.degrees(np.arccos((zi_0_truth - zi_c_truth) / R_eq_truth))) + + # Build the analytic density on a centred grid (xi, yi span the + # droplet; zi spans the wall + apex). + xi_cc = np.linspace(-30.0, 30.0, 25) + yi_cc = np.linspace(-30.0, 30.0, 25) + zi_cc = np.linspace(0.0, 35.0, 25) + XI, YI, ZI = np.meshgrid(xi_cc, yi_cc, zi_cc, indexing="ij") + r = np.sqrt( + (XI - xi_c_truth) ** 2 + (YI - yi_c_truth) ** 2 + (ZI - zi_c_truth) ** 2 + ) + g_r = 0.5 * ( + (rho1_truth + rho2_truth) + - (rho1_truth - rho2_truth) * np.tanh(2 * (r - R_eq_truth) / t1_truth) + ) + h_z = 0.5 * (1.0 + np.tanh(2 * (ZI - zi_0_truth) / t2_truth)) + density = g_r * h_z + + model = _HyperbolicTangentModel3D() + model.fit((XI.ravel(), YI.ravel(), ZI.ravel()), density.ravel()) + recovered_angle = model.compute_contact_angle() + + drift = abs(recovered_angle - truth_angle) + print( + f"\n3D-tanh analytic recovery: truth = {truth_angle:.4f}°, " + f"recovered = {recovered_angle:.4f}°, |drift| = {drift:.3e}°, " + f"R_eq = {model.params[2]:.3f} (truth {R_eq_truth}), " + f"zi_c = {model.params[5]:.3f} (truth {zi_c_truth}), " + f"zi_0 = {model.params[6]:.3f} (truth {zi_0_truth})" + ) + assert drift < 0.1 + + +def test_coupled_binning_3d_rejects_cylinder() -> None: + """Constructing the analyzer with a cylinder droplet raises clearly.""" + + class _MockParser: + filepath = "_mock_" + + def box_size_x(self, frame_index: int) -> float: + return 100.0 + + def box_size_y(self, frame_index: int) -> float: + return 100.0 + + def frame_count(self) -> int: + return 1 + + # Avoid the detect_parser_type call in the shared base by faking + # the filepath check. The base validates via filepath only here, + # so a temporary file would work but the parser-shape error is + # what would fire first if we passed a real one. The cylinder + # rejection sits **after** super().__init__, so we expect a + # ValueError from the cylinder check. + with pytest.raises(ValueError): + CoupledBinning3DAnalyzer( + parser=_MockParser(), + droplet_geometry="cylinder_y", + binning_params={ + "xi_0": -30, + "xi_f": 30, + "nbins_xi": 10, + "yi_0": -30, + "yi_f": 30, + "nbins_yi": 10, + "zi_0": 0, + "zi_f": 30, + "nbins_zi": 10, + }, + ) + + +_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) + + +@pytest.mark.integration +@pytest.mark.slow +def test_coupled_binning_3d_close_to_2d_on_lammps_fixture() -> None: + """On an axisymmetric droplet the 3D and 2D fits should agree within ~few°.""" + pytest.importorskip("ovito") + from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer + from wetting_angle_kit.parsers import ( + LammpsDumpParser, + LammpsDumpWaterFinder, + ) + + finder = LammpsDumpWaterFinder(_FIXTURE, oxygen_type=1, hydrogen_type=2) + oxygen_indices = finder.get_water_oxygen_ids(0) + + # 2D analyzer — radial (xi, zi). + binning_params_2d = { + "xi_0": 0, + "xi_f": 40, + "nbins_xi": 40, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 40, + } + legacy_2d = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params_2d, + ) + angle_2d = float(legacy_2d.analyze([1]).batches[0].angle) + + # 3D analyzer — full (xi, yi, zi). + binning_params_3d = { + "xi_0": -40, + "xi_f": 40, + "nbins_xi": 25, + "yi_0": -40, + "yi_f": 40, + "nbins_yi": 25, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 25, + } + new_3d = CoupledBinning3DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params_3d, + ) + angle_3d = float(new_3d.analyze([1]).batches[0].angle) + + drift = abs(angle_2d - angle_3d) + print( + f"\nCoupledBinning2DAnalyzer angle = {angle_2d:.3f}°" + f"\nCoupledBinning3DAnalyzer angle = {angle_3d:.3f}°" + f"\n|drift| = {drift:.3f}°" + ) + # Both should land in the physically plausible band. + for angle in (angle_2d, angle_3d): + assert 70.0 < angle < 110.0 + # For an axisymmetric droplet the 3D fit's extra degrees of + # freedom (xi_c, yi_c) collapse to ~0 and the radial profile + # mirrors the 2D one; allow up to 8° drift to absorb noise from + # the sparser 3D grid on the 4k-atom fixture. + assert drift < 8.0 From 4cb0dd6faf07ed8c8df66f75fc64fbe56dbb065f Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 9 Jun 2026 21:55:53 +0200 Subject: [PATCH 23/53] Comment in pyproject. --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 8f0ba61..46ac190 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -133,9 +133,9 @@ disable_error_code = ["union-attr", "arg-type", "no-untyped-call"] [[tool.mypy.overrides]] # scikit-image is an optional dependency installed via the ``grid3d`` -# extra; the grid-based whole-kind extractors import it lazily and raise -# a clean ImportError otherwise. Telling mypy to ignore missing imports -# for ``skimage.*`` makes the inline ``# type: ignore[import-not-found]`` +# extra; the grid extractors import it lazily and raise a clean +# ImportError otherwise. Telling mypy to ignore missing imports for +# ``skimage.*`` makes inline ``# type: ignore[import-not-found]`` # unnecessary AND keeps the check passing whether or not the user has # scikit-image in their environment — otherwise running mypy with and # without the extra produces flipped error states. From 36f6c4f6d8261153f3667c7c1d749ae222259ff9 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Wed, 10 Jun 2026 09:03:27 +0200 Subject: [PATCH 24/53] Added tests, noticed and fixed a bug in Fibonacci algo where only the sphere above the COM was sampled. --- src/wetting_angle_kit/analysis/extractors.py | 37 ++- tests/test_analysis/test_binning_method.py | 126 +++++--- .../test_rays_gaussian_extractor.py | 18 +- .../test_analysis/test_slicing_edge_cases.py | 134 ++------ tests/test_analysis/test_slicing_method.py | 124 ++++---- .../test_trajectory_analyzer_integration.py | 297 ++++++++++++++++++ tests/test_analysis/test_whole_fitter.py | 6 +- 7 files changed, 509 insertions(+), 233 deletions(-) create mode 100644 tests/test_analysis/test_trajectory_analyzer_integration.py diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py index ec26073..3e85ec7 100644 --- a/src/wetting_angle_kit/analysis/extractors.py +++ b/src/wetting_angle_kit/analysis/extractors.py @@ -153,9 +153,15 @@ def rays_gaussian( Step (Å) along the cylinder axis between slices for the cylinder modes (both slicing and whole). n_rays_sphere : int, optional - Total number of rays in the upper hemisphere for the + Total number of rays covering the **full sphere** for the spherical whole-fit mode. Rays are placed via an equal-area - ``(cos θ, φ)`` construction to avoid pole bias. + Fibonacci ``(cos θ, φ)`` construction so the angular density + is uniform from south to north pole. Full-sphere (rather + than upper-hemisphere) coverage is intentional: downward + rays from the droplet COM traverse the liquid and hit the + wall plane, producing interface points at the wall — that + keeps :meth:`WallDetector.min_plus_offset` consistent with + the physical wall position. delta_polar : float, default 8.0 In-plane ray step (degrees) for every mode that emits rays in the ``(x, z)`` plane (i.e. everything except @@ -320,14 +326,21 @@ def _validate_grid_params( ) -def _fibonacci_hemisphere_directions(n: int) -> np.ndarray: - """Equal-area Fibonacci-spiral directions on the upper hemisphere. +def _fibonacci_sphere_directions(n: int) -> np.ndarray: + """Equal-area Fibonacci-spiral directions on the full sphere. - ``cos θ`` is uniformly spaced over ``[0, 1]`` (so the surface - density is uniform on the sphere) and ``φ`` is incremented by the - golden angle for low-discrepancy azimuthal coverage. ``i = 0`` - sits at the horizon (``cos θ = 0``) and ``i = n - 1`` at the pole - (``cos θ = 1``). + ``cos θ`` is uniformly spaced over ``[-1, 1]`` (so the surface + density is uniform over the whole sphere) and ``φ`` is incremented + by the golden angle for low-discrepancy azimuthal coverage. + ``i = 0`` sits at the south pole (``cos θ = -1``) and + ``i = n - 1`` at the north pole (``cos θ = 1``). + + The full sphere coverage is important for sessile droplets: rays + emitted from the droplet COM in downward directions traverse the + liquid, hit the wall plane, and contribute interface points at the + wall — making :meth:`WallDetector.min_plus_offset` work correctly + in the whole-fit pipeline. (Restricting to the upper hemisphere + misses the wall, so ``min(shell z)`` lands on ``COM_z`` instead.) Parameters ---------- @@ -337,12 +350,12 @@ def _fibonacci_hemisphere_directions(n: int) -> np.ndarray: Returns ------- ndarray, shape (n, 3) - Unit direction vectors. + Unit direction vectors covering the full sphere. """ if n <= 0: return np.empty((0, 3)) i = np.arange(n, dtype=np.float64) - cos_theta = i / (n - 1) if n > 1 else np.array([1.0]) + cos_theta = 2.0 * i / (n - 1) - 1.0 if n > 1 else np.array([1.0]) sin_theta = np.sqrt(np.maximum(0.0, 1.0 - cos_theta * cos_theta)) golden_angle = np.pi * (3.0 - np.sqrt(5.0)) phi = (i * golden_angle) % (2.0 * np.pi) @@ -464,7 +477,7 @@ def _extract_rays( # surface_kind == "whole" if droplet_geometry.is_spherical: assert n_rays_sphere is not None - directions = _fibonacci_hemisphere_directions(n_rays_sphere) + directions = _fibonacci_sphere_directions(n_rays_sphere) positions_rm = ( center_geom[None, None, :] + distances[None, :, None] * directions[:, None, :] diff --git a/tests/test_analysis/test_binning_method.py b/tests/test_analysis/test_binning_method.py index 5d98050..b7cb3ed 100644 --- a/tests/test_analysis/test_binning_method.py +++ b/tests/test_analysis/test_binning_method.py @@ -1,48 +1,48 @@ +"""Binning-method integration tests on a LAMMPS cylinder-droplet fixture. + +Phase 11 migration: the new ``CoupledBinning2DAnalyzer`` is exercised +end-to-end against the same fixture as the legacy +``BinningTrajectoryAnalyzer``. One legacy test is kept as a frozen +regression net (to be removed in Phase 12 alongside the legacy class). +""" + import pathlib import numpy as np import pytest -# 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.analysis import ( # noqa: E402 + BinningTrajectoryAnalyzer, + CoupledBinning2DAnalyzer, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 from wetting_angle_kit.parsers import ( # noqa: E402 LammpsDumpParser, LammpsDumpWaterFinder, ) -# --- Fixtures --- @pytest.fixture -def filename(): - # Use the correct path for your test file +def filename() -> pathlib.Path: return ( - pathlib.Path(__file__).parent.parent + pathlib.Path(__file__).parent + / ".." / "trajectories" / "traj_10_3_330w_nve_4k_reajust.lammpstrj" ) @pytest.fixture -def wat_find(filename): - return LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) - - -@pytest.fixture -def oxygen_indices(wat_find): - return wat_find.get_water_oxygen_ids(0) +def oxygen_indices(filename: pathlib.Path) -> np.ndarray: + return LammpsDumpWaterFinder( + filename, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) @pytest.fixture -def parser(filename): - return LammpsDumpParser(filename) - - -@pytest.fixture -def binning_params(): +def binning_params() -> dict: return { "xi_0": 0, "xi_f": 100.0, @@ -53,47 +53,83 @@ def binning_params(): } -# --- Unit Test for BinningTrajectoryAnalyzer --- +# --- Frozen legacy regression -------------------------------------------------- +# To be removed in Phase 12 alongside ``BinningTrajectoryAnalyzer`` itself. @pytest.mark.integration -def test_binning_contact_angle_analyzer_with_real_data( - filename, oxygen_indices, binning_params -): +def test_legacy_binning_trajectory_analyzer_regression( + filename: pathlib.Path, + oxygen_indices: np.ndarray, + binning_params: dict, +) -> None: + """Frozen-legacy regression on the cylinder-droplet fixture.""" analyzer = BinningTrajectoryAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", binning_params=binning_params, ) - results = analyzer.analyze([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) + # Legacy binning on the cylinder fixture, frame 1: 99.110°. + # ±3° band. + assert 96.0 <= results.mean_angle <= 102.0 + # Single batch → std across batches is 0. + assert results.std_angle == 0.0 -# --- 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. +# --- New-API equivalent -------------------------------------------------------- @pytest.mark.integration -def test_binning_contact_angle_analyzer_per_frame_with_split_factor( - filename, oxygen_indices, binning_params -): - analyzer = BinningTrajectoryAnalyzer( +def test_coupled_binning_2d_with_cylinder_fixture( + filename: pathlib.Path, + oxygen_indices: np.ndarray, + binning_params: dict, +) -> None: + """End-to-end ``CoupledBinning2DAnalyzer`` on the cylinder droplet.""" + analyzer = CoupledBinning2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", binning_params=binning_params, ) + results = analyzer.analyze([1]) - # split_factor=1 → one batch per frame → 3 batch-level angles. - results = analyzer.analyze([1, 2, 3], split_factor=1) + assert len(results) == 1 + angle = float(results.batches[0].angle) + # New analyzer matches legacy bit-for-bit (Phase 9 parity test) + # so the same 99.110° ±3° band applies. + assert 96.0 <= angle <= 102.0 + assert np.isfinite(results.mean_angle) + # Single batch → std across batches is 0. + assert results.std_angle == 0.0 - 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_per_batch: - assert np.isnan(angle) or (0.0 <= angle <= 180.0) + +@pytest.mark.integration +def test_coupled_binning_2d_per_frame_batches( + filename: pathlib.Path, + oxygen_indices: np.ndarray, + binning_params: dict, +) -> None: + """``batch_size=1`` ↔ legacy ``split_factor=1``: one fit per frame.""" + frames = [1, 2, 3] + analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="cylinder_y", + binning_params=binning_params, + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + results = analyzer.analyze(frames) + + # One batch per frame ⇒ three angles. + assert len(results) == 3 + assert results.per_batch_angles.shape == (3,) + # Observed per-frame angles on this fixture: ~99°, ~96°, ~93° + # (some thermal drift across frames). Pin a per-frame ±5° band + # to absorb that drift while catching real regressions. + expected_angles = (99.11, 96.10, 92.65) + for batch, expected in zip(results.batches, expected_angles, strict=True): + assert len(batch.frames) == 1 + # Allow either a converged angle near the expected value, or + # NaN on per-frame fit failure (matches the legacy contract). + assert np.isnan(batch.angle) or abs(batch.angle - expected) < 5.0 diff --git a/tests/test_analysis/test_rays_gaussian_extractor.py b/tests/test_analysis/test_rays_gaussian_extractor.py index f6add52..585253f 100644 --- a/tests/test_analysis/test_rays_gaussian_extractor.py +++ b/tests/test_analysis/test_rays_gaussian_extractor.py @@ -173,10 +173,10 @@ def test_whole_spherical_recovers_known_sphere_radius() -> None: """ radius = 20.0 sigma = 3.0 - # Use a full sphere (no hemisphere cut) so the Fibonacci-spaced - # upper-hemisphere rays probe an angularly isotropic atom cloud. - # This isolates the sampling pattern + tanh-fit recovery from any - # cap-induced inward bias near the equator. + # Use a full sphere of atoms (no hemisphere cut) so the + # Fibonacci-spaced full-sphere rays probe an angularly isotropic + # atom cloud. This isolates the sampling pattern + tanh-fit + # recovery from any cap-induced bias near the equator. atoms = _uniform_sphere_atoms(radius=radius, n_atoms=15000, seed=0) n_rays = 400 @@ -195,9 +195,13 @@ def test_whole_spherical_recovers_known_sphere_radius() -> None: ) assert isinstance(shell, np.ndarray) assert shell.shape == (n_rays, 3) - # Fibonacci hemisphere directions all have cos θ ≥ 0; the - # recovered shell points should mirror that. - assert np.all(shell[:, 2] >= -1e-12) + # Full-sphere Fibonacci directions span ``cos θ ∈ [-1, 1]``; the + # recovered shell should cover both hemispheres roughly equally + # — no zero crossing in ``z`` is allowed to be biased. + assert np.any(shell[:, 2] < 0) + assert np.any(shell[:, 2] > 0) + # Symmetric cloud → mean z should sit near zero. + assert abs(float(np.mean(shell[:, 2]))) < 1.0 r = np.linalg.norm(shell, axis=1) mean_r = float(np.mean(r)) diff --git a/tests/test_analysis/test_slicing_edge_cases.py b/tests/test_analysis/test_slicing_edge_cases.py index 7df2f27..671e9a3 100644 --- a/tests/test_analysis/test_slicing_edge_cases.py +++ b/tests/test_analysis/test_slicing_edge_cases.py @@ -1,115 +1,33 @@ +"""Edge-case tests for the trajectory analyzer pipeline. + +Phase 11 migration: the legacy ``SlicingFrameFitter``-internal tests +(``find_intersection``, ``calculate_y_axis_list``, etc.) no longer have +direct analogues in the new architecture (the per-slice helpers live +inside ``_SlicingFitter`` / ``_RaysGaussianExtractor`` and are exercised +by the Phase 2–4 tests). The only edge case worth keeping in this file +is the parser-extension validation, which the new ``TrajectoryAnalyzer`` +inherits from the shared ``_BatchedTrajectoryAnalyzer`` base. +""" + import numpy as np import pytest -from wetting_angle_kit.analysis.slicing.analyzer import ( - SlicingTrajectoryAnalyzer, - _SlicingFrameResult, -) -from wetting_angle_kit.analysis.slicing.angle_fitting import ( - SlicingFrameFitter, +from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, ) -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, - **kwargs, - ) - - -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_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", 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", 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_unsupported_extension_raises_at_construction(tmp_path) -> None: + """Unknown trajectory extension must fail fast at construction. - -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", - 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) - # 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() - assert len(y_axis) == 2 - assert len(gammas) == 2 - assert all(g >= 0 for g in gammas) - - -# --- SlicingTrajectoryAnalyzer worker internals --- - - -def test_run_one_frame_invokes_pipeline_on_real_lammps(): - """Drive ``_run_one_frame`` on a real LAMMPS fixture in the current process. - - 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. + The shared ``_BatchedTrajectoryAnalyzer`` calls + ``detect_parser_type(parser.filepath)`` in ``__init__`` for the same + reason the legacy code did: the actual parser is rebuilt inside + worker processes, where a parser-type error would be silently + swallowed. """ - pytest.importorskip("ovito") - from tests.conftest import trajectory_path - - SlicingTrajectoryAnalyzer._init_worker( - filename=trajectory_path("traj_spherical_drop_4k.lammpstrj"), - droplet_geometry="spherical", - atom_indices=np.array([]), - delta_gamma=20.0, - delta_cylinder=None, - points_per_angstrom=1.0, - precentered=False, - ) - try: - result = SlicingTrajectoryAnalyzer._run_one_frame(0) - finally: - SlicingTrajectoryAnalyzer._WORKER_STATE.clear() - assert isinstance(result, _SlicingFrameResult) - assert result.frame_num == 0 - - -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") @@ -117,8 +35,12 @@ class _FakeParser: filepath = str(fake) with pytest.raises(ValueError, match="Unsupported trajectory file format"): - SlicingTrajectoryAnalyzer( + TrajectoryAnalyzer( parser=_FakeParser(), + atom_indices=np.array([]), droplet_geometry="spherical", - delta_gamma=20.0, + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, delta_polar=8.0 + ), + surface_fitter=SurfaceFitter.slicing(), ) diff --git a/tests/test_analysis/test_slicing_method.py b/tests/test_analysis/test_slicing_method.py index cb9a2cb..d234cbe 100644 --- a/tests/test_analysis/test_slicing_method.py +++ b/tests/test_analysis/test_slicing_method.py @@ -1,23 +1,37 @@ +"""Slicing-method integration tests on the LAMMPS water/graphene fixture. + +Phase 11 migration: the bulk of the testing here moved to the new +``TrajectoryAnalyzer`` (slicing fitter + rays_gaussian extractor + +min_plus_offset wall detector). One legacy ``SlicingTrajectoryAnalyzer`` +test is kept as a frozen regression net — scheduled for removal in +Phase 12 once :class:`SlicingTrajectoryAnalyzer` itself goes away. +""" + import pathlib import numpy as np import pytest -# 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). +# The slicing fixture is a LAMMPS dump 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.analysis import ( # noqa: E402 + InterfaceExtractor, + SlicingTrajectoryAnalyzer, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) from wetting_angle_kit.parsers import ( # noqa: E402 LammpsDumpParser, LammpsDumpWaterFinder, ) -# --- Fixtures --- @pytest.fixture -def filename(): +def filename() -> pathlib.Path: return ( pathlib.Path(__file__).parent / ".." @@ -27,76 +41,66 @@ def filename(): @pytest.fixture -def wat_find(filename): - return LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) +def oxygen_indices(filename: pathlib.Path) -> np.ndarray: + return LammpsDumpWaterFinder( + filename, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) -@pytest.fixture -def oxygen_indices(wat_find): - return wat_find.get_water_oxygen_ids(0) - - -@pytest.fixture -def parser(filename): - return LammpsDumpParser(filename) - - -# --- Unit Tests for SlicingFrameFitter --- +# --- Frozen legacy regression -------------------------------------------------- +# To be removed in Phase 12 alongside ``SlicingTrajectoryAnalyzer`` itself. @pytest.mark.integration @pytest.mark.slow -def test_contact_angle_slicing_with_real_data(parser, oxygen_indices): - # Parse liquid positions for frame 0 - liquid_positions = parser.parse(frame_index=0, indices=oxygen_indices) - max_dist = int( - np.max( - np.array( - [parser.box_size_y(frame_index=0), parser.box_size_x(frame_index=0)] - ) - ) - / 2 - ) - mean_liquid_position = np.mean(liquid_positions, axis=0) - - # Initialize SlicingFrameFitter - from wetting_angle_kit.analysis.slicing import ( - SlicingFrameFitter, - ) - - predictor = SlicingFrameFitter( - liquid_coordinates=liquid_positions, - liquid_geom_center=mean_liquid_position, +def test_legacy_slicing_trajectory_analyzer_regression( + filename: pathlib.Path, oxygen_indices: np.ndarray +) -> None: + """Frozen-legacy parity check on the spherical-droplet fixture.""" + analyzer = SlicingTrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, droplet_geometry="spherical", delta_gamma=20, - max_dist=max_dist, ) + results = analyzer.analyze([1]) - # Test predict_contact_angle - angles, surfaces, popt_arrays = predictor.predict_contact_angle() - assert isinstance(angles, list) - assert isinstance(surfaces, list) - assert isinstance(popt_arrays, list) - assert len(angles) > 0 + assert len(results) == 1 + assert results.frames == [1] + # Legacy slicing on this fixture: mean angle = 94.873°, per-slice + # std = 1.829°. ±3° band so the test catches real regressions + # while tolerating numerical jitter. + mean_angle = float(np.mean(results.angles[0])) + assert 92.0 <= mean_angle <= 98.0 + slice_std = float(np.std(results.angles[0])) + assert 0.5 < slice_std < 4.0 -# --- Integration Test for SlicingTrajectoryAnalyzer --- +# --- New-API equivalent -------------------------------------------------------- @pytest.mark.integration @pytest.mark.slow -def test_slicing_contact_angle_analyzer_with_real_data(filename, oxygen_indices): - analyzer = SlicingTrajectoryAnalyzer( +def test_trajectory_analyzer_slicing_with_real_data( + filename: pathlib.Path, oxygen_indices: np.ndarray +) -> None: + """End-to-end ``TrajectoryAnalyzer`` (rays_gaussian + slicing fitter).""" + analyzer = TrajectoryAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - delta_gamma=20, + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, delta_polar=8.0 + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), ) - results = analyzer.analyze([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. - mean_angle = float(np.mean(results.angles[0])) - assert 80.0 <= mean_angle <= 110.0 - assert np.isfinite(np.std(results.angles[0])) + batch = results.batches[0] + assert batch.frames == [1] + # New slicing pipeline on this fixture: 95.159° (matches the + # legacy 94.873° within 0.3° — see Phase 3 parity test). + assert 92.0 <= batch.angle <= 98.0 + # Across-slice std on this fixture: ~1.9°. + assert 0.5 < batch.angle_std < 4.0 + # Aggregate properties on the results container. + assert np.isfinite(results.mean_angle) + assert np.isfinite(results.std_angle) diff --git a/tests/test_analysis/test_trajectory_analyzer_integration.py b/tests/test_analysis/test_trajectory_analyzer_integration.py new file mode 100644 index 0000000..78e0440 --- /dev/null +++ b/tests/test_analysis/test_trajectory_analyzer_integration.py @@ -0,0 +1,297 @@ +"""End-to-end integration coverage for the new methodology combinations. + +The Phase 2–10 quantification tests exercise each new component +individually (extractor, fitter, wall detector, analyzer scaffolding). +This file fills in the matrix of *new* end-to-end combinations that +have no legacy analogue: + +- ``TrajectoryAnalyzer`` + whole-fit + ``rays_gaussian`` on real data; +- slicing pipeline on a cylinder LAMMPS fixture; +- bootstrap σ_θ flowing through the analyzer into ``WholeBatchResult``; +- multi-frame pooled batches via ``TemporalAggregator(batch_size=N)``; +- ``WallDetector.from_atoms`` paired with the whole fitter. +""" + +import pathlib +from typing import Any, cast + +import numpy as np +import pytest + +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import ( # noqa: E402 + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.analysis.results import ( # noqa: E402 + SlicingBatchResult, + WholeBatchResult, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +_SPHERICAL_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) +_CYLINDER_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_10_3_330w_nve_4k_reajust.lammpstrj" +) + + +@pytest.fixture +def spherical_oxygen_ids() -> np.ndarray: + return LammpsDumpWaterFinder( + _SPHERICAL_FIXTURE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +@pytest.fixture +def cylinder_oxygen_ids() -> np.ndarray: + return LammpsDumpWaterFinder( + _CYLINDER_FIXTURE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +@pytest.fixture +def spherical_carbon_ids() -> np.ndarray: + """Carbon (type 3) particle IDs on the spherical fixture.""" + from ovito.io import import_file + + pipeline = cast(Any, import_file(str(_SPHERICAL_FIXTURE))) + data = pipeline.compute(0) + types = np.array(data.particles["Particle Type"].array) + type3 = np.where(types == 3)[0] + return np.asarray(data.particles["Particle Identifier"][type3]) + + +# --- whole-fit pipeline end-to-end -------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.slow +def test_trajectory_analyzer_whole_fit_rays_gaussian_on_spherical( + spherical_oxygen_ids: np.ndarray, +) -> None: + """End-to-end ``TrajectoryAnalyzer`` with ``SurfaceFitter.whole()``.""" + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_SPHERICAL_FIXTURE), + atom_indices=spherical_oxygen_ids, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + n_rays_sphere=400, density_sigma=3.0 + ), + surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + results = analyzer.analyze([1]) + + assert len(results) == 1 + batch = results.batches[0] + assert isinstance(batch, WholeBatchResult) + # Full-sphere Fibonacci rays from the COM hit the wall on the way + # down → ``min(shell z) ≈ z_wall`` → ``min_plus_offset(0)`` + # returns the physical wall position → the whole-fit recovers + # the physical contact angle (~95.4°) matching the slicing / + # binning pipelines on this fixture. ±3° band. + assert 92.0 < batch.angle < 99.0 + # WholeBatchResult fields: shell, popt of length 5 (xc, yc, zc, R, z_wall). + assert batch.interface_shell.ndim == 2 and batch.interface_shell.shape[1] == 3 + assert batch.popt.shape == (5,) + # No bootstrap configured ⇒ angle_std is None. + assert batch.angle_std is None + assert batch.rms_residual > 0.0 + # RMS residual on this fixture sits ~1.3 Å; flag growth past 3 Å. + assert batch.rms_residual < 3.0 + # ``z_wall`` is the interface-derived baseline; observed ~6.5 Å. + assert 5.5 < batch.z_wall < 8.0 + + +@pytest.mark.integration +@pytest.mark.slow +def test_trajectory_analyzer_whole_fit_with_explicit_wall( + spherical_oxygen_ids: np.ndarray, +) -> None: + """Whole-fit at the physical wall position (graphene top ~4.9 Å). + + With the full-sphere Fibonacci, ``min_plus_offset`` already + finds the physical wall; setting it explicitly is mostly a + convenience for trajectories where the wall position is known a + priori from the simulation setup. On this fixture the explicit + wall is ~1.6 Å below the interface-derived baseline, lifting the + measured angle from ~95° to ~99° (Δθ ≈ -Δz_wall / (R sin θ)). + """ + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_SPHERICAL_FIXTURE), + atom_indices=spherical_oxygen_ids, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + n_rays_sphere=400, density_sigma=3.0 + ), + surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), + wall_detector=WallDetector.explicit(z_wall=4.9), + ) + batch = analyzer.analyze([1]).batches[0] + assert isinstance(batch, WholeBatchResult) + # Observed 99.12° on this fixture; ±2° band. + assert 97.0 < batch.angle < 101.0 + # ``z_wall`` is reported back as supplied. + assert batch.z_wall == pytest.approx(4.9) + + +@pytest.mark.integration +@pytest.mark.slow +def test_whole_fitter_bootstrap_through_analyzer( + spherical_oxygen_ids: np.ndarray, +) -> None: + """``bootstrap_samples > 0`` populates ``WholeBatchResult.angle_std``.""" + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_SPHERICAL_FIXTURE), + atom_indices=spherical_oxygen_ids, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + n_rays_sphere=400, density_sigma=3.0 + ), + surface_fitter=SurfaceFitter.whole( + surface_filter_offset=3.0, bootstrap_samples=100 + ), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + batch = analyzer.analyze([1]).batches[0] + assert isinstance(batch, WholeBatchResult) + assert batch.angle_std is not None + assert batch.angle_std > 0.0 + # The 400-ray shell on this fixture is well-determined; observed + # σ_θ ~ 0.5°. Any value above 2° points at a numerical regression. + assert batch.angle_std < 2.0 + # Same pipeline as the companion test above ⇒ ~95° band. + assert 92.0 < batch.angle < 99.0 + + +@pytest.mark.integration +@pytest.mark.slow +def test_whole_fit_with_from_atoms_wall_detector( + spherical_oxygen_ids: np.ndarray, spherical_carbon_ids: np.ndarray +) -> None: + """``WallDetector.from_atoms`` flowing into the whole fitter end-to-end.""" + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_SPHERICAL_FIXTURE), + atom_indices=spherical_oxygen_ids, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + n_rays_sphere=400, density_sigma=3.0 + ), + surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), + wall_detector=WallDetector.from_atoms( + wall_atom_indices=spherical_carbon_ids, + method="mean_top_layer", + top_layer_tolerance=1.0, + ), + wall_atom_indices=spherical_carbon_ids, + ) + batch = analyzer.analyze([1]).batches[0] + assert isinstance(batch, WholeBatchResult) + # Top graphene layer on this fixture sits at z ≈ 4.897 Å. + assert 4.5 < batch.z_wall < 5.3 + # The from_atoms wall sits ~1.6 Å below the interface-derived + # baseline → angle ~ 99° here (vs 95° with min_plus_offset(0)). + # ±2° around the observed value. + assert 97.0 < batch.angle < 101.0 + + +# --- slicing pipeline on cylinder data ---------------------------------------- + + +@pytest.mark.integration +@pytest.mark.slow +def test_slicing_pipeline_on_cylinder_lammps_fixture( + cylinder_oxygen_ids: np.ndarray, +) -> None: + """New ``TrajectoryAnalyzer`` slicing fit on the cylinder LAMMPS fixture.""" + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_CYLINDER_FIXTURE), + atom_indices=cylinder_oxygen_ids, + droplet_geometry="cylinder_y", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_cylinder=4.0, delta_polar=8.0, density_sigma=3.0 + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + batch = analyzer.analyze([1]).batches[0] + assert isinstance(batch, SlicingBatchResult) + # On this cylinder fixture the slicing pipeline recovers ~102°; + # ±4° accommodates per-slice scatter. + assert 98.0 < batch.angle < 106.0 + # Six slices fall out of ``y_extent / delta_cylinder``; per-slice + # scatter on this fixture is sub-3°. + assert batch.per_slice_angles.shape == (6,) + assert 0.5 < batch.angle_std < 4.0 + + +# --- multi-frame pooled batching ---------------------------------------------- + + +@pytest.mark.integration +@pytest.mark.slow +def test_trajectory_analyzer_pooled_batches( + spherical_oxygen_ids: np.ndarray, +) -> None: + """``TemporalAggregator(batch_size=2)`` produces 2-frame pooled batches.""" + frames = [0, 1, 2, 3] + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_SPHERICAL_FIXTURE), + atom_indices=spherical_oxygen_ids, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, delta_polar=8.0 + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=2), + ) + results = analyzer.analyze(frames) + assert len(results) == 2 + for batch in results.batches: + # Each batch pools two consecutive frames. + assert len(batch.frames) == 2 + # Pooled batches sit within ±3° of the per-frame angle (~95°) + # on this fixture; observed 94.8°. + assert 91.0 < batch.angle < 98.0 + + +@pytest.mark.integration +@pytest.mark.slow +def test_trajectory_analyzer_fully_pooled( + spherical_oxygen_ids: np.ndarray, +) -> None: + """``batch_size=-1`` pools every requested frame into a single fit.""" + frames = [0, 1, 2, 3] + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_SPHERICAL_FIXTURE), + atom_indices=spherical_oxygen_ids, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, delta_polar=8.0 + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=-1), + ) + results = analyzer.analyze(frames) + assert len(results) == 1 + batch = results.batches[0] + assert batch.frames == frames + # Fully pooled angle on this fixture: 94.5°; ±3.5° band. + assert 91.0 < batch.angle < 98.0 diff --git a/tests/test_analysis/test_whole_fitter.py b/tests/test_analysis/test_whole_fitter.py index a357030..05fbd21 100644 --- a/tests/test_analysis/test_whole_fitter.py +++ b/tests/test_analysis/test_whole_fitter.py @@ -17,7 +17,7 @@ from wetting_angle_kit.analysis.extractors import ( InterfaceExtractor, - _fibonacci_hemisphere_directions, + _fibonacci_sphere_directions, ) from wetting_angle_kit.analysis.fitters import SurfaceFitter from wetting_angle_kit.analysis.geometry import DropletGeometry @@ -39,7 +39,7 @@ def test_whole_fitter_exact_sphere_recovers_angle_to_numerical_precision() -> No z_wall = 5.0 # cos θ = 0.25 → θ ≈ 75.522° truth_angle = float(np.degrees(np.arccos((z_wall - zc_truth) / R_truth))) - directions = _fibonacci_hemisphere_directions(400) + directions = _fibonacci_sphere_directions(400) shell = directions * R_truth + np.array([0.0, 0.0, zc_truth]) fitter = SurfaceFitter.whole(surface_filter_offset=0.0) @@ -165,7 +165,7 @@ def test_whole_fitter_bootstrap_sigma_scales_inverse_sqrt_n_shell() -> None: def make_noisy_shell(n_rays: int, seed: int) -> np.ndarray: rng = np.random.default_rng(seed) - directions = _fibonacci_hemisphere_directions(n_rays) + directions = _fibonacci_sphere_directions(n_rays) shell = directions * R_truth + np.array([0.0, 0.0, zc_truth]) return shell + rng.normal(0.0, point_noise, size=shell.shape) From 92e9d6dde4b191475d82aed90ae1e815dda56ad5 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Wed, 10 Jun 2026 11:06:31 +0200 Subject: [PATCH 25/53] Removed legacy code and tests. Update of visualization tools. --- src/wetting_angle_kit/analysis/__init__.py | 22 +- .../analysis/binning/__init__.py | 17 - .../analysis/binning/analyzer.py | 92 ----- .../analysis/binning/angle_fitting.py | 334 --------------- .../analysis/binning/results.py | 91 ---- .../analysis/binning/surface_definition.py | 391 ------------------ .../analysis/coupled_binning_2d.py | 119 +++++- .../analysis/slicing/__init__.py | 17 - .../analysis/slicing/analyzer.py | 299 -------------- .../analysis/slicing/angle_fitting.py | 299 -------------- .../analysis/slicing/results.py | 53 --- .../analysis/slicing/surface_definition.py | 255 ------------ .../visualization/__init__.py | 14 +- .../visualization/angle_evolution_plotter.py | 330 +++++++++++++++ .../binning_trajectory_plotter.py | 224 ---------- .../visualization/density_contour_plotter.py | 279 +++++++++++++ .../slicing_trajectory_plotter.py | 215 ---------- tests/test_analysis/test_binning_method.py | 44 +- .../test_binning_surface_definition.py | 212 ---------- .../test_analysis/test_coupled_binning_2d.py | 164 -------- .../test_rays_gaussian_extractor.py | 164 +------- tests/test_analysis/test_slicing_fitter.py | 139 +------ tests/test_analysis/test_slicing_method.py | 42 +- .../test_slicing_surface_definition.py | 192 --------- tests/test_edge_cases.py | 248 ----------- .../test_angle_evolution_plotter.py | 153 +++++++ .../test_density_contour_plotter.py | 156 +++++++ .../test_trajectory_plotters.py | 180 -------- 28 files changed, 1068 insertions(+), 3677 deletions(-) delete mode 100644 src/wetting_angle_kit/analysis/binning/__init__.py delete mode 100644 src/wetting_angle_kit/analysis/binning/analyzer.py delete mode 100644 src/wetting_angle_kit/analysis/binning/angle_fitting.py delete mode 100644 src/wetting_angle_kit/analysis/binning/results.py delete mode 100644 src/wetting_angle_kit/analysis/binning/surface_definition.py delete mode 100644 src/wetting_angle_kit/analysis/slicing/__init__.py delete mode 100644 src/wetting_angle_kit/analysis/slicing/analyzer.py delete mode 100644 src/wetting_angle_kit/analysis/slicing/angle_fitting.py delete mode 100644 src/wetting_angle_kit/analysis/slicing/results.py delete mode 100644 src/wetting_angle_kit/analysis/slicing/surface_definition.py create mode 100644 src/wetting_angle_kit/visualization/angle_evolution_plotter.py delete mode 100644 src/wetting_angle_kit/visualization/binning_trajectory_plotter.py create mode 100644 src/wetting_angle_kit/visualization/density_contour_plotter.py delete mode 100644 src/wetting_angle_kit/visualization/slicing_trajectory_plotter.py delete mode 100644 tests/test_analysis/test_binning_surface_definition.py delete mode 100644 tests/test_analysis/test_coupled_binning_2d.py delete mode 100644 tests/test_analysis/test_slicing_surface_definition.py delete mode 100644 tests/test_edge_cases.py create mode 100644 tests/test_visualization/test_angle_evolution_plotter.py create mode 100644 tests/test_visualization/test_density_contour_plotter.py delete mode 100644 tests/test_visualization/test_trajectory_plotters.py diff --git a/src/wetting_angle_kit/analysis/__init__.py b/src/wetting_angle_kit/analysis/__init__.py index 50f9fb9..c44bd75 100644 --- a/src/wetting_angle_kit/analysis/__init__.py +++ b/src/wetting_angle_kit/analysis/__init__.py @@ -22,24 +22,11 @@ TrajectoryResults, BatchResult, SlicingBatchResult, WholeBatchResult CoupledBinning2DResults, CoupledBinning2DBatchResult CoupledBinning3DResults, CoupledBinning3DBatchResult - -Legacy entry points (kept while the new pipeline's algorithm bodies are -being ported; scheduled for removal once :class:`TrajectoryAnalyzer` -subsumes them):: - - SlicingTrajectoryAnalyzer, SlicingFrameFitter - BinningTrajectoryAnalyzer, BinningBatchFitter """ from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer -# Legacy entry points. Kept while the new pipeline's algorithm bodies -# are being ported. To be removed once TrajectoryAnalyzer + -# CoupledBinning{2,3}DAnalyzer fully subsume them. -from wetting_angle_kit.analysis.binning.analyzer import BinningTrajectoryAnalyzer -from wetting_angle_kit.analysis.binning.angle_fitting import BinningBatchFitter - -# New top-level analyzers. +# Top-level analyzers. from wetting_angle_kit.analysis.coupled_binning_2d import CoupledBinning2DAnalyzer from wetting_angle_kit.analysis.coupled_binning_3d import CoupledBinning3DAnalyzer @@ -64,8 +51,6 @@ TrajectoryResults, WholeBatchResult, ) -from wetting_angle_kit.analysis.slicing.analyzer import SlicingTrajectoryAnalyzer -from wetting_angle_kit.analysis.slicing.angle_fitting import SlicingFrameFitter from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.analysis.trajectory import TrajectoryAnalyzer from wetting_angle_kit.analysis.wall import WallDetector @@ -94,9 +79,4 @@ "CoupledBinning2DResults", "CoupledBinning3DBatchResult", "CoupledBinning3DResults", - # Legacy. - "SlicingTrajectoryAnalyzer", - "SlicingFrameFitter", - "BinningTrajectoryAnalyzer", - "BinningBatchFitter", ] diff --git a/src/wetting_angle_kit/analysis/binning/__init__.py b/src/wetting_angle_kit/analysis/binning/__init__.py deleted file mode 100644 index c3b400c..0000000 --- a/src/wetting_angle_kit/analysis/binning/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""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 ( - BinningBatchFitter, -) -from wetting_angle_kit.analysis.binning.surface_definition import ( - 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 deleted file mode 100644 index f53f85a..0000000 --- a/src/wetting_angle_kit/analysis/binning/analyzer.py +++ /dev/null @@ -1,92 +0,0 @@ -"""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 deleted file mode 100644 index 776c851..0000000 --- a/src/wetting_angle_kit/analysis/binning/angle_fitting.py +++ /dev/null @@ -1,334 +0,0 @@ -"""Binning-method contact-angle analyzer. - -Algorithm ---------- - -The trajectory is aggregated into a 2D density field ``rho(xi, zi)`` on a -regular bin grid, where ``xi`` is the in-plane radial coordinate produced -by :func:`project_to_profile` and ``zi`` is the lab-frame vertical -coordinate. The histogram uses :func:`numpy.histogram2d` (left-edge -inclusive, right-edge exclusive, -last bin closed on both ends). - -Per-bin volume elements: - -* ``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. - -A :class:`HyperbolicTangentModel` is then fitted to the time-averaged -density field and the implied contact angle is derived from the fitted -sphere radius, center, and wall position. Lengths are in Å, densities in -particles · Å⁻³, and the final contact angle is returned in degrees. -""" - -import logging -import warnings -from collections.abc import Sequence -from typing import Any - -import numpy as np - -from wetting_angle_kit.analysis.binning.results import BinningBatch -from wetting_angle_kit.analysis.binning.surface_definition import ( - HyperbolicTangentModel, -) -from wetting_angle_kit.io_utils import ( - project_to_profile, - validate_droplet_geometry, -) - -logger = logging.getLogger(__name__) - -_PARAM_NAMES = ("rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2") - - -class BinningBatchFitter: - """Binning-based contact angle estimator using density field fitting. - - Frames aggregated in spatial bins form a time-averaged density field. - A hyperbolic tangent interface model is fitted and the implied contact - angle is computed from fitted geometric parameters. - """ - - 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 - 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. - """ - validate_droplet_geometry(droplet_geometry) - self.parser = parser - self.atom_indices = atom_indices - self.droplet_geometry = droplet_geometry - self.precentered = precentered - if binning_params is None: - max_dist = int( - np.max( - np.array( - [ - parser.box_size_y(frame_index=0), - parser.box_size_x(frame_index=0), - ] - ) - ) - / 3 - ) - self.binning_params = { - "xi_0": 0, - "xi_f": max_dist, - "nbins_xi": 50, - "zi_0": 0.0, - "zi_f": max_dist, - "nbins_zi": 50, - } - warnings.warn( - "binning_params was not supplied; using a heuristic default " - f"(xi_0=0, xi_f={max_dist}, zi_0=0, zi_f={max_dist}, " - "50x50 bins) derived from one third of the largest in-plane " - "box dimension. For accurate density fields, supply " - "system-specific binning_params matching your droplet size " - "and per-frame sampling.", - UserWarning, - stacklevel=2, - ) - else: - self.binning_params = binning_params - self._initialize_grid() - 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.""" - self.xi = np.linspace( - self.binning_params["xi_0"], - self.binning_params["xi_f"], - int(self.binning_params["nbins_xi"]), - ) - self.zi = np.linspace( - self.binning_params["zi_0"], - self.binning_params["zi_f"], - int(self.binning_params["nbins_zi"]), - ) - self.dxi = self.xi[1] - self.xi[0] - self.dzi = self.zi[1] - self.zi[0] - self.xi_cc = 0.5 * (self.xi[1:] + self.xi[:-1]) - self.zi_cc = 0.5 * (self.zi[1:] + self.zi[:-1]) - - def get_profile_coordinates( - self, - frame_indices: Sequence[int], - ) -> tuple[np.ndarray, np.ndarray, int]: - """Compute 2D projection coordinates (r, z) for contact angle analysis. - - Projects 3D atomic positions onto a 2D plane based on the assumed - droplet geometry. Coordinates are accumulated across all requested - frames in lockstep. - - Parameters - ---------- - frame_indices : Sequence[int] - Frame indices to process. - - Returns - ------- - r_values : ndarray - Concatenated radial distances. - z_values : ndarray - Concatenated vertical coordinates. - n_frames : int - Number of frames processed (``len(frame_indices)``). - """ - validate_droplet_geometry(self.droplet_geometry) - r_chunks: list[np.ndarray] = [] - z_chunks: list[np.ndarray] = [] - # ``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: - 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: - box_size = ( - self.parser.box_size_x(frame_index=frame_idx), - self.parser.box_size_y(frame_index=frame_idx), - ) - r_frame, z_frame = project_to_profile( - positions, self.droplet_geometry, box_size=box_size - ) - r_chunks.append(r_frame) - z_chunks.append(z_frame) - if frame_idx % 10 == 0: - x_cm = ( - np.mean(positions, axis=0) if positions.size else np.full(3, np.nan) - ) - logger.info( - f"Frame {frame_idx}: {len(positions)} particles, " - f"center of mass {np.array2string(x_cm, precision=3)}" - ) - r_values = np.concatenate(r_chunks) if r_chunks else np.empty(0) - z_values = np.concatenate(z_chunks) if z_chunks else np.empty(0) - if r_values.size > 0: - logger.info( - f"r range: ({float(r_values.min()):.3f}, {float(r_values.max()):.3f})" - ) - logger.info( - f"z range: ({float(z_values.min()):.3f}, {float(z_values.max()):.3f})" - ) - return r_values, z_values, len(frame_indices) - - def binning( - self, xi_par: np.ndarray, zi_par: np.ndarray, len_frames: int - ) -> np.ndarray: - """Return 2D density field by binning particle coordinates. - - Uses :func:`numpy.histogram2d`, which is vectorized (O(N) in the - particle count) and correctly handles particles on bin edges - (inclusive on the left/lower edge, inclusive on the right/upper - edge of the last bin only). This makes the legacy ``+0.01`` shift - on the radial coordinate unnecessary. - - Parameters - ---------- - xi_par : ndarray - Radial/in-plane coordinate values for particles over frames. - zi_par : ndarray - Vertical coordinate values for particles over frames. - len_frames : int - Number of frames aggregated. - - Returns - ------- - ndarray, shape (nbins_xi-1, nbins_zi-1) - Averaged density field on cell centers. - """ - counts, _, _ = np.histogram2d( - xi_par, - zi_par, - bins=(self.xi, self.zi), - ) - if self.droplet_geometry in ("cylinder_x", "cylinder_y"): - 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 - rho_cc = counts / dV_per_row[:, np.newaxis] - if len_frames > 0: - rho_cc /= len_frames - return rho_cc - - def process_batch( - self, - frame_list: list[int], - model: Any | None = None, - batch_index: int | None = None, - ) -> 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; a new - :class:`HyperbolicTangentModel` is created if None. - batch_index : int, optional - Sequential identifier copied into the returned :class:`BinningBatch` - (defaults to 1 when not supplied). - - Returns - ------- - 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, - ) - n_particles = len(xi_par) / max(len_frames, 1) - batch_label = f" {batch_index}" if batch_index is not None else "" - logger.info( - f"Number of fluid particles in batch{batch_label}: {n_particles:.2f}" - ) - rho_cc = self.binning(xi_par, zi_par, len_frames) - if model is None: - model = HyperbolicTangentModel() - msh_zi_cc_grid, msh_xi_cc_grid = np.meshgrid(self.zi_cc, self.xi_cc) - msh_zi_cc = msh_zi_cc_grid.reshape( - (len(self.xi_cc) * len(self.zi_cc)), order="F" - ) - msh_xi_cc = msh_xi_cc_grid.reshape( - (len(self.xi_cc) * len(self.zi_cc)), order="F" - ) - 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) - logger.info( - 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}") - 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, - ) - 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." - ) - 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 deleted file mode 100644 index 15d9202..0000000 --- a/src/wetting_angle_kit/analysis/binning/results.py +++ /dev/null @@ -1,91 +0,0 @@ -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 deleted file mode 100644 index e9153be..0000000 --- a/src/wetting_angle_kit/analysis/binning/surface_definition.py +++ /dev/null @@ -1,391 +0,0 @@ -"""Interface models used by the binning analyzer. - -Algorithm ---------- - -The implemented :class:`HyperbolicTangentModel` represents the -liquid–vapor interface as a product of two sigmoids, - -:: - - rho(xi, zi) = g(r) * h(z), - g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], - h(z) = 0.5 * [1 + tanh(2 * (zi - zi_0) / t2)], - r = sqrt(xi**2 + (zi - zi_c)**2), - -with seven free parameters fitted by non-linear least squares: - -* ``rho1`` — liquid-phase number density (particles · Å⁻³). -* ``rho2`` — vapor-phase number density (particles · Å⁻³). -* ``R_eq`` — equivalent spherical radius (Å). -* ``zi_c`` — z-coordinate of the spherical center (Å). -* ``zi_0`` — wall reference z-coordinate (Å). -* ``t1`` — radial interface thickness (Å). -* ``t2`` — vertical interface thickness (Å). - -Bounds keep densities and lengths in their physical ranges. Once the fit -converges, the contact angle is the geometric tangent angle of the -fitted sphere at the wall intersection. Lengths are in Å, angles in -degrees. -""" - -import warnings -from abc import ABC, abstractmethod -from typing import Any - -import numpy as np -from scipy.optimize import curve_fit - - -class SurfaceModel(ABC): - """Abstract base for surface models used in contact angle analysis. - - Subclasses must implement ``fit`` and ``evaluate``. - """ - - def __init__(self, initial_params: list[float] | None = None) -> None: - """ - Parameters - ---------- - initial_params : sequence of float, optional - Initial guess for model parameters. Interpretation is left to subclasses. - """ - self.params = initial_params - self.covariance = None - - @abstractmethod - def fit(self, x_data: Any, density_data: np.ndarray) -> "SurfaceModel": - """Fit the model to density data. - - Parameters - ---------- - x_data : Any - Coordinate representation consumed by the concrete model. - density_data : ndarray - 1D array of density values matching ``x_data``. - - Returns - ------- - SurfaceModel - The fitted model instance (``self``) for chaining. - """ - - @abstractmethod - def evaluate(self, x: Any) -> float: - """Evaluate the fitted function at point ``x``. - - Parameters - ---------- - x : Any - Coordinate(s) accepted by the concrete model. - - Returns - ------- - float - Evaluated density value. - """ - - def evaluate_on_grid(self, xi_grid: np.ndarray, zi_grid: np.ndarray) -> np.ndarray: - """Evaluate the fitted function on a 2D (xi, zi) grid. - - Parameters - ---------- - xi_grid : sequence of float - Radial or in-plane coordinate values. - zi_grid : sequence of float - Height (z) coordinate values. - - Returns - ------- - ndarray, shape (len(xi_grid), len(zi_grid)) - 2D array of evaluated density values. - """ - # ``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): - """Liquid–vapor interface model using a hyperbolic tangent profile. - - The density field is modeled as the product of two sigmoidal (tanh) terms: one - depending on the spherical radial distance and one along the vertical axis. - """ - - #: Default initial guess for the seven fit parameters for full atomistic model of - # water at RT. - DEFAULT_INITIAL_PARAMS = [1e-3, 3e-2, 40.0, 20.0, 4.0, 1.0, 1.0] - - def __init__(self, initial_params: list[float] | None = None) -> None: - """ - Parameters - ---------- - initial_params : list[float], optional - Seven parameters ``[rho1, rho2, R_eq, zi_c, zi_0, t1, t2]`` used as - the starting guess for the non-linear fit. Defaults to - :attr:`DEFAULT_INITIAL_PARAMS`, tuned for the full atomistic model of - liquid water at room temperature - in Å units; supply system-specific values if your density or droplet - size differs. - - - rho1 : Liquid-phase density. - - rho2 : Vapor-phase density. - - R_eq : Equivalent spherical radius. - - zi_c : z-coordinate of the sphere center. - - zi_0 : Reference wall z-coordinate. - - t1 : Interface thickness (radial component). - - t2 : Interface thickness (vertical component). - """ - if initial_params is None: - initial_params = list(self.DEFAULT_INITIAL_PARAMS) - super().__init__(initial_params) - - def _fitting_function( - self, - x: Any, - rho1: float, - rho2: float, - R_eq: float, - zi_c: float, - zi_0: float, - t1: float, - t2: float, - ) -> Any: - """Evaluate the two-component hyperbolic tangent - density model at position ``x``. - - Parameters - ---------- - x : tuple(float, float) - Coordinates ``(xi, zi)``. - rho1, rho2 : float - Liquid and vapor densities. - R_eq : float - Sphere radius. - zi_c : float - Sphere center z-coordinate. - zi_0 : float - Wall reference z-coordinate. - t1, t2 : float - Interface thickness parameters (radial, vertical). - - Returns - ------- - float - Density value at the given coordinates. - """ - xi, zi = x[0], x[1] - - def g(r: Any) -> Any: - return 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) - - def h(z: Any) -> Any: - return 0.5 * (1 + np.tanh(2 * z / t2)) - - r = np.sqrt(xi**2 + (zi - zi_c) ** 2) - z = zi - zi_0 - return g(r) * h(z) - - # Physical bounds on the seven parameters - # [rho1, rho2, R_eq, zi_c, zi_0, t1, t2]. - # Densities are non-negative, radius and interface thicknesses are - # strictly positive. Center coordinates are unconstrained. - _PARAM_LOWER = np.array([0.0, 0.0, 1e-6, -np.inf, -np.inf, 1e-6, 1e-6]) - _PARAM_UPPER = np.array([np.inf] * 7) - - def fit(self, x_data: Any, density_data: np.ndarray) -> "HyperbolicTangentModel": - """Fit the model parameters to provided density samples. - - Parameters - ---------- - x_data : tuple(ndarray, ndarray) - Coordinate arrays ``(xi_array, zi_array)`` flattened or broadcastable. - density_data : ndarray - Density values corresponding to ``x_data``. - - Returns - ------- - HyperbolicTangentModel - Fitted model instance (``self``). - """ - self.params, self.covariance = curve_fit( - self._fitting_function, - x_data, - density_data, - p0=self.params, - bounds=(self._PARAM_LOWER, self._PARAM_UPPER), - maxfev=1_000_000, - ) - self._warn_if_at_bounds() - return self - - def _warn_if_at_bounds(self) -> None: - """Emit a warning if any fitted parameter is pinned at a finite bound. - - This usually indicates the hyperbolic tangent model is not a good - fit (e.g. too few frames, wrong geometry, or noisy density field). - """ - assert self.params is not None - param_names = ["rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2"] - tol = 1e-6 - at_bound = [] - for name, value, lo, hi in zip( - param_names, self.params, self._PARAM_LOWER, self._PARAM_UPPER, strict=False - ): - if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): - at_bound.append(f"{name}={value:.3g} at lower bound {lo}") - elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): - at_bound.append(f"{name}={value:.3g} at upper bound {hi}") - if at_bound: - warnings.warn( - "Hyperbolic tangent fit converged with parameter(s) at the " - "physical bound, suggesting a poor fit: " + "; ".join(at_bound), - RuntimeWarning, - stacklevel=3, - ) - - def evaluate(self, x: Any) -> float: - """Evaluate the fitted hyperbolic tangent model at ``x``. - - Parameters - ---------- - x : tuple(float, float) - Coordinates ``(xi, zi)``. - - Returns - ------- - float - Density value at the given point. - """ - if self.params is None: - raise ValueError("Model must be fitted before evaluation") - return self._fitting_function( - x, - self.params[0], - self.params[1], - self.params[2], - self.params[3], - self.params[4], - self.params[5], - self.params[6], - ) - - def compute_isoline( - self, scale_factor: float = 0.95 - ) -> 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 - Visualization-only scaling applied to the fitted equivalent - radius before computing the iso-line traces. - - Returns - ------- - tuple(ndarray, ndarray, ndarray, ndarray) - ``(circle_xi, circle_zi, wall_line_xi, wall_line_zi)`` arrays. - """ - if self.params is None: - raise ValueError("Model must be fitted before computing isoline") - - r = scale_factor * self.params[2] # R_eq - z_center = self.params[3] # zi_c - z_wall = self.params[4] # zi_0 - - discriminant = r**2 - (z_wall - z_center) ** 2 - if discriminant < 0: - raise ValueError( - "Fitted wall is outside the fitted droplet radius " - f"(r={r:.3f}, |z_wall - z_center|={abs(z_wall - z_center):.3f}); " - "isoline cannot be computed. The hyperbolic tangent fit " - "likely did not converge to a physical solution." - ) - xi_wall = np.sqrt(discriminant) - alpha_inf = np.arctan((z_wall - z_center) / xi_wall) - alpha = np.linspace(alpha_inf, np.pi / 2, 100) - - xi_center = 0.0 - circle_xi = xi_center + r * np.cos(alpha) - circle_zi = z_center + r * np.sin(alpha) - - wall_line_xi = np.linspace(xi_center, xi_wall, 100) - wall_line_zi = np.ones(len(wall_line_xi)) * z_wall - - return circle_xi, circle_zi, wall_line_xi, wall_line_zi - - def compute_contact_angle(self) -> float: - """Return the contact angle (degrees) implied by fitted parameters. - - Returns - ------- - float - Contact angle in degrees. Returns ``nan`` if the fitted wall - position lies outside the fitted droplet sphere (no - intersection), which indicates a poor fit. - """ - if self.params is None: - raise ValueError("Model must be fitted before computing contact angle") - - R_eq = self.params[2] - zita_c = self.params[3] - zita_wall = self.params[4] - - discriminant = R_eq**2 - (zita_wall - zita_c) ** 2 - if discriminant < 0: - warnings.warn( - "Fitted wall is outside the fitted droplet sphere " - f"(R_eq={R_eq:.3f}, |zita_wall - zita_c|=" - f"{abs(zita_wall - zita_c):.3f}); contact angle is undefined.", - RuntimeWarning, - stacklevel=2, - ) - return float("nan") - xi_cross = np.sqrt(discriminant) - theta = (np.pi / 2 - np.arctan((zita_wall - zita_c) / xi_cross)) * 180 / np.pi - return theta - - def get_parameters(self) -> dict[str, float]: - """Return a mapping of parameter names to fitted values. - - Returns - ------- - dict[str, float] - Dictionary of parameter names and values. - """ - if self.params is None: - raise ValueError("Model must be fitted before getting parameters") - - param_names = ["rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2"] - return { - name: value for name, value in zip(param_names, self.params, strict=False) - } - - def get_parameter_strings(self) -> list[str]: - """Return formatted parameter strings suitable for logging. - - Returns - ------- - list[str] - Formatted parameter strings (``"name:value\\n"``). - """ - if self.params is None: - raise ValueError("Model must be fitted before getting parameter strings") - - param_names = ["rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2"] - return [ - f"{name}:{value}\n" - for name, value in zip(param_names, self.params, strict=False) - ] diff --git a/src/wetting_angle_kit/analysis/coupled_binning_2d.py b/src/wetting_angle_kit/analysis/coupled_binning_2d.py index 87566b8..51ef92d 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_binning_2d.py @@ -27,14 +27,12 @@ from typing import Any, ClassVar import numpy as np +from scipy.optimize import curve_fit from wetting_angle_kit.analysis.base import ( _BatchedTrajectoryAnalyzer, build_parser, ) -from wetting_angle_kit.analysis.binning.surface_definition import ( - HyperbolicTangentModel, -) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( CoupledBinning2DBatchResult, @@ -48,6 +46,113 @@ _PARAM_NAMES = ("rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2") +class _HyperbolicTangentModel2D: + """Coupled 2D-binning joint contact-angle model. + + Density field modelled as a product of two sigmoidal (tanh) terms, + one radial and one vertical: + + :: + + rho(xi, zi) = g(r) * h(zi - zi_0), + g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], + h(z) = 0.5 * [1 + tanh(2 z / t2)], + r = sqrt(xi^2 + (zi - zi_c)^2). + + Seven free parameters fitted by bounded NLLS. Private (the public + entry point is :class:`CoupledBinning2DAnalyzer`); the 3D + counterpart lives in :mod:`coupled_binning_3d` as + ``_HyperbolicTangentModel3D``. + """ + + DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 20.0, 4.0, 1.0, 1.0) + + _PARAM_LOWER = np.array([0.0, 0.0, 1e-6, -np.inf, -np.inf, 1e-6, 1e-6]) + _PARAM_UPPER = np.array([np.inf] * 7) + + def __init__(self, initial_params: list[float] | None = None) -> None: + if initial_params is None: + initial_params = list(self.DEFAULT_INITIAL_PARAMS) + self.params: list[float] | np.ndarray | None = initial_params + self.covariance: np.ndarray | None = None + + @staticmethod + def _fitting_function( + x: tuple[np.ndarray, np.ndarray], + rho1: float, + rho2: float, + R_eq: float, + zi_c: float, + zi_0: float, + t1: float, + t2: float, + ) -> np.ndarray: + xi, zi = x[0], x[1] + r = np.sqrt(xi**2 + (zi - zi_c) ** 2) + g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) + h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) + return g_r * h_z + + def fit( + self, + x_data: tuple[np.ndarray, np.ndarray], + density_data: np.ndarray, + ) -> "_HyperbolicTangentModel2D": + self.params, self.covariance = curve_fit( + self._fitting_function, + x_data, + density_data, + p0=self.params, + bounds=(self._PARAM_LOWER, self._PARAM_UPPER), + maxfev=1_000_000, + ) + self._warn_if_at_bounds() + return self + + def _warn_if_at_bounds(self) -> None: + if self.params is None: + return + tol = 1e-6 + at_bound = [] + for name, value, lo, hi in zip( + _PARAM_NAMES, + self.params, + self._PARAM_LOWER, + self._PARAM_UPPER, + strict=False, + ): + if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): + at_bound.append(f"{name}={value:.3g} at lower bound {lo}") + elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): + at_bound.append(f"{name}={value:.3g} at upper bound {hi}") + if at_bound: + warnings.warn( + "Hyperbolic tangent fit converged with parameter(s) at the " + "physical bound, suggesting a poor fit: " + "; ".join(at_bound), + RuntimeWarning, + stacklevel=3, + ) + + def compute_contact_angle(self) -> float: + if self.params is None: + raise ValueError("Model must be fitted before computing contact angle.") + R_eq = float(self.params[2]) + zi_c = float(self.params[3]) + zi_0 = float(self.params[4]) + discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 + if discriminant < 0: + warnings.warn( + "Fitted wall is outside the fitted droplet sphere " + f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|={abs(zi_0 - zi_c):.3f}); " + "contact angle is undefined.", + RuntimeWarning, + stacklevel=2, + ) + return float("nan") + xi_cross = np.sqrt(discriminant) + return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) + + def _heuristic_binning_params(parser: Any) -> dict[str, Any]: """Build the legacy heuristic binning grid: 50×50 cells over a third of the largest in-plane box dimension. @@ -109,7 +214,7 @@ class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): Initial guess for the seven tanh-model parameters ``[rho1, rho2, R_eq, zi_c, zi_0, t1, t2]``. Defaults to the values tuned for room-temperature water in the existing - :class:`HyperbolicTangentModel`. + :class:`_HyperbolicTangentModel2D`. temporal_aggregator : TemporalAggregator, optional Defaults to a single fully pooled batch (``batch_size=-1``) — the coupled fit benefits from as much @@ -261,10 +366,10 @@ def _process_batch_worker( if n_frames > 0: rho_cc /= n_frames - # Joint tanh fit. ``HyperbolicTangentModel`` expects the + # Joint tanh fit. ``_HyperbolicTangentModel2D`` expects the # density and grid axes flattened in Fortran order — same # as the legacy ``BinningBatchFitter.process_batch``. - model = HyperbolicTangentModel(initial_params=initial_params) + model = _HyperbolicTangentModel2D(initial_params=initial_params) msh_zi_grid, msh_xi_grid = np.meshgrid(zi_cc, xi_cc) n_flat = len(xi_cc) * len(zi_cc) msh_zi = msh_zi_grid.reshape(n_flat, order="F") @@ -275,7 +380,7 @@ def _process_batch_worker( params = model.params if params is None: raise RuntimeError( - "HyperbolicTangentModel did not set model parameters; " + "_HyperbolicTangentModel2D did not set model parameters; " "cannot build CoupledBinning2DBatchResult." ) model_params = { diff --git a/src/wetting_angle_kit/analysis/slicing/__init__.py b/src/wetting_angle_kit/analysis/slicing/__init__.py deleted file mode 100644 index 770bf52..0000000 --- a/src/wetting_angle_kit/analysis/slicing/__init__.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Public exports for the slicing contact angle method.""" - -from wetting_angle_kit.analysis.slicing.analyzer import ( - SlicingTrajectoryAnalyzer, -) -from wetting_angle_kit.analysis.slicing.angle_fitting import ( - SlicingFrameFitter, -) -from wetting_angle_kit.analysis.slicing.surface_definition import ( - SurfaceDefinition, -) - -__all__ = [ - "SlicingFrameFitter", - "SlicingTrajectoryAnalyzer", - "SurfaceDefinition", -] diff --git a/src/wetting_angle_kit/analysis/slicing/analyzer.py b/src/wetting_angle_kit/analysis/slicing/analyzer.py deleted file mode 100644 index bc24d4e..0000000 --- a/src/wetting_angle_kit/analysis/slicing/analyzer.py +++ /dev/null @@ -1,299 +0,0 @@ -"""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 deleted file mode 100644 index 4196968..0000000 --- a/src/wetting_angle_kit/analysis/slicing/angle_fitting.py +++ /dev/null @@ -1,299 +0,0 @@ -import numpy as np - -from wetting_angle_kit.analysis.slicing.surface_definition import ( - SurfaceDefinition, -) -from wetting_angle_kit.io_utils import validate_droplet_geometry - - -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 - (cylinder modes) or by gamma azimuthal angle (spherical). For each slice / tilt - a set of radial lines is sampled, a circle is fit to interface points, and - the contact angle is derived from intersection with the lowest surface level. - """ - - #: Default azimuthal step (degrees) between radial sampling lines used - #: by :class:`SurfaceDefinition` when building the per-slice interface. - DEFAULT_DELTA_ANGLE = 8.0 - - def __init__( - self, - liquid_coordinates: np.ndarray, - max_dist: float, - liquid_geom_center: np.ndarray, - droplet_geometry: str = "spherical", - delta_gamma: float | None = None, - delta_cylinder: float | None = None, - surface_filter_offset: float = 2.0, - points_per_angstrom: float = 1.0, - density_sigma: float = SurfaceDefinition.DEFAULT_DENSITY_SIGMA, - delta_angle: float = DEFAULT_DELTA_ANGLE, - ) -> None: - """ - Parameters - ---------- - liquid_coordinates : ndarray, shape (N, 3) - Oxygen (or liquid marker) coordinates. - max_dist : float - Maximum radial distance for line sampling. - liquid_geom_center : ndarray, shape (3,) - Geometric droplet center; y component overridden per slice in cylinder - modes. - 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). - delta_cylinder : float, optional - 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 - Sampling density along each radial ray. - density_sigma : float, default SurfaceDefinition.DEFAULT_DENSITY_SIGMA - Gaussian smoothing width (Å) for the density-along-ray kernel. - delta_angle : float, default DEFAULT_DELTA_ANGLE - 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 - # and we must not modify the caller's array. - self.liquid_geom_center = np.array(liquid_geom_center, copy=True) - self.droplet_geometry = droplet_geometry - self.delta_gamma = delta_gamma - 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 - # higher) for small droplets where 1 sample per Å is insufficient - # to fit the interface tanh profile. - self.points_per_angstrom = points_per_angstrom - # Gaussian smoothing width (Å) for the density-along-ray kernel and - # azimuthal spacing (deg) between radial lines. - # Tuned for the full atomistic model of liquid water - # at room temperature by default; adjust for other liquids. - self.density_sigma = density_sigma - self.delta_angle = delta_angle - - 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, - ) - ) - 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 the per-slice center position along the slicing axis. - - Returns - ------- - list[float] - Y positions of slice centers; for spherical, the droplet center - y is repeated ``180 / delta_gamma`` times. - """ - return self._slice_sweep()[0] - - def calculate_gammas_list(self) -> list[float]: - """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. - - Parameters - ---------- - v_gamma : float - Gamma inclination in degrees (0 for cylindrical slices). - - Returns - ------- - tuple(ndarray, ndarray) - (surf_xz, radial_info); surf_xz (M,2), radial_info (M,2). - """ - surface_def = SurfaceDefinition( - self.liquid_coordinates, - self.delta_angle, - self.max_dist, - self.liquid_geom_center, - v_gamma, - points_per_angstrom=self.points_per_angstrom, - density_sigma=self.density_sigma, - ) - rr, xz = surface_def.analyze_lines() - return np.array(xz), np.array(rr) - - def separate_surface_data(self, surf: np.ndarray, limit_med: float) -> np.ndarray: - """Filter surface points above reference height. - - Parameters - ---------- - surf : ndarray, shape (M, 2) - Surface XZ points. - limit_med : float - Baseline (minimum droplet height + offset). - - Returns - ------- - ndarray - Filtered subset of ``surf`` with z > ``limit_med``. - """ - return surf[surf[:, 1] > limit_med] - - @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 - ---------- - x_data : ndarray - X coordinates. - y_data : ndarray - Z coordinates. - - Returns - ------- - 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). - """ - 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. - - Parameters - ---------- - popt : sequence - Circle parameters [x_center, z_center, radius]. - y_line : float - Baseline z-coordinate (minimum droplet height). - - Returns - ------- - float | None - Contact angle in degrees or None if circle does not intersect baseline. - """ - _, z_center, radius = popt - delta_z = y_line - z_center - discriminant = radius**2 - delta_z**2 - if discriminant < 0: - return None - theta = np.arccos(delta_z / radius) - return float(np.degrees(theta)) - - def predict_contact_angle( - self, - ) -> tuple[list[float], list[np.ndarray], list[np.ndarray]]: - """Run slicing loop and return per-slice contact angles and geometry. - - Only slices for which the full pipeline (surface detection, circle - fit, and baseline intersection) succeeds contribute to the returned - lists. The three lists are kept in lockstep: ``angles[i]``, - ``surfaces[i]``, and ``popt_arrays[i]`` always describe the same - slice, so that a single index can be used across all three. - - Returns - ------- - tuple(list[float], list[np.ndarray], list[np.ndarray]) - (angles, surfaces, popt_arrays) where - angles : list of contact angles (deg) - surfaces : list of surface point arrays (each (M, 2)) - popt_arrays : list of fitted circle parameter arrays extended by - baseline + offset - """ - gammas = self.calculate_gammas_list() - y_axis_list = self.calculate_y_axis_list() - angles: list[float] = [] - surfaces: list[np.ndarray] = [] - popt_arrays: list[np.ndarray] = [] - for counter, value_gamma in enumerate(gammas): - self.liquid_geom_center[1] = y_axis_list[counter] - surf, rr = self.surface_definition(value_gamma) - if surf.size == 0: - continue - min_drop = float(np.min(surf[:, 1])) - limit_med = min_drop + self.surface_filter_offset - surf_line = self.separate_surface_data(surf, limit_med) - if len(surf_line) < 3: # need at least 3 points to fit a circle - continue - x_data = surf_line[:, 0] - y_data = surf_line[:, 1] - try: - 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: - continue - angles.append(angle) - surfaces.append(surf) - popt_arrays.append(np.append(popt, limit_med)) - return angles, surfaces, popt_arrays diff --git a/src/wetting_angle_kit/analysis/slicing/results.py b/src/wetting_angle_kit/analysis/slicing/results.py deleted file mode 100644 index 769749b..0000000 --- a/src/wetting_angle_kit/analysis/slicing/results.py +++ /dev/null @@ -1,53 +0,0 @@ -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 deleted file mode 100644 index df88737..0000000 --- a/src/wetting_angle_kit/analysis/slicing/surface_definition.py +++ /dev/null @@ -1,255 +0,0 @@ -"""Slicing-method interface estimator. - -Algorithm ---------- - -For a single droplet slice the interface is recovered in two steps: - -1. **Radial line scan.** A fan of rays is emitted from the droplet - geometric center in the slice plane, with one ray every - ``delta_angle`` degrees. Along each ray we evaluate a - 3D-Gaussian-smoothed density at uniformly spaced sampling points - (``points_per_angstrom`` per Å, with a hard minimum of - ``MIN_POINTS_PER_RAY``). The Gaussian kernel width - ``density_sigma`` (Å) defaults to 3.0 Å, tuned for the full atomistic model of - liquid water at room temperature. -2. **Interface fit.** A hyperbolic tangent profile - ``rho(s) = d * tanh(zd - s) + h`` is fitted to the density along - the ray, where ``s`` is the distance from the center (Å). The - fitted ``zd`` is the interface position; the corresponding (x, z) - point in the slice plane is returned. - -The density evaluator and the batched tanh fit are imported from -:mod:`wetting_angle_kit.analysis._density`, which is also used by the -new ray-based interface extractors. This module only adds the -slice-specific :meth:`SurfaceDefinition.analyze_lines` orchestration -on top. - -All lengths are expected in Ångströms; angles are in degrees. -""" - -import numpy as np - -from wetting_angle_kit.analysis._density import ( - DEFAULT_CUTOFF_SIGMA as _DEFAULT_CUTOFF_SIGMA, -) -from wetting_angle_kit.analysis._density import ( - DEFAULT_DENSITY_SIGMA as _DEFAULT_DENSITY_SIGMA, -) -from wetting_angle_kit.analysis._density import ( - MIN_POINTS_PER_RAY as _MIN_POINTS_PER_RAY, -) -from wetting_angle_kit.analysis._density import ( - GaussianDensityField, - fit_tanh_profiles_batched, - tanh_profile, -) - - -class SurfaceDefinition: - """Radial line sampling interface estimator for slicing contact angle. - - For each attitudinal angle beta the density is sampled along a ray emerging - from the droplet geometric center. A simple tanh profile is fitted to obtain - the interface position ("re") which is then projected back to XZ plane. - """ - - # Re-exported class attributes for callers that read e.g. - # ``SurfaceDefinition.DEFAULT_DENSITY_SIGMA`` (notably - # ``SlicingFrameFitter.__init__`` in this package). The canonical - # values live in :mod:`wetting_angle_kit.analysis._density`. - MIN_POINTS_PER_RAY = _MIN_POINTS_PER_RAY - DEFAULT_DENSITY_SIGMA = _DEFAULT_DENSITY_SIGMA - DEFAULT_CUTOFF_SIGMA = _DEFAULT_CUTOFF_SIGMA - - def __init__( - self, - atom_coords: np.ndarray, - delta_angle: float, - max_dist: float, - center_geom: np.ndarray, - gamma: float, - 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 - ---------- - atom_coords : ndarray, shape (N, 3) - Cartesian coordinates of liquid atoms. - delta_angle : float - Angular step (degrees) between successive sampling rays. - max_dist : float - Maximum radial distance sampled along each ray. - center_geom : ndarray, shape (3,) - Approximate droplet geometric center. - gamma : float - Tilt angle (degrees) controlling rotation about the x-axis. - density_conversion : float, default 1.0 - Factor applied multiplicatively to raw density contributions. - points_per_angstrom : float, default 1.0 - 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 - self.density_conversion = density_conversion - self.gamma = gamma - self.delta_angle = delta_angle - self.max_dist = max_dist - self.points_per_angstrom = points_per_angstrom - self.density_sigma = density_sigma - self.cutoff_sigma = cutoff_sigma - # Density evaluator now shared with the new ray-based - # extractors via ``analysis/_density.py``. Empty atom clouds - # are handled inside the field (it short-circuits to zeros). - self._density = GaussianDensityField( - atom_coords=atom_coords, - density_sigma=density_sigma, - cutoff_sigma=cutoff_sigma, - ) - - def density_contribution(self, positions: np.ndarray) -> np.ndarray: - """Return Gaussian-smoothed density contributions at sample positions. - - Delegates to :meth:`GaussianDensityField.evaluate`; kept as a - method for backwards compatibility with code that wraps - ``SurfaceDefinition``. - - Parameters - ---------- - positions : ndarray, shape (M, 3) - 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. - """ - return self._density.evaluate(positions) - - @staticmethod - def density_profile(z: np.ndarray, zd: float, d: float, h: float) -> np.ndarray: - """Hyperbolic-tangent liquid–vapor density profile. - - Thin wrapper around - :func:`wetting_angle_kit.analysis._density.tanh_profile`; kept - as a static method for backwards compatibility. - - Parameters - ---------- - z : ndarray - Distances along the sampling ray (Å). - zd : float - Liquid-vapor interface position parameter to be fitted. - d : float - Amplitude scaling parameter. - h : float - Offset parameter. - - Returns - ------- - ndarray - Modeled density values at each z. - """ - return tanh_profile(z, zd, d, h) - - def _fit_density_profiles_batched( - self, - 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. - - Delegates to - :func:`wetting_angle_kit.analysis._density.fit_tanh_profiles_batched`; - this method exists to preserve the per-instance ``self.max_dist`` - binding for in-process callers. - - Parameters - ---------- - 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 - ------- - 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. - """ - return fit_tanh_profiles_batched( - distances, - densities, - max_dist=self.max_dist, - max_iter=max_iter, - tol=tol, - ) - - 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]] - Fitted interface distances and azimuth angles ``[interface_re, beta_deg]``. - xz : 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) - 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)) - - # 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/visualization/__init__.py b/src/wetting_angle_kit/visualization/__init__.py index bf10e4d..222019a 100644 --- a/src/wetting_angle_kit/visualization/__init__.py +++ b/src/wetting_angle_kit/visualization/__init__.py @@ -1,19 +1,19 @@ +from wetting_angle_kit.visualization.angle_evolution_plotter import ( + AngleEvolutionPlotter, +) from wetting_angle_kit.visualization.base_trajectory_plotter import ( BaseTrajectoryPlotter, ) -from wetting_angle_kit.visualization.binning_trajectory_plotter import ( - BinningTrajectoryPlotter, +from wetting_angle_kit.visualization.density_contour_plotter import ( + DensityContourPlotter, ) 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__ = [ + "AngleEvolutionPlotter", "BaseTrajectoryPlotter", - "BinningTrajectoryPlotter", + "DensityContourPlotter", "DropletSlicePlotter", - "SlicingTrajectoryPlotter", "TrajectoryStats", ] diff --git a/src/wetting_angle_kit/visualization/angle_evolution_plotter.py b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py new file mode 100644 index 0000000..1333472 --- /dev/null +++ b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py @@ -0,0 +1,330 @@ +"""Trajectory-level contact-angle evolution plot. + +Mirrors the visual conventions of the legacy +``SlicingTrajectoryPlotter.plot_angle_evolution`` — a per-batch +contact-angle line with an optional inter-batch ``±σ`` band and a +cumulative running mean overlay — but consumes the new +:class:`TrajectoryResults` / :class:`CoupledBinning2DResults` / +:class:`CoupledBinning3DResults` shapes. + +The plotter implements :class:`BaseTrajectoryPlotter`, so callers can +also fetch a :class:`TrajectoryStats` summary alongside the figure. +""" + +from typing import Any, Literal + +import numpy as np +import plotly.graph_objects as go + +from wetting_angle_kit.analysis.results import ( + CoupledBinning2DBatchResult, + CoupledBinning3DBatchResult, + SlicingBatchResult, + WholeBatchResult, +) +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.""" + if points.size == 0: + return 0.0 + 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 _circular_segment_area(R: float, z_center: float, z_cut: float) -> float: + """Area of the circular segment of radius ``R`` above ``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 _batch_surface_area(batch: Any) -> float: + """Per-batch surface-area dispatch over the four result types.""" + if isinstance(batch, SlicingBatchResult): + if not batch.slice_surfaces: + return 0.0 + return float(np.mean([_shoelace_area(s) for s in batch.slice_surfaces])) + if isinstance(batch, WholeBatchResult): + popt = np.asarray(batch.popt) + if popt.size == 5: # spherical: [xc, yc, zc, R, z_wall] + zc, R = float(popt[2]), float(popt[3]) + elif popt.size == 4: # cylinder: [xc, zc, R, z_wall] + zc, R = float(popt[1]), float(popt[2]) + else: + return float("nan") + return _circular_segment_area(R, zc, float(batch.z_wall)) + if isinstance(batch, (CoupledBinning2DBatchResult, CoupledBinning3DBatchResult)): + params = batch.model_params + return _circular_segment_area( + float(params["R_eq"]), + float(params["zi_c"]), + float(params["zi_0"]), + ) + return float("nan") + + +def _batch_central_angle(batch: Any, stat: Literal["mean", "median"]) -> float: + """Per-batch central tendency. + + For slicing batches with per-slice angles, ``stat`` selects mean or + median over the slices. For all other result shapes the only + available scalar is :attr:`BatchResult.angle`, which is returned + directly. + """ + if isinstance(batch, SlicingBatchResult) and batch.per_slice_angles.size: + if stat == "median": + return float(np.median(batch.per_slice_angles)) + return float(np.mean(batch.per_slice_angles)) + return float(batch.angle) + + +class AngleEvolutionPlotter(BaseTrajectoryPlotter): + """Plot per-batch contact-angle evolution across a trajectory. + + Parameters + ---------- + results + A ``*Results`` object exposing ``.batches`` with ``.angle`` and + ``.frames`` (i.e. :class:`TrajectoryResults`, + :class:`CoupledBinning2DResults`, or + :class:`CoupledBinning3DResults`). For per-frame analyses use + :class:`TemporalAggregator` with ``batch_size=1``; for pooled + batches the evolution is shown per batch with each x-value at + the pooled-frames midpoint. + label : str, default ``"trajectory"`` + Display label used in legend entries and in + :class:`TrajectoryStats`. + timestep : float, default 1.0 + Time between two consecutive frames *in the trajectory file* + (dump interval × MD integration timestep, not the integration + timestep itself). Applied to ``frames`` to produce the x-axis. + time_unit : str, default ``"ps"`` + Unit shown on the x-axis label. + stat : {"median", "mean"}, default ``"median"`` + Per-batch central-tendency aggregation across slices for + slicing results. Ignored for other result shapes (where only a + single :attr:`BatchResult.angle` is defined). + method_name : str, optional + Free-form method tag used in :class:`TrajectoryStats`. + Defaults to the underlying analyzer's class name when + inferable. + """ + + def __init__( + self, + results: Any, + *, + label: str = "trajectory", + timestep: float = 1.0, + time_unit: str = "ps", + stat: Literal["median", "mean"] = "median", + method_name: str | None = None, + ) -> None: + if stat not in ("median", "mean"): + raise ValueError(f"stat must be 'median' or 'mean', got {stat!r}") + self.results = results + self.label = label + self.timestep = timestep + self.time_unit = time_unit + self.stat = stat + if method_name is None: + method_name = type(results).__name__.replace("Results", "") + if not method_name: + method_name = "Analysis" + self.method_name = method_name + + # ------------------------------------------------------------------ + # BaseTrajectoryPlotter interface. + # ------------------------------------------------------------------ + + def summary(self) -> list[TrajectoryStats]: + batches = list(getattr(self.results, "batches", [])) + if not batches: + return [ + TrajectoryStats( + method_name=self.method_name, + label=self.label, + mean_surface_area=float("nan"), + mean_contact_angle=float("nan"), + std_contact_angle=float("nan"), + n_samples=0, + ) + ] + areas = np.array([_batch_surface_area(b) for b in batches]) + return [ + TrajectoryStats( + method_name=self.method_name, + label=self.label, + mean_surface_area=float(np.nanmean(areas)), + mean_contact_angle=float(self.results.mean_angle), + std_contact_angle=float(self.results.std_angle), + n_samples=len(batches), + ) + ] + + # ------------------------------------------------------------------ + # Plot. + # ------------------------------------------------------------------ + + def plot( + self, + *, + per_frame_std: bool = True, + running_mean: bool = True, + title: str | None = None, + save_path: str | None = None, + ) -> go.Figure: + """Build the angle-evolution figure. + + Parameters + ---------- + per_frame_std : bool, default True + If ``True``, draw a transparent ``±σ`` band around the + per-batch curve using the within-batch standard deviation + (per-slice scatter for slicing fits, bootstrap σ for + whole fits, otherwise no band). + running_mean : bool, default True + If ``True``, overlay the cumulative running mean of the + per-batch central tendency as a dashed line, plus a + transparent ``±σ`` band of that cumulative series. + title : str, optional + Figure title. Defaults to a ``"Contact angle evolution + ({stat})"`` string. + save_path : str, optional + If provided, also write the figure to standalone HTML. + + Returns + ------- + plotly.graph_objects.Figure + Figure with the per-batch line (always), the within-batch + band (when ``per_frame_std`` and the batches expose a + finite ``angle_std``), and the running mean line + its + cumulative band (when ``running_mean``). + """ + batches = list(getattr(self.results, "batches", [])) + line_color = "rgb(31, 119, 180)" + band_fill = "rgba(31, 119, 180, 0.2)" + + if not batches: + fig = go.Figure() + fig.update_layout( + title=title or f"Contact angle evolution ({self.stat})", + xaxis_title=f"Time ({self.time_unit})", + yaxis_title="Contact angle (°)", + template="plotly_white", + ) + return fig + + times = np.array([float(np.mean(b.frames)) * self.timestep for b in batches]) + per_batch = np.array([_batch_central_angle(b, self.stat) for b in batches]) + + band_traces: list[go.Scatter] = [] + line_traces: list[go.Scatter] = [] + per_batch_group = self.label + running_group = f"{self.label} running mean" + + if per_frame_std: + std = np.array( + [ + float(b.angle_std) + if getattr(b, "angle_std", None) is not None + and np.isfinite(b.angle_std) + else float("nan") + for b in batches + ] + ) + if np.any(np.isfinite(std)): + std_filled = np.nan_to_num(std, nan=0.0) + band_traces.append( + go.Scatter( + x=np.concatenate([times, times[::-1]]), + y=np.concatenate( + [ + per_batch + std_filled, + (per_batch - std_filled)[::-1], + ] + ), + fill="toself", + fillcolor=band_fill, + line={"width": 0}, + name=f"{self.label} ±σ", + legendgroup=per_batch_group, + showlegend=False, + hoverinfo="skip", + ) + ) + + line_traces.append( + go.Scatter( + x=times, + y=per_batch, + mode="lines", + name=self.label, + line={"width": 2, "color": line_color}, + legendgroup=per_batch_group, + ) + ) + + if running_mean: + counts = np.arange(1, len(per_batch) + 1) + cum_mean = np.cumsum(per_batch) / counts + sq_mean = np.cumsum(per_batch**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_fill, + line={"width": 0}, + name=f"{self.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={"width": 2, "color": line_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=title or f"Contact angle evolution ({self.stat})", + xaxis_title=f"Time ({self.time_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/binning_trajectory_plotter.py b/src/wetting_angle_kit/visualization/binning_trajectory_plotter.py deleted file mode 100644 index 277ffbb..0000000 --- a/src/wetting_angle_kit/visualization/binning_trajectory_plotter.py +++ /dev/null @@ -1,224 +0,0 @@ -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/density_contour_plotter.py b/src/wetting_angle_kit/visualization/density_contour_plotter.py new file mode 100644 index 0000000..ddf9d7f --- /dev/null +++ b/src/wetting_angle_kit/visualization/density_contour_plotter.py @@ -0,0 +1,279 @@ +"""Density-field contour plot with the fitted spherical cap overlay. + +Mirrors the visuals of the legacy +``BinningTrajectoryPlotter.plot_density_contour`` (Jet colormap, +dashed cap arc, dotted wall line, equal x/y aspect) while accepting +the new coupled-binning result shapes: + +- :class:`CoupledBinning2DBatchResult` — single batch, plotted directly. +- :class:`CoupledBinning2DResults` — densities averaged across batches. +- :class:`CoupledBinning3DBatchResult` — 3D density azimuthally + averaged on the ``(xi, yi)`` plane to a 2D ``(r, zi)`` field. +- :class:`CoupledBinning3DResults` — averaged across batches first, + then azimuthally collapsed. +""" + +from typing import Any + +import numpy as np +import plotly.graph_objects as go + +from wetting_angle_kit.analysis.results import ( + CoupledBinning2DBatchResult, + CoupledBinning2DResults, + CoupledBinning3DBatchResult, + CoupledBinning3DResults, +) + + +class DensityContourPlotter: + """Plot a binned density field with the fitted cap and wall overlaid. + + Parameters + ---------- + source + Single batch or full results object as listed in the module + docstring. + label : str, default ``"trajectory"`` + Display label used in the figure title. + colorscale : str, default ``"Jet"`` + Plotly colorscale for the density contour. The legacy plotter + used ``"Jet"``; the default is kept for visual continuity. + """ + + def __init__( + self, + source: Any, + *, + label: str = "trajectory", + colorscale: str = "Jet", + ) -> None: + self.source = source + self.label = label + self.colorscale = colorscale + + # ------------------------------------------------------------------ + # Plot. + # ------------------------------------------------------------------ + + def plot( + self, + *, + title: str | None = None, + save_path: str | None = None, + ) -> go.Figure: + """Build the density contour figure. + + Parameters + ---------- + title : str, optional + Figure title. Defaults to a ``"Density field — {label} + (batch_descriptor)"`` string, where the batch descriptor + names the batch when the source is a single batch and + ``"averaged"`` when it is a full results object. + save_path : str, optional + If provided, write the figure to standalone HTML. + + Returns + ------- + plotly.graph_objects.Figure + Contour + dashed fitted cap + dotted wall line. + """ + ( + xi, + zi, + density, + model_params, + batch_descriptor, + ) = self._extract(self.source) + + dxi = xi[-1] - xi[-2] if len(xi) >= 2 else 0.0 + xi_lo = float(xi[0] - dxi / 2) + xi_hi = float(xi[-1] + dxi / 2) + + fig = go.Figure() + fig.add_trace( + go.Contour( + x=xi, + y=zi, + z=density.T, + colorscale=self.colorscale, + name="Liquid density", + colorbar={ + "title": {"text": "ρ", "font": {"size": 16}}, + "tickfont": {"size": 14}, + "len": 0.75, + "y": 0, + "yanchor": "bottom", + }, + ) + ) + + circle_xi, circle_zi, wall_xi, wall_zi = self._cap_and_wall_traces( + model_params, xi_lo, xi_hi + ) + if circle_xi.size > 0: + fig.add_trace( + go.Scatter( + x=circle_xi, + y=circle_zi, + mode="lines", + name="Fitted droplet", + line={"color": "black", "dash": "dash", "width": 2}, + ) + ) + fig.add_trace( + go.Scatter( + x=wall_xi, + y=wall_zi, + mode="lines", + name="Fitted wall", + line={"color": "black", "dash": "dot", "width": 2}, + ) + ) + + default_title = f"Density field — {self.label}" + if batch_descriptor: + default_title += f" ({batch_descriptor})" + fig.update_layout( + title=title or default_title, + template="plotly_white", + xaxis={ + "title": {"text": "ξ (Å)", "font": {"size": 16}}, + "tickfont": {"size": 14}, + "range": [xi_lo, xi_hi], + "constrain": "domain", + }, + yaxis={ + "title": {"text": "z (Å)", "font": {"size": 16}}, + "tickfont": {"size": 14}, + "scaleanchor": "x", + "scaleratio": 1, + "constrain": "domain", + }, + legend={"x": 1.02, "y": 1.0, "xanchor": "left", "yanchor": "top"}, + ) + if save_path: + fig.write_html(save_path) + return fig + + # ------------------------------------------------------------------ + # Internals — source dispatch. + # ------------------------------------------------------------------ + + def _extract( + self, source: Any + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict, str]: + if isinstance(source, CoupledBinning2DBatchResult): + return ( + source.xi_grid, + source.zi_grid, + source.density, + source.model_params, + "", + ) + if isinstance(source, CoupledBinning2DResults): + if not source.batches: + raise ValueError("CoupledBinning2DResults has no batches to plot.") + ref2d = source.batches[0] + densities = np.stack([b.density for b in source.batches], axis=0) + mean_density = densities.mean(axis=0) + return ( + ref2d.xi_grid, + ref2d.zi_grid, + mean_density, + ref2d.model_params, + f"averaged over {len(source.batches)} batches", + ) + if isinstance(source, CoupledBinning3DBatchResult): + xi, zi, density2d = self._azimuthal_average_3d( + source.xi_grid, + source.yi_grid, + source.zi_grid, + source.density, + ) + return ( + xi, + zi, + density2d, + source.model_params, + "azimuthally averaged", + ) + if isinstance(source, CoupledBinning3DResults): + if not source.batches: + raise ValueError("CoupledBinning3DResults has no batches to plot.") + ref3d: CoupledBinning3DBatchResult = source.batches[0] + densities = np.stack([b.density for b in source.batches], axis=0) + mean_density = densities.mean(axis=0) + xi, zi, density2d = self._azimuthal_average_3d( + ref3d.xi_grid, + ref3d.yi_grid, + ref3d.zi_grid, + mean_density, + ) + return ( + xi, + zi, + density2d, + ref3d.model_params, + f"averaged over {len(source.batches)} batches, azimuthally averaged", + ) + raise TypeError( + f"DensityContourPlotter does not know how to plot {type(source).__name__}." + ) + + # ------------------------------------------------------------------ + # Internals — 3D → 2D azimuthal average. + # ------------------------------------------------------------------ + + @staticmethod + def _azimuthal_average_3d( + xi_cc: np.ndarray, + yi_cc: np.ndarray, + zi_cc: np.ndarray, + density: np.ndarray, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Collapse ``density(xi, yi, zi)`` onto ``density(r, zi)``.""" + XI, YI = np.meshgrid(xi_cc, yi_cc, indexing="ij") + r_flat = np.sqrt(XI**2 + YI**2).ravel() + r_max = float(r_flat.max()) + n_r = min(len(xi_cc), len(yi_cc)) + r_edges = np.linspace(0.0, r_max, n_r + 1) + r_centers = 0.5 * (r_edges[:-1] + r_edges[1:]) + bin_idx = np.clip( + np.searchsorted(r_edges, r_flat, side="right") - 1, 0, n_r - 1 + ) + density2d = np.zeros((n_r, len(zi_cc))) + for k in range(len(zi_cc)): + slice_flat = density[:, :, k].ravel() + sums = np.bincount(bin_idx, weights=slice_flat, minlength=n_r) + counts = np.bincount(bin_idx, minlength=n_r) + with np.errstate(invalid="ignore", divide="ignore"): + density2d[:, k] = np.where(counts > 0, sums / counts, 0.0) + return r_centers, zi_cc, density2d + + # ------------------------------------------------------------------ + # Internals — cap arc + wall line geometry. + # ------------------------------------------------------------------ + + @staticmethod + def _cap_and_wall_traces( + model_params: dict, xi_lo: float, xi_hi: float + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build the fitted spherical-cap arc and the wall-line trace.""" + R_eq = float(model_params["R_eq"]) + zi_c = float(model_params["zi_c"]) + zi_0 = float(model_params["zi_0"]) + discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 + if discriminant < 0: + cap_xi = np.array([]) + cap_zi = np.array([]) + else: + xi_cross = float(np.sqrt(discriminant)) + alpha_inf = np.arctan((zi_0 - zi_c) / xi_cross) + alpha = np.linspace(alpha_inf, np.pi / 2, 200) + cap_xi = R_eq * np.cos(alpha) + cap_zi = zi_c + R_eq * np.sin(alpha) + wall_xi = np.array([xi_lo, xi_hi]) + wall_zi = np.array([zi_0, zi_0]) + return cap_xi, cap_zi, wall_xi, wall_zi diff --git a/src/wetting_angle_kit/visualization/slicing_trajectory_plotter.py b/src/wetting_angle_kit/visualization/slicing_trajectory_plotter.py deleted file mode 100644 index bba6ca4..0000000 --- a/src/wetting_angle_kit/visualization/slicing_trajectory_plotter.py +++ /dev/null @@ -1,215 +0,0 @@ -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/tests/test_analysis/test_binning_method.py b/tests/test_analysis/test_binning_method.py index b7cb3ed..73fb823 100644 --- a/tests/test_analysis/test_binning_method.py +++ b/tests/test_analysis/test_binning_method.py @@ -1,9 +1,7 @@ """Binning-method integration tests on a LAMMPS cylinder-droplet fixture. -Phase 11 migration: the new ``CoupledBinning2DAnalyzer`` is exercised -end-to-end against the same fixture as the legacy -``BinningTrajectoryAnalyzer``. One legacy test is kept as a frozen -regression net (to be removed in Phase 12 alongside the legacy class). +End-to-end ``CoupledBinning2DAnalyzer`` runs on the cylinder droplet +fixture, both single-batch and per-frame batching. """ import pathlib @@ -13,10 +11,7 @@ pytest.importorskip("ovito") -from wetting_angle_kit.analysis import ( # noqa: E402 - BinningTrajectoryAnalyzer, - CoupledBinning2DAnalyzer, -) +from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer # noqa: E402 from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 from wetting_angle_kit.parsers import ( # noqa: E402 LammpsDumpParser, @@ -53,32 +48,6 @@ def binning_params() -> dict: } -# --- Frozen legacy regression -------------------------------------------------- -# To be removed in Phase 12 alongside ``BinningTrajectoryAnalyzer`` itself. -@pytest.mark.integration -def test_legacy_binning_trajectory_analyzer_regression( - filename: pathlib.Path, - oxygen_indices: np.ndarray, - binning_params: dict, -) -> None: - """Frozen-legacy regression on the cylinder-droplet fixture.""" - analyzer = BinningTrajectoryAnalyzer( - parser=LammpsDumpParser(filename), - atom_indices=oxygen_indices, - droplet_geometry="cylinder_y", - binning_params=binning_params, - ) - results = analyzer.analyze([1]) - - assert len(results) == 1 - # Legacy binning on the cylinder fixture, frame 1: 99.110°. - # ±3° band. - assert 96.0 <= results.mean_angle <= 102.0 - # Single batch → std across batches is 0. - assert results.std_angle == 0.0 - - -# --- New-API equivalent -------------------------------------------------------- @pytest.mark.integration def test_coupled_binning_2d_with_cylinder_fixture( filename: pathlib.Path, @@ -96,8 +65,7 @@ def test_coupled_binning_2d_with_cylinder_fixture( assert len(results) == 1 angle = float(results.batches[0].angle) - # New analyzer matches legacy bit-for-bit (Phase 9 parity test) - # so the same 99.110° ±3° band applies. + # Coupled-binning angle on this fixture, frame 1: 99.110°. ±3° band. assert 96.0 <= angle <= 102.0 assert np.isfinite(results.mean_angle) # Single batch → std across batches is 0. @@ -110,7 +78,7 @@ def test_coupled_binning_2d_per_frame_batches( oxygen_indices: np.ndarray, binning_params: dict, ) -> None: - """``batch_size=1`` ↔ legacy ``split_factor=1``: one fit per frame.""" + """``batch_size=1``: one fit per frame.""" frames = [1, 2, 3] analyzer = CoupledBinning2DAnalyzer( parser=LammpsDumpParser(filename), @@ -131,5 +99,5 @@ def test_coupled_binning_2d_per_frame_batches( for batch, expected in zip(results.batches, expected_angles, strict=True): assert len(batch.frames) == 1 # Allow either a converged angle near the expected value, or - # NaN on per-frame fit failure (matches the legacy contract). + # NaN on per-frame fit failure. assert np.isnan(batch.angle) or abs(batch.angle - expected) < 5.0 diff --git a/tests/test_analysis/test_binning_surface_definition.py b/tests/test_analysis/test_binning_surface_definition.py deleted file mode 100644 index 00d335d..0000000 --- a/tests/test_analysis/test_binning_surface_definition.py +++ /dev/null @@ -1,212 +0,0 @@ -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 _fitted_model(params=_REF_PARAMS) -> HyperbolicTangentModel: - model = HyperbolicTangentModel() - 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 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_coupled_binning_2d.py b/tests/test_analysis/test_coupled_binning_2d.py deleted file mode 100644 index 72f366f..0000000 --- a/tests/test_analysis/test_coupled_binning_2d.py +++ /dev/null @@ -1,164 +0,0 @@ -"""Phase 9 quantification: ``CoupledBinning2DAnalyzer`` vs legacy parity. - -The new ``CoupledBinning2DAnalyzer`` is a structural rewrite of the -legacy ``BinningTrajectoryAnalyzer`` on top of the shared -``_BatchedTrajectoryAnalyzer`` scaffolding. Same per-frame projection -(``project_to_profile``), same 2D histogram + dV normalisation, same -:class:`HyperbolicTangentModel` fit. The angle should match the -legacy to floating-point precision on the same fixture. -""" - -import pathlib - -import numpy as np -import pytest - -# Coupled binning fixtures rely on OVITO for the LAMMPS dump parser. -pytest.importorskip("ovito") - -from wetting_angle_kit.analysis import ( # noqa: E402 - BinningTrajectoryAnalyzer, - CoupledBinning2DAnalyzer, -) -from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 -from wetting_angle_kit.parsers import ( # noqa: E402 - LammpsDumpParser, - LammpsDumpWaterFinder, -) - -_FIXTURE = ( - pathlib.Path(__file__).parent - / ".." - / "trajectories" - / "traj_spherical_drop_4k.lammpstrj" -) - - -@pytest.fixture -def oxygen_indices() -> np.ndarray: - return LammpsDumpWaterFinder( - _FIXTURE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) - - -def _binning_params() -> dict: - """Explicit grid (skip the heuristic-warning code path).""" - return { - "xi_0": 0, - "xi_f": 40, - "nbins_xi": 50, - "zi_0": 0.0, - "zi_f": 40.0, - "nbins_zi": 50, - } - - -@pytest.mark.integration -@pytest.mark.slow -def test_coupled_binning_2d_matches_legacy_single_frame( - oxygen_indices: np.ndarray, -) -> None: - """One-frame batch: legacy and new pipelines should produce the same angle.""" - binning_params = _binning_params() - - legacy = BinningTrajectoryAnalyzer( - parser=LammpsDumpParser(_FIXTURE), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - binning_params=binning_params, - ) - legacy_results = legacy.analyze([1]) - legacy_angle = float(legacy_results.batches[0].angle) - - new = CoupledBinning2DAnalyzer( - parser=LammpsDumpParser(_FIXTURE), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - binning_params=binning_params, - temporal_aggregator=TemporalAggregator(batch_size=-1), - ) - new_results = new.analyze([1]) - new_angle = float(new_results.batches[0].angle) - - drift = abs(legacy_angle - new_angle) - print( - f"\nlegacy BinningTrajectoryAnalyzer angle = {legacy_angle:.6f}°" - f"\nnew CoupledBinning2DAnalyzer angle = {new_angle:.6f}°" - f"\n|drift| = {drift:.3e}°" - ) - # Same projection, same histogram, same fit → bit-for-bit parity. - assert drift < 1e-9 - - -@pytest.mark.integration -@pytest.mark.slow -def test_coupled_binning_2d_matches_legacy_multi_frame( - oxygen_indices: np.ndarray, -) -> None: - """Three-frame pooled batch: same parity check across multiple frames.""" - binning_params = _binning_params() - frames = [0, 1, 2] - - legacy = BinningTrajectoryAnalyzer( - parser=LammpsDumpParser(_FIXTURE), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - binning_params=binning_params, - ) - legacy_results = legacy.analyze(frames) - legacy_angle = float(legacy_results.batches[0].angle) - - new = CoupledBinning2DAnalyzer( - parser=LammpsDumpParser(_FIXTURE), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - binning_params=binning_params, - temporal_aggregator=TemporalAggregator(batch_size=-1), - ) - new_results = new.analyze(frames) - new_angle = float(new_results.batches[0].angle) - - drift = abs(legacy_angle - new_angle) - print( - f"\n3-frame legacy angle = {legacy_angle:.6f}°" - f"\n3-frame new angle = {new_angle:.6f}°" - f"\n|drift| = {drift:.3e}°" - ) - assert drift < 1e-9 - - -@pytest.mark.integration -@pytest.mark.slow -def test_coupled_binning_2d_split_batches_match_legacy( - oxygen_indices: np.ndarray, -) -> None: - """Block-pooled batches: same angles as legacy's ``split_factor`` path.""" - binning_params = _binning_params() - frames = [0, 1, 2, 3] - - legacy = BinningTrajectoryAnalyzer( - parser=LammpsDumpParser(_FIXTURE), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - binning_params=binning_params, - ) - legacy_results = legacy.analyze(frames, split_factor=2) - legacy_angles = sorted(float(b.angle) for b in legacy_results.batches) - - new = CoupledBinning2DAnalyzer( - parser=LammpsDumpParser(_FIXTURE), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - binning_params=binning_params, - temporal_aggregator=TemporalAggregator(batch_size=2), - ) - new_results = new.analyze(frames) - new_angles = sorted(float(b.angle) for b in new_results.batches) - - print( - f"\nlegacy split-batch angles = {legacy_angles}" - f"\nnew batch-size=2 angles = {new_angles}" - ) - assert len(legacy_angles) == len(new_angles) == 2 - for la, na in zip(legacy_angles, new_angles, strict=True): - assert abs(la - na) < 1e-9 diff --git a/tests/test_analysis/test_rays_gaussian_extractor.py b/tests/test_analysis/test_rays_gaussian_extractor.py index 585253f..dea8002 100644 --- a/tests/test_analysis/test_rays_gaussian_extractor.py +++ b/tests/test_analysis/test_rays_gaussian_extractor.py @@ -1,166 +1,28 @@ -"""Quantification tests for the new ``rays_gaussian`` extractor. +"""Quantification tests for the ``rays_gaussian`` extractor. -Two flavors: - -- **Parity** with the legacy slicing primitive - (:class:`SurfaceDefinition.analyze_lines`) on slicing-mode cases. - Both pipelines route through the same - :mod:`wetting_angle_kit.analysis._density` helpers, so agreement - should be bit-for-bit (max-abs diff at the 1e-12 level). -- **Fibonacci correctness** for the new whole+spherical case where - there's no legacy comparator. We build a synthetic uniform-volume - sphere of atoms and verify the recovered shell sits at the sphere - radius to within the density-smoothing tolerance. +- **Fibonacci correctness** for the whole+spherical case: build a + synthetic uniform-volume sphere of atoms and verify the recovered + shell sits near the sphere radius and spans both hemispheres. +- **Cylinder ridge smoke test** for the whole+cylinder case. """ import numpy as np import pytest -from wetting_angle_kit.analysis._density import ( - DEFAULT_CUTOFF_SIGMA, - DEFAULT_DENSITY_SIGMA, -) from wetting_angle_kit.analysis.extractors import InterfaceExtractor from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.slicing.surface_definition import ( - SurfaceDefinition, -) - - -def _make_random_droplet( - seed: int = 0, n_atoms: int = 2000, sigma_xy: float = 8.0, sigma_z: float = 6.0 -) -> np.ndarray: - """Random atom cloud that loosely resembles a sessile droplet.""" - rng = np.random.default_rng(seed) - xy = rng.normal(0.0, sigma_xy, size=(n_atoms, 2)) - z = np.abs(rng.normal(0.0, sigma_z, size=n_atoms)) + 1.0 - return np.column_stack([xy, z]) - - -def test_slicing_spherical_matches_legacy_surface_definition() -> None: - """Parity: new extractor (slicing+spherical) vs legacy SurfaceDefinition.""" - atoms = _make_random_droplet(seed=0) - center = np.array([0.0, 0.0, 0.0]) - delta_azimuthal = 30.0 - delta_polar = 8.0 - max_dist = 30.0 - - # Legacy: instantiate one SurfaceDefinition per gamma and call - # analyze_lines() — exactly what the legacy - # SlicingFrameFitter does for spherical droplets. - n_slices = int(180 / delta_azimuthal) - gammas = np.linspace(0.0, 180.0, n_slices) - legacy_xz: list[np.ndarray] = [] - for gamma in gammas: - sd = SurfaceDefinition( - atom_coords=atoms, - delta_angle=delta_polar, - max_dist=max_dist, - center_geom=center, - gamma=float(gamma), - density_sigma=DEFAULT_DENSITY_SIGMA, - cutoff_sigma=DEFAULT_CUTOFF_SIGMA, - ) - _, xz = sd.analyze_lines() - legacy_xz.append(np.asarray(xz, dtype=float)) - - # New: rays_gaussian extractor in slicing+spherical mode. - extractor = InterfaceExtractor.rays_gaussian( - delta_azimuthal=delta_azimuthal, - delta_polar=delta_polar, - density_sigma=DEFAULT_DENSITY_SIGMA, - cutoff_sigma=DEFAULT_CUTOFF_SIGMA, - ) - geom = DropletGeometry.coerce("spherical") - extractor.validate_compatibility(surface_kind="slicing", droplet_geometry=geom) - new_xz = extractor.extract( - liquid_coordinates=atoms, - center_geom=center, - droplet_geometry=geom, - max_dist=max_dist, - surface_kind="slicing", - ) - - assert isinstance(new_xz, list) - assert len(new_xz) == len(legacy_xz) - - max_diff = 0.0 - for legacy, new in zip(legacy_xz, new_xz, strict=True): - assert legacy.shape == new.shape - diff = float(np.max(np.abs(legacy - new))) - max_diff = max(max_diff, diff) - - # Quantification: agreement is at the floating-point round-off - # level (the two paths call the same density / tanh helpers). - print(f"\nslicing+spherical parity: max |diff| = {max_diff:.3e} Å") - assert max_diff < 1e-10 - - -def test_slicing_cylinder_matches_legacy_surface_definition() -> None: - """Parity check for the cylinder slicing case.""" - atoms = _make_random_droplet(seed=1) - center = np.array([0.0, 0.0, 0.0]) - delta_cylinder = 4.0 - delta_polar = 8.0 - max_dist = 30.0 - - # Legacy: gamma stays at 0, y sweeps over delta_cylinder steps. - y_vals = atoms[:, 1] - ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) - legacy_xz: list[np.ndarray] = [] - for y in ys: - slice_center = np.array([center[0], float(y), center[2]]) - sd = SurfaceDefinition( - atom_coords=atoms, - delta_angle=delta_polar, - max_dist=max_dist, - center_geom=slice_center, - gamma=0.0, - density_sigma=DEFAULT_DENSITY_SIGMA, - cutoff_sigma=DEFAULT_CUTOFF_SIGMA, - ) - _, xz = sd.analyze_lines() - legacy_xz.append(np.asarray(xz, dtype=float)) - - extractor = InterfaceExtractor.rays_gaussian( - delta_cylinder=delta_cylinder, - delta_polar=delta_polar, - density_sigma=DEFAULT_DENSITY_SIGMA, - cutoff_sigma=DEFAULT_CUTOFF_SIGMA, - ) - geom = DropletGeometry.coerce("cylinder_y") - extractor.validate_compatibility(surface_kind="slicing", droplet_geometry=geom) - new_xz = extractor.extract( - liquid_coordinates=atoms, - center_geom=center, - droplet_geometry=geom, - max_dist=max_dist, - surface_kind="slicing", - ) - - assert isinstance(new_xz, list) - assert len(new_xz) == len(legacy_xz) - max_diff = 0.0 - for legacy, new in zip(legacy_xz, new_xz, strict=True): - assert legacy.shape == new.shape - diff = float(np.max(np.abs(legacy - new))) - max_diff = max(max_diff, diff) - print(f"slicing+cylinder parity: max |diff| = {max_diff:.3e} Å") - assert max_diff < 1e-10 def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndarray: - """Atoms uniformly distributed inside a sphere of given radius.""" + """Rejection-sample atoms uniformly inside a sphere of the given radius.""" rng = np.random.default_rng(seed) - # Rejection-sample inside a cube; cheap and unbiased. - pts = [] - while len(pts) * 3 < n_atoms * 3: - sample = rng.uniform(-radius, radius, size=(4 * n_atoms, 3)) - inside = np.linalg.norm(sample, axis=1) < radius - pts.append(sample[inside]) - if sum(len(p) for p in pts) >= n_atoms: - break - return np.concatenate(pts, axis=0)[:n_atoms] + pts: list[np.ndarray] = [] + target = n_atoms + while sum(len(p) for p in pts) < target: + chunk = rng.uniform(-radius, radius, size=(target * 3, 3)) + mask = np.linalg.norm(chunk, axis=1) <= radius + pts.append(chunk[mask]) + return np.concatenate(pts, axis=0)[:target] def test_whole_spherical_recovers_known_sphere_radius() -> None: diff --git a/tests/test_analysis/test_slicing_fitter.py b/tests/test_analysis/test_slicing_fitter.py index 72e9f7d..7e7e028 100644 --- a/tests/test_analysis/test_slicing_fitter.py +++ b/tests/test_analysis/test_slicing_fitter.py @@ -1,39 +1,14 @@ -"""Phase 3 quantification: ``SurfaceFitter.slicing()`` correctness + parity. +"""Synthetic-correctness tests for ``SurfaceFitter.slicing()``. -Two flavors: - -- **Synthetic-circle correctness.** Per-slice points lying on a known - circle. The fitter should recover the truth contact angle to - numerical precision and report (near-)zero fit residual. -- **End-to-end parity** against the legacy - :class:`SlicingTrajectoryAnalyzer` on the LAMMPS water-on-graphene - fixture. The two pipelines compute the angle baseline slightly - differently (legacy: per-slice ``min(z)``; new: per-batch - ``min(z) + offset``), so a small drift is expected — quantified - with and without the ``min_plus_offset`` offset. +Per-slice points lying on a known circle. The fitter should recover +the truth contact angle to numerical precision and report (near-)zero +fit residual. """ -import pathlib - import numpy as np -import pytest -# Slicing fixture is a LAMMPS dump parsed through OVITO; skip if the -# optional backend is missing (typically macOS CI without ovito). -pytest.importorskip("ovito") - -from wetting_angle_kit.analysis import ( # noqa: E402 - InterfaceExtractor, - SlicingTrajectoryAnalyzer, - SurfaceFitter, - TrajectoryAnalyzer, - WallDetector, -) -from wetting_angle_kit.analysis.geometry import DropletGeometry # noqa: E402 -from wetting_angle_kit.parsers import ( # noqa: E402 - LammpsDumpParser, - LammpsDumpWaterFinder, -) +from wetting_angle_kit.analysis import SurfaceFitter +from wetting_angle_kit.analysis.geometry import DropletGeometry # --- Synthetic correctness ------------------------------------------------- @@ -127,105 +102,3 @@ def test_slicing_fitter_aggregates_across_slices() -> None: assert out.per_slice_angles.shape == (8,) assert out.slice_popts.shape == (8, 4) assert len(out.slice_surfaces) == 8 - - -# --- End-to-end parity vs legacy SlicingTrajectoryAnalyzer ----------------- - - -_FIXTURE = ( - pathlib.Path(__file__).parent - / ".." - / "trajectories" - / "traj_spherical_drop_4k.lammpstrj" -) - - -@pytest.fixture -def fixture_path() -> pathlib.Path: - return _FIXTURE - - -@pytest.fixture -def oxygen_indices(fixture_path: pathlib.Path) -> np.ndarray: - finder = LammpsDumpWaterFinder(fixture_path, oxygen_type=1, hydrogen_type=2) - return finder.get_water_oxygen_ids(0) - - -def _run_legacy_angle( - fixture_path: pathlib.Path, oxygen_indices: np.ndarray, frame: int -) -> float: - analyzer = SlicingTrajectoryAnalyzer( - parser=LammpsDumpParser(fixture_path), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - delta_gamma=20, - ) - results = analyzer.analyze([frame]) - return float(np.mean(results.angles[0])) - - -def _run_new_angle( - fixture_path: pathlib.Path, - oxygen_indices: np.ndarray, - frame: int, - *, - wall_offset: float, -) -> float: - analyzer = TrajectoryAnalyzer( - parser=LammpsDumpParser(fixture_path), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, - delta_polar=8.0, - ), - surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), - wall_detector=WallDetector.min_plus_offset(offset=wall_offset), - ) - results = analyzer.analyze([frame]) - return float(results.per_batch_angles[0]) - - -@pytest.mark.integration -@pytest.mark.slow -def test_slicing_end_to_end_parity_vs_legacy( - fixture_path: pathlib.Path, oxygen_indices: np.ndarray -) -> None: - """Quantify the drift between legacy and new slicing pipelines. - - Two new-pipeline configurations are reported: - - - ``min_plus_offset(0)``: angle baseline ≈ legacy's per-slice - ``min(z)`` (modulo per-slice → per-batch substitution). - - ``min_plus_offset(2)``: the new default; angle baseline shifted - 2 Å above the lowest interface point. - """ - frame = 1 - legacy_angle = _run_legacy_angle(fixture_path, oxygen_indices, frame) - new_angle_offset0 = _run_new_angle( - fixture_path, oxygen_indices, frame, wall_offset=0.0 - ) - new_angle_offset2 = _run_new_angle( - fixture_path, oxygen_indices, frame, wall_offset=2.0 - ) - - drift_0 = abs(new_angle_offset0 - legacy_angle) - drift_2 = abs(new_angle_offset2 - legacy_angle) - - print( - f"\nLegacy mean angle = {legacy_angle:.3f}°" - f"\nNew (min_plus_offset offset=0) mean angle = " - f"{new_angle_offset0:.3f}° |drift| = {drift_0:.3f}°" - f"\nNew (min_plus_offset offset=2, default) mean = " - f"{new_angle_offset2:.3f}° |drift| = {drift_2:.3f}°" - ) - - # Both new configurations should land near the legacy result. - # offset=0 is the closest analogue (legacy uses per-slice min(z)); - # offset=2 shifts the baseline up by 2 Å (smaller measured angle). - assert drift_0 < 3.0, f"offset=0 drift too large: {drift_0:.3f}°" - assert drift_2 < 10.0, f"offset=2 drift too large: {drift_2:.3f}°" - # Both new angles should still sit in the physically plausible - # 80–110° band the legacy test enforces for this fixture. - assert 70.0 < new_angle_offset0 < 110.0 - assert 70.0 < new_angle_offset2 < 110.0 diff --git a/tests/test_analysis/test_slicing_method.py b/tests/test_analysis/test_slicing_method.py index d234cbe..4955973 100644 --- a/tests/test_analysis/test_slicing_method.py +++ b/tests/test_analysis/test_slicing_method.py @@ -1,10 +1,8 @@ -"""Slicing-method integration tests on the LAMMPS water/graphene fixture. +"""Slicing-method integration test on the LAMMPS water/graphene fixture. -Phase 11 migration: the bulk of the testing here moved to the new -``TrajectoryAnalyzer`` (slicing fitter + rays_gaussian extractor + -min_plus_offset wall detector). One legacy ``SlicingTrajectoryAnalyzer`` -test is kept as a frozen regression net — scheduled for removal in -Phase 12 once :class:`SlicingTrajectoryAnalyzer` itself goes away. +End-to-end ``TrajectoryAnalyzer`` with the slicing fitter + rays_gaussian +extractor + min_plus_offset wall detector on the spherical-droplet +fixture. Anchored against the well-characterised ~95° contact angle. """ import pathlib @@ -19,7 +17,6 @@ from wetting_angle_kit.analysis import ( # noqa: E402 InterfaceExtractor, - SlicingTrajectoryAnalyzer, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -47,34 +44,6 @@ def oxygen_indices(filename: pathlib.Path) -> np.ndarray: ).get_water_oxygen_ids(0) -# --- Frozen legacy regression -------------------------------------------------- -# To be removed in Phase 12 alongside ``SlicingTrajectoryAnalyzer`` itself. -@pytest.mark.integration -@pytest.mark.slow -def test_legacy_slicing_trajectory_analyzer_regression( - filename: pathlib.Path, oxygen_indices: np.ndarray -) -> None: - """Frozen-legacy parity check on the spherical-droplet fixture.""" - analyzer = SlicingTrajectoryAnalyzer( - parser=LammpsDumpParser(filename), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - delta_gamma=20, - ) - results = analyzer.analyze([1]) - - assert len(results) == 1 - assert results.frames == [1] - # Legacy slicing on this fixture: mean angle = 94.873°, per-slice - # std = 1.829°. ±3° band so the test catches real regressions - # while tolerating numerical jitter. - mean_angle = float(np.mean(results.angles[0])) - assert 92.0 <= mean_angle <= 98.0 - slice_std = float(np.std(results.angles[0])) - assert 0.5 < slice_std < 4.0 - - -# --- New-API equivalent -------------------------------------------------------- @pytest.mark.integration @pytest.mark.slow def test_trajectory_analyzer_slicing_with_real_data( @@ -96,8 +65,7 @@ def test_trajectory_analyzer_slicing_with_real_data( assert len(results) == 1 batch = results.batches[0] assert batch.frames == [1] - # New slicing pipeline on this fixture: 95.159° (matches the - # legacy 94.873° within 0.3° — see Phase 3 parity test). + # Slicing pipeline on this fixture: 95.159°. ±3° band. assert 92.0 <= batch.angle <= 98.0 # Across-slice std on this fixture: ~1.9°. assert 0.5 < batch.angle_std < 4.0 diff --git a/tests/test_analysis/test_slicing_surface_definition.py b/tests/test_analysis/test_slicing_surface_definition.py deleted file mode 100644 index f04cadd..0000000 --- a/tests/test_analysis/test_slicing_surface_definition.py +++ /dev/null @@ -1,192 +0,0 @@ -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 deleted file mode 100644 index 35e2b7b..0000000 --- a/tests/test_edge_cases.py +++ /dev/null @@ -1,248 +0,0 @@ -"""Edge-case tests for input validation, NaN guards, and deprecation paths.""" - -import numpy as np -import pytest - -from wetting_angle_kit.analysis.binning.surface_definition import ( - HyperbolicTangentModel, -) -from wetting_angle_kit.analysis.slicing.angle_fitting import ( - SlicingFrameFitter, -) - -# --- Invalid droplet_geometry should be rejected by both analyzers --- - - -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"): - SlicingFrameFitter( - liquid_coordinates=coords, - max_dist=10, - liquid_geom_center=np.zeros(3), - droplet_geometry="not-a-real-geometry", - delta_gamma=20, - ) - - -# --- Slicing predictor: empty result lists stay in lockstep --- - - -def test_predict_contact_angle_returns_aligned_lists(): - """Even if some slices fail, the three returned lists must have the same - 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 = SlicingFrameFitter( - liquid_coordinates=coords, - max_dist=10, - liquid_geom_center=np.zeros(3), - droplet_geometry="spherical", - delta_gamma=45, - ) - angles, surfaces, popts = predictor.predict_contact_angle() - assert len(angles) == len(surfaces) == len(popts) - - -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 = SlicingFrameFitter( - liquid_coordinates=np.zeros((1, 3)), - max_dist=10, - liquid_geom_center=center, - droplet_geometry="spherical", - delta_gamma=45, - ) - predictor.liquid_geom_center[1] = 999.0 - # Caller's array must be untouched. - np.testing.assert_array_equal(center, np.array([1.0, 2.0, 3.0])) - - -# --- Cylindrical mode without delta_cylinder raises --- - - -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), - droplet_geometry="cylinder_y", - ) - - -def test_slicing_spherical_requires_delta_gamma(): - with pytest.raises(ValueError, match="delta_gamma must be provided"): - SlicingFrameFitter( - liquid_coordinates=np.zeros((3, 3)), - max_dist=10, - liquid_geom_center=np.zeros(3), - droplet_geometry="spherical", - ) - - -# --- HyperbolicTangentModel --- - - -def test_hyperbolic_tangent_requires_fit_before_use(): - model = HyperbolicTangentModel() - # params is the initial guess (not None), so evaluate works, but - # computing the contact angle / isoline requires the params to come - # from a real fit. We at least verify the path explicitly: - model.params = None - with pytest.raises(ValueError, match="must be fitted"): - model.compute_contact_angle() - with pytest.raises(ValueError, match="must be fitted"): - model.compute_isoline() - with pytest.raises(ValueError, match="must be fitted"): - model.evaluate((0.0, 0.0)) - - -def test_hyperbolic_tangent_compute_contact_angle_nan_for_unphysical_fit(): - """When the wall sits outside the fitted sphere, the analyzer should - return NaN rather than crash.""" - model = HyperbolicTangentModel() - # rho1, rho2, R_eq, zi_c, zi_0, t1, t2 — wall far below center, R small. - model.params = [1.0, 0.0, 5.0, 10.0, -50.0, 1.0, 1.0] - with pytest.warns(RuntimeWarning, match="wall is outside"): - angle = model.compute_contact_angle() - assert np.isnan(angle) - - -def test_hyperbolic_tangent_compute_isoline_raises_for_unphysical_fit(): - model = HyperbolicTangentModel() - model.params = [1.0, 0.0, 5.0, 10.0, -50.0, 1.0, 1.0] - with pytest.raises(ValueError, match="wall is outside"): - model.compute_isoline() - - -# --- BinningBatchFitter.get_profile_coordinates --- - - -def _make_binning_analyzer(parser): - from wetting_angle_kit.analysis.binning import BinningBatchFitter - - return BinningBatchFitter( - parser=parser, - atom_indices=None, - droplet_geometry="spherical", - binning_params={ - "xi_0": 0.0, - "xi_f": 10.0, - "nbins_xi": 5, - "zi_0": 0.0, - "zi_f": 10.0, - "nbins_zi": 5, - }, - ) - - -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(_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()) - 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(): - """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(_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()) - 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 - # collapses pairs of mirror atoms to the same radius (1, 1, 0) and (2, 2, 0). - np.testing.assert_allclose(r, np.array([1.0, 1.0, 0.0, 2.0, 2.0, 0.0])) - # z is lab-frame, concatenated as-is. - np.testing.assert_array_equal(z, np.array([5.0, 6.0, 7.0, 8.0, 9.0, 10.0])) - - -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 _NoBoxParser(BaseParser): - def parse(self, frame_index, indices=None): - return frame - - def frame_count(self): - return 1 - - def box_size_x(self, frame_index): - raise AssertionError("box_size_x must not be called when precentered=True") - - def box_size_y(self, frame_index): - raise AssertionError("box_size_y must not be called when precentered=True") - - def box_length_max(self, frame_index): - raise AssertionError( - "box_length_max must not be called when precentered=True" - ) - - analyzer = BinningBatchFitter( - parser=_NoBoxParser(), - atom_indices=None, - droplet_geometry="spherical", - binning_params={ - "xi_0": 0.0, - "xi_f": 10.0, - "nbins_xi": 5, - "zi_0": 0.0, - "zi_f": 10.0, - "nbins_zi": 5, - }, - precentered=True, - ) - 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])) diff --git a/tests/test_visualization/test_angle_evolution_plotter.py b/tests/test_visualization/test_angle_evolution_plotter.py new file mode 100644 index 0000000..0d4a9f6 --- /dev/null +++ b/tests/test_visualization/test_angle_evolution_plotter.py @@ -0,0 +1,153 @@ +"""Smoke tests for :class:`AngleEvolutionPlotter`.""" + +import numpy as np +import plotly.graph_objects as go +import pytest + +from wetting_angle_kit.analysis.results import ( + CoupledBinning2DBatchResult, + CoupledBinning2DResults, + SlicingBatchResult, + TrajectoryResults, + WholeBatchResult, +) +from wetting_angle_kit.visualization import AngleEvolutionPlotter +from wetting_angle_kit.visualization.stats import TrajectoryStats + + +def _slicing_results() -> TrajectoryResults: + batches = [ + SlicingBatchResult( + frames=[i], + angle=95.0 + i, + z_wall=5.0, + rms_residual=0.1, + angle_std=1.5, + per_slice_angles=np.array([94.0, 95.0, 96.0]) + i, + slice_surfaces=[np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])], + slice_popts=np.zeros((1, 4)), + ) + for i in range(3) + ] + return TrajectoryResults(batches=batches, method_metadata={}) + + +def _coupled_2d_results() -> CoupledBinning2DResults: + batches = [ + CoupledBinning2DBatchResult( + frames=[i, i + 1], + angle=99.0 - 0.5 * i, + model_params={ + "rho1": 0.03, + "rho2": 1e-4, + "R_eq": 25.0, + "zi_c": 5.0, + "zi_0": 5.0, + "t1": 1.0, + "t2": 1.0, + }, + xi_grid=np.linspace(0, 40, 10), + zi_grid=np.linspace(0, 40, 10), + density=np.zeros((10, 10)), + ) + for i in range(2) + ] + return CoupledBinning2DResults(batches=batches, method_metadata={}) + + +def test_angle_evolution_plotter_slicing_runs_with_all_overlays() -> None: + """Slicing results with per_frame_std + running_mean → bands + lines.""" + plotter = AngleEvolutionPlotter( + _slicing_results(), + label="run-A", + timestep=2.0, + time_unit="ps", + ) + fig = plotter.plot(per_frame_std=True, running_mean=True) + assert isinstance(fig, go.Figure) + # 2 bands (within-batch + running) + 2 lines (per-batch + running mean). + assert len(fig.data) == 4 + # x is times = frames * timestep. + main_line = fig.data[2] + np.testing.assert_allclose(main_line.x, [0.0, 2.0, 4.0]) + + +def test_angle_evolution_plotter_stat_median_recomputes_central() -> None: + """stat='median' picks median of per_slice_angles instead of batch.angle.""" + plotter = AngleEvolutionPlotter( + _slicing_results(), + stat="median", + ) + fig = plotter.plot(per_frame_std=False, running_mean=False) + # Median of [94, 95, 96] = 95 (batch 0), 96 (batch 1), 97 (batch 2). + main_line = fig.data[0] + np.testing.assert_allclose(main_line.y, [95.0, 96.0, 97.0]) + + +def test_angle_evolution_plotter_stat_mean_matches_batch_angle() -> None: + """stat='mean' matches batch.angle on slicing results.""" + plotter = AngleEvolutionPlotter(_slicing_results(), stat="mean") + fig = plotter.plot(per_frame_std=False, running_mean=False) + np.testing.assert_allclose(fig.data[0].y, [95.0, 96.0, 97.0]) + + +def test_angle_evolution_plotter_coupled_results_no_band() -> None: + """Coupled-binning batches have no angle_std → no within-batch band.""" + plotter = AngleEvolutionPlotter(_coupled_2d_results()) + fig = plotter.plot(per_frame_std=True, running_mean=False) + # One main line, no band. + assert len(fig.data) == 1 + np.testing.assert_allclose(fig.data[0].y, [99.0, 98.5]) + + +def test_angle_evolution_plotter_whole_bootstrap_band() -> None: + """``WholeBatchResult.angle_std`` from bootstrap renders the band.""" + batch = WholeBatchResult( + frames=[0], + angle=75.0, + z_wall=5.0, + rms_residual=0.1, + angle_std=0.5, + interface_shell=np.zeros((10, 3)), + popt=np.array([0.0, 0.0, 0.0, 20.0, 5.0]), + ) + results = TrajectoryResults(batches=[batch], method_metadata={}) + fig = AngleEvolutionPlotter(results).plot(per_frame_std=True, running_mean=False) + assert isinstance(fig, go.Figure) + # 1 band + 1 line = 2 traces. + assert len(fig.data) == 2 + + +def test_angle_evolution_plotter_empty_results_returns_empty_figure() -> None: + empty = TrajectoryResults(batches=[], method_metadata={}) + fig = AngleEvolutionPlotter(empty).plot() + assert isinstance(fig, go.Figure) + assert len(fig.data) == 0 + + +def test_angle_evolution_plotter_rejects_invalid_stat() -> None: + with pytest.raises(ValueError, match="stat must be"): + AngleEvolutionPlotter(_slicing_results(), stat="bogus") # type: ignore[arg-type] + + +def test_angle_evolution_plotter_summary_returns_trajectory_stats() -> None: + plotter = AngleEvolutionPlotter( + _slicing_results(), label="run-A", method_name="Slicing" + ) + summary = plotter.summary() + assert isinstance(summary, list) + assert len(summary) == 1 + stats = summary[0] + assert isinstance(stats, TrajectoryStats) + assert stats.label == "run-A" + assert stats.method_name == "Slicing" + assert stats.n_samples == 3 + # 1×1 unit square ⇒ shoelace area = 1.0 per frame; mean across + # 3 frames is also 1.0. + assert stats.mean_surface_area == pytest.approx(1.0) + + +def test_angle_evolution_plotter_time_axis_label() -> None: + plotter = AngleEvolutionPlotter(_slicing_results(), timestep=0.5, time_unit="ns") + fig = plotter.plot() + assert fig.layout.xaxis.title.text == "Time (ns)" diff --git a/tests/test_visualization/test_density_contour_plotter.py b/tests/test_visualization/test_density_contour_plotter.py new file mode 100644 index 0000000..2235a8a --- /dev/null +++ b/tests/test_visualization/test_density_contour_plotter.py @@ -0,0 +1,156 @@ +"""Smoke tests for :class:`DensityContourPlotter`.""" + +import numpy as np +import plotly.graph_objects as go +import pytest + +from wetting_angle_kit.analysis.results import ( + CoupledBinning2DBatchResult, + CoupledBinning2DResults, + CoupledBinning3DBatchResult, + CoupledBinning3DResults, +) +from wetting_angle_kit.visualization import DensityContourPlotter + + +def _model_params_2d() -> dict: + return { + "rho1": 0.03, + "rho2": 1e-4, + "R_eq": 25.0, + "zi_c": 5.0, + "zi_0": 5.0, + "t1": 1.0, + "t2": 1.0, + } + + +def _model_params_3d() -> dict: + return { + "rho1": 0.03, + "rho2": 1e-4, + "R_eq": 25.0, + "xi_c": 0.0, + "yi_c": 0.0, + "zi_c": 5.0, + "zi_0": 5.0, + "t1": 1.0, + "t2": 1.0, + } + + +def _make_2d_batch(seed: int = 0) -> CoupledBinning2DBatchResult: + rng = np.random.default_rng(seed) + xi = np.linspace(0.0, 40.0, 15) + zi = np.linspace(0.0, 40.0, 15) + density = rng.uniform(0.0, 0.03, size=(15, 15)) + return CoupledBinning2DBatchResult( + frames=[0, 1], + angle=95.0, + model_params=_model_params_2d(), + xi_grid=xi, + zi_grid=zi, + density=density, + ) + + +def _make_3d_batch() -> CoupledBinning3DBatchResult: + xi = np.linspace(-30.0, 30.0, 10) + yi = np.linspace(-30.0, 30.0, 10) + zi = np.linspace(0.0, 35.0, 12) + XI, YI, ZI = np.meshgrid(xi, yi, zi, indexing="ij") + r = np.sqrt(XI**2 + YI**2 + (ZI - 5.0) ** 2) + density = ( + 0.5 + * (0.03 + 1e-4 - (0.03 - 1e-4) * np.tanh(2 * (r - 25.0) / 1.0)) + * 0.5 + * (1.0 + np.tanh(2 * (ZI - 5.0) / 1.0)) + ) + return CoupledBinning3DBatchResult( + frames=[0], + angle=90.0, + model_params=_model_params_3d(), + xi_grid=xi, + yi_grid=yi, + zi_grid=zi, + density=density, + ) + + +# ----------------------------- 2D -------------------------------------------- + + +def test_density_contour_plotter_2d_batch_runs() -> None: + fig = DensityContourPlotter(_make_2d_batch(), label="run-A").plot() + assert isinstance(fig, go.Figure) + # Contour + cap + wall ⇒ 3 traces. + assert len(fig.data) == 3 + names = {getattr(t, "name", None) for t in fig.data} + assert {"Liquid density", "Fitted droplet", "Fitted wall"} <= names + # Title carries the label but does NOT include the frame list + # (which can be long for pooled batches). + title_text = fig.layout.title.text + assert "run-A" in title_text + assert "frames" not in title_text + assert "[0, 1]" not in title_text + # No empty trailing parenthesis either. + assert not title_text.rstrip().endswith("()") + + +def test_density_contour_plotter_2d_results_averages_density() -> None: + b1 = _make_2d_batch(seed=0) + b2 = _make_2d_batch(seed=1) + results = CoupledBinning2DResults(batches=[b1, b2], method_metadata={}) + fig = DensityContourPlotter(results).plot() + assert isinstance(fig, go.Figure) + contour_z = np.array(fig.data[0].z) + expected = 0.5 * (b1.density + b2.density) + np.testing.assert_allclose(contour_z, expected.T, atol=1e-12) + assert "averaged over 2 batches" in fig.layout.title.text + + +def test_density_contour_plotter_2d_empty_results_raises() -> None: + results = CoupledBinning2DResults(batches=[], method_metadata={}) + with pytest.raises(ValueError, match="no batches"): + DensityContourPlotter(results).plot() + + +def test_density_contour_plotter_legacy_visuals() -> None: + """Cap is dashed black, wall is dotted black, colorbar shows ρ.""" + fig = DensityContourPlotter(_make_2d_batch()).plot() + contour, cap, wall = fig.data + assert cap.line.dash == "dash" + assert wall.line.dash == "dot" + assert cap.line.color == "black" + assert wall.line.color == "black" + # Colorbar title preserves the legacy ρ glyph. + assert contour.colorbar.title.text == "ρ" + # Equal x/y aspect ratio is preserved. + assert fig.layout.yaxis.scaleanchor == "x" + assert fig.layout.yaxis.scaleratio == 1 + + +# ----------------------------- 3D -------------------------------------------- + + +def test_density_contour_plotter_3d_batch_runs() -> None: + fig = DensityContourPlotter(_make_3d_batch()).plot() + assert isinstance(fig, go.Figure) + assert len(fig.data) == 3 + contour_x = np.array(fig.data[0].x) + assert contour_x.min() >= 0.0 # r ≥ 0 + assert "azimuthally averaged" in fig.layout.title.text + + +def test_density_contour_plotter_3d_results_runs() -> None: + results = CoupledBinning3DResults( + batches=[_make_3d_batch(), _make_3d_batch()], method_metadata={} + ) + fig = DensityContourPlotter(results).plot() + assert isinstance(fig, go.Figure) + assert len(fig.data) == 3 + + +def test_density_contour_plotter_unknown_source_raises() -> None: + with pytest.raises(TypeError, match="does not know how to plot"): + DensityContourPlotter("not a results object").plot() diff --git a/tests/test_visualization/test_trajectory_plotters.py b/tests/test_visualization/test_trajectory_plotters.py deleted file mode 100644 index 03f3ea0..0000000 --- a/tests/test_visualization/test_trajectory_plotters.py +++ /dev/null @@ -1,180 +0,0 @@ -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 From d24ccbfb97f748bc704b131d6db9c5beef32c725 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Wed, 10 Jun 2026 11:15:48 +0200 Subject: [PATCH 26/53] Gamma/beta renaming to azimuthal/polar in source code. Documentation still needs to be updated. --- src/wetting_angle_kit/analysis/extractors.py | 42 ++++++++++---------- tests/test_analysis/test_whole_fitter.py | 12 +++--- 2 files changed, 28 insertions(+), 26 deletions(-) diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py index 3e85ec7..f7631b1 100644 --- a/src/wetting_angle_kit/analysis/extractors.py +++ b/src/wetting_angle_kit/analysis/extractors.py @@ -397,7 +397,7 @@ def _validate_rays_params( def _ray_slice_in_plane( field: DensityFieldProtocol, center: np.ndarray, - gamma: float, + azimuthal: float, max_dist: float, distances: np.ndarray, delta_polar: float, @@ -408,20 +408,22 @@ def _ray_slice_in_plane( parameterised on a generic :class:`DensityFieldProtocol` so both ``rays_gaussian`` and ``rays_binning`` can share the geometry. """ - beta = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) - cos_beta = np.cos(np.deg2rad(beta)) - sin_beta = np.sin(np.deg2rad(beta)) - cos_gamma = np.cos(np.deg2rad(gamma)) - sin_gamma = np.sin(np.deg2rad(gamma)) - directions = np.column_stack((cos_beta * cos_gamma, cos_beta * sin_gamma, sin_beta)) + polar = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) + cos_polar = np.cos(np.deg2rad(polar)) + sin_polar = np.sin(np.deg2rad(polar)) + cos_azimuthal = np.cos(np.deg2rad(azimuthal)) + sin_azimuthal = np.sin(np.deg2rad(azimuthal)) + directions = np.column_stack( + (cos_polar * cos_azimuthal, cos_polar * sin_azimuthal, sin_polar) + ) positions_rm = ( center[None, None, :] + distances[None, :, None] * directions[:, None, :] ) density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(beta), len(distances)) + densities = density_flat.reshape(len(polar), len(distances)) interface_re = fit_tanh_profiles_batched(distances, densities, max_dist=max_dist) - x_proj = cos_beta * interface_re + center[0] - z_proj = sin_beta * interface_re + center[2] + x_proj = cos_polar * interface_re + center[0] + z_proj = sin_polar * interface_re + center[2] return np.column_stack([x_proj, z_proj]) @@ -453,12 +455,12 @@ def _extract_rays( if droplet_geometry.is_spherical: assert delta_azimuthal is not None n_slices = int(180 / delta_azimuthal) - gammas = np.linspace(0.0, 180.0, n_slices) + azimuthals = np.linspace(0.0, 180.0, n_slices) return [ _ray_slice_in_plane( field, center_geom, float(g), max_dist, distances, delta_polar ) - for g in gammas + for g in azimuthals ] # cylinder_*: y-step slice fan assert delta_cylinder is not None @@ -493,10 +495,10 @@ def _extract_rays( assert delta_cylinder is not None y_vals = liquid_coordinates[:, 1] ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) - beta = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) - cos_beta = np.cos(np.deg2rad(beta)) - sin_beta = np.sin(np.deg2rad(beta)) - cyl_directions = np.column_stack([cos_beta, np.zeros_like(beta), sin_beta]) + polar = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) + cos_polar = np.cos(np.deg2rad(polar)) + sin_polar = np.sin(np.deg2rad(polar)) + cyl_directions = np.column_stack([cos_polar, np.zeros_like(polar), sin_polar]) shells: list[np.ndarray] = [] for y in ys: slice_center = np.array([center_geom[0], float(y), center_geom[2]]) @@ -505,15 +507,15 @@ def _extract_rays( + distances[None, :, None] * cyl_directions[:, None, :] ) density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(beta), len(distances)) + densities = density_flat.reshape(len(polar), len(distances)) interface_re = fit_tanh_profiles_batched( distances, densities, max_dist=max_dist ) points = np.column_stack( [ - cos_beta * interface_re + slice_center[0], - np.full(len(beta), float(y)), - sin_beta * interface_re + slice_center[2], + cos_polar * interface_re + slice_center[0], + np.full(len(polar), float(y)), + sin_polar * interface_re + slice_center[2], ] ) shells.append(points) diff --git a/tests/test_analysis/test_whole_fitter.py b/tests/test_analysis/test_whole_fitter.py index 05fbd21..98bf6fb 100644 --- a/tests/test_analysis/test_whole_fitter.py +++ b/tests/test_analysis/test_whole_fitter.py @@ -68,18 +68,18 @@ def test_whole_fitter_exact_cylinder_recovers_angle_to_numerical_precision() -> truth_angle = float(np.degrees(np.arccos((z_wall - zc_truth) / R_truth))) # Half-cylinder along y, radius R_truth, in (x, z) plane. - beta = np.linspace(0, 360, 45, endpoint=False) - cos_beta = np.cos(np.deg2rad(beta)) - sin_beta = np.sin(np.deg2rad(beta)) + polar = np.linspace(0, 360, 45, endpoint=False) + cos_polar = np.cos(np.deg2rad(polar)) + sin_polar = np.sin(np.deg2rad(polar)) y_vals = np.arange(-15.0, 15.0, 3.0) shell_parts: list[np.ndarray] = [] for y in y_vals: shell_parts.append( np.column_stack( [ - R_truth * cos_beta, - np.full_like(beta, y), - R_truth * sin_beta + zc_truth, + R_truth * cos_polar, + np.full_like(polar, y), + R_truth * sin_polar + zc_truth, ] ) ) From 9682474a615763b355143c760fc7acbbae3b99a8 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 11 Jun 2026 09:31:18 +0200 Subject: [PATCH 27/53] Modified the analysis/ directory structure to make it clearer. --- src/wetting_angle_kit/analysis/__init__.py | 8 +- .../analysis/{base.py => _base.py} | 0 .../analysis/coupled_binning/__init__.py | 28 + .../analysis/coupled_binning/_models.py | 344 ++++++ .../analyzer_2d.py} | 152 +-- .../analyzer_3d.py} | 181 +--- src/wetting_angle_kit/analysis/extractors.py | 994 ------------------ .../analysis/extractors/__init__.py | 29 + .../analysis/extractors/_grid.py | 416 ++++++++ .../analysis/extractors/_rays.py | 291 +++++ .../analysis/extractors/_sampling.py | 41 + .../analysis/extractors/base.py | 269 +++++ src/wetting_angle_kit/analysis/fitters.py | 531 ---------- .../analysis/fitters/__init__.py | 35 + .../analysis/fitters/_kasa.py | 107 ++ .../analysis/fitters/_slicing.py | 93 ++ .../analysis/fitters/_whole.py | 134 +++ .../analysis/fitters/base.py | 223 ++++ src/wetting_angle_kit/analysis/trajectory.py | 2 +- .../test_analysis/test_coupled_binning_3d.py | 2 +- tests/test_analysis/test_whole_fitter.py | 4 +- 21 files changed, 2032 insertions(+), 1852 deletions(-) rename src/wetting_angle_kit/analysis/{base.py => _base.py} (100%) create mode 100644 src/wetting_angle_kit/analysis/coupled_binning/__init__.py create mode 100644 src/wetting_angle_kit/analysis/coupled_binning/_models.py rename src/wetting_angle_kit/analysis/{coupled_binning_2d.py => coupled_binning/analyzer_2d.py} (68%) rename src/wetting_angle_kit/analysis/{coupled_binning_3d.py => coupled_binning/analyzer_3d.py} (63%) delete mode 100644 src/wetting_angle_kit/analysis/extractors.py create mode 100644 src/wetting_angle_kit/analysis/extractors/__init__.py create mode 100644 src/wetting_angle_kit/analysis/extractors/_grid.py create mode 100644 src/wetting_angle_kit/analysis/extractors/_rays.py create mode 100644 src/wetting_angle_kit/analysis/extractors/_sampling.py create mode 100644 src/wetting_angle_kit/analysis/extractors/base.py delete mode 100644 src/wetting_angle_kit/analysis/fitters.py create mode 100644 src/wetting_angle_kit/analysis/fitters/__init__.py create mode 100644 src/wetting_angle_kit/analysis/fitters/_kasa.py create mode 100644 src/wetting_angle_kit/analysis/fitters/_slicing.py create mode 100644 src/wetting_angle_kit/analysis/fitters/_whole.py create mode 100644 src/wetting_angle_kit/analysis/fitters/base.py diff --git a/src/wetting_angle_kit/analysis/__init__.py b/src/wetting_angle_kit/analysis/__init__.py index c44bd75..cf6b476 100644 --- a/src/wetting_angle_kit/analysis/__init__.py +++ b/src/wetting_angle_kit/analysis/__init__.py @@ -27,8 +27,12 @@ from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer # Top-level analyzers. -from wetting_angle_kit.analysis.coupled_binning_2d import CoupledBinning2DAnalyzer -from wetting_angle_kit.analysis.coupled_binning_3d import CoupledBinning3DAnalyzer +from wetting_angle_kit.analysis.coupled_binning.analyzer_2d import ( + CoupledBinning2DAnalyzer, +) +from wetting_angle_kit.analysis.coupled_binning.analyzer_3d import ( + CoupledBinning3DAnalyzer, +) # Strategy components. from wetting_angle_kit.analysis.extractors import InterfaceExtractor diff --git a/src/wetting_angle_kit/analysis/base.py b/src/wetting_angle_kit/analysis/_base.py similarity index 100% rename from src/wetting_angle_kit/analysis/base.py rename to src/wetting_angle_kit/analysis/_base.py diff --git a/src/wetting_angle_kit/analysis/coupled_binning/__init__.py b/src/wetting_angle_kit/analysis/coupled_binning/__init__.py new file mode 100644 index 0000000..119d5f7 --- /dev/null +++ b/src/wetting_angle_kit/analysis/coupled_binning/__init__.py @@ -0,0 +1,28 @@ +"""Coupled-binning joint contact-angle analyzers. + +Two top-level analyzers that solve interface extraction, wall +detection, and surface fit jointly via a hyperbolic-tangent density +model: + +- :class:`CoupledBinning2DAnalyzer` — seven-parameter fit on a 2D + ``(xi, zi)`` density grid (radial symmetry assumption). +- :class:`CoupledBinning3DAnalyzer` — nine-parameter fit on a full 3D + ``(xi, yi, zi)`` density grid (no symmetry assumption; spherical + droplets only — cylinder droplets are rejected at construction). + +Use these when you have many frames per batch and want a single robust +estimate; use :class:`TrajectoryAnalyzer` with separable strategies +for per-frame time resolution. +""" + +from wetting_angle_kit.analysis.coupled_binning.analyzer_2d import ( + CoupledBinning2DAnalyzer, +) +from wetting_angle_kit.analysis.coupled_binning.analyzer_3d import ( + CoupledBinning3DAnalyzer, +) + +__all__ = [ + "CoupledBinning2DAnalyzer", + "CoupledBinning3DAnalyzer", +] diff --git a/src/wetting_angle_kit/analysis/coupled_binning/_models.py b/src/wetting_angle_kit/analysis/coupled_binning/_models.py new file mode 100644 index 0000000..8fb8c3c --- /dev/null +++ b/src/wetting_angle_kit/analysis/coupled_binning/_models.py @@ -0,0 +1,344 @@ +"""Hyperbolic-tangent models + heuristic-grid helpers for the coupled-binning analyzers. + +Both the 2D (seven-parameter) and 3D (nine-parameter) joint density +models are kept in this module so the shared bounds / warning / cap-angle +formula sit side by side. Public access goes through +:class:`CoupledBinning2DAnalyzer` and :class:`CoupledBinning3DAnalyzer`. +""" + +import warnings +from typing import Any + +import numpy as np +from scipy.optimize import curve_fit + +# Parameter names for the 2D and 3D models, used for at-bound warnings +# and for the public ``model_params`` dict on the batch result types. +_PARAM_NAMES = ("rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2") +_PARAM_NAMES_3D = ( + "rho1", + "rho2", + "R_eq", + "xi_c", + "yi_c", + "zi_c", + "zi_0", + "t1", + "t2", +) + + +# ---------------------------------------------------------------------- +# 2D model. +# ---------------------------------------------------------------------- + + +class _HyperbolicTangentModel2D: + """Coupled 2D-binning joint contact-angle model. + + Density field modelled as a product of two sigmoidal (tanh) terms, + one radial and one vertical: + + :: + + rho(xi, zi) = g(r) * h(zi - zi_0), + g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], + h(z) = 0.5 * [1 + tanh(2 z / t2)], + r = sqrt(xi^2 + (zi - zi_c)^2). + + Seven free parameters fitted by bounded NLLS. Private (the public + entry point is :class:`CoupledBinning2DAnalyzer`); the 3D + counterpart :class:`_HyperbolicTangentModel3D` lives in the same + module. + """ + + DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 20.0, 4.0, 1.0, 1.0) + + _PARAM_LOWER = np.array([0.0, 0.0, 1e-6, -np.inf, -np.inf, 1e-6, 1e-6]) + _PARAM_UPPER = np.array([np.inf] * 7) + + def __init__(self, initial_params: list[float] | None = None) -> None: + if initial_params is None: + initial_params = list(self.DEFAULT_INITIAL_PARAMS) + self.params: list[float] | np.ndarray | None = initial_params + self.covariance: np.ndarray | None = None + + @staticmethod + def _fitting_function( + x: tuple[np.ndarray, np.ndarray], + rho1: float, + rho2: float, + R_eq: float, + zi_c: float, + zi_0: float, + t1: float, + t2: float, + ) -> np.ndarray: + xi, zi = x[0], x[1] + r = np.sqrt(xi**2 + (zi - zi_c) ** 2) + g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) + h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) + return g_r * h_z + + def fit( + self, + x_data: tuple[np.ndarray, np.ndarray], + density_data: np.ndarray, + ) -> "_HyperbolicTangentModel2D": + self.params, self.covariance = curve_fit( + self._fitting_function, + x_data, + density_data, + p0=self.params, + bounds=(self._PARAM_LOWER, self._PARAM_UPPER), + maxfev=1_000_000, + ) + self._warn_if_at_bounds() + return self + + def _warn_if_at_bounds(self) -> None: + if self.params is None: + return + tol = 1e-6 + at_bound = [] + for name, value, lo, hi in zip( + _PARAM_NAMES, + self.params, + self._PARAM_LOWER, + self._PARAM_UPPER, + strict=False, + ): + if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): + at_bound.append(f"{name}={value:.3g} at lower bound {lo}") + elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): + at_bound.append(f"{name}={value:.3g} at upper bound {hi}") + if at_bound: + warnings.warn( + "Hyperbolic tangent fit converged with parameter(s) at the " + "physical bound, suggesting a poor fit: " + "; ".join(at_bound), + RuntimeWarning, + stacklevel=3, + ) + + def compute_contact_angle(self) -> float: + if self.params is None: + raise ValueError("Model must be fitted before computing contact angle.") + R_eq = float(self.params[2]) + zi_c = float(self.params[3]) + zi_0 = float(self.params[4]) + discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 + if discriminant < 0: + warnings.warn( + "Fitted wall is outside the fitted droplet sphere " + f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|={abs(zi_0 - zi_c):.3f}); " + "contact angle is undefined.", + RuntimeWarning, + stacklevel=2, + ) + return float("nan") + xi_cross = np.sqrt(discriminant) + return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) + + +# ---------------------------------------------------------------------- +# 3D model. +# ---------------------------------------------------------------------- + + +class _HyperbolicTangentModel3D: + """3D extension of the binning method's hyperbolic-tangent model. + + Density factorises into a radial sigmoid centred at ``(xi_c, yi_c, + zi_c)`` and a vertical sigmoid above the wall ``zi_0``: + + :: + + rho(xi, yi, zi) = g(r) * h(zi - zi_0), + g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], + h(z) = 0.5 * [1 + tanh(2 z / t2)], + r = sqrt((xi - xi_c)^2 + (yi - yi_c)^2 + (zi - zi_c)^2). + + Bounds keep densities and lengths in their physical ranges, same as + the 2D model. ``xi_c`` / ``yi_c`` carry the only extra degrees of + freedom over the 2D fit. + """ + + #: Initial guess tuned for room-temperature water; the two + #: horizontal centres default to ``0`` because the analyzer + #: pre-centers the atoms on the droplet COM before binning. + DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 0.0, 0.0, 20.0, 4.0, 1.0, 1.0) + + # Bounds vector order matches DEFAULT_INITIAL_PARAMS. + _PARAM_LOWER = np.array( + [0.0, 0.0, 1e-6, -np.inf, -np.inf, -np.inf, -np.inf, 1e-6, 1e-6] + ) + _PARAM_UPPER = np.array([np.inf] * 9) + + def __init__(self, initial_params: list[float] | None = None) -> None: + if initial_params is None: + initial_params = list(self.DEFAULT_INITIAL_PARAMS) + self.params: list[float] | np.ndarray | None = initial_params + self.covariance: np.ndarray | None = None + + @staticmethod + def _fitting_function( + x: tuple[np.ndarray, np.ndarray, np.ndarray], + rho1: float, + rho2: float, + R_eq: float, + xi_c: float, + yi_c: float, + zi_c: float, + zi_0: float, + t1: float, + t2: float, + ) -> np.ndarray: + xi, yi, zi = x[0], x[1], x[2] + r = np.sqrt((xi - xi_c) ** 2 + (yi - yi_c) ** 2 + (zi - zi_c) ** 2) + g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) + h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) + return g_r * h_z + + def fit( + self, + x_data: tuple[np.ndarray, np.ndarray, np.ndarray], + density_data: np.ndarray, + ) -> "_HyperbolicTangentModel3D": + self.params, self.covariance = curve_fit( + self._fitting_function, + x_data, + density_data, + p0=self.params, + bounds=(self._PARAM_LOWER, self._PARAM_UPPER), + maxfev=1_000_000, + ) + self._warn_if_at_bounds() + return self + + def _warn_if_at_bounds(self) -> None: + if self.params is None: + return + tol = 1e-6 + at_bound = [] + for name, value, lo, hi in zip( + _PARAM_NAMES_3D, + self.params, + self._PARAM_LOWER, + self._PARAM_UPPER, + strict=False, + ): + if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): + at_bound.append(f"{name}={value:.3g} at lower bound {lo}") + elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): + at_bound.append(f"{name}={value:.3g} at upper bound {hi}") + if at_bound: + warnings.warn( + "3D hyperbolic tangent fit converged with parameter(s) at " + "the physical bound, suggesting a poor fit: " + "; ".join(at_bound), + RuntimeWarning, + stacklevel=3, + ) + + def compute_contact_angle(self) -> float: + """Return the contact angle (degrees) implied by the fitted parameters. + + Same geometric formula as the 2D model: the sphere of radius + ``R_eq`` centred at ``(xi_c, yi_c, zi_c)`` intersects the wall + plane ``z = zi_0`` in a circle whose tangent makes the contact + angle with the wall. + """ + if self.params is None: + raise ValueError("Model must be fitted before computing contact angle.") + R_eq = float(self.params[2]) + zi_c = float(self.params[5]) + zi_0 = float(self.params[6]) + discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 + if discriminant < 0: + warnings.warn( + "3D fit wall is outside the fitted droplet sphere " + f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|=" + f"{abs(zi_0 - zi_c):.3f}); contact angle is undefined.", + RuntimeWarning, + stacklevel=2, + ) + return float("nan") + xi_cross = np.sqrt(discriminant) + return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) + + +# ---------------------------------------------------------------------- +# Heuristic binning grids. +# ---------------------------------------------------------------------- + + +def _heuristic_binning_params(parser: Any) -> dict[str, Any]: + """Build the legacy heuristic binning grid: 50×50 cells over a third + of the largest in-plane box dimension. + """ + max_dist = int( + np.max( + np.array( + [ + parser.box_size_y(frame_index=0), + parser.box_size_x(frame_index=0), + ] + ) + ) + / 3 + ) + warnings.warn( + "binning_params was not supplied; using a heuristic default " + f"(xi_0=0, xi_f={max_dist}, zi_0=0, zi_f={max_dist}, 50x50 bins) " + "derived from one third of the largest in-plane box dimension. " + "For accurate density fields, supply system-specific " + "binning_params matching your droplet size and per-frame sampling.", + UserWarning, + stacklevel=3, + ) + return { + "xi_0": 0, + "xi_f": max_dist, + "nbins_xi": 50, + "zi_0": 0.0, + "zi_f": max_dist, + "nbins_zi": 50, + } + + +def _heuristic_binning_params_3d(parser: Any) -> dict[str, Any]: + """Build a heuristic 3D binning grid centred on the droplet COM. + + Same one-third-of-box heuristic as the 2D version but tripled + along all three axes. Emits a warning because the user almost + always wants to override this. + """ + half = int( + np.max( + np.array( + [ + parser.box_size_y(frame_index=0), + parser.box_size_x(frame_index=0), + ] + ) + ) + / 6 + ) + warnings.warn( + "binning_params was not supplied; using a heuristic default " + f"(xi/yi in [-{half}, {half}], zi in [0, {2 * half}], 30^3 bins). " + "For accurate density fields, supply system-specific " + "binning_params matching your droplet size and per-frame sampling.", + UserWarning, + stacklevel=3, + ) + return { + "xi_0": -half, + "xi_f": half, + "nbins_xi": 30, + "yi_0": -half, + "yi_f": half, + "nbins_yi": 30, + "zi_0": 0.0, + "zi_f": 2 * half, + "nbins_zi": 30, + } diff --git a/src/wetting_angle_kit/analysis/coupled_binning_2d.py b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py similarity index 68% rename from src/wetting_angle_kit/analysis/coupled_binning_2d.py rename to src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py index 51ef92d..1cf4dcf 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py @@ -23,16 +23,19 @@ """ import logging -import warnings from typing import Any, ClassVar import numpy as np -from scipy.optimize import curve_fit -from wetting_angle_kit.analysis.base import ( +from wetting_angle_kit.analysis._base import ( _BatchedTrajectoryAnalyzer, build_parser, ) +from wetting_angle_kit.analysis.coupled_binning._models import ( + _PARAM_NAMES, + _heuristic_binning_params, + _HyperbolicTangentModel2D, +) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( CoupledBinning2DBatchResult, @@ -43,149 +46,6 @@ logger = logging.getLogger(__name__) -_PARAM_NAMES = ("rho1", "rho2", "R_eq", "zi_c", "zi_0", "t1", "t2") - - -class _HyperbolicTangentModel2D: - """Coupled 2D-binning joint contact-angle model. - - Density field modelled as a product of two sigmoidal (tanh) terms, - one radial and one vertical: - - :: - - rho(xi, zi) = g(r) * h(zi - zi_0), - g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], - h(z) = 0.5 * [1 + tanh(2 z / t2)], - r = sqrt(xi^2 + (zi - zi_c)^2). - - Seven free parameters fitted by bounded NLLS. Private (the public - entry point is :class:`CoupledBinning2DAnalyzer`); the 3D - counterpart lives in :mod:`coupled_binning_3d` as - ``_HyperbolicTangentModel3D``. - """ - - DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 20.0, 4.0, 1.0, 1.0) - - _PARAM_LOWER = np.array([0.0, 0.0, 1e-6, -np.inf, -np.inf, 1e-6, 1e-6]) - _PARAM_UPPER = np.array([np.inf] * 7) - - def __init__(self, initial_params: list[float] | None = None) -> None: - if initial_params is None: - initial_params = list(self.DEFAULT_INITIAL_PARAMS) - self.params: list[float] | np.ndarray | None = initial_params - self.covariance: np.ndarray | None = None - - @staticmethod - def _fitting_function( - x: tuple[np.ndarray, np.ndarray], - rho1: float, - rho2: float, - R_eq: float, - zi_c: float, - zi_0: float, - t1: float, - t2: float, - ) -> np.ndarray: - xi, zi = x[0], x[1] - r = np.sqrt(xi**2 + (zi - zi_c) ** 2) - g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) - h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) - return g_r * h_z - - def fit( - self, - x_data: tuple[np.ndarray, np.ndarray], - density_data: np.ndarray, - ) -> "_HyperbolicTangentModel2D": - self.params, self.covariance = curve_fit( - self._fitting_function, - x_data, - density_data, - p0=self.params, - bounds=(self._PARAM_LOWER, self._PARAM_UPPER), - maxfev=1_000_000, - ) - self._warn_if_at_bounds() - return self - - def _warn_if_at_bounds(self) -> None: - if self.params is None: - return - tol = 1e-6 - at_bound = [] - for name, value, lo, hi in zip( - _PARAM_NAMES, - self.params, - self._PARAM_LOWER, - self._PARAM_UPPER, - strict=False, - ): - if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): - at_bound.append(f"{name}={value:.3g} at lower bound {lo}") - elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): - at_bound.append(f"{name}={value:.3g} at upper bound {hi}") - if at_bound: - warnings.warn( - "Hyperbolic tangent fit converged with parameter(s) at the " - "physical bound, suggesting a poor fit: " + "; ".join(at_bound), - RuntimeWarning, - stacklevel=3, - ) - - def compute_contact_angle(self) -> float: - if self.params is None: - raise ValueError("Model must be fitted before computing contact angle.") - R_eq = float(self.params[2]) - zi_c = float(self.params[3]) - zi_0 = float(self.params[4]) - discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 - if discriminant < 0: - warnings.warn( - "Fitted wall is outside the fitted droplet sphere " - f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|={abs(zi_0 - zi_c):.3f}); " - "contact angle is undefined.", - RuntimeWarning, - stacklevel=2, - ) - return float("nan") - xi_cross = np.sqrt(discriminant) - return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) - - -def _heuristic_binning_params(parser: Any) -> dict[str, Any]: - """Build the legacy heuristic binning grid: 50×50 cells over a third - of the largest in-plane box dimension. - """ - max_dist = int( - np.max( - np.array( - [ - parser.box_size_y(frame_index=0), - parser.box_size_x(frame_index=0), - ] - ) - ) - / 3 - ) - warnings.warn( - "binning_params was not supplied; using a heuristic default " - f"(xi_0=0, xi_f={max_dist}, zi_0=0, zi_f={max_dist}, 50x50 bins) " - "derived from one third of the largest in-plane box dimension. " - "For accurate density fields, supply system-specific " - "binning_params matching your droplet size and per-frame sampling.", - UserWarning, - stacklevel=3, - ) - return { - "xi_0": 0, - "xi_f": max_dist, - "nbins_xi": 50, - "zi_0": 0.0, - "zi_f": max_dist, - "nbins_zi": 50, - } - class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): """Joint contact-angle fit on a 2D binned density grid. diff --git a/src/wetting_angle_kit/analysis/coupled_binning_3d.py b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py similarity index 63% rename from src/wetting_angle_kit/analysis/coupled_binning_3d.py rename to src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py index dc0840e..e0d9ca2 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning_3d.py +++ b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py @@ -21,16 +21,19 @@ """ import logging -import warnings from typing import Any, ClassVar import numpy as np -from scipy.optimize import curve_fit -from wetting_angle_kit.analysis.base import ( +from wetting_angle_kit.analysis._base import ( _BatchedTrajectoryAnalyzer, build_parser, ) +from wetting_angle_kit.analysis.coupled_binning._models import ( + _PARAM_NAMES_3D, + _heuristic_binning_params_3d, + _HyperbolicTangentModel3D, +) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( CoupledBinning3DBatchResult, @@ -41,178 +44,6 @@ logger = logging.getLogger(__name__) -_PARAM_NAMES_3D = ( - "rho1", - "rho2", - "R_eq", - "xi_c", - "yi_c", - "zi_c", - "zi_0", - "t1", - "t2", -) - - -class _HyperbolicTangentModel3D: - """3D extension of the binning method's hyperbolic-tangent model. - - Density factorises into a radial sigmoid centred at ``(xi_c, yi_c, - zi_c)`` and a vertical sigmoid above the wall ``zi_0``: - - :: - - rho(xi, yi, zi) = g(r) * h(zi - zi_0), - g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], - h(z) = 0.5 * [1 + tanh(2 z / t2)], - r = sqrt((xi - xi_c)^2 + (yi - yi_c)^2 + (zi - zi_c)^2). - - Bounds keep densities and lengths in their physical ranges, same as - the 2D model. ``xi_c`` / ``yi_c`` carry the only extra degrees of - freedom over the 2D fit. - """ - - #: Initial guess tuned for room-temperature water; the two - #: horizontal centres default to ``0`` because the analyzer - #: pre-centers the atoms on the droplet COM before binning. - DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 0.0, 0.0, 20.0, 4.0, 1.0, 1.0) - - # Bounds vector order matches DEFAULT_INITIAL_PARAMS. - _PARAM_LOWER = np.array( - [0.0, 0.0, 1e-6, -np.inf, -np.inf, -np.inf, -np.inf, 1e-6, 1e-6] - ) - _PARAM_UPPER = np.array([np.inf] * 9) - - def __init__(self, initial_params: list[float] | None = None) -> None: - if initial_params is None: - initial_params = list(self.DEFAULT_INITIAL_PARAMS) - self.params: list[float] | np.ndarray | None = initial_params - self.covariance: np.ndarray | None = None - - @staticmethod - def _fitting_function( - x: tuple[np.ndarray, np.ndarray, np.ndarray], - rho1: float, - rho2: float, - R_eq: float, - xi_c: float, - yi_c: float, - zi_c: float, - zi_0: float, - t1: float, - t2: float, - ) -> np.ndarray: - xi, yi, zi = x[0], x[1], x[2] - r = np.sqrt((xi - xi_c) ** 2 + (yi - yi_c) ** 2 + (zi - zi_c) ** 2) - g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) - h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) - return g_r * h_z - - def fit( - self, - x_data: tuple[np.ndarray, np.ndarray, np.ndarray], - density_data: np.ndarray, - ) -> "_HyperbolicTangentModel3D": - self.params, self.covariance = curve_fit( - self._fitting_function, - x_data, - density_data, - p0=self.params, - bounds=(self._PARAM_LOWER, self._PARAM_UPPER), - maxfev=1_000_000, - ) - self._warn_if_at_bounds() - return self - - def _warn_if_at_bounds(self) -> None: - if self.params is None: - return - tol = 1e-6 - at_bound = [] - for name, value, lo, hi in zip( - _PARAM_NAMES_3D, - self.params, - self._PARAM_LOWER, - self._PARAM_UPPER, - strict=False, - ): - if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): - at_bound.append(f"{name}={value:.3g} at lower bound {lo}") - elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): - at_bound.append(f"{name}={value:.3g} at upper bound {hi}") - if at_bound: - warnings.warn( - "3D hyperbolic tangent fit converged with parameter(s) at " - "the physical bound, suggesting a poor fit: " + "; ".join(at_bound), - RuntimeWarning, - stacklevel=3, - ) - - def compute_contact_angle(self) -> float: - """Return the contact angle (degrees) implied by the fitted parameters. - - Same geometric formula as the 2D model: the sphere of radius - ``R_eq`` centred at ``(xi_c, yi_c, zi_c)`` intersects the wall - plane ``z = zi_0`` in a circle whose tangent makes the contact - angle with the wall. - """ - if self.params is None: - raise ValueError("Model must be fitted before computing contact angle.") - R_eq = float(self.params[2]) - zi_c = float(self.params[5]) - zi_0 = float(self.params[6]) - discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 - if discriminant < 0: - warnings.warn( - "3D fit wall is outside the fitted droplet sphere " - f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|=" - f"{abs(zi_0 - zi_c):.3f}); contact angle is undefined.", - RuntimeWarning, - stacklevel=2, - ) - return float("nan") - xi_cross = np.sqrt(discriminant) - return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) - - -def _heuristic_binning_params_3d(parser: Any) -> dict[str, Any]: - """Build a heuristic 3D binning grid centred on the droplet COM. - - Same one-third-of-box heuristic as the 2D version but tripled - along all three axes. Emits a warning because the user almost - always wants to override this. - """ - half = int( - np.max( - np.array( - [ - parser.box_size_y(frame_index=0), - parser.box_size_x(frame_index=0), - ] - ) - ) - / 6 - ) - warnings.warn( - "binning_params was not supplied; using a heuristic default " - f"(xi/yi in [-{half}, {half}], zi in [0, {2 * half}], 30^3 bins). " - "For accurate density fields, supply system-specific " - "binning_params matching your droplet size and per-frame sampling.", - UserWarning, - stacklevel=3, - ) - return { - "xi_0": -half, - "xi_f": half, - "nbins_xi": 30, - "yi_0": -half, - "yi_f": half, - "nbins_yi": 30, - "zi_0": 0.0, - "zi_f": 2 * half, - "nbins_zi": 30, - } - class CoupledBinning3DAnalyzer(_BatchedTrajectoryAnalyzer): """Joint contact-angle fit on a 3D binned density grid. diff --git a/src/wetting_angle_kit/analysis/extractors.py b/src/wetting_angle_kit/analysis/extractors.py deleted file mode 100644 index f7631b1..0000000 --- a/src/wetting_angle_kit/analysis/extractors.py +++ /dev/null @@ -1,994 +0,0 @@ -"""Interface extractors: build the interface point set from raw atoms. - -An :class:`InterfaceExtractor` converts pooled liquid-atom coordinates -into one of two output shapes, determined by the :class:`SurfaceFitter` -the analyzer is paired with: - -- ``surface_kind="slicing"`` → a list of per-slice ``(M_i, 2)`` arrays - in the slice ``(x, z)`` plane; -- ``surface_kind="whole"`` → a single ``(N, 3)`` shell array in the - internal ``(x, y, z)`` frame. - -Extractors are constructed through classmethod factories on the base -class — each factory configures one sampling + density-kernel -combination:: - - InterfaceExtractor.rays_gaussian(...) # ray fan + Gaussian KDE + tanh - InterfaceExtractor.rays_binning(...) # ray fan + histogram bins + tanh - InterfaceExtractor.grid_gaussian(...) # 2D KDE map + isocontour (slicing only) - InterfaceExtractor.grid_binning(...) # 2D histogram map + isocontour - # (slicing only) - -The pairing between the chosen extractor and the analyzer's -:class:`SurfaceFitter` is validated at :class:`TrajectoryAnalyzer` -construction via :meth:`InterfaceExtractor.validate_compatibility`. - -Algorithm bodies are stubbed (``raise NotImplementedError``) at this -skeleton stage; only constructor surfaces and compatibility checks are -implemented here. Density / tanh-fit primitives will be pulled into -``analysis/_density.py`` when porting begins. -""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any, ClassVar, Literal - -import numpy as np - -from wetting_angle_kit.analysis._density import ( - MIN_POINTS_PER_RAY, - DensityFieldProtocol, - GaussianDensityField, - HistogramDensityField, - fit_tanh_profiles_batched, -) -from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.wall import InterfaceData - -#: What the downstream :class:`SurfaceFitter` will consume. -SurfaceKind = Literal["slicing", "whole"] - -#: Sampling strategy used by the extractor. -SamplingKind = Literal["rays", "grid"] - - -class InterfaceExtractor(ABC): - """Abstract base for interface point extractors. - - Concrete extractors are constructed through the classmethod - factories :meth:`rays_gaussian`, :meth:`rays_binning`, - :meth:`grid_gaussian`, :meth:`grid_binning`. Direct subclassing is - supported for custom strategies; the factories cover the built-in - cases. - """ - - #: Sampling strategy this extractor uses. Set by each concrete subclass. - sampling: ClassVar[SamplingKind] - - @abstractmethod - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - max_dist: float, - surface_kind: SurfaceKind, - ) -> InterfaceData: - """Build the interface point set for one batch. - - Parameters - ---------- - liquid_coordinates : ndarray, shape (N, 3) - Pooled liquid-atom coordinates in the internal frame. - center_geom : ndarray, shape (3,) - Geometric droplet center. - droplet_geometry : DropletGeometry - Droplet symmetry; drives the per-slice axis choice for - slicing modes and the ray-fan layout for whole modes. - max_dist : float - Maximum radial distance sampled along each ray (Å). - surface_kind : {"slicing", "whole"} - What the downstream :class:`SurfaceFitter` will consume. - Determines the output shape (per-slice 2D points vs 3D - shell). The analyzer enforces ``surface_kind == fitter.kind`` - via :meth:`validate_compatibility` at construction. - - Returns - ------- - InterfaceData - ``list[ndarray]`` of ``(M_i, 2)`` per-slice points when - ``surface_kind="slicing"``; a single ``(N, 3)`` shell when - ``surface_kind="whole"``. - """ - - @abstractmethod - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - """Raise if this extractor cannot serve ``(surface_kind, geometry)``. - - Called by :class:`TrajectoryAnalyzer.__init__` so misconfigurations - fail fast at construction instead of at the first batch. - """ - - @classmethod - def rays_gaussian( - cls, - *, - delta_azimuthal: float | None = None, - delta_cylinder: float | None = None, - n_rays_sphere: int | None = None, - delta_polar: float = 8.0, - points_per_angstrom: float = 1.0, - density_sigma: float = 3.0, - cutoff_sigma: float = 5.0, - ) -> "InterfaceExtractor": - """Ray fan + Gaussian KDE + per-ray tanh interface fit. - - The interface position along each ray is recovered by smoothing - atom positions with a Gaussian kernel and fitting a hyperbolic - tangent profile to the resulting 1D density. - - Required ray-fan parameters depend on the - ``(surface_kind, droplet_geometry)`` the extractor will be - paired with: - - ========================== ========================================= - surface_kind, geometry required ray params - ========================== ========================================= - slicing, spherical ``delta_azimuthal`` (+ ``delta_polar``) - slicing, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) - whole, spherical ``n_rays_sphere`` - whole, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) - ========================== ========================================= - - Parameters - ---------- - delta_azimuthal : float, optional - Azimuthal step (degrees) between slicing planes for the - spherical slicing mode. - delta_cylinder : float, optional - Step (Å) along the cylinder axis between slices for the - cylinder modes (both slicing and whole). - n_rays_sphere : int, optional - Total number of rays covering the **full sphere** for the - spherical whole-fit mode. Rays are placed via an equal-area - Fibonacci ``(cos θ, φ)`` construction so the angular density - is uniform from south to north pole. Full-sphere (rather - than upper-hemisphere) coverage is intentional: downward - rays from the droplet COM traverse the liquid and hit the - wall plane, producing interface points at the wall — that - keeps :meth:`WallDetector.min_plus_offset` consistent with - the physical wall position. - delta_polar : float, default 8.0 - In-plane ray step (degrees) for every mode that emits rays - in the ``(x, z)`` plane (i.e. everything except - whole + spherical). - points_per_angstrom : float, default 1.0 - Sampling density along each ray (samples per Å). - density_sigma : float, default 3.0 - Gaussian kernel width (Å) for the density smoothing. - Default tuned for full-atomistic water at room temperature. - cutoff_sigma : float, default 5.0 - Per-atom kernel truncation in units of ``density_sigma``. - """ - return _RaysGaussianExtractor( - delta_azimuthal=delta_azimuthal, - delta_cylinder=delta_cylinder, - n_rays_sphere=n_rays_sphere, - delta_polar=delta_polar, - points_per_angstrom=points_per_angstrom, - density_sigma=density_sigma, - cutoff_sigma=cutoff_sigma, - ) - - @classmethod - def rays_binning( - cls, - *, - delta_azimuthal: float | None = None, - delta_cylinder: float | None = None, - n_rays_sphere: int | None = None, - delta_polar: float = 8.0, - bin_width: float = 1.0, - points_per_angstrom: float = 1.0, - ) -> "InterfaceExtractor": - """Ray fan + histogram density + per-ray tanh interface fit. - - Same ray-fan geometry as :meth:`rays_gaussian` (see that method - for the parameter compatibility table) but density along each - ray is estimated via a 1D histogram rather than a Gaussian - kernel. - - Parameters - ---------- - delta_azimuthal, delta_cylinder, n_rays_sphere, delta_polar : - See :meth:`rays_gaussian`. - bin_width : float, default 1.0 - Diameter (Å) of the 3D top-hat kernel used at each sample - position along the ray: atoms within ``bin_width / 2`` of - a sample contribute uniformly to the density, atoms outside - do not. The natural analogue of :meth:`rays_gaussian`'s - ``density_sigma``, but with a hard cutoff instead of a - smooth fall-off. - points_per_angstrom : float, default 1.0 - Sampling density along each ray (samples per Å). - """ - return _RaysBinningExtractor( - delta_azimuthal=delta_azimuthal, - delta_cylinder=delta_cylinder, - n_rays_sphere=n_rays_sphere, - delta_polar=delta_polar, - bin_width=bin_width, - points_per_angstrom=points_per_angstrom, - ) - - @classmethod - def grid_gaussian( - cls, - *, - grid_params: dict[str, Any], - density_sigma: float = 3.0, - cutoff_sigma: float = 5.0, - ) -> "InterfaceExtractor": - """Gaussian-KDE density grid + isocontour interface extraction. - - Supports both slicing and whole fitters: - - - For ``surface_kind="slicing"``, the grid is 2D in the slice - ``(x, z)`` plane and a marching-squares-style isocontour gives - one ``(M, 2)`` interface curve per slice. - - For ``surface_kind="whole"``, the grid is 3D in - ``(x, y, z)`` and the interface shell is recovered by - :func:`skimage.measure.marching_cubes`. This requires the - optional ``grid3d`` extra (``scikit-image``); construction - via :class:`TrajectoryAnalyzer` raises a clear - :class:`ImportError` if it is missing. - - Parameters - ---------- - grid_params : dict - Grid spec. For slicing, six keys: ``"xi_0"``, ``"xi_f"``, - ``"nbins_xi"``, ``"zi_0"``, ``"zi_f"``, ``"nbins_zi"``. - For whole, add three more: ``"yi_0"``, ``"yi_f"``, - ``"nbins_yi"``. - density_sigma : float, default 3.0 - Gaussian kernel width (Å) for the density smoothing. - cutoff_sigma : float, default 5.0 - Per-atom kernel truncation in units of ``density_sigma``. - """ - return _GridGaussianExtractor( - grid_params=dict(grid_params), - density_sigma=density_sigma, - cutoff_sigma=cutoff_sigma, - ) - - @classmethod - def grid_binning( - cls, - *, - grid_params: dict[str, Any], - ) -> "InterfaceExtractor": - """Histogram density grid + isocontour interface extraction. - - Same dimensionality + dependency rules as - :meth:`grid_gaussian`: 2D grid for slicing, 3D grid + marching - cubes (via optional ``scikit-image``) for whole. - - Parameters - ---------- - grid_params : dict - Grid spec; see :meth:`grid_gaussian` for the required keys. - """ - return _GridBinningExtractor(grid_params=dict(grid_params)) - - -_GRID_KEYS_2D = frozenset({"xi_0", "xi_f", "nbins_xi", "zi_0", "zi_f", "nbins_zi"}) -_GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "nbins_yi"} - - -def _validate_grid_params( - *, - name: str, - grid_params: dict[str, Any], - surface_kind: SurfaceKind, -) -> None: - """Shared validation for the two grid-based extractors. - - Checks that ``grid_params`` has the right dimensionality for - ``surface_kind`` and, for whole-kind, that ``scikit-image`` is - importable (it is the marching-cubes backend). - """ - if surface_kind == "slicing": - missing = _GRID_KEYS_2D - grid_params.keys() - if missing: - raise ValueError( - f"{name} for slicing requires a 2D grid_params; missing " - f"keys: {sorted(missing)}." - ) - return - # surface_kind == "whole" - try: - import skimage.measure # noqa: F401 - except ImportError as e: - raise ImportError( - f"{name} for whole-kind extraction requires scikit-image " - "(used for marching_cubes). Install with: " - "pip install 'wetting-angle-kit[grid3d]'." - ) from e - missing = _GRID_KEYS_3D - grid_params.keys() - if missing: - raise ValueError( - f"{name} for whole requires a 3D grid_params; missing keys: " - f"{sorted(missing)}." - ) - - -def _fibonacci_sphere_directions(n: int) -> np.ndarray: - """Equal-area Fibonacci-spiral directions on the full sphere. - - ``cos θ`` is uniformly spaced over ``[-1, 1]`` (so the surface - density is uniform over the whole sphere) and ``φ`` is incremented - by the golden angle for low-discrepancy azimuthal coverage. - ``i = 0`` sits at the south pole (``cos θ = -1``) and - ``i = n - 1`` at the north pole (``cos θ = 1``). - - The full sphere coverage is important for sessile droplets: rays - emitted from the droplet COM in downward directions traverse the - liquid, hit the wall plane, and contribute interface points at the - wall — making :meth:`WallDetector.min_plus_offset` work correctly - in the whole-fit pipeline. (Restricting to the upper hemisphere - misses the wall, so ``min(shell z)`` lands on ``COM_z`` instead.) - - Parameters - ---------- - n : int - Number of directions. - - Returns - ------- - ndarray, shape (n, 3) - Unit direction vectors covering the full sphere. - """ - if n <= 0: - return np.empty((0, 3)) - i = np.arange(n, dtype=np.float64) - cos_theta = 2.0 * i / (n - 1) - 1.0 if n > 1 else np.array([1.0]) - sin_theta = np.sqrt(np.maximum(0.0, 1.0 - cos_theta * cos_theta)) - golden_angle = np.pi * (3.0 - np.sqrt(5.0)) - phi = (i * golden_angle) % (2.0 * np.pi) - return np.column_stack( - [sin_theta * np.cos(phi), sin_theta * np.sin(phi), cos_theta] - ) - - -def _validate_rays_params( - *, - name: str, - delta_azimuthal: float | None, - delta_cylinder: float | None, - n_rays_sphere: int | None, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, -) -> None: - """Shared validation for the two ray-based extractors. - - Both ``rays_gaussian`` and ``rays_binning`` use the same ray-fan - parameter set; only the density estimator differs. - """ - if surface_kind == "slicing": - if droplet_geometry.is_spherical and delta_azimuthal is None: - raise ValueError(f"{name} for slicing+spherical requires delta_azimuthal.") - if droplet_geometry.is_cylinder and delta_cylinder is None: - raise ValueError( - f"{name} for slicing+{droplet_geometry.name} requires delta_cylinder." - ) - elif surface_kind == "whole": - if droplet_geometry.is_spherical and n_rays_sphere is None: - raise ValueError(f"{name} for whole+spherical requires n_rays_sphere.") - if droplet_geometry.is_cylinder and delta_cylinder is None: - raise ValueError( - f"{name} for whole+{droplet_geometry.name} requires delta_cylinder." - ) - - -def _ray_slice_in_plane( - field: DensityFieldProtocol, - center: np.ndarray, - azimuthal: float, - max_dist: float, - distances: np.ndarray, - delta_polar: float, -) -> np.ndarray: - """Per-slice ``(R, 2)`` interface from a tilted ray fan. - - Matches the legacy ``SurfaceDefinition.analyze_lines`` body but - parameterised on a generic :class:`DensityFieldProtocol` so both - ``rays_gaussian`` and ``rays_binning`` can share the geometry. - """ - polar = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) - cos_polar = np.cos(np.deg2rad(polar)) - sin_polar = np.sin(np.deg2rad(polar)) - cos_azimuthal = np.cos(np.deg2rad(azimuthal)) - sin_azimuthal = np.sin(np.deg2rad(azimuthal)) - directions = np.column_stack( - (cos_polar * cos_azimuthal, cos_polar * sin_azimuthal, sin_polar) - ) - positions_rm = ( - center[None, None, :] + distances[None, :, None] * directions[:, None, :] - ) - density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(polar), len(distances)) - interface_re = fit_tanh_profiles_batched(distances, densities, max_dist=max_dist) - x_proj = cos_polar * interface_re + center[0] - z_proj = sin_polar * interface_re + center[2] - return np.column_stack([x_proj, z_proj]) - - -def _extract_rays( - *, - field: DensityFieldProtocol, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - max_dist: float, - surface_kind: SurfaceKind, - points_per_angstrom: float, - delta_azimuthal: float | None, - delta_cylinder: float | None, - n_rays_sphere: int | None, - delta_polar: float, -) -> InterfaceData: - """Dispatch a ray-fan extraction over the four ``(kind, geometry)`` cells. - - Shared by :class:`_RaysGaussianExtractor` and - :class:`_RaysBinningExtractor` — only the density evaluator - differs between them, so the geometry, sampling cadence, and - tanh-fit invocation all live here. - """ - n_samples = max(int(max_dist * points_per_angstrom), MIN_POINTS_PER_RAY) - distances = np.linspace(0.0, max_dist, n_samples) - - if surface_kind == "slicing": - if droplet_geometry.is_spherical: - assert delta_azimuthal is not None - n_slices = int(180 / delta_azimuthal) - azimuthals = np.linspace(0.0, 180.0, n_slices) - return [ - _ray_slice_in_plane( - field, center_geom, float(g), max_dist, distances, delta_polar - ) - for g in azimuthals - ] - # cylinder_*: y-step slice fan - assert delta_cylinder is not None - y_vals = liquid_coordinates[:, 1] - ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) - slices: list[np.ndarray] = [] - for y in ys: - slice_center = np.array([center_geom[0], float(y), center_geom[2]]) - slices.append( - _ray_slice_in_plane( - field, slice_center, 0.0, max_dist, distances, delta_polar - ) - ) - return slices - - # surface_kind == "whole" - if droplet_geometry.is_spherical: - assert n_rays_sphere is not None - directions = _fibonacci_sphere_directions(n_rays_sphere) - positions_rm = ( - center_geom[None, None, :] - + distances[None, :, None] * directions[:, None, :] - ) - density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(directions), len(distances)) - interface_re = fit_tanh_profiles_batched( - distances, densities, max_dist=max_dist - ) - return center_geom[None, :] + interface_re[:, None] * directions - - # whole + cylinder_*: pool a per-y ray fan into a 3D shell. - assert delta_cylinder is not None - y_vals = liquid_coordinates[:, 1] - ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) - polar = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) - cos_polar = np.cos(np.deg2rad(polar)) - sin_polar = np.sin(np.deg2rad(polar)) - cyl_directions = np.column_stack([cos_polar, np.zeros_like(polar), sin_polar]) - shells: list[np.ndarray] = [] - for y in ys: - slice_center = np.array([center_geom[0], float(y), center_geom[2]]) - positions_rm = ( - slice_center[None, None, :] - + distances[None, :, None] * cyl_directions[:, None, :] - ) - density_flat = field.evaluate(positions_rm.reshape(-1, 3)) - densities = density_flat.reshape(len(polar), len(distances)) - interface_re = fit_tanh_profiles_batched( - distances, densities, max_dist=max_dist - ) - points = np.column_stack( - [ - cos_polar * interface_re + slice_center[0], - np.full(len(polar), float(y)), - sin_polar * interface_re + slice_center[2], - ] - ) - shells.append(points) - return np.concatenate(shells, axis=0) if shells else np.empty((0, 3)) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class _RaysGaussianExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.rays_gaussian`.""" - - sampling: ClassVar[SamplingKind] = "rays" - - delta_azimuthal: float | None - delta_cylinder: float | None - n_rays_sphere: int | None - delta_polar: float - points_per_angstrom: float - density_sigma: float - cutoff_sigma: float - - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - _validate_rays_params( - name="rays_gaussian", - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - n_rays_sphere=self.n_rays_sphere, - surface_kind=surface_kind, - droplet_geometry=droplet_geometry, - ) - - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - max_dist: float, - surface_kind: SurfaceKind, - ) -> InterfaceData: - field = GaussianDensityField( - atom_coords=liquid_coordinates, - density_sigma=self.density_sigma, - cutoff_sigma=self.cutoff_sigma, - ) - return _extract_rays( - field=field, - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - max_dist=max_dist, - surface_kind=surface_kind, - points_per_angstrom=self.points_per_angstrom, - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - n_rays_sphere=self.n_rays_sphere, - delta_polar=self.delta_polar, - ) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class _RaysBinningExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.rays_binning`.""" - - sampling: ClassVar[SamplingKind] = "rays" - - delta_azimuthal: float | None - delta_cylinder: float | None - n_rays_sphere: int | None - delta_polar: float - bin_width: float - points_per_angstrom: float - - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - _validate_rays_params( - name="rays_binning", - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - n_rays_sphere=self.n_rays_sphere, - surface_kind=surface_kind, - droplet_geometry=droplet_geometry, - ) - - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - max_dist: float, - surface_kind: SurfaceKind, - ) -> InterfaceData: - field = HistogramDensityField( - atom_coords=liquid_coordinates, - bin_width=self.bin_width, - ) - return _extract_rays( - field=field, - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - max_dist=max_dist, - surface_kind=surface_kind, - points_per_angstrom=self.points_per_angstrom, - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - n_rays_sphere=self.n_rays_sphere, - delta_polar=self.delta_polar, - ) - - -def _project_atoms_to_rz( - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, -) -> tuple[np.ndarray, np.ndarray]: - """Collapse 3D atom coordinates to 2D ``(r, z)`` via droplet symmetry. - - For spherical droplets, ``r = sqrt((x - cx)² + (y - cy)²)``. - For cylinder droplets (axis along ``y`` in the internal frame after - the ``cylinder_x`` axis swap), ``r = |x - cx|``. ``z`` is kept in - the lab frame so the wall position retains physical meaning. - """ - dx = liquid_coordinates[:, 0] - center_geom[0] - if droplet_geometry.is_spherical: - dy = liquid_coordinates[:, 1] - center_geom[1] - r = np.hypot(dx, dy) - else: - r = np.abs(dx) - return r, liquid_coordinates[:, 2] - - -def _build_2d_density_grid( - atoms_r: np.ndarray, - atoms_z: np.ndarray, - grid_params: dict[str, Any], - droplet_geometry: DropletGeometry, - *, - smooth_sigma: float | None, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Build a 2D density grid in ``(r, z)``. - - Volume normalisation: - - - **Spherical:** ``dV = 2π r dxi dzi`` per cell (annular shell). - Required so the recovered isocontour isn't pulled toward - smaller ``r``. - - **Cylinder:** ``dV = dxi dzi`` per cell (constant axial extent). - The cylinder-axis length cancels out for an isocontour at a - fraction of the max, so the simpler normalisation is used. - - Returns ``(r_centers, z_centers, density)`` with - ``density.shape == (n_r_cells, n_z_cells)``. - """ - r_edges = np.linspace( - float(grid_params["xi_0"]), - float(grid_params["xi_f"]), - int(grid_params["nbins_xi"]), - ) - z_edges = np.linspace( - float(grid_params["zi_0"]), - float(grid_params["zi_f"]), - int(grid_params["nbins_zi"]), - ) - counts, _, _ = np.histogram2d(atoms_r, atoms_z, bins=(r_edges, z_edges)) - r_centers = 0.5 * (r_edges[:-1] + r_edges[1:]) - z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) - dr = float(r_edges[1] - r_edges[0]) - dz = float(z_edges[1] - z_edges[0]) - - if droplet_geometry.is_spherical: - # Avoid division by zero at the innermost row (r=0); the contour - # at fraction-of-max never traverses that point anyway. - dV_per_row = 2.0 * np.pi * np.maximum(r_centers, 0.5 * dr) * dr * dz - density = counts / dV_per_row[:, None] - else: - density = counts / (dr * dz) - - if smooth_sigma is not None and smooth_sigma > 0.0: - from scipy.ndimage import gaussian_filter - - sigma_cells = (smooth_sigma / dr, smooth_sigma / dz) - density = gaussian_filter(density, sigma=sigma_cells) - return r_centers, z_centers, density - - -def _extract_isocontour_2d( - r_centers: np.ndarray, - z_centers: np.ndarray, - density: np.ndarray, - *, - fraction_of_bulk: float = 0.5, - bulk_percentile: float = 95.0, -) -> np.ndarray: - """Return the longest density iso-line as ``(M, 2)`` ``(r, z)`` points. - - The contour level is ``fraction_of_bulk * percentile(density, - bulk_percentile)`` — using a high percentile rather than ``max`` - makes the bulk estimate robust to the Poisson spikes that the - ``dV_per_row ∝ 1/r`` normalisation can introduce in small-``r`` - bins. - """ - from skimage.measure import find_contours - - if density.size == 0 or float(density.max()) <= 0: - return np.empty((0, 2)) - bulk = float(np.percentile(density, bulk_percentile)) - if bulk <= 0: - return np.empty((0, 2)) - level = fraction_of_bulk * bulk - # ``no-untyped-call`` fires only when scikit-image is not installed - # in the type-check env; ``unused-ignore`` keeps the comment tolerant - # when it IS installed and the call resolves to a typed function. - contours = find_contours(density, level) # type: ignore[no-untyped-call,unused-ignore] - if not contours: - return np.empty((0, 2)) - longest = max(contours, key=len) - # find_contours returns (row, col) fractional pixel indices, where - # row indexes axis 0 (= r) and col indexes axis 1 (= z). - dr = (float(r_centers[-1]) - float(r_centers[0])) / max(len(r_centers) - 1, 1) - dz = (float(z_centers[-1]) - float(z_centers[0])) / max(len(z_centers) - 1, 1) - r_phys = float(r_centers[0]) + dr * longest[:, 0] - z_phys = float(z_centers[0]) + dz * longest[:, 1] - return np.column_stack([r_phys, z_phys]) - - -def _build_3d_density_grid( - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - grid_params: dict[str, Any], - *, - smooth_sigma: float | None, -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Build a 3D density grid centered laterally on the droplet COM. - - The ``(x, y)`` coordinates are recentered on ``(center_geom[0], - center_geom[1])`` so the user can specify symmetric grid bounds - (e.g. ``xi_0 = -30, xi_f = 30``) regardless of where the droplet - sits in the box after PBC recentering. ``z`` stays in the lab - frame so the wall position retains physical meaning. - - Returns ``(x_centers, y_centers, z_centers, density)`` with - ``density.shape == (n_x_cells, n_y_cells, n_z_cells)``. - """ - x_edges = np.linspace( - float(grid_params["xi_0"]), - float(grid_params["xi_f"]), - int(grid_params["nbins_xi"]), - ) - y_edges = np.linspace( - float(grid_params["yi_0"]), - float(grid_params["yi_f"]), - int(grid_params["nbins_yi"]), - ) - z_edges = np.linspace( - float(grid_params["zi_0"]), - float(grid_params["zi_f"]), - int(grid_params["nbins_zi"]), - ) - - atoms_centered = liquid_coordinates - np.array( - [center_geom[0], center_geom[1], 0.0] - ) - counts, _ = np.histogramdd(atoms_centered, bins=(x_edges, y_edges, z_edges)) - dx = float(x_edges[1] - x_edges[0]) - dy = float(y_edges[1] - y_edges[0]) - dz = float(z_edges[1] - z_edges[0]) - x_centers = 0.5 * (x_edges[:-1] + x_edges[1:]) - y_centers = 0.5 * (y_edges[:-1] + y_edges[1:]) - z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) - density = counts / (dx * dy * dz) - - if smooth_sigma is not None and smooth_sigma > 0.0: - from scipy.ndimage import gaussian_filter - - sigma_cells = ( - smooth_sigma / dx, - smooth_sigma / dy, - smooth_sigma / dz, - ) - density = gaussian_filter(density, sigma=sigma_cells) - return x_centers, y_centers, z_centers, density - - -def _extract_isosurface_3d( - x_centers: np.ndarray, - y_centers: np.ndarray, - z_centers: np.ndarray, - density: np.ndarray, - center_geom: np.ndarray, - *, - fraction_of_bulk: float = 0.5, - bulk_percentile: float = 95.0, -) -> np.ndarray: - """Run marching cubes and return ``(N, 3)`` shell points in the internal frame. - - The shell is shifted back by ``(center_geom[0], center_geom[1], 0)`` - so its coordinates match the convention used by the rays-whole - extractor: absolute internal-frame positions, not droplet-centered. - """ - from skimage.measure import marching_cubes - - if density.size == 0 or float(density.max()) <= 0: - return np.empty((0, 3)) - bulk = float(np.percentile(density, bulk_percentile)) - if bulk <= 0: - return np.empty((0, 3)) - level = fraction_of_bulk * bulk - try: - # ``no-untyped-call`` fires when scikit-image is not installed - # in the type-check env; ``unused-ignore`` keeps the comment - # tolerant when it is. - verts, _faces, _normals, _values = marching_cubes( # type: ignore[no-untyped-call,unused-ignore] - density, level - ) - except (RuntimeError, ValueError): - return np.empty((0, 3)) - - dx = float(x_centers[-1] - x_centers[0]) / max(len(x_centers) - 1, 1) - dy = float(y_centers[-1] - y_centers[0]) / max(len(y_centers) - 1, 1) - dz = float(z_centers[-1] - z_centers[0]) / max(len(z_centers) - 1, 1) - x_phys = float(x_centers[0]) + dx * verts[:, 0] + float(center_geom[0]) - y_phys = float(y_centers[0]) + dy * verts[:, 1] + float(center_geom[1]) - z_phys = float(z_centers[0]) + dz * verts[:, 2] - return np.column_stack([x_phys, y_phys, z_phys]) - - -def _extract_grid_whole( - *, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - grid_params: dict[str, Any], - smooth_sigma: float | None, -) -> np.ndarray: - """Build a 3D density grid + marching-cubes shell for whole-kind grid extractors. - - Currently spherical only — for cylindrical droplets the cylinder - axis would need to span the full simulation box, which the - centered-grid convention doesn't accommodate naturally. Use a - ``rays_*`` extractor for cylinder whole-fits. - """ - if droplet_geometry.is_cylinder: - raise NotImplementedError( - "grid + whole-kind extraction is currently spherical-only. " - "For cylinder droplets use InterfaceExtractor.rays_gaussian " - "or rays_binning with surface_kind='whole'." - ) - x_centers, y_centers, z_centers, density = _build_3d_density_grid( - liquid_coordinates, - center_geom, - grid_params, - smooth_sigma=smooth_sigma, - ) - return _extract_isosurface_3d( - x_centers, y_centers, z_centers, density, center_geom=center_geom - ) - - -def _extract_grid_slicing( - *, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - grid_params: dict[str, Any], - smooth_sigma: float | None, -) -> list[np.ndarray]: - """Build a ``(r, z)`` density map + isocontour for a slicing-mode grid extractor. - - Returns a single-element list since the symmetry-collapsed - ``(r, z)`` reduction makes the slice axis disappear; the downstream - :class:`SlicingFitter` runs one Kasa circle fit on that contour. - """ - r, z = _project_atoms_to_rz(liquid_coordinates, center_geom, droplet_geometry) - r_centers, z_centers, density = _build_2d_density_grid( - r, z, grid_params, droplet_geometry, smooth_sigma=smooth_sigma - ) - contour = _extract_isocontour_2d(r_centers, z_centers, density) - return [contour] - - -# eq=False avoids the auto __eq__ tripping on the dict field; equality -# between extractor instances is not a use case the package needs. -@dataclass(frozen=True, eq=False, kw_only=True) -class _GridGaussianExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.grid_gaussian`.""" - - sampling: ClassVar[SamplingKind] = "grid" - - grid_params: dict[str, Any] - density_sigma: float - cutoff_sigma: float - - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - _validate_grid_params( - name="grid_gaussian", - grid_params=self.grid_params, - surface_kind=surface_kind, - ) - - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - max_dist: float, - surface_kind: SurfaceKind, - ) -> InterfaceData: - if surface_kind == "slicing": - return _extract_grid_slicing( - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=self.density_sigma, - ) - return _extract_grid_whole( - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=self.density_sigma, - ) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class _GridBinningExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.grid_binning`.""" - - sampling: ClassVar[SamplingKind] = "grid" - - grid_params: dict[str, Any] - - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - _validate_grid_params( - name="grid_binning", - grid_params=self.grid_params, - surface_kind=surface_kind, - ) - - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - max_dist: float, - surface_kind: SurfaceKind, - ) -> InterfaceData: - if surface_kind == "slicing": - return _extract_grid_slicing( - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=None, - ) - return _extract_grid_whole( - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=None, - ) diff --git a/src/wetting_angle_kit/analysis/extractors/__init__.py b/src/wetting_angle_kit/analysis/extractors/__init__.py new file mode 100644 index 0000000..e8de8c9 --- /dev/null +++ b/src/wetting_angle_kit/analysis/extractors/__init__.py @@ -0,0 +1,29 @@ +"""Interface extractors: build the interface point set from raw atoms. + +An :class:`InterfaceExtractor` converts pooled liquid-atom coordinates +into one of two output shapes, determined by the :class:`SurfaceFitter` +the analyzer is paired with: + +- ``surface_kind="slicing"`` → a list of per-slice ``(M_i, 2)`` arrays + in the slice ``(x, z)`` plane; +- ``surface_kind="whole"`` → a single ``(N, 3)`` shell array in the + internal ``(x, y, z)`` frame. + +Extractors are constructed through classmethod factories on the base +class — each factory configures one sampling + density-kernel +combination:: + + InterfaceExtractor.rays_gaussian(...) # ray fan + Gaussian KDE + tanh + InterfaceExtractor.rays_binning(...) # ray fan + histogram bins + tanh + InterfaceExtractor.grid_gaussian(...) # 2D KDE map + isocontour (slicing only) + InterfaceExtractor.grid_binning(...) # 2D histogram map + isocontour + # (slicing only) + +The pairing between the chosen extractor and the analyzer's +:class:`SurfaceFitter` is validated at :class:`TrajectoryAnalyzer` +construction via :meth:`InterfaceExtractor.validate_compatibility`. +""" + +from wetting_angle_kit.analysis.extractors.base import InterfaceExtractor + +__all__ = ["InterfaceExtractor"] diff --git a/src/wetting_angle_kit/analysis/extractors/_grid.py b/src/wetting_angle_kit/analysis/extractors/_grid.py new file mode 100644 index 0000000..d0547fd --- /dev/null +++ b/src/wetting_angle_kit/analysis/extractors/_grid.py @@ -0,0 +1,416 @@ +"""Grid-based extractor implementations + shared density/isocontour helpers.""" + +from dataclasses import dataclass +from typing import Any, ClassVar + +import numpy as np + +from wetting_angle_kit.analysis.extractors.base import ( + InterfaceExtractor, + SamplingKind, + SurfaceKind, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.wall import InterfaceData + +_GRID_KEYS_2D = frozenset({"xi_0", "xi_f", "nbins_xi", "zi_0", "zi_f", "nbins_zi"}) +_GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "nbins_yi"} + + +def _validate_grid_params( + *, + name: str, + grid_params: dict[str, Any], + surface_kind: SurfaceKind, +) -> None: + """Shared validation for the two grid-based extractors. + + Checks that ``grid_params`` has the right dimensionality for + ``surface_kind`` and, for whole-kind, that ``scikit-image`` is + importable (it is the marching-cubes backend). + """ + if surface_kind == "slicing": + missing = _GRID_KEYS_2D - grid_params.keys() + if missing: + raise ValueError( + f"{name} for slicing requires a 2D grid_params; missing " + f"keys: {sorted(missing)}." + ) + return + # surface_kind == "whole" + try: + import skimage.measure # noqa: F401 + except ImportError as e: + raise ImportError( + f"{name} for whole-kind extraction requires scikit-image " + "(used for marching_cubes). Install with: " + "pip install 'wetting-angle-kit[grid3d]'." + ) from e + missing = _GRID_KEYS_3D - grid_params.keys() + if missing: + raise ValueError( + f"{name} for whole requires a 3D grid_params; missing keys: " + f"{sorted(missing)}." + ) + + +def _project_atoms_to_rz( + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, +) -> tuple[np.ndarray, np.ndarray]: + """Collapse 3D atom coordinates to 2D ``(r, z)`` via droplet symmetry. + + For spherical droplets, ``r = sqrt((x - cx)² + (y - cy)²)``. + For cylinder droplets (axis along ``y`` in the internal frame after + the ``cylinder_x`` axis swap), ``r = |x - cx|``. ``z`` is kept in + the lab frame so the wall position retains physical meaning. + """ + dx = liquid_coordinates[:, 0] - center_geom[0] + if droplet_geometry.is_spherical: + dy = liquid_coordinates[:, 1] - center_geom[1] + r = np.hypot(dx, dy) + else: + r = np.abs(dx) + return r, liquid_coordinates[:, 2] + + +def _build_2d_density_grid( + atoms_r: np.ndarray, + atoms_z: np.ndarray, + grid_params: dict[str, Any], + droplet_geometry: DropletGeometry, + *, + smooth_sigma: float | None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Build a 2D density grid in ``(r, z)``. + + Volume normalisation: + + - **Spherical:** ``dV = 2π r dxi dzi`` per cell (annular shell). + Required so the recovered isocontour isn't pulled toward + smaller ``r``. + - **Cylinder:** ``dV = dxi dzi`` per cell (constant axial extent). + The cylinder-axis length cancels out for an isocontour at a + fraction of the max, so the simpler normalisation is used. + + Returns ``(r_centers, z_centers, density)`` with + ``density.shape == (n_r_cells, n_z_cells)``. + """ + r_edges = np.linspace( + float(grid_params["xi_0"]), + float(grid_params["xi_f"]), + int(grid_params["nbins_xi"]), + ) + z_edges = np.linspace( + float(grid_params["zi_0"]), + float(grid_params["zi_f"]), + int(grid_params["nbins_zi"]), + ) + counts, _, _ = np.histogram2d(atoms_r, atoms_z, bins=(r_edges, z_edges)) + r_centers = 0.5 * (r_edges[:-1] + r_edges[1:]) + z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) + dr = float(r_edges[1] - r_edges[0]) + dz = float(z_edges[1] - z_edges[0]) + + if droplet_geometry.is_spherical: + # Avoid division by zero at the innermost row (r=0); the contour + # at fraction-of-max never traverses that point anyway. + dV_per_row = 2.0 * np.pi * np.maximum(r_centers, 0.5 * dr) * dr * dz + density = counts / dV_per_row[:, None] + else: + density = counts / (dr * dz) + + if smooth_sigma is not None and smooth_sigma > 0.0: + from scipy.ndimage import gaussian_filter + + sigma_cells = (smooth_sigma / dr, smooth_sigma / dz) + density = gaussian_filter(density, sigma=sigma_cells) + return r_centers, z_centers, density + + +def _extract_isocontour_2d( + r_centers: np.ndarray, + z_centers: np.ndarray, + density: np.ndarray, + *, + fraction_of_bulk: float = 0.5, + bulk_percentile: float = 95.0, +) -> np.ndarray: + """Return the longest density iso-line as ``(M, 2)`` ``(r, z)`` points. + + The contour level is ``fraction_of_bulk * percentile(density, + bulk_percentile)`` — using a high percentile rather than ``max`` + makes the bulk estimate robust to the Poisson spikes that the + ``dV_per_row ∝ 1/r`` normalisation can introduce in small-``r`` + bins. + """ + from skimage.measure import find_contours + + if density.size == 0 or float(density.max()) <= 0: + return np.empty((0, 2)) + bulk = float(np.percentile(density, bulk_percentile)) + if bulk <= 0: + return np.empty((0, 2)) + level = fraction_of_bulk * bulk + # ``no-untyped-call`` fires only when scikit-image is not installed + # in the type-check env; ``unused-ignore`` keeps the comment tolerant + # when it IS installed and the call resolves to a typed function. + contours = find_contours(density, level) # type: ignore[no-untyped-call,unused-ignore] + if not contours: + return np.empty((0, 2)) + longest = max(contours, key=len) + # find_contours returns (row, col) fractional pixel indices, where + # row indexes axis 0 (= r) and col indexes axis 1 (= z). + dr = (float(r_centers[-1]) - float(r_centers[0])) / max(len(r_centers) - 1, 1) + dz = (float(z_centers[-1]) - float(z_centers[0])) / max(len(z_centers) - 1, 1) + r_phys = float(r_centers[0]) + dr * longest[:, 0] + z_phys = float(z_centers[0]) + dz * longest[:, 1] + return np.column_stack([r_phys, z_phys]) + + +def _build_3d_density_grid( + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + grid_params: dict[str, Any], + *, + smooth_sigma: float | None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Build a 3D density grid centered laterally on the droplet COM. + + The ``(x, y)`` coordinates are recentered on ``(center_geom[0], + center_geom[1])`` so the user can specify symmetric grid bounds + (e.g. ``xi_0 = -30, xi_f = 30``) regardless of where the droplet + sits in the box after PBC recentering. ``z`` stays in the lab + frame so the wall position retains physical meaning. + + Returns ``(x_centers, y_centers, z_centers, density)`` with + ``density.shape == (n_x_cells, n_y_cells, n_z_cells)``. + """ + x_edges = np.linspace( + float(grid_params["xi_0"]), + float(grid_params["xi_f"]), + int(grid_params["nbins_xi"]), + ) + y_edges = np.linspace( + float(grid_params["yi_0"]), + float(grid_params["yi_f"]), + int(grid_params["nbins_yi"]), + ) + z_edges = np.linspace( + float(grid_params["zi_0"]), + float(grid_params["zi_f"]), + int(grid_params["nbins_zi"]), + ) + + atoms_centered = liquid_coordinates - np.array( + [center_geom[0], center_geom[1], 0.0] + ) + counts, _ = np.histogramdd(atoms_centered, bins=(x_edges, y_edges, z_edges)) + dx = float(x_edges[1] - x_edges[0]) + dy = float(y_edges[1] - y_edges[0]) + dz = float(z_edges[1] - z_edges[0]) + x_centers = 0.5 * (x_edges[:-1] + x_edges[1:]) + y_centers = 0.5 * (y_edges[:-1] + y_edges[1:]) + z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) + density = counts / (dx * dy * dz) + + if smooth_sigma is not None and smooth_sigma > 0.0: + from scipy.ndimage import gaussian_filter + + sigma_cells = ( + smooth_sigma / dx, + smooth_sigma / dy, + smooth_sigma / dz, + ) + density = gaussian_filter(density, sigma=sigma_cells) + return x_centers, y_centers, z_centers, density + + +def _extract_isosurface_3d( + x_centers: np.ndarray, + y_centers: np.ndarray, + z_centers: np.ndarray, + density: np.ndarray, + center_geom: np.ndarray, + *, + fraction_of_bulk: float = 0.5, + bulk_percentile: float = 95.0, +) -> np.ndarray: + """Run marching cubes and return ``(N, 3)`` shell points in the internal frame. + + The shell is shifted back by ``(center_geom[0], center_geom[1], 0)`` + so its coordinates match the convention used by the rays-whole + extractor: absolute internal-frame positions, not droplet-centered. + """ + from skimage.measure import marching_cubes + + if density.size == 0 or float(density.max()) <= 0: + return np.empty((0, 3)) + bulk = float(np.percentile(density, bulk_percentile)) + if bulk <= 0: + return np.empty((0, 3)) + level = fraction_of_bulk * bulk + try: + # ``no-untyped-call`` fires when scikit-image is not installed + # in the type-check env; ``unused-ignore`` keeps the comment + # tolerant when it is. + verts, _faces, _normals, _values = marching_cubes( # type: ignore[no-untyped-call,unused-ignore] + density, level + ) + except (RuntimeError, ValueError): + return np.empty((0, 3)) + + dx = float(x_centers[-1] - x_centers[0]) / max(len(x_centers) - 1, 1) + dy = float(y_centers[-1] - y_centers[0]) / max(len(y_centers) - 1, 1) + dz = float(z_centers[-1] - z_centers[0]) / max(len(z_centers) - 1, 1) + x_phys = float(x_centers[0]) + dx * verts[:, 0] + float(center_geom[0]) + y_phys = float(y_centers[0]) + dy * verts[:, 1] + float(center_geom[1]) + z_phys = float(z_centers[0]) + dz * verts[:, 2] + return np.column_stack([x_phys, y_phys, z_phys]) + + +def _extract_grid_whole( + *, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + grid_params: dict[str, Any], + smooth_sigma: float | None, +) -> np.ndarray: + """Build a 3D density grid + marching-cubes shell for whole-kind grid extractors. + + Currently spherical only — for cylindrical droplets the cylinder + axis would need to span the full simulation box, which the + centered-grid convention doesn't accommodate naturally. Use a + ``rays_*`` extractor for cylinder whole-fits. + """ + if droplet_geometry.is_cylinder: + raise NotImplementedError( + "grid + whole-kind extraction is currently spherical-only. " + "For cylinder droplets use InterfaceExtractor.rays_gaussian " + "or rays_binning with surface_kind='whole'." + ) + x_centers, y_centers, z_centers, density = _build_3d_density_grid( + liquid_coordinates, + center_geom, + grid_params, + smooth_sigma=smooth_sigma, + ) + return _extract_isosurface_3d( + x_centers, y_centers, z_centers, density, center_geom=center_geom + ) + + +def _extract_grid_slicing( + *, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + grid_params: dict[str, Any], + smooth_sigma: float | None, +) -> list[np.ndarray]: + """Build a ``(r, z)`` density map + isocontour for a slicing-mode grid extractor. + + Returns a single-element list since the symmetry-collapsed + ``(r, z)`` reduction makes the slice axis disappear; the downstream + :class:`SlicingFitter` runs one Kasa circle fit on that contour. + """ + r, z = _project_atoms_to_rz(liquid_coordinates, center_geom, droplet_geometry) + r_centers, z_centers, density = _build_2d_density_grid( + r, z, grid_params, droplet_geometry, smooth_sigma=smooth_sigma + ) + contour = _extract_isocontour_2d(r_centers, z_centers, density) + return [contour] + + +# eq=False avoids the auto __eq__ tripping on the dict field; equality +# between extractor instances is not a use case the package needs. +@dataclass(frozen=True, eq=False, kw_only=True) +class _GridGaussianExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.grid_gaussian`.""" + + sampling: ClassVar[SamplingKind] = "grid" + + grid_params: dict[str, Any] + density_sigma: float + cutoff_sigma: float + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_grid_params( + name="grid_gaussian", + grid_params=self.grid_params, + surface_kind=surface_kind, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + if surface_kind == "slicing": + return _extract_grid_slicing( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=self.density_sigma, + ) + return _extract_grid_whole( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=self.density_sigma, + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _GridBinningExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.grid_binning`.""" + + sampling: ClassVar[SamplingKind] = "grid" + + grid_params: dict[str, Any] + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_grid_params( + name="grid_binning", + grid_params=self.grid_params, + surface_kind=surface_kind, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + if surface_kind == "slicing": + return _extract_grid_slicing( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=None, + ) + return _extract_grid_whole( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + grid_params=self.grid_params, + smooth_sigma=None, + ) diff --git a/src/wetting_angle_kit/analysis/extractors/_rays.py b/src/wetting_angle_kit/analysis/extractors/_rays.py new file mode 100644 index 0000000..93d8e1e --- /dev/null +++ b/src/wetting_angle_kit/analysis/extractors/_rays.py @@ -0,0 +1,291 @@ +"""Ray-based extractor implementations + shared geometry/validation helpers.""" + +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np + +from wetting_angle_kit.analysis._density import ( + MIN_POINTS_PER_RAY, + DensityFieldProtocol, + GaussianDensityField, + HistogramDensityField, + fit_tanh_profiles_batched, +) +from wetting_angle_kit.analysis.extractors._sampling import ( + _fibonacci_sphere_directions, +) +from wetting_angle_kit.analysis.extractors.base import ( + InterfaceExtractor, + SamplingKind, + SurfaceKind, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.wall import InterfaceData + + +def _validate_rays_params( + *, + name: str, + delta_azimuthal: float | None, + delta_cylinder: float | None, + n_rays_sphere: int | None, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, +) -> None: + """Shared validation for the two ray-based extractors. + + Both ``rays_gaussian`` and ``rays_binning`` use the same ray-fan + parameter set; only the density estimator differs. + """ + if surface_kind == "slicing": + if droplet_geometry.is_spherical and delta_azimuthal is None: + raise ValueError(f"{name} for slicing+spherical requires delta_azimuthal.") + if droplet_geometry.is_cylinder and delta_cylinder is None: + raise ValueError( + f"{name} for slicing+{droplet_geometry.name} requires delta_cylinder." + ) + elif surface_kind == "whole": + if droplet_geometry.is_spherical and n_rays_sphere is None: + raise ValueError(f"{name} for whole+spherical requires n_rays_sphere.") + if droplet_geometry.is_cylinder and delta_cylinder is None: + raise ValueError( + f"{name} for whole+{droplet_geometry.name} requires delta_cylinder." + ) + + +def _ray_slice_in_plane( + field: DensityFieldProtocol, + center: np.ndarray, + azimuthal: float, + max_dist: float, + distances: np.ndarray, + delta_polar: float, +) -> np.ndarray: + """Per-slice ``(R, 2)`` interface from a tilted ray fan. + + Parameterised on a generic :class:`DensityFieldProtocol` so both + ``rays_gaussian`` and ``rays_binning`` can share the geometry. + """ + polar = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) + cos_polar = np.cos(np.deg2rad(polar)) + sin_polar = np.sin(np.deg2rad(polar)) + cos_azimuthal = np.cos(np.deg2rad(azimuthal)) + sin_azimuthal = np.sin(np.deg2rad(azimuthal)) + directions = np.column_stack( + (cos_polar * cos_azimuthal, cos_polar * sin_azimuthal, sin_polar) + ) + positions_rm = ( + center[None, None, :] + distances[None, :, None] * directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(polar), len(distances)) + interface_re = fit_tanh_profiles_batched(distances, densities, max_dist=max_dist) + x_proj = cos_polar * interface_re + center[0] + z_proj = sin_polar * interface_re + center[2] + return np.column_stack([x_proj, z_proj]) + + +def _extract_rays( + *, + field: DensityFieldProtocol, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + points_per_angstrom: float, + delta_azimuthal: float | None, + delta_cylinder: float | None, + n_rays_sphere: int | None, + delta_polar: float, +) -> InterfaceData: + """Dispatch a ray-fan extraction over the four ``(kind, geometry)`` cells. + + Shared by :class:`_RaysGaussianExtractor` and + :class:`_RaysBinningExtractor` — only the density evaluator + differs between them, so the geometry, sampling cadence, and + tanh-fit invocation all live here. + """ + n_samples = max(int(max_dist * points_per_angstrom), MIN_POINTS_PER_RAY) + distances = np.linspace(0.0, max_dist, n_samples) + + if surface_kind == "slicing": + if droplet_geometry.is_spherical: + assert delta_azimuthal is not None + n_slices = int(180 / delta_azimuthal) + azimuthals = np.linspace(0.0, 180.0, n_slices) + return [ + _ray_slice_in_plane( + field, center_geom, float(g), max_dist, distances, delta_polar + ) + for g in azimuthals + ] + # cylinder_*: y-step slice fan + assert delta_cylinder is not None + y_vals = liquid_coordinates[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) + slices: list[np.ndarray] = [] + for y in ys: + slice_center = np.array([center_geom[0], float(y), center_geom[2]]) + slices.append( + _ray_slice_in_plane( + field, slice_center, 0.0, max_dist, distances, delta_polar + ) + ) + return slices + + # surface_kind == "whole" + if droplet_geometry.is_spherical: + assert n_rays_sphere is not None + directions = _fibonacci_sphere_directions(n_rays_sphere) + positions_rm = ( + center_geom[None, None, :] + + distances[None, :, None] * directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(directions), len(distances)) + interface_re = fit_tanh_profiles_batched( + distances, densities, max_dist=max_dist + ) + return center_geom[None, :] + interface_re[:, None] * directions + + # whole + cylinder_*: pool a per-y ray fan into a 3D shell. + assert delta_cylinder is not None + y_vals = liquid_coordinates[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) + polar = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) + cos_polar = np.cos(np.deg2rad(polar)) + sin_polar = np.sin(np.deg2rad(polar)) + cyl_directions = np.column_stack([cos_polar, np.zeros_like(polar), sin_polar]) + shells: list[np.ndarray] = [] + for y in ys: + slice_center = np.array([center_geom[0], float(y), center_geom[2]]) + positions_rm = ( + slice_center[None, None, :] + + distances[None, :, None] * cyl_directions[:, None, :] + ) + density_flat = field.evaluate(positions_rm.reshape(-1, 3)) + densities = density_flat.reshape(len(polar), len(distances)) + interface_re = fit_tanh_profiles_batched( + distances, densities, max_dist=max_dist + ) + points = np.column_stack( + [ + cos_polar * interface_re + slice_center[0], + np.full(len(polar), float(y)), + sin_polar * interface_re + slice_center[2], + ] + ) + shells.append(points) + return np.concatenate(shells, axis=0) if shells else np.empty((0, 3)) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _RaysGaussianExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.rays_gaussian`.""" + + sampling: ClassVar[SamplingKind] = "rays" + + delta_azimuthal: float | None + delta_cylinder: float | None + n_rays_sphere: int | None + delta_polar: float + points_per_angstrom: float + density_sigma: float + cutoff_sigma: float + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_rays_params( + name="rays_gaussian", + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + surface_kind=surface_kind, + droplet_geometry=droplet_geometry, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + field = GaussianDensityField( + atom_coords=liquid_coordinates, + density_sigma=self.density_sigma, + cutoff_sigma=self.cutoff_sigma, + ) + return _extract_rays( + field=field, + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + max_dist=max_dist, + surface_kind=surface_kind, + points_per_angstrom=self.points_per_angstrom, + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + delta_polar=self.delta_polar, + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _RaysBinningExtractor(InterfaceExtractor): + """Concrete extractor for :meth:`InterfaceExtractor.rays_binning`.""" + + sampling: ClassVar[SamplingKind] = "rays" + + delta_azimuthal: float | None + delta_cylinder: float | None + n_rays_sphere: int | None + delta_polar: float + bin_width: float + points_per_angstrom: float + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + _validate_rays_params( + name="rays_binning", + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + surface_kind=surface_kind, + droplet_geometry=droplet_geometry, + ) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + field = HistogramDensityField( + atom_coords=liquid_coordinates, + bin_width=self.bin_width, + ) + return _extract_rays( + field=field, + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + max_dist=max_dist, + surface_kind=surface_kind, + points_per_angstrom=self.points_per_angstrom, + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + n_rays_sphere=self.n_rays_sphere, + delta_polar=self.delta_polar, + ) diff --git a/src/wetting_angle_kit/analysis/extractors/_sampling.py b/src/wetting_angle_kit/analysis/extractors/_sampling.py new file mode 100644 index 0000000..88b7e04 --- /dev/null +++ b/src/wetting_angle_kit/analysis/extractors/_sampling.py @@ -0,0 +1,41 @@ +"""Direction-generation helpers used by the ray-based extractors.""" + +import numpy as np + + +def _fibonacci_sphere_directions(n: int) -> np.ndarray: + """Equal-area Fibonacci-spiral directions on the full sphere. + + ``cos θ`` is uniformly spaced over ``[-1, 1]`` (so the surface + density is uniform over the whole sphere) and ``φ`` is incremented + by the golden angle for low-discrepancy azimuthal coverage. + ``i = 0`` sits at the south pole (``cos θ = -1``) and + ``i = n - 1`` at the north pole (``cos θ = 1``). + + The full sphere coverage is important for sessile droplets: rays + emitted from the droplet COM in downward directions traverse the + liquid, hit the wall plane, and contribute interface points at the + wall — making :meth:`WallDetector.min_plus_offset` work correctly + in the whole-fit pipeline. (Restricting to the upper hemisphere + misses the wall, so ``min(shell z)`` lands on ``COM_z`` instead.) + + Parameters + ---------- + n : int + Number of directions. + + Returns + ------- + ndarray, shape (n, 3) + Unit direction vectors covering the full sphere. + """ + if n <= 0: + return np.empty((0, 3)) + i = np.arange(n, dtype=np.float64) + cos_theta = 2.0 * i / (n - 1) - 1.0 if n > 1 else np.array([1.0]) + sin_theta = np.sqrt(np.maximum(0.0, 1.0 - cos_theta * cos_theta)) + golden_angle = np.pi * (3.0 - np.sqrt(5.0)) + phi = (i * golden_angle) % (2.0 * np.pi) + return np.column_stack( + [sin_theta * np.cos(phi), sin_theta * np.sin(phi), cos_theta] + ) diff --git a/src/wetting_angle_kit/analysis/extractors/base.py b/src/wetting_angle_kit/analysis/extractors/base.py new file mode 100644 index 0000000..d6bc94c --- /dev/null +++ b/src/wetting_angle_kit/analysis/extractors/base.py @@ -0,0 +1,269 @@ +"""``InterfaceExtractor`` ABC and the four classmethod factories. + +The factories use deferred imports of the concrete extractor classes +to avoid a circular dependency with the sibling ``_rays`` / ``_grid`` +modules (which inherit from :class:`InterfaceExtractor`). +""" + +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Literal + +import numpy as np + +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.wall import InterfaceData + +#: What the downstream :class:`SurfaceFitter` will consume. +SurfaceKind = Literal["slicing", "whole"] + +#: Sampling strategy used by the extractor. +SamplingKind = Literal["rays", "grid"] + + +class InterfaceExtractor(ABC): + """Abstract base for interface point extractors. + + Concrete extractors are constructed through the classmethod + factories :meth:`rays_gaussian`, :meth:`rays_binning`, + :meth:`grid_gaussian`, :meth:`grid_binning`. Direct subclassing is + supported for custom strategies; the factories cover the built-in + cases. + """ + + #: Sampling strategy this extractor uses. Set by each concrete subclass. + sampling: ClassVar[SamplingKind] + + @abstractmethod + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + max_dist: float, + surface_kind: SurfaceKind, + ) -> InterfaceData: + """Build the interface point set for one batch. + + Parameters + ---------- + liquid_coordinates : ndarray, shape (N, 3) + Pooled liquid-atom coordinates in the internal frame. + center_geom : ndarray, shape (3,) + Geometric droplet center. + droplet_geometry : DropletGeometry + Droplet symmetry; drives the per-slice axis choice for + slicing modes and the ray-fan layout for whole modes. + max_dist : float + Maximum radial distance sampled along each ray (Å). + surface_kind : {"slicing", "whole"} + What the downstream :class:`SurfaceFitter` will consume. + Determines the output shape (per-slice 2D points vs 3D + shell). The analyzer enforces ``surface_kind == fitter.kind`` + via :meth:`validate_compatibility` at construction. + + Returns + ------- + InterfaceData + ``list[ndarray]`` of ``(M_i, 2)`` per-slice points when + ``surface_kind="slicing"``; a single ``(N, 3)`` shell when + ``surface_kind="whole"``. + """ + + @abstractmethod + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + """Raise if this extractor cannot serve ``(surface_kind, geometry)``. + + Called by :class:`TrajectoryAnalyzer.__init__` so misconfigurations + fail fast at construction instead of at the first batch. + """ + + @classmethod + def rays_gaussian( + cls, + *, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, + n_rays_sphere: int | None = None, + delta_polar: float = 8.0, + points_per_angstrom: float = 1.0, + density_sigma: float = 3.0, + cutoff_sigma: float = 5.0, + ) -> "InterfaceExtractor": + """Ray fan + Gaussian KDE + per-ray tanh interface fit. + + The interface position along each ray is recovered by smoothing + atom positions with a Gaussian kernel and fitting a hyperbolic + tangent profile to the resulting 1D density. + + Required ray-fan parameters depend on the + ``(surface_kind, droplet_geometry)`` the extractor will be + paired with: + + ========================== ========================================= + surface_kind, geometry required ray params + ========================== ========================================= + slicing, spherical ``delta_azimuthal`` (+ ``delta_polar``) + slicing, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) + whole, spherical ``n_rays_sphere`` + whole, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) + ========================== ========================================= + + Parameters + ---------- + delta_azimuthal : float, optional + Azimuthal step (degrees) between slicing planes for the + spherical slicing mode. + delta_cylinder : float, optional + Step (Å) along the cylinder axis between slices for the + cylinder modes (both slicing and whole). + n_rays_sphere : int, optional + Total number of rays covering the **full sphere** for the + spherical whole-fit mode. Rays are placed via an equal-area + Fibonacci ``(cos θ, φ)`` construction so the angular density + is uniform from south to north pole. Full-sphere (rather + than upper-hemisphere) coverage is intentional: downward + rays from the droplet COM traverse the liquid and hit the + wall plane, producing interface points at the wall — that + keeps :meth:`WallDetector.min_plus_offset` consistent with + the physical wall position. + delta_polar : float, default 8.0 + In-plane ray step (degrees) for every mode that emits rays + in the ``(x, z)`` plane (i.e. everything except + whole + spherical). + points_per_angstrom : float, default 1.0 + Sampling density along each ray (samples per Å). + density_sigma : float, default 3.0 + Gaussian kernel width (Å) for the density smoothing. + Default tuned for full-atomistic water at room temperature. + cutoff_sigma : float, default 5.0 + Per-atom kernel truncation in units of ``density_sigma``. + """ + from wetting_angle_kit.analysis.extractors._rays import ( + _RaysGaussianExtractor, + ) + + return _RaysGaussianExtractor( + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, + n_rays_sphere=n_rays_sphere, + delta_polar=delta_polar, + points_per_angstrom=points_per_angstrom, + density_sigma=density_sigma, + cutoff_sigma=cutoff_sigma, + ) + + @classmethod + def rays_binning( + cls, + *, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, + n_rays_sphere: int | None = None, + delta_polar: float = 8.0, + bin_width: float = 1.0, + points_per_angstrom: float = 1.0, + ) -> "InterfaceExtractor": + """Ray fan + histogram density + per-ray tanh interface fit. + + Same ray-fan geometry as :meth:`rays_gaussian` (see that method + for the parameter compatibility table) but density along each + ray is estimated via a 1D histogram rather than a Gaussian + kernel. + + Parameters + ---------- + delta_azimuthal, delta_cylinder, n_rays_sphere, delta_polar : + See :meth:`rays_gaussian`. + bin_width : float, default 1.0 + Diameter (Å) of the 3D top-hat kernel used at each sample + position along the ray: atoms within ``bin_width / 2`` of + a sample contribute uniformly to the density, atoms outside + do not. The natural analogue of :meth:`rays_gaussian`'s + ``density_sigma``, but with a hard cutoff instead of a + smooth fall-off. + points_per_angstrom : float, default 1.0 + Sampling density along each ray (samples per Å). + """ + from wetting_angle_kit.analysis.extractors._rays import ( + _RaysBinningExtractor, + ) + + return _RaysBinningExtractor( + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, + n_rays_sphere=n_rays_sphere, + delta_polar=delta_polar, + bin_width=bin_width, + points_per_angstrom=points_per_angstrom, + ) + + @classmethod + def grid_gaussian( + cls, + *, + grid_params: dict[str, Any], + density_sigma: float = 3.0, + cutoff_sigma: float = 5.0, + ) -> "InterfaceExtractor": + """Gaussian-KDE density grid + isocontour interface extraction. + + Supports both slicing and whole fitters: + + - For ``surface_kind="slicing"``, the grid is 2D in the slice + ``(x, z)`` plane and a marching-squares-style isocontour gives + one ``(M, 2)`` interface curve per slice. + - For ``surface_kind="whole"``, the grid is 3D in + ``(x, y, z)`` and the interface shell is recovered by + :func:`skimage.measure.marching_cubes`. This requires the + optional ``grid3d`` extra (``scikit-image``); construction + via :class:`TrajectoryAnalyzer` raises a clear + :class:`ImportError` if it is missing. + + Parameters + ---------- + grid_params : dict + Grid spec. For slicing, six keys: ``"xi_0"``, ``"xi_f"``, + ``"nbins_xi"``, ``"zi_0"``, ``"zi_f"``, ``"nbins_zi"``. + For whole, add three more: ``"yi_0"``, ``"yi_f"``, + ``"nbins_yi"``. + density_sigma : float, default 3.0 + Gaussian kernel width (Å) for the density smoothing. + cutoff_sigma : float, default 5.0 + Per-atom kernel truncation in units of ``density_sigma``. + """ + from wetting_angle_kit.analysis.extractors._grid import ( + _GridGaussianExtractor, + ) + + return _GridGaussianExtractor( + grid_params=dict(grid_params), + density_sigma=density_sigma, + cutoff_sigma=cutoff_sigma, + ) + + @classmethod + def grid_binning( + cls, + *, + grid_params: dict[str, Any], + ) -> "InterfaceExtractor": + """Histogram density grid + isocontour interface extraction. + + Same dimensionality + dependency rules as + :meth:`grid_gaussian`: 2D grid for slicing, 3D grid + marching + cubes (via optional ``scikit-image``) for whole. + + Parameters + ---------- + grid_params : dict + Grid spec; see :meth:`grid_gaussian` for the required keys. + """ + from wetting_angle_kit.analysis.extractors._grid import ( + _GridBinningExtractor, + ) + + return _GridBinningExtractor(grid_params=dict(grid_params)) diff --git a/src/wetting_angle_kit/analysis/fitters.py b/src/wetting_angle_kit/analysis/fitters.py deleted file mode 100644 index 321155c..0000000 --- a/src/wetting_angle_kit/analysis/fitters.py +++ /dev/null @@ -1,531 +0,0 @@ -"""Surface fitters: derive a contact angle from interface points + wall. - -A :class:`SurfaceFitter` consumes an interface point set produced by an -:class:`InterfaceExtractor` plus a wall z-coordinate produced by a -:class:`WallDetector`, and returns one :class:`BatchResult` per call -holding the contact angle and fit diagnostics. - -Two fitter kinds are supported: - -- ``slicing``: one algebraic-circle fit per slice in the slice's - ``(x, z)`` plane, then a mean across slices. -- ``whole``: one algebraic-sphere fit (spherical droplet) or - algebraic-cylinder fit (cylindrical droplet) to the 3D interface - shell. - -Users construct fitters through classmethod factories on the base -class:: - - SurfaceFitter.slicing() - SurfaceFitter.whole(bootstrap_samples=0) - -Algorithm bodies are stubbed (``raise NotImplementedError``) at this -skeleton stage; only constructor surfaces and compatibility checks are -implemented here. -""" - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import ClassVar, Literal - -import numpy as np - -from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.results import ( - BatchResult, - SlicingBatchResult, - WholeBatchResult, -) -from wetting_angle_kit.analysis.wall import InterfaceData - -#: Surface-representation kind the fitter consumes. Mirrors -#: :data:`wetting_angle_kit.analysis.extractors.SurfaceKind` — the two -#: are kept in sync by the analyzer's compatibility check, which raises -#: if ``extractor.kind != fitter.kind``. -SurfaceKind = Literal["slicing", "whole"] - - -class FitOutput(ABC): - """Frames-less per-batch fit output returned by :meth:`SurfaceFitter.fit`. - - The fitter computes the geometric fit and returns one of - :class:`SlicingFitOutput` or :class:`WholeFitOutput`. The analyzer - then calls :meth:`to_batch_result` with the batch's frame indices - to produce the user-facing :class:`BatchResult` — keeping - bookkeeping (frames) and computation (the fit) separate. - """ - - @abstractmethod - def to_batch_result(self, frames: list[int]) -> BatchResult: - """Attach ``frames`` to this fit output and return a BatchResult.""" - - -@dataclass(frozen=True, eq=False, kw_only=True) -class SlicingFitOutput(FitOutput): - """Output of :meth:`SurfaceFitter.slicing` for one batch. - - Carries the same payload as :class:`SlicingBatchResult` minus - ``frames``. Field semantics are identical; see that class for - documentation. - """ - - angle: float - z_wall: float - rms_residual: float - angle_std: float - per_slice_angles: np.ndarray - slice_surfaces: list[np.ndarray] - slice_popts: np.ndarray - - def to_batch_result(self, frames: list[int]) -> SlicingBatchResult: - return SlicingBatchResult( - frames=frames, - angle=self.angle, - z_wall=self.z_wall, - rms_residual=self.rms_residual, - angle_std=self.angle_std, - per_slice_angles=self.per_slice_angles, - slice_surfaces=self.slice_surfaces, - slice_popts=self.slice_popts, - ) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class WholeFitOutput(FitOutput): - """Output of :meth:`SurfaceFitter.whole` for one batch. - - Carries the same payload as :class:`WholeBatchResult` minus - ``frames``. Field semantics are identical; see that class for - documentation. - """ - - angle: float - z_wall: float - rms_residual: float - angle_std: float | None - interface_shell: np.ndarray - popt: np.ndarray - - def to_batch_result(self, frames: list[int]) -> WholeBatchResult: - return WholeBatchResult( - frames=frames, - angle=self.angle, - z_wall=self.z_wall, - rms_residual=self.rms_residual, - angle_std=self.angle_std, - interface_shell=self.interface_shell, - popt=self.popt, - ) - - -class SurfaceFitter(ABC): - """Abstract base for contact-angle surface fitters. - - Concrete fitters are constructed through the classmethod factories - :meth:`slicing` and :meth:`whole`. Direct subclassing is supported - for custom strategies but the factories cover all built-in cases. - """ - - #: Surface-representation kind this fitter consumes. Set by each - #: concrete subclass; the analyzer matches this against the chosen - #: :class:`InterfaceExtractor` at construction time. - kind: ClassVar[SurfaceKind] - - @abstractmethod - def fit( - self, - interface_data: InterfaceData, - z_wall: float, - droplet_geometry: DropletGeometry, - ) -> FitOutput: - """Fit the contact angle for one batch. - - Parameters - ---------- - interface_data : InterfaceData - Interface point set produced by the - :class:`InterfaceExtractor`. Per-slice 2D points for - ``kind="slicing"``; a 3D shell for ``kind="whole"``. - z_wall : float - Wall-plane z-coordinate from the :class:`WallDetector`. - droplet_geometry : DropletGeometry - Droplet symmetry; controls the geometric model - (circle per slice / sphere / cylinder). - - Returns - ------- - FitOutput - :class:`SlicingFitOutput` for slicing fitters, - :class:`WholeFitOutput` for whole fitters. The analyzer - attaches the batch's frame indices via - :meth:`FitOutput.to_batch_result` to produce the - user-facing :class:`BatchResult`. - """ - - @abstractmethod - def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: - """Raise if this fitter cannot handle ``droplet_geometry``. - - Called by :class:`TrajectoryAnalyzer.__init__`. The kind - compatibility (slicing vs whole) is enforced separately at - the analyzer level by matching :attr:`SurfaceFitter.kind` - against the extractor's chosen ``surface_kind``. - """ - - @classmethod - def slicing( - cls, - *, - surface_filter_offset: float = 2.0, - ) -> "SurfaceFitter": - """Per-slice algebraic circle fits, averaged across slices. - - Each slice's 2D interface points are filtered to - ``z > z_wall + surface_filter_offset`` (to exclude - wall-adjacent density distortions), an algebraic Kasa circle - is fit to the kept points, and the contact angle is the - angle of intersection between that circle and the line - ``z = z_wall``. The batch angle is the mean over slices; - :attr:`BatchResult.angle_std` is the empirical std across - slices. - - Parameters - ---------- - surface_filter_offset : float, default 2.0 - Vertical offset above ``z_wall`` (Å) below which interface - points are excluded from the circle fit. This is distinct - from any offset baked into the :class:`WallDetector`: this - offset is a fit-quality knob for the per-slice circle, and - the wall detector's offset (if it uses one, e.g. - :meth:`WallDetector.min_plus_offset`) defines where the - wall plane sits. - """ - return _SlicingFitter(surface_filter_offset=surface_filter_offset) - - @classmethod - def whole( - cls, - *, - surface_filter_offset: float = 2.0, - bootstrap_samples: int = 0, - ) -> "SurfaceFitter": - """Algebraic sphere or cylinder fit to the 3D interface shell. - - Spherical droplets get a sphere fit; cylindrical droplets get - a circular-cylinder fit whose axis is parallel to ``y`` - (internal frame, post axis-swap for ``cylinder_x``). The - contact angle follows from the cap geometry: - ``cos θ = (z_wall - z_center) / R``. - - Parameters - ---------- - surface_filter_offset : float, default 2.0 - Vertical offset above ``z_wall`` (Å) below which shell - points are excluded from the geometric fit. Same role as - in :meth:`slicing`: distinct from the wall detector's - offset. - bootstrap_samples : int, default 0 - If positive, the fit is repeated on this many bootstrap - resamples of the filtered shell, and the resulting std - of the angles is reported as - :attr:`BatchResult.angle_std`. ``0`` disables bootstrap; - the field is then ``None`` in the returned - :class:`WholeBatchResult`. - """ - return _WholeFitter( - surface_filter_offset=surface_filter_offset, - bootstrap_samples=bootstrap_samples, - ) - - -def _kasa_circle_fit_2d(x: np.ndarray, z: np.ndarray) -> tuple[float, float, float]: - """Algebraic (Kasa) least-squares circle fit in 2D. - - 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 with :func:`numpy.linalg.lstsq`. - - Parameters - ---------- - x, z : ndarray - 2D point coordinates. - - Returns - ------- - (xc, zc, R) : tuple of float - Fitted circle centre and radius. - - Raises - ------ - np.linalg.LinAlgError - If the points are collinear (rank-deficient system). - ValueError - If the algebraic solution gives a non-positive ``R^2``. - """ - x = np.asarray(x, dtype=float) - z = np.asarray(z, dtype=float) - a_matrix = np.column_stack((2.0 * x, 2.0 * z, np.ones_like(x))) - rhs = x * x + z * z - 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 points are likely degenerate." - ) - return xc, zc, float(np.sqrt(r_sq)) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class _SlicingFitter(SurfaceFitter): - """Concrete fitter for :meth:`SurfaceFitter.slicing`.""" - - kind: ClassVar[SurfaceKind] = "slicing" - - surface_filter_offset: float - - def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: - # Slicing handles all three geometries (spherical and both - # cylinder orientations); nothing geometry-specific to reject. - return None - - def fit( - self, - interface_data: InterfaceData, - z_wall: float, - droplet_geometry: DropletGeometry, - ) -> SlicingFitOutput: - if not isinstance(interface_data, list): - raise TypeError( - "slicing fitter expects a list of per-slice (M, 2) arrays; " - f"got {type(interface_data).__name__}." - ) - - z_filter = z_wall + self.surface_filter_offset - per_slice_angles: list[float] = [] - slice_surfaces: list[np.ndarray] = [] - slice_popts: list[np.ndarray] = [] - slice_rms_residuals: list[float] = [] - - for surf in interface_data: - if surf.size == 0: - continue - kept = surf[surf[:, 1] > z_filter] - # Need at least 3 non-collinear points to fit a circle. - if len(kept) < 3: - continue - try: - xc, zc, radius = _kasa_circle_fit_2d(kept[:, 0], kept[:, 1]) - except (np.linalg.LinAlgError, ValueError): - continue - # Contact angle from circle / wall-line intersection: - # ``cos θ = (z_wall - z_center) / R``. Drop slices where - # the fitted circle doesn't intersect the wall. - delta_z = z_wall - zc - if abs(delta_z) >= radius: - continue - angle = float(np.degrees(np.arccos(delta_z / radius))) - # Per-slice RMS of the circle-fit residuals (Å). The - # batch-level rms_residual reported in - # :class:`SlicingBatchResult` is the mean across slices. - radii = np.hypot(kept[:, 0] - xc, kept[:, 1] - zc) - rms = float(np.sqrt(np.mean((radii - radius) ** 2))) - - per_slice_angles.append(angle) - slice_surfaces.append(surf) - slice_popts.append(np.array([xc, zc, radius, z_wall])) - slice_rms_residuals.append(rms) - - if not per_slice_angles: - raise RuntimeError( - "slicing fit: no slice produced a valid contact angle " - "after filtering and circle fitting." - ) - - angles_arr = np.asarray(per_slice_angles, dtype=float) - return SlicingFitOutput( - angle=float(np.mean(angles_arr)), - z_wall=z_wall, - rms_residual=float(np.mean(slice_rms_residuals)), - angle_std=float(np.std(angles_arr)), - per_slice_angles=angles_arr, - slice_surfaces=slice_surfaces, - slice_popts=np.asarray(slice_popts, dtype=float), - ) - - -def _kasa_sphere_fit_3d( - x: np.ndarray, y: np.ndarray, z: np.ndarray -) -> tuple[float, float, float, float]: - """Algebraic (Kasa) least-squares sphere fit in 3D. - - Linearises ``(x - xc)^2 + (y - yc)^2 + (z - zc)^2 = R^2`` into - ``2 xc x + 2 yc y + 2 zc z + c = x^2 + y^2 + z^2`` with - ``c = R^2 - xc^2 - yc^2 - zc^2`` and solves with - :func:`numpy.linalg.lstsq`. - - Returns ``(xc, yc, zc, R)``. - - Raises - ------ - np.linalg.LinAlgError - If the input points are co-planar (rank-deficient system). - ValueError - If the algebraic solution gives a non-positive ``R^2``. - """ - x = np.asarray(x, dtype=float) - y = np.asarray(y, dtype=float) - z = np.asarray(z, dtype=float) - a_matrix = np.column_stack((2.0 * x, 2.0 * y, 2.0 * z, np.ones_like(x))) - rhs = x * x + y * y + z * z - sol, _, _, _ = np.linalg.lstsq(a_matrix, rhs, rcond=None) - xc, yc, zc, c = (float(sol[0]), float(sol[1]), float(sol[2]), float(sol[3])) - r_sq = c + xc * xc + yc * yc + zc * zc - if r_sq <= 0.0: - raise ValueError( - f"Algebraic sphere fit produced non-positive R^2 ({r_sq:.3g}); " - "the points are likely degenerate." - ) - return xc, yc, zc, float(np.sqrt(r_sq)) - - -def _whole_fit_one( - points: np.ndarray, *, spherical: bool -) -> tuple[np.ndarray, float, float]: - """Fit a sphere (spherical=True) or cylinder (spherical=False) to ``points``. - - Returns ``(popt_no_wall, R, zc)`` where ``popt_no_wall`` is - ``[xc, yc, zc, R]`` for spherical or ``[xc, zc, R]`` for cylinder. - The cylinder fit drops the ``y`` column and fits a 2D circle in - ``(x, z)``. - """ - if spherical: - xc, yc, zc, R = _kasa_sphere_fit_3d(points[:, 0], points[:, 1], points[:, 2]) - return np.array([xc, yc, zc, R]), R, zc - xc, zc, R = _kasa_circle_fit_2d(points[:, 0], points[:, 2]) - return np.array([xc, zc, R]), R, zc - - -def _angle_from_cap(z_wall: float, zc: float, R: float) -> float | None: - """Contact angle from ``cos θ = (z_wall - zc) / R`` or None if no intersection.""" - delta_z = z_wall - zc - if abs(delta_z) >= R: - return None - return float(np.degrees(np.arccos(delta_z / R))) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class _WholeFitter(SurfaceFitter): - """Concrete fitter for :meth:`SurfaceFitter.whole`.""" - - kind: ClassVar[SurfaceKind] = "whole" - - surface_filter_offset: float - bootstrap_samples: int - - def __post_init__(self) -> None: - if self.bootstrap_samples < 0: - raise ValueError( - f"bootstrap_samples must be >= 0; got {self.bootstrap_samples}." - ) - - def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: - # Whole-fit covers spherical (sphere fit) and both cylinder - # orientations (cylinder fit with the standard axis swap); - # nothing geometry-specific to reject. - return None - - def fit( - self, - interface_data: InterfaceData, - z_wall: float, - droplet_geometry: DropletGeometry, - ) -> WholeFitOutput: - if not isinstance(interface_data, np.ndarray): - raise TypeError( - "whole fitter expects an (N, 3) ndarray shell; " - f"got {type(interface_data).__name__}." - ) - if interface_data.ndim != 2 or interface_data.shape[1] != 3: - raise ValueError( - "whole fitter expects an (N, 3) ndarray shell; " - f"got shape {interface_data.shape}." - ) - - z_filter = z_wall + self.surface_filter_offset - kept = interface_data[interface_data[:, 2] > z_filter] - spherical = droplet_geometry.is_spherical - # Minimum points: 4 for a 3D sphere, 3 for a 2D circle. - min_points = 4 if spherical else 3 - if len(kept) < min_points: - raise RuntimeError( - f"whole fit: only {len(kept)} shell points above " - f"z_wall + surface_filter_offset = {z_filter:.3f} Å; " - f"need at least {min_points} for the " - f"{'sphere' if spherical else 'cylinder'} fit." - ) - - try: - popt_shape, radius, zc = _whole_fit_one(kept, spherical=spherical) - except (np.linalg.LinAlgError, ValueError) as e: - raise RuntimeError(f"whole fit: geometric fit failed: {e}") from e - - angle = _angle_from_cap(z_wall, zc, radius) - if angle is None: - raise RuntimeError( - f"whole fit: fitted shape (R={radius:.3f}, zc={zc:.3f}) " - f"does not intersect wall plane z={z_wall:.3f}." - ) - - # Per-point residuals: distance to the fitted shape, in Å. - if spherical: - xc, yc, zc_fit, R_fit = popt_shape - point_radius = np.sqrt( - (kept[:, 0] - xc) ** 2 - + (kept[:, 1] - yc) ** 2 - + (kept[:, 2] - zc_fit) ** 2 - ) - else: - xc, zc_fit, R_fit = popt_shape - point_radius = np.hypot(kept[:, 0] - xc, kept[:, 2] - zc_fit) - rms = float(np.sqrt(np.mean((point_radius - R_fit) ** 2))) - - angle_std = self._bootstrap_angle_std(kept, z_wall, spherical=spherical) - - # Pack popt with the wall position appended for plotting / - # downstream reproduction. Spherical: [xc, yc, zc, R, z_wall]. - # Cylinder: [xc, zc, R, z_wall]. - popt = np.concatenate([popt_shape, [z_wall]]) - - return WholeFitOutput( - angle=angle, - z_wall=z_wall, - rms_residual=rms, - angle_std=angle_std, - interface_shell=kept, - popt=popt, - ) - - def _bootstrap_angle_std( - self, kept: np.ndarray, z_wall: float, *, spherical: bool - ) -> float | None: - if self.bootstrap_samples <= 0: - return None - # Deterministic seed so result is reproducible per (analyzer, batch). - rng = np.random.default_rng(0) - n = len(kept) - bootstrap_angles: list[float] = [] - for _ in range(self.bootstrap_samples): - idx = rng.integers(0, n, n) - sample = kept[idx] - try: - _, b_R, b_zc = _whole_fit_one(sample, spherical=spherical) - except (np.linalg.LinAlgError, ValueError): - continue - a = _angle_from_cap(z_wall, b_zc, b_R) - if a is not None: - bootstrap_angles.append(a) - if not bootstrap_angles: - return None - return float(np.std(bootstrap_angles)) diff --git a/src/wetting_angle_kit/analysis/fitters/__init__.py b/src/wetting_angle_kit/analysis/fitters/__init__.py new file mode 100644 index 0000000..4d802e9 --- /dev/null +++ b/src/wetting_angle_kit/analysis/fitters/__init__.py @@ -0,0 +1,35 @@ +"""Surface fitters: derive a contact angle from interface points + wall. + +A :class:`SurfaceFitter` consumes an interface point set produced by an +:class:`InterfaceExtractor` plus a wall z-coordinate produced by a +:class:`WallDetector`, and returns one :class:`BatchResult` per call +holding the contact angle and fit diagnostics. + +Two fitter kinds are supported: + +- ``slicing``: one algebraic-circle fit per slice in the slice's + ``(x, z)`` plane, then a mean across slices. +- ``whole``: one algebraic-sphere fit (spherical droplet) or + algebraic-cylinder fit (cylindrical droplet) to the 3D interface + shell. + +Users construct fitters through classmethod factories on the base +class:: + + SurfaceFitter.slicing() + SurfaceFitter.whole(bootstrap_samples=0) +""" + +from wetting_angle_kit.analysis.fitters.base import ( + FitOutput, + SlicingFitOutput, + SurfaceFitter, + WholeFitOutput, +) + +__all__ = [ + "SurfaceFitter", + "FitOutput", + "SlicingFitOutput", + "WholeFitOutput", +] diff --git a/src/wetting_angle_kit/analysis/fitters/_kasa.py b/src/wetting_angle_kit/analysis/fitters/_kasa.py new file mode 100644 index 0000000..dee597f --- /dev/null +++ b/src/wetting_angle_kit/analysis/fitters/_kasa.py @@ -0,0 +1,107 @@ +"""Algebraic (Kasa) circle/sphere fit helpers + cap-angle utilities. + +Shared by both :class:`_SlicingFitter` (2D circle on per-slice points) +and :class:`_WholeFitter` (2D circle for cylinder droplets, 3D sphere +for spherical droplets). +""" + +import numpy as np + + +def _kasa_circle_fit_2d(x: np.ndarray, z: np.ndarray) -> tuple[float, float, float]: + """Algebraic (Kasa) least-squares circle fit in 2D. + + 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 with :func:`numpy.linalg.lstsq`. + + Parameters + ---------- + x, z : ndarray + 2D point coordinates. + + Returns + ------- + (xc, zc, R) : tuple of float + Fitted circle centre and radius. + + Raises + ------ + np.linalg.LinAlgError + If the points are collinear (rank-deficient system). + ValueError + If the algebraic solution gives a non-positive ``R^2``. + """ + x = np.asarray(x, dtype=float) + z = np.asarray(z, dtype=float) + a_matrix = np.column_stack((2.0 * x, 2.0 * z, np.ones_like(x))) + rhs = x * x + z * z + 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 points are likely degenerate." + ) + return xc, zc, float(np.sqrt(r_sq)) + + +def _kasa_sphere_fit_3d( + x: np.ndarray, y: np.ndarray, z: np.ndarray +) -> tuple[float, float, float, float]: + """Algebraic (Kasa) least-squares sphere fit in 3D. + + Linearises ``(x - xc)^2 + (y - yc)^2 + (z - zc)^2 = R^2`` into + ``2 xc x + 2 yc y + 2 zc z + c = x^2 + y^2 + z^2`` with + ``c = R^2 - xc^2 - yc^2 - zc^2`` and solves with + :func:`numpy.linalg.lstsq`. + + Returns ``(xc, yc, zc, R)``. + + Raises + ------ + np.linalg.LinAlgError + If the input points are co-planar (rank-deficient system). + ValueError + If the algebraic solution gives a non-positive ``R^2``. + """ + x = np.asarray(x, dtype=float) + y = np.asarray(y, dtype=float) + z = np.asarray(z, dtype=float) + a_matrix = np.column_stack((2.0 * x, 2.0 * y, 2.0 * z, np.ones_like(x))) + rhs = x * x + y * y + z * z + sol, _, _, _ = np.linalg.lstsq(a_matrix, rhs, rcond=None) + xc, yc, zc, c = (float(sol[0]), float(sol[1]), float(sol[2]), float(sol[3])) + r_sq = c + xc * xc + yc * yc + zc * zc + if r_sq <= 0.0: + raise ValueError( + f"Algebraic sphere fit produced non-positive R^2 ({r_sq:.3g}); " + "the points are likely degenerate." + ) + return xc, yc, zc, float(np.sqrt(r_sq)) + + +def _whole_fit_one( + points: np.ndarray, *, spherical: bool +) -> tuple[np.ndarray, float, float]: + """Fit a sphere (spherical=True) or cylinder (spherical=False) to ``points``. + + Returns ``(popt_no_wall, R, zc)`` where ``popt_no_wall`` is + ``[xc, yc, zc, R]`` for spherical or ``[xc, zc, R]`` for cylinder. + The cylinder fit drops the ``y`` column and fits a 2D circle in + ``(x, z)``. + """ + if spherical: + xc, yc, zc, R = _kasa_sphere_fit_3d(points[:, 0], points[:, 1], points[:, 2]) + return np.array([xc, yc, zc, R]), R, zc + xc, zc, R = _kasa_circle_fit_2d(points[:, 0], points[:, 2]) + return np.array([xc, zc, R]), R, zc + + +def _angle_from_cap(z_wall: float, zc: float, R: float) -> float | None: + """Contact angle from ``cos θ = (z_wall - zc) / R`` or None if no intersection.""" + delta_z = z_wall - zc + if abs(delta_z) >= R: + return None + return float(np.degrees(np.arccos(delta_z / R))) diff --git a/src/wetting_angle_kit/analysis/fitters/_slicing.py b/src/wetting_angle_kit/analysis/fitters/_slicing.py new file mode 100644 index 0000000..60aa9ca --- /dev/null +++ b/src/wetting_angle_kit/analysis/fitters/_slicing.py @@ -0,0 +1,93 @@ +"""Concrete slicing fitter: per-slice algebraic circle fits.""" + +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np + +from wetting_angle_kit.analysis.fitters._kasa import _kasa_circle_fit_2d +from wetting_angle_kit.analysis.fitters.base import ( + SlicingFitOutput, + SurfaceFitter, + SurfaceKind, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.wall import InterfaceData + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _SlicingFitter(SurfaceFitter): + """Concrete fitter for :meth:`SurfaceFitter.slicing`.""" + + kind: ClassVar[SurfaceKind] = "slicing" + + surface_filter_offset: float + + def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + # Slicing handles all three geometries (spherical and both + # cylinder orientations); nothing geometry-specific to reject. + return None + + def fit( + self, + interface_data: InterfaceData, + z_wall: float, + droplet_geometry: DropletGeometry, + ) -> SlicingFitOutput: + if not isinstance(interface_data, list): + raise TypeError( + "slicing fitter expects a list of per-slice (M, 2) arrays; " + f"got {type(interface_data).__name__}." + ) + + z_filter = z_wall + self.surface_filter_offset + per_slice_angles: list[float] = [] + slice_surfaces: list[np.ndarray] = [] + slice_popts: list[np.ndarray] = [] + slice_rms_residuals: list[float] = [] + + for surf in interface_data: + if surf.size == 0: + continue + kept = surf[surf[:, 1] > z_filter] + # Need at least 3 non-collinear points to fit a circle. + if len(kept) < 3: + continue + try: + xc, zc, radius = _kasa_circle_fit_2d(kept[:, 0], kept[:, 1]) + except (np.linalg.LinAlgError, ValueError): + continue + # Contact angle from circle / wall-line intersection: + # ``cos θ = (z_wall - z_center) / R``. Drop slices where + # the fitted circle doesn't intersect the wall. + delta_z = z_wall - zc + if abs(delta_z) >= radius: + continue + angle = float(np.degrees(np.arccos(delta_z / radius))) + # Per-slice RMS of the circle-fit residuals (Å). The + # batch-level rms_residual reported in + # :class:`SlicingBatchResult` is the mean across slices. + radii = np.hypot(kept[:, 0] - xc, kept[:, 1] - zc) + rms = float(np.sqrt(np.mean((radii - radius) ** 2))) + + per_slice_angles.append(angle) + slice_surfaces.append(surf) + slice_popts.append(np.array([xc, zc, radius, z_wall])) + slice_rms_residuals.append(rms) + + if not per_slice_angles: + raise RuntimeError( + "slicing fit: no slice produced a valid contact angle " + "after filtering and circle fitting." + ) + + angles_arr = np.asarray(per_slice_angles, dtype=float) + return SlicingFitOutput( + angle=float(np.mean(angles_arr)), + z_wall=z_wall, + rms_residual=float(np.mean(slice_rms_residuals)), + angle_std=float(np.std(angles_arr)), + per_slice_angles=angles_arr, + slice_surfaces=slice_surfaces, + slice_popts=np.asarray(slice_popts, dtype=float), + ) diff --git a/src/wetting_angle_kit/analysis/fitters/_whole.py b/src/wetting_angle_kit/analysis/fitters/_whole.py new file mode 100644 index 0000000..b19b1e0 --- /dev/null +++ b/src/wetting_angle_kit/analysis/fitters/_whole.py @@ -0,0 +1,134 @@ +"""Concrete whole fitter: 3D sphere or 2D cylinder fit to the shell.""" + +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np + +from wetting_angle_kit.analysis.fitters._kasa import ( + _angle_from_cap, + _whole_fit_one, +) +from wetting_angle_kit.analysis.fitters.base import ( + SurfaceFitter, + SurfaceKind, + WholeFitOutput, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.wall import InterfaceData + + +@dataclass(frozen=True, eq=False, kw_only=True) +class _WholeFitter(SurfaceFitter): + """Concrete fitter for :meth:`SurfaceFitter.whole`.""" + + kind: ClassVar[SurfaceKind] = "whole" + + surface_filter_offset: float + bootstrap_samples: int + + def __post_init__(self) -> None: + if self.bootstrap_samples < 0: + raise ValueError( + f"bootstrap_samples must be >= 0; got {self.bootstrap_samples}." + ) + + def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + # Whole-fit covers spherical (sphere fit) and both cylinder + # orientations (cylinder fit with the standard axis swap); + # nothing geometry-specific to reject. + return None + + def fit( + self, + interface_data: InterfaceData, + z_wall: float, + droplet_geometry: DropletGeometry, + ) -> WholeFitOutput: + if not isinstance(interface_data, np.ndarray): + raise TypeError( + "whole fitter expects an (N, 3) ndarray shell; " + f"got {type(interface_data).__name__}." + ) + if interface_data.ndim != 2 or interface_data.shape[1] != 3: + raise ValueError( + "whole fitter expects an (N, 3) ndarray shell; " + f"got shape {interface_data.shape}." + ) + + z_filter = z_wall + self.surface_filter_offset + kept = interface_data[interface_data[:, 2] > z_filter] + spherical = droplet_geometry.is_spherical + # Minimum points: 4 for a 3D sphere, 3 for a 2D circle. + min_points = 4 if spherical else 3 + if len(kept) < min_points: + raise RuntimeError( + f"whole fit: only {len(kept)} shell points above " + f"z_wall + surface_filter_offset = {z_filter:.3f} Å; " + f"need at least {min_points} for the " + f"{'sphere' if spherical else 'cylinder'} fit." + ) + + try: + popt_shape, radius, zc = _whole_fit_one(kept, spherical=spherical) + except (np.linalg.LinAlgError, ValueError) as e: + raise RuntimeError(f"whole fit: geometric fit failed: {e}") from e + + angle = _angle_from_cap(z_wall, zc, radius) + if angle is None: + raise RuntimeError( + f"whole fit: fitted shape (R={radius:.3f}, zc={zc:.3f}) " + f"does not intersect wall plane z={z_wall:.3f}." + ) + + # Per-point residuals: distance to the fitted shape, in Å. + if spherical: + xc, yc, zc_fit, R_fit = popt_shape + point_radius = np.sqrt( + (kept[:, 0] - xc) ** 2 + + (kept[:, 1] - yc) ** 2 + + (kept[:, 2] - zc_fit) ** 2 + ) + else: + xc, zc_fit, R_fit = popt_shape + point_radius = np.hypot(kept[:, 0] - xc, kept[:, 2] - zc_fit) + rms = float(np.sqrt(np.mean((point_radius - R_fit) ** 2))) + + angle_std = self._bootstrap_angle_std(kept, z_wall, spherical=spherical) + + # Pack popt with the wall position appended for plotting / + # downstream reproduction. Spherical: [xc, yc, zc, R, z_wall]. + # Cylinder: [xc, zc, R, z_wall]. + popt = np.concatenate([popt_shape, [z_wall]]) + + return WholeFitOutput( + angle=angle, + z_wall=z_wall, + rms_residual=rms, + angle_std=angle_std, + interface_shell=kept, + popt=popt, + ) + + def _bootstrap_angle_std( + self, kept: np.ndarray, z_wall: float, *, spherical: bool + ) -> float | None: + if self.bootstrap_samples <= 0: + return None + # Deterministic seed so result is reproducible per (analyzer, batch). + rng = np.random.default_rng(0) + n = len(kept) + bootstrap_angles: list[float] = [] + for _ in range(self.bootstrap_samples): + idx = rng.integers(0, n, n) + sample = kept[idx] + try: + _, b_R, b_zc = _whole_fit_one(sample, spherical=spherical) + except (np.linalg.LinAlgError, ValueError): + continue + a = _angle_from_cap(z_wall, b_zc, b_R) + if a is not None: + bootstrap_angles.append(a) + if not bootstrap_angles: + return None + return float(np.std(bootstrap_angles)) diff --git a/src/wetting_angle_kit/analysis/fitters/base.py b/src/wetting_angle_kit/analysis/fitters/base.py new file mode 100644 index 0000000..3161e8f --- /dev/null +++ b/src/wetting_angle_kit/analysis/fitters/base.py @@ -0,0 +1,223 @@ +"""``SurfaceFitter`` ABC + ``FitOutput`` types + factory classmethods. + +The factories use deferred imports of the concrete fitter classes to +avoid a circular dependency with the sibling ``_slicing`` / ``_whole`` +modules (which inherit from :class:`SurfaceFitter`). +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ClassVar, Literal + +import numpy as np + +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.results import ( + BatchResult, + SlicingBatchResult, + WholeBatchResult, +) +from wetting_angle_kit.analysis.wall import InterfaceData + +#: Surface-representation kind the fitter consumes. Mirrors +#: :data:`wetting_angle_kit.analysis.extractors.SurfaceKind` — the two +#: are kept in sync by the analyzer's compatibility check, which raises +#: if ``extractor.kind != fitter.kind``. +SurfaceKind = Literal["slicing", "whole"] + + +class FitOutput(ABC): + """Frames-less per-batch fit output returned by :meth:`SurfaceFitter.fit`. + + The fitter computes the geometric fit and returns one of + :class:`SlicingFitOutput` or :class:`WholeFitOutput`. The analyzer + then calls :meth:`to_batch_result` with the batch's frame indices + to produce the user-facing :class:`BatchResult` — keeping + bookkeeping (frames) and computation (the fit) separate. + """ + + @abstractmethod + def to_batch_result(self, frames: list[int]) -> BatchResult: + """Attach ``frames`` to this fit output and return a BatchResult.""" + + +@dataclass(frozen=True, eq=False, kw_only=True) +class SlicingFitOutput(FitOutput): + """Output of :meth:`SurfaceFitter.slicing` for one batch. + + Carries the same payload as :class:`SlicingBatchResult` minus + ``frames``. Field semantics are identical; see that class for + documentation. + """ + + angle: float + z_wall: float + rms_residual: float + angle_std: float + per_slice_angles: np.ndarray + slice_surfaces: list[np.ndarray] + slice_popts: np.ndarray + + def to_batch_result(self, frames: list[int]) -> SlicingBatchResult: + return SlicingBatchResult( + frames=frames, + angle=self.angle, + z_wall=self.z_wall, + rms_residual=self.rms_residual, + angle_std=self.angle_std, + per_slice_angles=self.per_slice_angles, + slice_surfaces=self.slice_surfaces, + slice_popts=self.slice_popts, + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class WholeFitOutput(FitOutput): + """Output of :meth:`SurfaceFitter.whole` for one batch. + + Carries the same payload as :class:`WholeBatchResult` minus + ``frames``. Field semantics are identical; see that class for + documentation. + """ + + angle: float + z_wall: float + rms_residual: float + angle_std: float | None + interface_shell: np.ndarray + popt: np.ndarray + + def to_batch_result(self, frames: list[int]) -> WholeBatchResult: + return WholeBatchResult( + frames=frames, + angle=self.angle, + z_wall=self.z_wall, + rms_residual=self.rms_residual, + angle_std=self.angle_std, + interface_shell=self.interface_shell, + popt=self.popt, + ) + + +class SurfaceFitter(ABC): + """Abstract base for contact-angle surface fitters. + + Concrete fitters are constructed through the classmethod factories + :meth:`slicing` and :meth:`whole`. Direct subclassing is supported + for custom strategies but the factories cover all built-in cases. + """ + + #: Surface-representation kind this fitter consumes. Set by each + #: concrete subclass; the analyzer matches this against the chosen + #: :class:`InterfaceExtractor` at construction time. + kind: ClassVar[SurfaceKind] + + @abstractmethod + def fit( + self, + interface_data: InterfaceData, + z_wall: float, + droplet_geometry: DropletGeometry, + ) -> FitOutput: + """Fit the contact angle for one batch. + + Parameters + ---------- + interface_data : InterfaceData + Interface point set produced by the + :class:`InterfaceExtractor`. Per-slice 2D points for + ``kind="slicing"``; a 3D shell for ``kind="whole"``. + z_wall : float + Wall-plane z-coordinate from the :class:`WallDetector`. + droplet_geometry : DropletGeometry + Droplet symmetry; controls the geometric model + (circle per slice / sphere / cylinder). + + Returns + ------- + FitOutput + :class:`SlicingFitOutput` for slicing fitters, + :class:`WholeFitOutput` for whole fitters. The analyzer + attaches the batch's frame indices via + :meth:`FitOutput.to_batch_result` to produce the + user-facing :class:`BatchResult`. + """ + + @abstractmethod + def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + """Raise if this fitter cannot handle ``droplet_geometry``. + + Called by :class:`TrajectoryAnalyzer.__init__`. The kind + compatibility (slicing vs whole) is enforced separately at + the analyzer level by matching :attr:`SurfaceFitter.kind` + against the extractor's chosen ``surface_kind``. + """ + + @classmethod + def slicing( + cls, + *, + surface_filter_offset: float = 2.0, + ) -> "SurfaceFitter": + """Per-slice algebraic circle fits, averaged across slices. + + Each slice's 2D interface points are filtered to + ``z > z_wall + surface_filter_offset`` (to exclude + wall-adjacent density distortions), an algebraic Kasa circle + is fit to the kept points, and the contact angle is the + angle of intersection between that circle and the line + ``z = z_wall``. The batch angle is the mean over slices; + :attr:`BatchResult.angle_std` is the empirical std across + slices. + + Parameters + ---------- + surface_filter_offset : float, default 2.0 + Vertical offset above ``z_wall`` (Å) below which interface + points are excluded from the circle fit. This is distinct + from any offset baked into the :class:`WallDetector`: this + offset is a fit-quality knob for the per-slice circle, and + the wall detector's offset (if it uses one, e.g. + :meth:`WallDetector.min_plus_offset`) defines where the + wall plane sits. + """ + from wetting_angle_kit.analysis.fitters._slicing import _SlicingFitter + + return _SlicingFitter(surface_filter_offset=surface_filter_offset) + + @classmethod + def whole( + cls, + *, + surface_filter_offset: float = 2.0, + bootstrap_samples: int = 0, + ) -> "SurfaceFitter": + """Algebraic sphere or cylinder fit to the 3D interface shell. + + Spherical droplets get a sphere fit; cylindrical droplets get + a circular-cylinder fit whose axis is parallel to ``y`` + (internal frame, post axis-swap for ``cylinder_x``). The + contact angle follows from the cap geometry: + ``cos θ = (z_wall - z_center) / R``. + + Parameters + ---------- + surface_filter_offset : float, default 2.0 + Vertical offset above ``z_wall`` (Å) below which shell + points are excluded from the geometric fit. Same role as + in :meth:`slicing`: distinct from the wall detector's + offset. + bootstrap_samples : int, default 0 + If positive, the fit is repeated on this many bootstrap + resamples of the filtered shell, and the resulting std + of the angles is reported as + :attr:`BatchResult.angle_std`. ``0`` disables bootstrap; + the field is then ``None`` in the returned + :class:`WholeBatchResult`. + """ + from wetting_angle_kit.analysis.fitters._whole import _WholeFitter + + return _WholeFitter( + surface_filter_offset=surface_filter_offset, + bootstrap_samples=bootstrap_samples, + ) diff --git a/src/wetting_angle_kit/analysis/trajectory.py b/src/wetting_angle_kit/analysis/trajectory.py index b9012d3..2b953fd 100644 --- a/src/wetting_angle_kit/analysis/trajectory.py +++ b/src/wetting_angle_kit/analysis/trajectory.py @@ -26,7 +26,7 @@ import numpy as np -from wetting_angle_kit.analysis.base import ( +from wetting_angle_kit.analysis._base import ( _BatchedTrajectoryAnalyzer, build_parser, gather_batch_coords, diff --git a/tests/test_analysis/test_coupled_binning_3d.py b/tests/test_analysis/test_coupled_binning_3d.py index b33b899..6519d9b 100644 --- a/tests/test_analysis/test_coupled_binning_3d.py +++ b/tests/test_analysis/test_coupled_binning_3d.py @@ -18,7 +18,7 @@ import numpy as np import pytest -from wetting_angle_kit.analysis.coupled_binning_3d import ( # noqa: E402 +from wetting_angle_kit.analysis.coupled_binning.analyzer_3d import ( # noqa: E402 CoupledBinning3DAnalyzer, _HyperbolicTangentModel3D, ) diff --git a/tests/test_analysis/test_whole_fitter.py b/tests/test_analysis/test_whole_fitter.py index 98bf6fb..1ad8bcc 100644 --- a/tests/test_analysis/test_whole_fitter.py +++ b/tests/test_analysis/test_whole_fitter.py @@ -15,8 +15,8 @@ import numpy as np -from wetting_angle_kit.analysis.extractors import ( - InterfaceExtractor, +from wetting_angle_kit.analysis.extractors import InterfaceExtractor +from wetting_angle_kit.analysis.extractors._sampling import ( _fibonacci_sphere_directions, ) from wetting_angle_kit.analysis.fitters import SurfaceFitter From a1024586764b8124e648d5d712c683fa9b184ca4 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 11 Jun 2026 10:26:58 +0200 Subject: [PATCH 28/53] Updated and augmented documentation. --- README.md | 73 ++- docs/examples/binning_ca.py | 62 +-- docs/examples/slicing_ca.py | 48 +- .../visualisation_evolution_density.py | 75 ++++ docs/examples/visualisation_slicing_traj.py | 67 +-- docs/examples/whole_fit_ca.py | 57 +++ docs/source/API/index.rst | 48 +- docs/source/examples/index.rst | 49 +- docs/source/introduction/introduction.rst | 187 +++++--- .../introduction/theoretical_foundations.rst | 419 +++++++++++++++++- docs/source/tutorials/binning_method_tuto.rst | 248 ++++++++--- .../tutorials/coupled_binning_3d_tuto.rst | 238 ++++++++++ docs/source/tutorials/grid_method_tuto.rst | 206 +++++++++ docs/source/tutorials/index.rst | 28 +- docs/source/tutorials/parser_tutorial.rst | 9 +- docs/source/tutorials/slicing_method_tuto.rst | 263 +++++++---- .../visualization_evolution_density.rst | 148 +++++++ .../visualization_slicing_droplet.rst | 108 +++-- docs/source/tutorials/whole_fit_tuto.rst | 238 ++++++++++ 19 files changed, 2176 insertions(+), 395 deletions(-) create mode 100644 docs/examples/visualisation_evolution_density.py create mode 100644 docs/examples/whole_fit_ca.py create mode 100644 docs/source/tutorials/coupled_binning_3d_tuto.rst create mode 100644 docs/source/tutorials/grid_method_tuto.rst create mode 100644 docs/source/tutorials/visualization_evolution_density.rst create mode 100644 docs/source/tutorials/whole_fit_tuto.rst diff --git a/README.md b/README.md index cfcfac5..debbce9 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,41 @@ [![License: BSD 3-Clause](https://img.shields.io/badge/License-BSD_3--Clause-blue.svg)](LICENSE) [![Documentation](https://img.shields.io/badge/docs-matgenix.github.io-blue)](https://matgenix.github.io/wetting-angle-kit) -wetting-angle-kit provides modular tools to parse MD trajectories (LAMMPS dump, XYZ, ASE) and compute droplet contact angles using two complementary approaches: +wetting-angle-kit parses MD trajectories (LAMMPS dump, XYZ, ASE) and computes the contact angle of a droplet sitting on a planar wall. The package follows the same conceptual recipe every method uses — extract the liquid-vapor interface from atom positions, decide where the wall plane sits, fit a geometric shape, read off the angle from the shape/wall intersection — but exposes each step as a swappable component so users can match the method to their system. -1. Slicing Method (per-frame circle fit) – robust against transient shape changes. -2. Binning Density Method – averages frames into a density field for a single representative angle. +## How the methods are built -The documentation is available [here](https://matgenix.github.io/wetting-angle-kit), you can find examples and tutorials. +### Interface extraction: how do we turn atoms into a surface? + +The liquid-vapor interface isn't a sharp surface in an MD simulation — the density drops smoothly over ~1 Å. Two extraction strategies recover a clean set of interface points from the noisy atom cloud: + +- **Ray-fan extractors** emit a fan of rays from the droplet centre of mass and locate the interface along each ray as the half-density point of a 1D tanh fit. The fan is azimuthal slices in the `(x, z)` plane (for a per-slice fit) or a Fibonacci sphere of directions (for a whole-shape fit). The density along each ray comes from either a Gaussian KDE (`rays_gaussian`) or a 1D histogram (`rays_binning`); both produce interface points robust to thermal noise. +- **Grid extractors** build a 2D or 3D density grid by histogramming the liquid atoms, then trace the iso-density contour at the half-bulk level via marching squares (`grid_*` in slicing mode) or marching cubes (`grid_*` in whole mode). Closer to the "average over many frames" intuition; works well when atom statistics are limited per frame. + +### Surface fitting: what geometric shape do we fit to those points? + +- **Slicing fit** — independently fits an algebraic circle in each slice's `(x, z)` plane, then averages the per-slice contact angles. Good when the droplet might be slightly non-spherical: the per-slice scatter naturally reports a `±σ` band. +- **Whole fit** — fits a single sphere (spherical droplet) or cylinder (cylindrical droplet) to the entire 3D interface shell. Uses the algebraic Kasa method, plus optional bootstrap resampling to put an uncertainty on the recovered angle. +- **Coupled-binning fit** (joint approach) — a 7-parameter (2D) or 9-parameter (3D) hyperbolic-tangent density model that solves "where is the interface", "where is the wall plane", and "what's the cap geometry" in one nonlinear least-squares fit on the binned density field. Statistically efficient when you pool many frames per batch. + +### Wall detection: where is the wall plane? + +The contact angle is measured at the cap–wall intersection, so the wall plane has to be located explicitly: + +- `min_plus_offset`: derive the wall from the interface itself (lowest interface point + offset). Works for slicing geometries and full-sphere ray fans, where the interface points reach the wall. +- `from_atoms`: read the actual wall atom positions from the trajectory and place the wall at the mean of the top atomic layer. Most physically faithful when the simulation explicitly contains substrate atoms. +- `explicit`: caller supplies the wall z directly — useful when the wall position is known a priori from the simulation setup. + +### Frame batching: per-frame angle or pooled batch? + +The `TemporalAggregator` groups trajectory frames into batches before fitting. `batch_size=1` runs the full pipeline once per frame (giving you an angle vs time curve); `batch_size=N` pools `N` frames together and fits one angle per pool (more atoms per fit → less noise, less time resolution); `batch_size=-1` pools everything into a single batch. + +## Two top-level entry points + +1. **`TrajectoryAnalyzer`** — composes the four strategies above (`InterfaceExtractor` × `SurfaceFitter` × `WallDetector` × `TemporalAggregator`). Use it when you want per-frame time resolution or when you want to mix-and-match approaches (e.g. ray-fan extractor + whole-fit + explicit wall + 5-frame batches). +2. **`CoupledBinning2DAnalyzer` / `CoupledBinning3DAnalyzer`** — the joint-fit alternative. One robust angle per pooled batch via the hyperbolic-tangent density model. Best when you have many frames and don't need per-frame time resolution. + +The documentation is available [here](https://matgenix.github.io/wetting-angle-kit), with worked examples and tutorials. ## Installation @@ -56,34 +85,50 @@ conda install --strict-channel-priority -c https://conda.ovito.org -c conda-forg ```python from wetting_angle_kit.analysis import ( - BinningTrajectoryAnalyzer, - SlicingTrajectoryAnalyzer, + CoupledBinning2DAnalyzer, + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, ) +from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import XYZParser, XYZWaterFinder trajectory_file = "trajectory.xyz" -# Identify water oxygen atoms by neighbor count. ``particle_type_wall`` +# Identify water oxygen atoms by neighbour count. ``particle_type_wall`` # lists the symbols of the substrate atoms so they are excluded. finder = XYZWaterFinder(trajectory_file, particle_type_wall=["C"]) oxygen_ids = finder.get_water_oxygen_indices(frame_index=0) parser = XYZParser(trajectory_file) -slicing = SlicingTrajectoryAnalyzer( - parser, +# --- Composable pipeline (per-frame slicing-fit angles) --- +slicing = TrajectoryAnalyzer( + parser=parser, atom_indices=oxygen_ids, droplet_geometry="spherical", - delta_gamma=5, + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=5.0, # 5° between slicing planes + delta_polar=8.0, + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), # one angle per frame ) -results = slicing.analyze(frame_range=range(0, 50)) +results = slicing.analyze(range(0, 50)) print(results.mean_angle, results.std_angle) -binning = BinningTrajectoryAnalyzer( - parser, +# --- Joint coupled-binning fit (one robust angle over a pooled batch) --- +binning = CoupledBinning2DAnalyzer( + parser=parser, atom_indices=oxygen_ids, droplet_geometry="spherical", + binning_params={ + "xi_0": 0, "xi_f": 70.0, "nbins_xi": 50, + "zi_0": 0.0, "zi_f": 70.0, "nbins_zi": 25, + }, ) -results_binning = binning.analyze(frame_range=range(0, 200)) +results_binning = binning.analyze(range(0, 200)) print(results_binning.mean_angle, results_binning.std_angle) ``` diff --git a/docs/examples/binning_ca.py b/docs/examples/binning_ca.py index 01972d4..4aa7af2 100644 --- a/docs/examples/binning_ca.py +++ b/docs/examples/binning_ca.py @@ -1,44 +1,56 @@ -# Import necessary modules -from wetting_angle_kit.analysis import BinningTrajectoryAnalyzer +"""Coupled-binning contact-angle example. + +Runs the joint hyperbolic-tangent fit on a 2D binned density grid via +:class:`CoupledBinning2DAnalyzer`. One angle per pooled batch — best +when you have many frames per batch. +""" + +from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer +from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder # --- Step 1: Define the trajectory file --- filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" -# --- Step 2: Initialize the water molecule finder --- -# This identifies O and H atoms in water molecules +# --- Step 2: Identify water-oxygen atoms --- wat_find = LammpsDumpWaterFinder( filename, - oxygen_type=1, # Oxygen atom type - hydrogen_type=2, # Hydrogen atom type + oxygen_type=1, + hydrogen_type=2, ) - -# --- 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 --- +# --- Step 3: Define the binning grid --- binning_params = { - "xi_0": 0.0, # Minimum x-coordinate - "xi_f": 70.0, # Maximum x-coordinate - "nbins_xi": 30, # Number of bins along x - "zi_0": 0.0, # Minimum z-coordinate - "zi_f": 70.0, # Maximum z-coordinate - "nbins_zi": 30, # Number of bins along z + "xi_0": 0.0, + "xi_f": 70.0, + "nbins_xi": 30, + "zi_0": 0.0, + "zi_f": 70.0, + "nbins_zi": 30, } -# --- Step 5: Initialize the parser --- -parser = LammpsDumpParser(filename) - -# --- Step 6: Create the contact angle analyzer --- -analyzer = BinningTrajectoryAnalyzer( - parser=parser, +# --- Step 4: Build the analyzer --- +analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, - droplet_geometry="spherical", # Interface fitting model + droplet_geometry="spherical", binning_params=binning_params, + # Pool 10 frames per batch (legacy split_factor=10 analog). + temporal_aggregator=TemporalAggregator(batch_size=10), ) -# --- Step 7: Run analysis for a frame range --- -results = analyzer.analyze([1]) # Analyze frame 1 +# --- Step 5: Run analysis on a frame range --- +results = analyzer.analyze([1]) print("Mean contact angle (°):", results.mean_angle) -print("Std contact angle (°):", results.std_angle) +print("Std across batches (°):", results.std_angle) + +# Per-batch detail: +batch = results.batches[0] +print( + f"Frames {batch.frames[0]}–{batch.frames[-1]}: " + f"angle = {batch.angle:.2f}°, " + f"R_eq = {batch.model_params['R_eq']:.2f} Å, " + f"z_wall = {batch.model_params['zi_0']:.2f} Å" +) diff --git a/docs/examples/slicing_ca.py b/docs/examples/slicing_ca.py index 7f9b614..4511f72 100644 --- a/docs/examples/slicing_ca.py +++ b/docs/examples/slicing_ca.py @@ -1,36 +1,56 @@ -"""Slicing contact-angle example. +"""Slicing-pipeline contact-angle example. -Runs the per-frame slicing (circle-fitting) analyzer on a LAMMPS dump -file and prints the resulting mean contact angle. +Runs the per-frame slicing-fit pipeline (ray-fan extractor + algebraic +circle fitter + interface-derived wall) on a LAMMPS dump file and prints +the recovered mean contact angle. """ -from wetting_angle_kit.analysis import SlicingTrajectoryAnalyzer +from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder # --- Step 1: Define the trajectory file --- filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" -# --- Step 2: Identify the water molecules (oxygen-bonded-to-two-H) --- +# --- Step 2: Identify the water-oxygen atoms --- wat_find = LammpsDumpWaterFinder( filename, oxygen_type=1, hydrogen_type=2, ) - -# `oxygen_indices` are LAMMPS particle IDs for the dump format. oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) print("Number of water molecules:", len(oxygen_indices)) -# --- Step 3: Build the slicing analyzer --- -parser = LammpsDumpParser(filename) -analyzer = SlicingTrajectoryAnalyzer( - parser=parser, +# --- Step 3: Build the trajectory analyzer --- +# Strategies: ray-fan Gaussian extractor + slicing fitter + +# interface-derived wall + per-frame batching. +analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - delta_gamma=20, # Azimuthal step for spherical slicing (degrees) + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, # 20° between slicing planes + delta_polar=8.0, # 8° in-plane ray step + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), ) -# --- Step 4: Run analysis for a frame range --- +# --- Step 4: Run analysis on a frame range --- results = analyzer.analyze([1]) -print("Frames analyzed:", results.frames) print("Mean contact angle (°):", results.mean_angle) +print("Std across batches (°):", results.std_angle) + +# Per-batch detail: +batch = results.batches[0] +print( + f"Frame {batch.frames[0]}: angle = {batch.angle:.2f}°, " + f"per-slice σ = {batch.angle_std:.2f}°, " + f"rms residual = {batch.rms_residual:.2f} Å" +) diff --git a/docs/examples/visualisation_evolution_density.py b/docs/examples/visualisation_evolution_density.py new file mode 100644 index 0000000..05c3433 --- /dev/null +++ b/docs/examples/visualisation_evolution_density.py @@ -0,0 +1,75 @@ +"""End-to-end example: angle evolution + density contour plots. + +Runs both the per-frame slicing pipeline and the coupled-binning +analyzer on the same trajectory, then renders the two trajectory-level +plots: the angle evolution curve (with per-batch ±σ band and running +mean) and the density contour with the fitted spherical cap overlaid. +""" + +from wetting_angle_kit.analysis import ( + CoupledBinning2DAnalyzer, + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder +from wetting_angle_kit.visualization import ( + AngleEvolutionPlotter, + DensityContourPlotter, +) + +filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + +# Water-oxygen atoms. +wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) +oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) + +# --- 1. Slicing pipeline → angle evolution figure --- +slicing = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, delta_polar=8.0 + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), +) +slicing_results = slicing.analyze(range(0, 50)) + +splot = AngleEvolutionPlotter( + slicing_results, + label="spherical_4k", + timestep=0.5, + time_unit="ps", +) +fig_evolution = splot.plot(per_frame_std=True, running_mean=True) +fig_evolution.write_html("angle_evolution.html") +print("Saved angle_evolution.html") + +# --- 2. Coupled-binning analyzer → density contour figure --- +binning = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params={ + "xi_0": 0.0, + "xi_f": 70.0, + "nbins_xi": 30, + "zi_0": 0.0, + "zi_f": 70.0, + "nbins_zi": 30, + }, + temporal_aggregator=TemporalAggregator(batch_size=10), +) +binning_results = binning.analyze(range(0, 100)) + +# Pick the first batch (or pass ``binning_results`` directly to average +# the density across all batches before contouring). +bplot = DensityContourPlotter(binning_results.batches[0], label="spherical_4k") +fig_density = bplot.plot() +fig_density.write_html("density_contour.html") +print("Saved density_contour.html") diff --git a/docs/examples/visualisation_slicing_traj.py b/docs/examples/visualisation_slicing_traj.py index ff89a2f..e88e2a2 100644 --- a/docs/examples/visualisation_slicing_traj.py +++ b/docs/examples/visualisation_slicing_traj.py @@ -1,13 +1,17 @@ -"""End-to-end example: slicing contact-angle pipeline plus visualization. +"""End-to-end example: slicing pipeline + per-frame droplet snapshot. -Run a single-frame slicing analysis on a LAMMPS dump file and save a PNG of -the droplet with the fitted circle, surface contour, and tangent at the -contact point. +Runs the slicing-fit pipeline on a LAMMPS dump file, pulls one slice's +interface contour + fitted circle off the result, and renders the +droplet snapshot with :class:`DropletSlicePlotter`. """ -import numpy as np - -from wetting_angle_kit.analysis.slicing import SlicingFrameFitter +from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import ( LammpsDumpParser, LammpsDumpWallParser, @@ -15,45 +19,50 @@ ) from wetting_angle_kit.visualization import DropletSlicePlotter -# --- 1. Define the Input Trajectory --- -# Adjust this to point to your local .lammpstrj file. +# --- 1. Define the input trajectory --- filename = "../../tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" +frame_index = 10 -# --- 2. Identify Water Molecules --- +# --- 2. Identify water-oxygen atoms --- 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)) -# --- 3. Parse Atomic Coordinates --- +# --- 3. Read atom and wall positions for the frame --- parser = LammpsDumpParser(filepath=filename) -oxygen_position = parser.parse(frame_index=10, indices=oxygen_indices) +oxygen_position = parser.parse(frame_index=frame_index, indices=oxygen_indices) -# Wall particles are everything not in the liquid types. -coord_wall = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) -wall_coords = coord_wall.parse(frame_index=10) +# Wall parser: ``liquid_particle_types`` lists what to EXCLUDE +# (the liquid), leaving the wall atoms. +wall_parser = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) +wall_coords = wall_parser.parse(frame_index=frame_index) -# --- 4. Compute Contact Angles --- -processor = SlicingFrameFitter( - liquid_coordinates=oxygen_position, - liquid_geom_center=np.mean(oxygen_position, axis=0), +# --- 4. Run the slicing pipeline on the chosen frame --- +analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - delta_cylinder=5, - max_dist=100, + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_cylinder=5.0, + delta_polar=8.0, + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), ) +batch = analyzer.analyze([frame_index]).batches[0] +print("Per-slice contact angles (°):", batch.per_slice_angles.tolist()) -list_alfas, array_surfaces, array_popt = processor.predict_contact_angle() -print("Per-slice contact angles (°):", list_alfas) - -# --- 5. Visualize the Droplet --- +# --- 5. Visualise one slice --- plotter = DropletSlicePlotter(center=True) +slice_idx = 0 # any 0..len(slice_surfaces)-1 fig = plotter.plot_surface_points( oxygen_position=oxygen_position, - surface_data=array_surfaces, - popt=array_popt[0], + surface_data=[batch.slice_surfaces[slice_idx]], + popt=batch.slice_popts[slice_idx], wall_coords=wall_coords, - alpha=list_alfas[0], + alpha=float(batch.per_slice_angles[slice_idx]), ) fig.write_html("droplet_plot.html") diff --git a/docs/examples/whole_fit_ca.py b/docs/examples/whole_fit_ca.py new file mode 100644 index 0000000..b216c54 --- /dev/null +++ b/docs/examples/whole_fit_ca.py @@ -0,0 +1,57 @@ +"""Whole-shape fit contact-angle example. + +Runs the whole-fit pipeline (full-sphere Fibonacci ray fan + algebraic +sphere fit + wall atoms from the trajectory) on a LAMMPS dump file, +with 100 bootstrap resamples for the angle uncertainty. +""" + +from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.parsers import ( + LammpsDumpParser, + LammpsDumpWallParser, + LammpsDumpWaterFinder, +) + +# --- Step 1: Define the trajectory file --- +filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + +# --- Step 2: Identify water-oxygen and wall-atom indices --- +wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) +oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) + +# Wall parser: ``liquid_particle_types`` lists the liquid types to EXCLUDE. +wall_parser = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) +carbon_indices = wall_parser.parse(frame_index=0) + +# --- Step 3: Build the whole-fit analyzer --- +# Strategies: full-sphere Fibonacci ray fan + sphere fit + from_atoms wall. +analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + n_rays_sphere=400, + density_sigma=3.0, + ), + surface_fitter=SurfaceFitter.whole( + surface_filter_offset=3.0, + bootstrap_samples=100, + ), + wall_detector=WallDetector.from_atoms( + wall_atom_indices=carbon_indices, + method="mean_top_layer", + top_layer_tolerance=1.0, + ), + wall_atom_indices=carbon_indices, +) + +# --- Step 4: Run the analysis --- +batch = analyzer.analyze([1]).batches[0] +print(f"angle = {batch.angle:.2f}° ± {batch.angle_std:.2f}° (bootstrap)") +print(f"R = {batch.popt[3]:.2f} Å, z_wall = {batch.z_wall:.2f} Å") +print(f"rms residual on the shell = {batch.rms_residual:.2f} Å") diff --git a/docs/source/API/index.rst b/docs/source/API/index.rst index b6188ad..087dc5f 100644 --- a/docs/source/API/index.rst +++ b/docs/source/API/index.rst @@ -14,33 +14,53 @@ Parser Module Analysis -------- -Base Analyzer -^^^^^^^^^^^^^ +Top-level analyzers +^^^^^^^^^^^^^^^^^^^ .. automodule:: wetting_angle_kit.analysis.analyzer :members: :show-inheritance: -Slicing Method -^^^^^^^^^^^^^^ +.. automodule:: wetting_angle_kit.analysis.trajectory + :members: + :show-inheritance: -.. automodule:: wetting_angle_kit.analysis.slicing +.. automodule:: wetting_angle_kit.analysis.coupled_binning :members: - :undoc-members: :show-inheritance: - :exclude-members: SlicingFrameFitter -Binning Method -^^^^^^^^^^^^^^ +Strategy components +^^^^^^^^^^^^^^^^^^^ -.. automodule:: wetting_angle_kit.analysis.binning +.. automodule:: wetting_angle_kit.analysis.extractors :members: - :undoc-members: :show-inheritance: - :exclude-members: BinningBatchFitter -Visualization and Statistics ------------------------------ +.. automodule:: wetting_angle_kit.analysis.fitters + :members: + :show-inheritance: + +.. automodule:: wetting_angle_kit.analysis.wall + :members: + :show-inheritance: + +.. automodule:: wetting_angle_kit.analysis.temporal + :members: + :show-inheritance: + +.. automodule:: wetting_angle_kit.analysis.geometry + :members: + :show-inheritance: + +Results dataclasses +^^^^^^^^^^^^^^^^^^^ + +.. automodule:: wetting_angle_kit.analysis.results + :members: + :show-inheritance: + +Visualisation +------------- .. automodule:: wetting_angle_kit.visualization :members: diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 506a4e1..f7fa33c 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -6,7 +6,8 @@ Ready-to-run example scripts demonstrating common workflows. Parsing Trajectory Files ------------------------- -This example demonstrates how to parse different trajectory file formats. +Parse different trajectory file formats (LAMMPS dump, ASE, XYZ) into a +unified ``(N, 3)`` coordinate array. .. literalinclude:: ../../examples/parsing_trajectory_files.py :language: python @@ -14,33 +15,61 @@ This example demonstrates how to parse different trajectory file formats. ---- -Binning Contact Angle Analysis +Slicing-Pipeline Contact Angle ------------------------------ -Example of using the binning method for contact angle analysis. +Per-frame angle via the composable :class:`TrajectoryAnalyzer` with the +ray-fan extractor and the slicing fitter. -.. literalinclude:: ../../examples/binning_ca.py +.. literalinclude:: ../../examples/slicing_ca.py + :language: python + :linenos: + +---- + +Whole-Fit Contact Angle with Bootstrap +--------------------------------------- + +Whole-shape sphere fit with the wall position taken from the actual +substrate atoms and a bootstrap uncertainty. + +.. literalinclude:: ../../examples/whole_fit_ca.py :language: python :linenos: ---- -Slicing Contact Angle Analysis +Coupled-Binning Contact Angle ------------------------------ -Example of using the slicing method for contact angle analysis. +Joint hyperbolic-tangent density-model fit via +:class:`CoupledBinning2DAnalyzer` — one angle per pooled batch. -.. literalinclude:: ../../examples/slicing_ca.py +.. literalinclude:: ../../examples/binning_ca.py :language: python :linenos: ---- -Visualizing Slicing Trajectories --------------------------------- +Visualising a Per-Frame Droplet Snapshot +----------------------------------------- -Example of visualizing droplet trajectories with the slicing method. +Pull a single slice's interface contour off a slicing-pipeline result +and render it with :class:`DropletSlicePlotter`. .. literalinclude:: ../../examples/visualisation_slicing_traj.py :language: python :linenos: + +---- + +Angle Evolution + Density Contour Plots +---------------------------------------- + +The two trajectory-level plotters +(:class:`AngleEvolutionPlotter` and :class:`DensityContourPlotter`) +on the same trajectory. + +.. literalinclude:: ../../examples/visualisation_evolution_density.py + :language: python + :linenos: diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index a43d474..0ae9e7d 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -6,18 +6,23 @@ Introduction :align: center :alt: wetting_angle_kit Logo -**wetting_angle_kit** is a Python package designed to analyze the contact angle of droplets from molecular dynamics simulations. It provides a modular workflow to parse trajectories, compute contact angles using different theoretical methods, and visualize the results. +**wetting_angle_kit** is a Python package that analyses droplet contact +angles from molecular dynamics simulations. It exposes a modular +workflow: parse trajectories, recover the liquid–vapor interface, +locate the wall plane, fit a geometric shape, and visualise the +result. Package Overview ---------------- -The package operates in three main stages: **Parsing**, **Calculation**, and **Visualization**. +The package operates in three stages: **Parsing**, **Analysis**, and +**Visualisation**. .. mermaid:: graph LR - A[Trajectory Parser] --> B[Contact Angle Calculation] - B --> C[Visualization] + A[Trajectory Parser] --> B[Contact Angle Analysis] + B --> C[Visualisation] subgraph Parsing A @@ -34,7 +39,8 @@ The package operates in three main stages: **Parsing**, **Calculation**, and **V 1. Trajectory Parser -------------------- -The first step is to import the simulation trajectory. wetting_angle_kit supports common formats used in molecular dynamics: +The first step is to import the simulation trajectory. wetting_angle_kit +supports common formats used in molecular dynamics: .. list-table:: :widths: 20 80 @@ -44,78 +50,139 @@ The first step is to import the simulation trajectory. wetting_angle_kit support * - .. image:: ../../images/Lammps-logo.png :width: 100 :align: center - - **LAMMPS**: The package can parse ``.lammpstrj`` files natively, handling periodic boundaries and extracting specific atom types (e.g., liquid vs. solid). + - **LAMMPS**: ``.lammpstrj`` files are parsed natively, handling + periodic boundaries and extracting specific atom types + (e.g. liquid vs. wall). * - .. image:: ../../images/ase256.png :width: 80 :align: center - - **ASE**: Support for the **Atomic Simulation Environment (ASE)** allows reading a wide range of trajectory formats beyond LAMMPS. - -The parser identifies the coordinate system (x, y, z) and separates the atoms of interest (e.g., water molecules) from the substrate/wall. - -2. Contact Angle Calculation ----------------------------- - -Once the trajectory is parsed, the core analysis is performed. Two main theoretical methods are available: - -**Supported Geometries** + - **ASE**: support for the **Atomic Simulation Environment** + allows reading a wide range of trajectory formats beyond + LAMMPS, plus plain ``.xyz`` files. + +Each format has a paired ``*WaterFinder`` that identifies water-oxygen +atoms via O–H connectivity, and an optional ``*WallParser`` for reading +the wall atoms when the analysis pipeline needs them. + +2. Contact Angle Analysis +------------------------- + +The analysis layer is built around four orthogonal strategy +components, each replaceable: + +- **Interface extractor** — turns the noisy liquid atom cloud into a + clean set of interface points (the liquid–vapor surface). Either a + ray fan with a 1D tanh fit along each ray, or a 2D/3D density grid + with an iso-density contour at the half-bulk level. +- **Wall detector** — locates the wall plane z-coordinate. Either + derived from the interface itself (``min_plus_offset``), set + explicitly, or read from the wall atom positions + (``from_atoms``). +- **Surface fitter** — fits a geometric shape (circle per slice, or + a single sphere/cylinder) to the interface points and reports the + cap/wall intersection angle. +- **Temporal aggregator** — groups frames into batches: per-frame, + pooled by ``N``, or fully pooled. + +Two top-level entry points compose these strategies in different ways. + +**Top-level analyzers** +^^^^^^^^^^^^^^^^^^^^^^^ + +:class:`TrajectoryAnalyzer` is the **composable pipeline**: you pick +an extractor, a wall detector, a surface fitter, and a temporal +aggregator, and the analyzer runs them per batch. Examples of useful +combinations: + +* ray-fan extractor + slicing fit + ``min_plus_offset`` wall + + per-frame batches — the closest analogue of the legacy slicing + method; +* ray-fan extractor + whole-fit + ``explicit`` wall + 10-frame pooled + batches — a whole-shape sphere fit with the wall position imported + from the simulation setup; +* grid extractor + slicing fit + ``from_atoms`` wall + per-frame + batches — interface from a 2D density iso-contour, wall from the + actual substrate atoms. + +:class:`CoupledBinning2DAnalyzer` and :class:`CoupledBinning3DAnalyzer` +are the **joint-fit alternative**. They skip the +extractor/wall/fitter decomposition and fit a seven-parameter (2D) or +nine-parameter (3D) hyperbolic-tangent density model directly to the +binned density. One robust angle per batch; ideal when you have many +frames per batch and don't need per-frame time resolution. + +**Supported geometries** ^^^^^^^^^^^^^^^^^^^^^^^^ -Both methods are capable of analyzing: - -* **Spherical Droplets**: Standard spherical cap shapes. -* **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. - -* **Theory**: The droplet is divided into vertical slices along the z-axis. -* **Process**: For each slice, the liquid-vapor interface is determined. A geometric model (such as a sphere or cylinder) is then fitted to these interface points. -* **Application**: Best for spherical droplets or specific 2D projections where a clear profile can be mathematically fitted. - -To accurately define the liquid-vapor interface of the droplet, we employ a vertical slicing strategy along the z-axis. First, a definition of a 2D slicing plane passing through the droplet's geometric center is determined by an azimuthal angle. +All methods can analyse: -Within this plane, we identify the interface coordinates by scanning radially from the geometric center. For a given axis (defined by an altitudinal angle), the local density is measured at discrete intervals. -A function is then fitted to this density profile to locate the sharp drop in density that marks the limit between the liquid and vapor phases. This operation is repeated across a range of altitudinal angles to generate a cloud of points representing the droplet’s profile on that plane. - -To calculate the contact angle, points near the substrate are first excluded to avoid boundary effects. A circle is then fitted to the remaining interface points, and the contact angle is derived from the intersection of this circle with the bottom of the droplet (the substrate). -Finally, the entire procedure is repeated for multiple azimuthal angles (rotating the slicing plane). This yields a distribution of contact angles, from which a mean contact angle is computed. - - -**Binning Method** -^^^^^^^^^^^^^^^^^^ +* **spherical droplets** — standard spherical-cap shapes, +* **cylindrical droplets** — cylindrical droplets along the ``x`` or + ``y`` axis (e.g. water on a nanowire or a periodic stripe). .. note:: - The binning and slicing methods both recenter the droplet per frame, using a periodic-image-aware (circular-mean) construction. This means trajectories where the droplet drifts during the run, or where atoms are wrapped across a periodic boundary, are handled transparently. Producing a pre-recentered trajectory at simulation time is therefore optional, though still convenient for visualization and post-processing: + Both methods recenter the droplet per frame using a + periodic-image-aware (circular-mean) construction. Trajectories + where the droplet drifts during the run, or where atoms wrap across + a periodic boundary, are handled transparently. Producing a + pre-recentered trajectory at simulation time is optional, though + still convenient for visualisation and post-processing: ``fix recenter group_id INIT INIT NULL`` - Both methods do require that the simulation box be large enough that the droplet does not interact with its periodic image (i.e. its lateral diameter is comfortably below the box length). If that condition is violated, the radial density profile will be physically meaningless regardless of the centering strategy. - -The **Binning Method** uses a spatial discretization approach, suitable for averaging over multiple frames to get a smooth density profile. + All methods do require that the simulation box be large enough + that the droplet does not interact with its periodic image + (i.e. its lateral diameter is comfortably below the box length). + If that condition is violated, the radial density profile is + physically meaningless regardless of the centering strategy. -* **Theory**: The simulation box is divided into a grid (bins) in the plane of interest (e.g., x-z). -* **Process**: The local density of liquid particles is calculated for each bin. The interface is defined by the isodensity contour (where density drops to half the bulk value). The contact angle is derived from the tangent of this contour at the solid surface. -* **Application**: Robust for irregular shapes or when high statistical averaging is needed. - -3. Visualization +3. Visualisation ---------------- -Finally, the results are visualized to validate the analysis. +Three visualisation classes cover the most common needs: -* **Profile Plots**: View the fitted geometric shape (circle, ellipse) overlaying the droplet points (as seen in the Slicing method). -* **Heatmaps**: For the Binning method, a 2D density heatmap is generated, showing the liquid distribution and the computed interface line. +* :class:`AngleEvolutionPlotter` — per-batch contact angle vs time, + with an optional ``±σ`` band (per-slice scatter for the slicing + fitter, bootstrap σ for the whole fitter) and a cumulative running + mean overlay. +* :class:`DensityContourPlotter` — 2D density field with the fitted + spherical cap and wall line overlaid; accepts a single batch or a + full results object (averaged density), and also collapses 3D + results azimuthally onto the same plot. +* :class:`DropletSlicePlotter` — single-frame snapshot of the droplet + with the fitted circle, surface contour, and tangent at the contact + point. -Examples of these visualizations can be found in the respective tutorials for each method. +Examples for each plot live in the :doc:`../tutorials/index` section. Troubleshooting --------------- -* **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 ``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**: ``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. +* **NaN angles**: usually mean the surface filter removed too many + points (empty slice). Raise the offset on + :meth:`SurfaceFitter.slicing` (``surface_filter_offset``) or relax + the slicing step. Make sure each slice has ≥3 surviving interface + points for the circle fit. + +* **Misconfiguration errors at construction**: + :class:`TrajectoryAnalyzer` validates the extractor / fitter / wall + detector trio in ``__init__`` — a ``ValueError`` at construction + catches incompatible configurations before any trajectory I/O + happens. Read the message: it names the constraint that was + violated. + +* **Multiprocessing hangs**: the batched analyzers use the ``spawn`` + start method. Avoid invoking OVITO parsers at module top level + before multiprocessing starts; pass file paths instead and let each + worker rebuild its own parser. + +* **OVITO ImportError**: install with the ovito extra or via the Conda + command listed in the installation section. Verify channel priority + and version pin if dependency resolution fails. + +* **Whole-fit angle off by tens of degrees**: pair the whole fitter + with :meth:`WallDetector.explicit` or + :meth:`WallDetector.from_atoms` rather than + :meth:`WallDetector.min_plus_offset` when the difference between + the interface-derived baseline and the physical wall is large + enough to matter for your droplet's geometry. diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index bc1ac0f..2947d70 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -1,37 +1,422 @@ 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. +This chapter walks through the physics and numerics behind +wetting_angle_kit, from the contact-angle definition to the +extraction, wall detection, and fitting strategies that the analyzers +compose. + +.. contents:: + :local: + :depth: 2 + +1. The contact angle and the cap geometry +----------------------------------------- + +The contact angle :math:`\theta` is the angle between the tangent to +the liquid-vapor interface and the wall surface, measured through +the liquid. For an idealised spherical-cap droplet of radius +:math:`R` whose centre sits at height :math:`z_c` above the wall +plane :math:`z = z_w`, simple geometry gives + +.. math:: + + \cos \theta \;=\; \frac{z_w - z_c}{R}. .. image:: ../../images/droplet_water_contact_angle.jpg :align: center +Physically: +* :math:`z_c < z_w` (sphere centre **below** the wall) ⇒ + :math:`\cos \theta > 0` ⇒ :math:`\theta < 90^\circ`: hydrophilic. +* :math:`z_c = z_w`: :math:`\theta = 90^\circ` (hemisphere). +* :math:`z_c > z_w`: :math:`\cos \theta < 0` ⇒ :math:`\theta > 90^\circ`: + hydrophobic. -The slicing method ------------------- +The same identity governs cylindrical droplets, replacing the +spherical cap by a circular cross-section in the plane perpendicular +to the cylinder axis. -.. image:: ../../images/wetting_angle_kit_3d_droplet.jpg - :align: center +The job of the analysis pipeline is to estimate :math:`R`, +:math:`z_c`, and :math:`z_w` from atom positions, robustly enough +that the recovered :math:`\theta` is meaningful. +2. The liquid–vapor interface in MD trajectories +------------------------------------------------ -To accurately define the liquid-vapor interface of the droplet, we employ a vertical slicing strategy along the z-axis. First, a definition of a 2D slicing plane passing through the droplet's geometric center is determined by an azimuthal angle. +There is no sharp surface in an MD frame: the density drops from +:math:`\rho_{\rm liq}` to :math:`\rho_{\rm vap}` smoothly over a few +Å, broadened by thermal motion. The package treats the +liquid–vapor interface as the locus of half-bulk density, recovered +via a one-dimensional density profile fit: -.. image:: ../../images/wetting_angle_kit_slicing_2d.jpg - :align: center +.. math:: -Within this plane, we identify the interface coordinates by scanning radially from the geometric center. For a given axis (defined by an altitudinal angle), the local density is measured at discrete intervals. A function is then fitted to this density profile to locate the sharp drop in density that marks the limit between the liquid and vapor phases. This operation is repeated across a range of altitudinal angles to generate a cloud of points representing the droplet's profile on that plane. + \rho(\zeta) \;=\; + \tfrac{1}{2} \bigl[(\rho_1 + \rho_2) + \;-\; (\rho_1 - \rho_2)\,\tanh\!\bigl(2 (\zeta - \zeta_d) / t\bigr)\bigr], -To calculate the contact angle, points near the substrate are first excluded to avoid boundary effects. A circle is then fitted to the remaining interface points, and the contact angle is derived from the intersection of this circle with the bottom of the droplet (the substrate). Finally, the entire procedure is repeated for multiple azimuthal angles (rotating the slicing plane). This yields a distribution of contact angles, from which a mean contact angle is computed. +where :math:`\zeta` is the running coordinate along a ray (or a row of +a density grid), :math:`\rho_1` and :math:`\rho_2` are the bulk +liquid and vapor densities, :math:`\zeta_d` is the interface +location, and :math:`t` is the interface thickness (~1 Å for water at +room temperature). The interface is :math:`\zeta = \zeta_d`. -.. image:: ../../images/wetting_angle_kit_cylinder.jpg - :align: center +This tanh profile is theoretically motivated by mean-field theory of +liquid–vapor interfaces (van der Waals / Cahn–Hilliard square-gradient +free energy) and is an excellent empirical fit to MD density +profiles in the same regime. + +3. Estimating local density +--------------------------- + +To fit the tanh profile to a sampled density, we first need a local +density estimate at each sample point. Two estimators are available, +swappable via :class:`InterfaceExtractor`: + +**Gaussian KDE** (``rays_gaussian`` / ``grid_gaussian``) + Each atom contributes a normalised 3D Gaussian of width + :math:`\sigma`: + + .. math:: + + \rho_{\rm KDE}(\mathbf{r}) \;=\; \sum_i + \frac{1}{(2\pi)^{3/2}\sigma^3}\, + e^{-\|\mathbf{r} - \mathbf{r}_i\|^2 / 2\sigma^2}. + + Smooth and bias-controlled (the only knob is :math:`\sigma`), + which makes it the default choice. For efficiency, a per-atom + cut-off at :math:`5\sigma` is applied via a cKDTree. + +**3D top-hat** (``rays_binning`` / ``grid_binning``) + Atoms within :math:`{\rm bin\_width}/2` of the sample contribute + uniformly: + + .. math:: + + \rho_{\rm bin}(\mathbf{r}) \;=\; + \frac{N(\mathbf{r}, {\rm bin\_width}/2)}{V_{\rm bin}}. + + Fast and conceptually simple, but the hard cut-off introduces + Poisson noise that can interfere with the tanh fit unless the bin + width is matched to the smoothing length you'd otherwise pick. + The legacy binning analyzer used this estimator. + +Both estimators implement the same +:class:`DensityFieldProtocol`, so the analysis pipeline can plug +either one into the same ray-fan or grid extraction. + +4. Sampling the interface: rays vs grid +--------------------------------------- + +Two strategies turn the density estimator into a clean point set on +the interface: + +4.1 Ray fans +^^^^^^^^^^^^ + +The :meth:`InterfaceExtractor.rays_gaussian` / +:meth:`rays_binning` factories emit a fan of rays from the droplet +COM, sample the density along each ray, and recover the interface +position as the half-density point of a 1D tanh fit on that ray. + +Three ray-fan geometries are used depending on the +``(surface_kind, droplet_geometry)`` pair: + +* **slicing + spherical**: a 2D ray fan in each azimuthal plane + through the droplet (planes spaced by ``delta_azimuthal``); + within each plane, rays at polar angles spaced by ``delta_polar``. +* **slicing + cylinder**: a 2D ray fan in each ``y``-step plane + (planes spaced by ``delta_cylinder``); same polar fan within each + plane. +* **whole + spherical**: a full-sphere Fibonacci ray fan from the + COM. Equal-area in :math:`(\cos\theta, \phi)` with the golden + angle in :math:`\phi`; total ray count is ``n_rays_sphere``. + Full-sphere coverage is important: downward rays from the COM + hit the wall plane and produce shell points at :math:`z \approx + z_w`, which is what makes + :meth:`WallDetector.min_plus_offset` work for the whole-fit. +* **whole + cylinder**: a per-:math:`y` ray fan in the ``(x, z)`` + plane (planes spaced by ``delta_cylinder``); the resulting shell + is the union of these per-:math:`y` rings. + +Why Fibonacci on the sphere? Naive uniform :math:`(\theta, \phi)` +gridding clusters rays near the poles, oversampling there and +undersampling the equator. The Fibonacci spiral (uniform +:math:`\cos\theta`, golden angle :math:`\phi`) gives near-perfect +equal-area coverage with no clustering anywhere. + +4.2 Grid + iso-contour +^^^^^^^^^^^^^^^^^^^^^^ + +The :meth:`InterfaceExtractor.grid_gaussian` / +:meth:`grid_binning` factories build a 2D or 3D density grid by +histogramming the atom positions and (optionally) smoothing with a +Gaussian. The interface is then recovered as the iso-density +contour at the half-bulk level, via +:func:`skimage.measure.find_contours` in 2D (marching squares) or +:func:`skimage.measure.marching_cubes` in 3D. + +Closer to the "average over many frames" intuition than ray fans; +works well when atom statistics are limited per frame. + +For slicing-mode grid extraction the atoms are projected onto +2D ``(r, z)`` via the droplet's symmetry (radial for spherical, +``|x - x_c|`` for cylinder). The density grid is normalised by + +.. math:: + + dV_{\rm cell} \;=\; + \begin{cases} + 2\pi\,r\,d r\,d z & \text{spherical} \\ + d r\,d z & \text{cylinder} + \end{cases} + +so the annular volume of each cell is accounted for. + +5. Fitting the cap: algebraic Kasa fits +--------------------------------------- + +Given a clean point set on the interface, the surface fitter +recovers the spherical-cap parameters :math:`(z_c, R)` (and +:math:`(x_c, y_c)` in 3D) via an **algebraic Kasa fit**. + +For a 3D sphere fit to points :math:`(x_i, y_i, z_i)`, the implicit +equation :math:`(x - x_c)^2 + (y - y_c)^2 + (z - z_c)^2 = R^2` +expands and linearises to + +.. math:: + + 2 x_c\,x \;+\; 2 y_c\,y \;+\; 2 z_c\,z \;+\; c + \;=\; x^2 + y^2 + z^2, + \qquad c \,=\, R^2 - x_c^2 - y_c^2 - z_c^2. + +The four-parameter linear system +:math:`A \cdot (x_c, y_c, z_c, c)^\top = b` is solved by +:func:`numpy.linalg.lstsq`; :math:`R` is recovered from :math:`c`. +The 2D version is identical with the :math:`y` column dropped. + +This is the **algebraic** fit (minimises the residual on the +implicit equation), not the **geometric** fit (minimises the +distance to the fitted shape). Algebraic Kasa has a closed-form +linear solution, no iteration, no initial guess — which makes it +the right default. Its known bias against small radii is harmless +when the recovered shell already sits very close to the true +interface. + +The slicing fitter (:meth:`SurfaceFitter.slicing`) runs one Kasa +**circle** fit per slice in the slice's ``(x, z)`` plane, then +averages the per-slice angles. The whole fitter +(:meth:`SurfaceFitter.whole`) runs one Kasa **sphere** fit +(spherical droplet) or one Kasa **circle** fit (cylindrical +droplet, exploiting translational symmetry along :math:`y`) on the +entire shell. + +6. Locating the wall plane +-------------------------- + +The contact angle is read from the cap–wall intersection, so the +wall plane :math:`z_w` has to be located explicitly: + +* :meth:`WallDetector.min_plus_offset` — derive :math:`z_w` from + the interface itself, as :math:`z_w = \min(z_{\rm interface}) + + \mathrm{offset}`. For slicing extractors the minimum across all + slices' interface points lands on the contact line; for the + full-sphere ray fan, downward rays from the COM reach the wall + plane, so :math:`\min(z_{\rm shell})` is again physically + meaningful. -In the case of cylindrical droplets, the procedure is similar, but the slicing plane is defined by the axis of the cylinder and not an azimuthal angle. The slice is done along the axis of the cylinder, giving a list of angles along the axis of the cylinder. +* :meth:`WallDetector.from_atoms` — read wall-atom positions from + the trajectory and place :math:`z_w` at the mean of the **top + atomic layer** (atoms within ``top_layer_tolerance`` of the + highest wall atom). Physically faithful when the simulation + explicitly models the substrate. -The binning method ------------------- +* :meth:`WallDetector.explicit` — caller supplies :math:`z_w` + directly. Useful when the wall position is known a priori from + the simulation setup (e.g. a Lennard-Jones 9-3 wall at a known + :math:`z`-coordinate). + +A consequence worth remembering: the recovered angle is +sensitive to the wall position via the cap geometry +:math:`\cos \theta = (z_w - z_c)/R`. A 1.5 Å shift in :math:`z_w` +on a 25 Å droplet at :math:`\theta \approx 95^\circ` corresponds +to roughly a 3° shift in the recovered angle. So either pick the +wall detector that matches your trust budget, or report the angle +for two choices to make the dependence visible. + +7. Joint coupled-binning fit +---------------------------- + +The :class:`CoupledBinning2DAnalyzer` and +:class:`CoupledBinning3DAnalyzer` skip the +extractor/wall/fitter decomposition and fit a multi-parameter +density model directly. + +7.1 The 2D model (7 parameters) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +After projecting atoms to ``(xi, zi)`` via the droplet symmetry, the +analyzer histograms the density and fits + +.. math:: + + \rho(\xi, z) \;=\; g(r) \cdot h(z - z_0), + \qquad r = \sqrt{\xi^2 + (z - z_c)^2}, + +with + +.. math:: + + g(r) \;=\; + \tfrac{1}{2}\bigl[(\rho_1 + \rho_2) + - (\rho_1 - \rho_2)\tanh\!\bigl(2(r - R_{eq})/t_1\bigr)\bigr], + \qquad + h(\eta) \;=\; + \tfrac{1}{2}\bigl[1 + \tanh\!\bigl(2 \eta / t_2\bigr)\bigr]. + +The radial sigmoid :math:`g(r)` describes the spherical-cap +interface; the vertical sigmoid :math:`h(z - z_0)` cuts off the +density below the wall plane :math:`z_0`. The seven free +parameters :math:`(\rho_1, \rho_2, R_{eq}, z_c, z_0, t_1, t_2)` are +fit jointly by a bounded nonlinear least-squares +(:func:`scipy.optimize.curve_fit`). + +The contact angle follows directly: + +.. math:: + + \cos \theta \;=\; \frac{z_0 - z_c}{R_{eq}}. + +7.2 The 3D model (9 parameters) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The 3D extension bins the full ``(xi, yi, zi)`` density and fits + +.. math:: + + \rho(\xi, \eta, z) \;=\; g(r) \cdot h(z - z_0), + \qquad r = \sqrt{(\xi - \xi_c)^2 + (\eta - \eta_c)^2 + (z - z_c)^2}, + +with two extra parameters :math:`\xi_c, \eta_c` for the +horizontal centre. Nine free parameters; same cap geometry for +:math:`\theta`. Spherical droplets only — cylindrical droplets are +rejected at construction because translational symmetry along the +cylinder axis already collapses the 3D problem onto the 2D one. + +7.3 Why a joint fit? +^^^^^^^^^^^^^^^^^^^^ + +The coupled fit shares information across the cap and the wall: +the radial sigmoid is constrained by the apex curvature and the +contact line simultaneously, and the vertical sigmoid pins the +wall plane against the cap's lower extent. Statistically more +efficient than the decoupled pipeline when you can afford to pool +many frames per batch; less informative per batch (single angle) +and slower per batch (a 7-parameter NLLS rather than four linear +solves). + +8. Periodic boundaries and droplet recentering +---------------------------------------------- + +MD simulations are run with periodic boundary conditions; a droplet +that drifts during the run can end up "split" across an :math:`x` +or :math:`y` periodic edge by the time you analyse a given frame. +A naive arithmetic mean of the atom positions would then place the +"centre" inside the empty vapor region between the two halves of +the droplet, ruining every downstream computation. + +wetting_angle_kit uses a **circular-mean** recentering that handles +this automatically. For each periodic direction :math:`u \in +\{x, y\}`, atom positions :math:`u_i` are first mapped to the unit +circle: + +.. math:: + + \phi_i \;=\; 2\pi \, u_i / L_u, + \qquad + \bar\phi \;=\; {\rm atan2}\!\Bigl(\textstyle\sum_i \sin\phi_i, + \;\textstyle\sum_i \cos\phi_i\Bigr). + +The mean angle :math:`\bar\phi` is the circular mean; the +recentered atom positions are then + +.. math:: + + u_i' \;=\; ((u_i - L_u\bar\phi/(2\pi) + L_u/2) \bmod L_u) - L_u/2, + +i.e. fold every atom into the box such that the droplet is +centred on the box's middle. Trajectories where the droplet +drifts or wraps across a periodic edge are handled transparently; +producing a pre-recentered trajectory at simulation time is +optional. + +The single precondition is that the **simulation box must be large +enough that the droplet does not interact with its periodic +image** — i.e. the droplet's lateral diameter must be comfortably +below the box length. If that condition is violated, the radial +density profile is physically meaningless regardless of the +centering strategy. + +9. Frame batching +----------------- + +The :class:`TemporalAggregator` groups trajectory frames into +batches before the full pipeline runs. Three regimes are useful: + +``batch_size=1`` + One pipeline run per frame. Best for time-resolved studies; + the per-frame ``angle_std`` (per-slice scatter for slicing fits, + bootstrap σ for whole fits) reports the within-frame + uncertainty. + +``batch_size=N`` + Pool :math:`N` consecutive frames before the fit. Fewer + batches, more atoms per fit → less noise per angle, but you + lose time resolution within each batch. + +``batch_size=-1`` + Pool every requested frame into a single batch — one angle for + the whole trajectory. The default for the coupled-binning + analyzers; useful for the slicing/whole pipeline too when you + only want a representative angle. + +The trade-off: the per-batch fit cost scales with the number of +atoms in the batch (roughly linearly for ray fans, sub-linearly +for grid binning), but the noise on the recovered angle scales +inversely with :math:`\sqrt{N}` in regimes where shot noise +dominates. For a 4k-atom droplet on a typical room-temperature +trajectory, ``batch_size`` between 1 and 10 covers the useful +range. + +10. Geometric symmetry classes +------------------------------ + +Three geometries are supported via :class:`DropletGeometry`: + +* ``"spherical"`` — full 3D droplet with no special axis. +* ``"cylinder_y"`` — cylindrical droplet along the :math:`y` axis + (the internal frame's cylinder axis). +* ``"cylinder_x"`` — cylindrical droplet along the :math:`x` axis; + internally swapped to ``cylinder_y`` for the analysis (atom + positions are permuted, then the result is permuted back). + +The geometry choice cascades through every component: + +.. image:: ../../images/wetting_angle_kit_cylinder.jpg + :align: center -The Binning Method utilizes a global averaging approach. It aggregates particle coordinates across multiple frames into a 2D spatial grid, generating a time-averaged density field. This density field is fitted with a hyperbolic tangent model to describe the liquid-vapor interface, from which the contact angle is derived. +* the interface extractor picks a 2D/3D ray fan or grid axis; +* the wall detector reads :math:`\min(z)` over either the full + interface or per-slice as appropriate; +* the surface fitter applies a sphere fit (spherical) or a 2D + circle fit in :math:`(x, z)` (cylinder, using the translational + invariance along :math:`y`). -This method is computationally efficient and is well suited for symmetric droplets or cases where a global, averaged representation is preferred. It is particularly well suited for processing large datasets due to the reduction in the problem’s dimensionality, but requires a sufficiently large sample size to generate smooth density profiles. +The cylindrical case is mechanically identical to the spherical +one — same Kasa fit, same cap geometry, same :math:`\cos \theta += (z_w - z_c)/R` — but applied per-axis-step rather than +azimuthally. The slicing tutorial includes a worked example; +the whole-fit tutorial covers the cylinder case under +"Alternative configurations". diff --git a/docs/source/tutorials/binning_method_tuto.rst b/docs/source/tutorials/binning_method_tuto.rst index 6d70f64..28f4ce7 100644 --- a/docs/source/tutorials/binning_method_tuto.rst +++ b/docs/source/tutorials/binning_method_tuto.rst @@ -1,124 +1,240 @@ -Tutorial: Contact Angle Analysis (Binning Method) -================================================= +Tutorial: Contact Angle Analysis (Coupled Binning) +=================================================== -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. +This tutorial covers :class:`CoupledBinning2DAnalyzer`, the +joint-fit alternative to the composable +:class:`TrajectoryAnalyzer` pipeline. The binning analyzer solves +interface extraction, wall detection, and surface fit together by +fitting a seven-parameter hyperbolic-tangent density model directly to +a binned 2D density field. One robust angle per pooled batch — ideal +when you have many frames and don't need per-frame time resolution. ---- 1. Overview ----------- -The **binning method** works by: +The pipeline does three things per batch: -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. +1. **Density grid.** Pool the liquid atom positions across the + batch's frames, project them to the ``(xi, zi)`` plane via the + droplet's symmetry (radial for spherical droplets, perpendicular + to the cylinder axis for cylindrical droplets), and build a 2D + histogram with the user-supplied grid bounds and bin counts. + Apply geometry-aware volume normalisation + (``dV = 2π xi dxi dzi`` for spherical, ``dV = box_y · dxi dzi`` + for cylinder). +2. **Joint NLLS fit.** Fit a seven-parameter hyperbolic-tangent + density model + + .. math:: + + \rho(\xi, z) = g(r) \cdot h(z - z_0), + \qquad r = \sqrt{\xi^2 + (z - z_c)^2}, + + with ``g(r)`` a radial sigmoid centred at ``(0, z_c)`` of + equivalent radius ``R_{eq}`` and interface thickness ``t_1``, and + ``h(z - z_0)`` a vertical sigmoid above the wall ``z_0`` of + thickness ``t_2``. The fit returns the parameters + ``(rho1, rho2, R_eq, z_c, z_0, t1, t2)``. +3. **Contact angle.** The cap–wall intersection geometry gives the + contact angle directly: + + .. math:: + + \cos \theta = \frac{z_0 - z_c}{R_{eq}}. ---- 2. Prerequisites ---------------- -Your trajectory file (e.g., a LAMMPS dump file) should contain: - -- Atom IDs, types, and positions -- Liquid particles (in this case, water molecules: O and H atoms) - +Your trajectory file should contain atom IDs, types, and positions. Example trajectory:: tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj ---- -3. Example Script ------------------ +3. Example Code +--------------- .. code-block:: python - # Import necessary modules + from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer + from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - from wetting_angle_kit.analysis import BinningTrajectoryAnalyzer # --- 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 --- + # --- Step 2: Identify water-oxygen atoms --- + 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:", len(oxygen_indices)) - # --- Step 4: Define binning parameters --- + # --- Step 3: Define the 2D binning grid --- 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 + "xi_0": 0.0, + "xi_f": 100.0, + "nbins_xi": 50, + "zi_0": 0.0, + "zi_f": 100.0, + "nbins_zi": 25, } - # --- Step 5: Initialize the parser --- - parser = LammpsDumpParser(filename) - - # --- Step 6: Create the contact angle analyzer --- - analyzer = BinningTrajectoryAnalyzer( - parser=parser, + # --- Step 4: Build the analyzer --- + analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, - droplet_geometry="cylinder_y", # Interface fitting model + droplet_geometry="cylinder_y", binning_params=binning_params, + # 10-frame pooled batches (legacy split_factor=10 analog) + temporal_aggregator=TemporalAggregator(batch_size=10), ) - # --- Step 7: Run analysis for a frame range --- - results = analyzer.analyze([1]) # Analyze frame 1 + # --- Step 5: Run analysis for a frame range --- + results = analyzer.analyze(range(0, 100)) print("Mean contact angle (°):", results.mean_angle) + print("Std across batches (°):", results.std_angle) + for batch in results.batches[:3]: + print( + f"Frames {batch.frames[0]}–{batch.frames[-1]}: " + f"angle = {batch.angle:.2f}°, " + f"R_eq = {batch.model_params['R_eq']:.2f} Å, " + f"z_wall = {batch.model_params['zi_0']:.2f} Å" + ) ---- 4. Output --------- -Running this example will: +The returned :class:`CoupledBinning2DResults` object exposes: -- 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). +* ``mean_angle`` / ``std_angle`` — mean and std across batches. +* ``per_batch_angles`` — array of one angle per batch. +* ``batches`` — list of :class:`CoupledBinning2DBatchResult` entries. + Each batch carries ``angle``, ``model_params`` (the seven tanh + parameters), and ``xi_grid`` / ``zi_grid`` / ``density`` — the + binned density field used for the fit. Feed any batch (or the + full results object) into :class:`DensityContourPlotter` to draw + the density contour with the fitted spherical cap overlaid (see + :doc:`visualization_evolution_density`). Example printed output:: Number of water molecules: 4000 - Mean contact angle (°): 94.58987060394456 + Mean contact angle (°): 99.11 + Std across batches (°): 0.0 + Frames 0–9: angle = 99.11°, R_eq = 42.13 Å, z_wall = 5.85 Å + +---- + +5. Tips +------- + +- **Grid bounds and bin counts**: pick ``xi_f`` and ``zi_f`` so the + droplet sits well inside the grid; pick ``nbins_xi`` and + ``nbins_zi`` so each cell receives many atoms when pooling. As a + rule of thumb, aim for at least 20 atoms per occupied cell after + pooling across the batch. +- **Batch size**: the joint fit benefits from statistics, so pool as + many frames as your time-resolution needs allow. ``batch_size=-1`` + (the default) pools everything into one batch and returns a single + angle. +- **Initial parameters**: the default initial guess + ``[rho1, rho2, R_eq, z_c, z_0, t1, t2]`` is tuned for + full-atomistic water at room temperature. Pass ``initial_params`` + explicitly if you see the fit's ``rho1`` or ``rho2`` pegged at the + zero bound (the analyzer warns when this happens). +- **3D extension**: if you suspect significant deviation from + axisymmetry, swap in :class:`CoupledBinning3DAnalyzer`. Spherical + droplets only; same API plus extra ``yi_*`` bin keys. The full + tutorial is :doc:`coupled_binning_3d_tuto`. + +For a side-by-side density contour plot with the fitted cap +overlaid, see :doc:`visualization_evolution_density`. + +---- + +6. Alternative configurations +----------------------------- + +6.1 Cylindrical droplet +^^^^^^^^^^^^^^^^^^^^^^^ -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: +The 2D coupled-binning analyzer handles cylindrical droplets out of +the box — pass ``droplet_geometry="cylinder_y"`` (or +``"cylinder_x"``). The projection switches from radial +(:math:`\xi = \sqrt{x^2 + y^2}`) to perpendicular-to-axis +(:math:`\xi = |x - x_c|`) and the density normalisation changes +from spherical (:math:`dV = 2\pi \xi\, d\xi\, dz`) to cartesian +(:math:`dV = L_y\, d\xi\, dz`, with :math:`L_y` the box length +along the cylinder axis): .. code-block:: python - from wetting_angle_kit.visualization import BinningTrajectoryPlotter + analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(cylinder_fixture), + atom_indices=oxygen_indices, + droplet_geometry="cylinder_y", + binning_params={ + "xi_0": 0.0, + "xi_f": 100.0, + "nbins_xi": 50, + "zi_0": 0.0, + "zi_f": 100.0, + "nbins_zi": 25, + }, + ) - plotter = BinningTrajectoryPlotter(results) - fig = plotter.plot_density_contour(batch_index=0) - fig.show() +The seven-parameter model and the cap-angle formula are identical +to the spherical case; the geometry change is fully absorbed into +the projection and the volume normalisation. ----- +6.2 Custom initial parameters +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -5. Tips -------- +The default initial guess +:math:`(\rho_1, \rho_2, R_{eq}, z_c, z_0, t_1, t_2) = +(10^{-3}, 0.03, 40, 20, 4, 1, 1)` +is tuned for full-atomistic water at room temperature in Å units. +If your simulation uses different units or a different liquid, pass +``initial_params=`` explicitly: + +.. code-block:: python + + analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + initial_params=[1e-3, 0.02, 25.0, 8.0, 5.0, 1.0, 1.0], + ) + +The analyzer emits a warning if any fitted parameter ends up +pinned at the physical lower bound (densities at 0, lengths at +:math:`10^{-6}`) — that's the usual sign your initial guess is +far from the true minimum or your grid bounds are wrong. + +6.3 Single fully-pooled batch +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Drop the ``temporal_aggregator`` argument (or set +``batch_size=-1``) to get one angle for the whole trajectory: + +.. code-block:: python + + results = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + ).analyze(range(0, 200)) + print(results.batches[0].angle) # single representative angle -- 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. -- Multi-batch analysis: ``analyzer.analyze(range(0, 100), split_factor=10)`` splits the frame range into batches of ten frames each. +This is the natural mode for the coupled fit — the joint NLLS +benefits from as much statistics as you can throw at it. Use +``batch_size=N`` only if you actually want time resolution +(e.g. to see contact-angle relaxation during a wetting event). diff --git a/docs/source/tutorials/coupled_binning_3d_tuto.rst b/docs/source/tutorials/coupled_binning_3d_tuto.rst new file mode 100644 index 0000000..5312212 --- /dev/null +++ b/docs/source/tutorials/coupled_binning_3d_tuto.rst @@ -0,0 +1,238 @@ +Tutorial: 3D Coupled-Binning Analyzer +====================================== + +:class:`CoupledBinning3DAnalyzer` is the 3D extension of +:class:`CoupledBinning2DAnalyzer`. Instead of projecting atoms to a +2D ``(xi, zi)`` plane and exploiting radial symmetry, it bins the +full 3D density ``rho(xi, yi, zi)`` and fits a nine-parameter +hyperbolic-tangent density model directly: + +.. math:: + + \rho(\xi, \eta, z) \;=\; g(r) \cdot h(z - z_0), + \qquad r = \sqrt{(\xi - \xi_c)^2 + (\eta - \eta_c)^2 + (z - z_c)^2}, + +with two extra horizontal-centre parameters +:math:`\xi_c, \eta_c` over the 2D model. See +:doc:`../introduction/theoretical_foundations` section 7 for the full +model. + +---- + +1. When to pick the 3D variant? +------------------------------- + +The 2D analyzer assumes axisymmetry: the joint fit collapses the +droplet onto a 2D ``(xi, zi)`` profile via the radial coordinate +:math:`\xi = \sqrt{x^2 + y^2}`. That assumption is excellent for +clean spherical droplets but breaks if the droplet is asymmetric — +e.g. on a heterogeneous wall, near a step edge, or under an external +field. + +The 3D analyzer **does not** assume axisymmetry. It still fits a +spherical cap (the radial sigmoid in the model is a sphere), but the +two extra parameters :math:`\xi_c, \eta_c` let the fit identify the +horizontal cap centre instead of requiring it to coincide with the +COM. Useful when: + +* you suspect the droplet's footprint is shifted away from the + geometric centre of mass; +* you want to verify visually (via + :class:`DensityContourPlotter`) that the recovered radial profile + is consistent in different azimuthal directions. + +For purely axisymmetric droplets, the 2D analyzer is several times +cheaper for the same statistical quality. The 3D analyzer is the +right choice when you suspect asymmetry, or when you want the +azimuthally-collapsed density plot from a 3D fit as cross-validation. + +Cylindrical droplets are **rejected at construction**: their +translational symmetry along the cylinder axis already collapses the +3D problem onto the 2D one, so the 3D analyzer would just be +wasting work. + +---- + +2. Worked example +------------------ + +.. code-block:: python + + from wetting_angle_kit.analysis import CoupledBinning3DAnalyzer + from wetting_angle_kit.analysis.temporal import TemporalAggregator + from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder + + filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + oxygen_indices = LammpsDumpWaterFinder( + filename, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(frame_index=0) + + # 3D grid spec. xi/yi are in the droplet-centred frame; zi is in the + # lab frame so the wall position retains physical meaning. + binning_params = { + "xi_0": -40.0, + "xi_f": 40.0, + "nbins_xi": 25, + "yi_0": -40.0, + "yi_f": 40.0, + "nbins_yi": 25, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 25, + } + + analyzer = CoupledBinning3DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + # 3D density grids need more frames per batch than 2D ones to + # reach the same per-cell noise; default is fully pooled. + temporal_aggregator=TemporalAggregator(batch_size=-1), + ) + results = analyzer.analyze(range(0, 100)) + batch = results.batches[0] + + print(f"Angle: {batch.angle:.2f}°") + print(f"R_eq: {batch.model_params['R_eq']:.2f} Å") + print( + f"Cap centre (x_c, y_c, z_c): " + f"({batch.model_params['xi_c']:.2f}, " + f"{batch.model_params['yi_c']:.2f}, " + f"{batch.model_params['zi_c']:.2f}) Å" + ) + print(f"Wall z_0: {batch.model_params['zi_0']:.2f} Å") + +---- + +3. Reading the recovered centre +------------------------------- + +For an axisymmetric droplet the recovered :math:`(\xi_c, \eta_c)` +should collapse to ``(0, 0)`` — the COM-centred grid is set up +exactly so that's what zero offset means. Substantial non-zero +values are diagnostic: + +* :math:`|\xi_c| + |\eta_c| < 0.5` Å: cleanly axisymmetric. +* a few Å: mild asymmetry — the cap is genuinely off-axis, or the + per-frame PBC recentering disagrees with the cap centre. +* tens of Å: something is wrong — the grid bounds are too tight, the + initial parameters are off, or the droplet is split across a + periodic boundary the PBC recentering didn't catch. + +The horizontal-centre recovery is the main reason to prefer the 3D +analyzer over the 2D one when you suspect a non-spherical-cap +geometry: it tells you *whether* the assumption holds, not just the +angle conditional on it holding. + +---- + +4. Visualising the 3D density +----------------------------- + +The 3D density tensor is too rich to plot directly. The +:class:`DensityContourPlotter` collapses the 3D density azimuthally +onto a 2D ``(r, z)`` plane (binning by :math:`r = +\sqrt{x^2 + y^2}` and averaging per :math:`z`-slice), then renders +it exactly like the 2D variant — with the fitted spherical cap +overlaid: + +.. code-block:: python + + from wetting_angle_kit.visualization import DensityContourPlotter + + # Single batch — azimuthally averaged onto (r, z). + fig = DensityContourPlotter(batch, label="spherical_4k").plot() + fig.show() + + # Whole results object — averaged across batches first, then + # azimuthally averaged. + fig = DensityContourPlotter(results, label="spherical_4k").plot() + fig.show() + +The default title indicates the azimuthal collapse so the plot is +unambiguous. + +---- + +5. Cross-check: 2D vs 3D +------------------------- + +On an axisymmetric droplet, the 2D and 3D analyzers should recover +the same angle within a few degrees. It's a useful sanity check: + +.. code-block:: python + + from wetting_angle_kit.analysis import ( + CoupledBinning2DAnalyzer, + CoupledBinning3DAnalyzer, + ) + + # Same trajectory, same frames; pick comparable grids. + binning_2d = { + "xi_0": 0.0, + "xi_f": 40.0, + "nbins_xi": 40, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 40, + } + binning_3d = { + "xi_0": -40.0, + "xi_f": 40.0, + "nbins_xi": 25, + "yi_0": -40.0, + "yi_f": 40.0, + "nbins_yi": 25, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 25, + } + + a2d = ( + CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_2d, + ) + .analyze([1]) + .batches[0] + ) + a3d = ( + CoupledBinning3DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_3d, + ) + .analyze([1]) + .batches[0] + ) + + print(f"2D: {a2d.angle:.2f}°") + print(f"3D: {a3d.angle:.2f}°") + print(f"|Δ|: {abs(a2d.angle - a3d.angle):.2f}°") + +On the test fixture this gives 94.46° (2D) vs 95.42° (3D), drift +≈ 0.96° — within the expected noise from the sparser 3D grid. + +---- + +6. Tips +------- + +- **Grid resolution**: 3D grids need substantially more atoms per + cell than 2D ones for comparable noise. Pool more frames per + batch (``batch_size`` larger), or use coarser grids (~25 bins per + axis is usually enough). The 9-parameter fit doesn't benefit from + arbitrarily fine grids the way one might naively expect. +- **Initial parameters**: the default initial guess + ``[rho1, rho2, R_eq, xi_c, yi_c, zi_c, z_0, t1, t2]`` is tuned + for full-atomistic water at room temperature with ``xi_c = + yi_c = 0``. Override via ``initial_params`` if you have a strong + prior on the cap geometry. +- **No cylinder support**: pass ``droplet_geometry="cylinder_y"`` + to ``CoupledBinning3DAnalyzer`` and you'll get a ``ValueError`` + at construction explaining the design choice; route cylindrical + droplets through :class:`CoupledBinning2DAnalyzer` instead. diff --git a/docs/source/tutorials/grid_method_tuto.rst b/docs/source/tutorials/grid_method_tuto.rst new file mode 100644 index 0000000..2d6761a --- /dev/null +++ b/docs/source/tutorials/grid_method_tuto.rst @@ -0,0 +1,206 @@ +Tutorial: Grid-Based Interface Extraction +========================================== + +This tutorial covers the **grid-based interface extractors** — +:meth:`InterfaceExtractor.grid_gaussian` and +:meth:`InterfaceExtractor.grid_binning`. They are an alternative to +the ray-fan extractors used in the +:doc:`slicing_method_tuto` and +:doc:`whole_fit_tuto`: instead of locating the interface as the +half-density point of a 1D tanh fit along each ray, they build a +2D or 3D density grid and recover the interface as the iso-density +contour at the half-bulk level. + +---- + +1. When to pick grid over rays? +------------------------------- + +Both extractors plug into the same :class:`TrajectoryAnalyzer` and +produce the same downstream result objects, so the choice is mostly +about how the noise/cost trade-off lands on your system: + +* **Ray fans** sample density along a small number of well-chosen + directions; each ray's 1D tanh fit is cheap. Best when atom + statistics per frame are high and the droplet has well-defined + symmetry. +* **Grids** estimate density on every cell of a fixed mesh, then + trace an iso-contour. Closer to the "average over many frames" + intuition; the per-cell density gets smoother as more frames are + pooled. Robust when individual frames are sparse, but it scales + with the number of cells rather than the number of rays so the + grid resolution matters more than in the ray case. + +The grid extractors require ``scikit-image`` for the iso-contour +tracing (marching squares in 2D, marching cubes in 3D). Install via +the ``grid3d`` extra:: + + pip install wetting-angle-kit[grid3d] + +---- + +2. Worked example: ``grid_gaussian`` + slicing fit +--------------------------------------------------- + +A spherical droplet, with a 2D density grid in the +:math:`(r, z)` plane (the slicing-mode grid extractor projects atoms +to ``(r, z)`` via the droplet's radial symmetry — see +:doc:`../introduction/theoretical_foundations` section 4.2 for the +volume normalisation): + +.. code-block:: python + + from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, + ) + from wetting_angle_kit.analysis.temporal import TemporalAggregator + from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder + + filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + oxygen_indices = LammpsDumpWaterFinder( + filename, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(frame_index=0) + + # 2D grid for slicing-mode extraction: (xi, zi) cells. + # Aim for cells small enough to resolve the interface (~1 Å is plenty + # for a Gaussian-smoothed grid) but large enough that occupied cells + # carry many atoms. + grid_params = { + "xi_0": 0.0, + "xi_f": 40.0, + "nbins_xi": 26, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 26, + } + + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.grid_gaussian( + grid_params=grid_params, + density_sigma=3.0, + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=3.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + batch = analyzer.analyze([1]).batches[0] + print( + f"Angle (grid_gaussian + slicing): {batch.angle:.2f}° " f"± {batch.angle_std:.2f}°" + ) + +---- + +3. Histogram alternative: ``grid_binning`` +------------------------------------------ + +Same flow but the density estimator is a 1D top-hat +(``rho = counts / dV``) rather than a Gaussian KDE. Numerically +cheaper but noisier per cell. Use a smaller grid to keep enough +atoms per cell: + +.. code-block:: python + + from wetting_angle_kit.analysis import InterfaceExtractor + + grid_params = { + "xi_0": 0.0, + "xi_f": 40.0, + "nbins_xi": 16, # coarser + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 16, + } + + extractor = InterfaceExtractor.grid_binning(grid_params=grid_params) + # ... plug into TrajectoryAnalyzer exactly as above. + +The slicing fitter's ``surface_filter_offset`` is a useful knob +here: histogram-based grids tend to have an iso-contour "floor" +just above the wall, which the filter drops out of the circle fit. + +---- + +4. 3D iso-surface for the whole-fit +------------------------------------ + +The grid extractors also work in whole-fit mode for spherical +droplets — the 2D density grid is replaced by a 3D one, and the +half-bulk iso-surface is traced via marching cubes: + +.. code-block:: python + + grid_params_3d = { + "xi_0": -30.0, + "xi_f": 30.0, + "nbins_xi": 26, + "yi_0": -30.0, + "yi_f": 30.0, + "nbins_yi": 26, + "zi_0": 0.0, + "zi_f": 35.0, + "nbins_zi": 26, + } + + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.grid_gaussian( + grid_params=grid_params_3d, + density_sigma=3.0, + ), + surface_fitter=SurfaceFitter.whole( + surface_filter_offset=3.0, + bootstrap_samples=100, + ), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + batch = analyzer.analyze([1]).batches[0] + print( + f"Angle (grid_gaussian + whole-fit): " + f"{batch.angle:.2f}° ± {batch.angle_std:.2f}°" + ) + +Three notes on the 3D case: + +* ``xi/yi`` are in the **droplet-centred frame** (the per-frame COM + is subtracted before binning); ``zi`` stays in the lab frame so + the wall position keeps its physical meaning. +* ``grid + whole-fit`` is currently spherical-only — cylindrical + droplets need the ray-fan extractor because the centred-grid + convention doesn't accommodate the cylinder axis spanning the + full box. For cylinder whole fits use + :meth:`InterfaceExtractor.rays_gaussian` with ``delta_cylinder``. +* Marching cubes can be slow on dense 3D grids; if performance + matters, start with 20–30 bins per axis and only refine if the + recovered angle is grid-resolution-limited. + +---- + +5. Tips +------- + +- **Grid bounds**: always pick ``xi_f``, ``yi_f``, ``zi_f`` so the + full droplet fits comfortably inside the grid; the iso-contour + tracer can't extrapolate. +- **Smoothing**: ``density_sigma`` on the Gaussian variant + controls cell smoothing; values around the interface thickness + (~1–3 Å for water) work well. The histogram variant exposes no + smoothing knob — choose the cell size accordingly. +- **Cell size vs ``surface_filter_offset``**: rows of the 2D grid + closest to the wall are normalised by a narrow annular volume + (``2π r dr dz``) which inflates noise. The slicing fitter's + ``surface_filter_offset=3.0`` (instead of the default 2.0) + reliably drops the noisy floor. +- **Comparison plot**: it's often useful to run the same trajectory + through both ``rays_gaussian`` and ``grid_gaussian`` and check + the two angles agree within method-dependent tolerance (a few + degrees on 4k-atom droplets). If they diverge more than ~5°, one + of them is misconfigured (most often the grid bounds are too + tight or ``surface_filter_offset`` is too small). diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 4903ad1..09f57b0 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -1,13 +1,35 @@ Tutorials ========= -Step-by-step guides for using wetting_angle_kit. +Step-by-step guides for using wetting_angle_kit. Each tutorial +covers one combination of strategy components; the theory chapter at +:doc:`../introduction/theoretical_foundations` collects the underlying +math. .. toctree:: :maxdepth: 1 - :caption: Available Tutorials: + :caption: Input handling: parser_tutorial - binning_method_tuto + +.. toctree:: + :maxdepth: 1 + :caption: Composable pipeline (TrajectoryAnalyzer): + slicing_method_tuto + whole_fit_tuto + grid_method_tuto + +.. toctree:: + :maxdepth: 1 + :caption: Joint-fit analyzers: + + binning_method_tuto + coupled_binning_3d_tuto + +.. toctree:: + :maxdepth: 1 + :caption: Visualisation: + visualization_slicing_droplet + visualization_evolution_density diff --git a/docs/source/tutorials/parser_tutorial.rst b/docs/source/tutorials/parser_tutorial.rst index ded5e5d..209852b 100644 --- a/docs/source/tutorials/parser_tutorial.rst +++ b/docs/source/tutorials/parser_tutorial.rst @@ -37,10 +37,11 @@ The ``.parse()`` method always returns a NumPy array of shape ``(N, 3)`` contain 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 - ) + # The LAMMPS finder only needs the oxygen and hydrogen type IDs; + # wall atoms are everything else and are read separately via + # LammpsDumpWallParser when (and only when) the analysis pipeline + # needs them. + wat_find = LammpsDumpWaterFinder(filename, 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) diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index 652697a..bea5244 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -1,26 +1,48 @@ -Tutorial: Contact Angle Analysis (Slicing Method) -================================================= +Tutorial: Contact Angle Analysis (Slicing Pipeline) +==================================================== -This tutorial explains how to compute the contact angle of a droplet using the **slicing method** in ``wetting_angle_kit``. +This tutorial walks through the **slicing pipeline** built from the +strategy components of :class:`TrajectoryAnalyzer`: a ray-fan +interface extractor, a per-slice algebraic-circle fitter, and an +interface-derived wall detector. The slicing pipeline is the right +choice when you want a per-frame angle trace plus a sense of the +spread across slices. ---- 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 studying the evolution of the angle along a trajectory. +The pipeline does three things per batch: + +1. **Interface extraction.** The droplet is divided into vertical + slicing planes (azimuthal slices for a spherical droplet, + ``y``-step slices for a cylindrical droplet). Inside each plane a + 2D ray fan emits rays from the droplet centre of mass and locates + the interface along each ray as the half-density point of a 1D + tanh fit on the local density profile (Gaussian KDE by default). +2. **Wall detection.** The wall plane z-coordinate is taken as the + minimum z over all interface points, plus a user-supplied offset + (``min_plus_offset(offset=0)`` for the bare baseline). +3. **Surface fit.** An algebraic Kasa circle is fit to each slice's + interface points after filtering out points within + ``surface_filter_offset`` of the wall. The contact angle on each + slice is the angle of intersection of that circle with the wall + line; the batch's reported angle is the mean across slices, and + :attr:`SlicingBatchResult.angle_std` is the empirical std. ---- 2. Requirements --------------- -Before running the example, ensure you have installed: +Before running the example, ensure you have installed the package +with the ovito extra (for LAMMPS dump files): .. code-block:: bash - pip install wetting-angle-kit ase numpy + pip install wetting-angle-kit[ovito] + # (and the OVITO package itself via conda — see installation page) Example trajectory:: @@ -33,138 +55,189 @@ Example trajectory:: .. code-block:: python - # Import necessary modules + from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, + ) + from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - from wetting_angle_kit.analysis import SlicingTrajectoryAnalyzer # --- Step 1: Define the trajectory file --- filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" - # --- Step 2: Initialize the water molecule finder --- + # --- Step 2: Identify water-oxygen atoms --- wat_find = LammpsDumpWaterFinder( filename, - particle_type_wall={3}, # Wall particle types - oxygen_type=1, # Oxygen atom type + oxygen_type=1, 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 = SlicingTrajectoryAnalyzer( - parser=parser, + # --- Step 3: Build the trajectory analyzer --- + # Strategies: rays_gaussian extractor + slicing fitter + + # interface-derived wall + per-frame batching. + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, - droplet_geometry="spherical", # Geometry fitting model - delta_gamma=20, # Azimuthal step (deg) for spherical slicing + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, # 20° between slicing planes + delta_polar=8.0, # 8° in-plane ray step + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), # one angle per frame ) - # --- Step 6: Run the analysis --- - results = analyzer.analyze([1]) # Analyze frame 1 + # --- Step 4: Run the analysis on a frame range --- + results = analyzer.analyze(range(0, 50)) - # --- Step 7: Display results --- + # --- Step 5: Inspect the results --- print("Mean contact angle (°):", results.mean_angle) - print("Std contact angle (°):", results.std_angle) - print("Frames analyzed:", results.frames) + print("Std across batches (°):", results.std_angle) + for batch in results.batches[:3]: + print( + f"Frame {batch.frames[0]}: " + f"angle = {batch.angle:.2f}°, " + f"per-slice σ = {batch.angle_std:.2f}°, " + f"rms residual = {batch.rms_residual:.2f} Å" + ) ---- 4. Expected Output ------------------ -After running the example, you'll see something like:: +On the water/graphene fixture above, single-frame output looks like:: Number of water molecules: 1320 - Mean contact angle (°): 94.46 - Std contact angle (°): 0.0 - Frames analyzed: [1] - -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. -* ``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). + Mean contact angle (°): 95.16 + Std across batches (°): 0.0 + Frame 0: angle = 95.16°, per-slice σ = 1.86°, rms residual = 0.45 Å + +``std_angle`` is 0 here because only one batch was requested; pass a +multi-frame range to see the spread across batches. + +The returned :class:`TrajectoryResults` object holds a list of +:class:`SlicingBatchResult` entries (one per batch). Each batch +carries: + +* ``angle`` — mean contact angle across slices (°). +* ``angle_std`` — empirical standard deviation across slices (°). +* ``per_slice_angles`` — array of per-slice angles. +* ``slice_surfaces`` / ``slice_popts`` — per-slice interface points + and fitted circle parameters (for plotting; see + :doc:`visualization_slicing_droplet`). +* ``z_wall`` — wall position used by the fitter. +* ``rms_residual`` — mean of per-slice circle-fit RMS residuals (Å). ---- 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 the spherical mode (azimuthal step in - degrees between successive slices — smaller = more slices, more - detail, more cost). For very small droplets, also raise - ``points_per_angstrom`` (default 1.0) on the analyzer to densify the - per-ray sampling used by the interface fit. -- To analyze multiple frames: - -.. code-block:: python - - results = analyzer.analyze(range(0, 50, 10)) - -- Output files include raw interface data and optional plots (if enabled). +- **Slicing step** (``delta_azimuthal`` for spherical droplets, + ``delta_cylinder`` for cylinders): smaller step → more slices, + more detail per batch, more cost. The default 20° gives 9 slices + for a spherical droplet, plenty for a stable mean. +- **In-plane ray step** (``delta_polar``, both geometries): smaller + step → more rays per slice, denser interface contour, more cost. +- **Wall offset** (``WallDetector.min_plus_offset(offset=O)``): + raise ``O`` if the interface-derived baseline lands slightly into + the wall layer (visible as inflated angles). +- **Surface filter offset** + (``SurfaceFitter.slicing(surface_filter_offset=...)``): excludes + interface points within this distance of the wall before the + circle fit. Raise it if the wall-adjacent density is distorted by + layering. +- **Cylindrical droplets**: pass ``droplet_geometry="cylinder_y"`` + (or ``"cylinder_x"``) and configure ``delta_cylinder`` instead of + ``delta_azimuthal`` on the extractor. + +For a side-by-side plot of the recovered interface and the fitted +circle, see :doc:`visualization_slicing_droplet`. ---- -6. Related Files ----------------- +6. Alternative configurations +----------------------------- + +6.1 Cylindrical droplets +^^^^^^^^^^^^^^^^^^^^^^^^ -**Example Script:** ``docs/examples/contact_angle_slicing/example_slicing.py`` +For a cylindrical droplet (e.g. water on a periodic stripe), swap +``delta_azimuthal`` for ``delta_cylinder`` (the step along the +cylinder axis) and tell the analyzer which axis the cylinder runs +along: .. code-block:: python - """ - Example: Contact Angle Analysis Using the Slicing Method + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="cylinder_y", # or "cylinder_x" + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_cylinder=5.0, # 5 Å between slicing planes + delta_polar=8.0, + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), + ) - This example demonstrates how to perform a contact angle analysis - using the 'slicing' method on a spherical droplet from a LAMMPS dump trajectory. - """ +The mechanics are identical to the spherical case — same Kasa +circle fit per slice, same cap-angle formula — but slices step +along the cylinder axis rather than rotating azimuthally. The +fixture ``tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj`` +in the repository is a cylindrical-droplet trajectory you can use +as a worked example. - from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - from wetting_angle_kit.analysis import SlicingTrajectoryAnalyzer +6.2 ``rays_binning`` alternative +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - # --- Step 1: Define input trajectory --- - filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" +The same ray-fan geometry is available with a 1D histogram density +estimator instead of the Gaussian KDE. Use it when you want a +hard-cutoff per-sample density (fast, no smoothing parameter beyond +the bin width): - # --- Step 2: Identify water molecules --- - wat_find = LammpsDumpWaterFinder( - filename, particle_type_wall={3}, oxygen_type=1, hydrogen_type=2 # Wall atom types +.. code-block:: python + + interface_extractor = InterfaceExtractor.rays_binning( + delta_azimuthal=20.0, + delta_polar=8.0, + bin_width=3.0, # 3 Å diameter top-hat + points_per_angstrom=1.0, ) - oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) - print(f"Number of water molecules: {len(oxygen_indices)}") +The ``bin_width`` parameter sets the diameter of the 3D top-hat +counted at each sample point along the ray; matching it to the +interface thickness (~1–3 Å for water) keeps the tanh fit +well-conditioned. Numerically the bin width plays the same role +``density_sigma`` plays for ``rays_gaussian``. - # --- Step 3: Initialize parser --- - parser = LammpsDumpParser(filename) +6.3 Pooled batches +^^^^^^^^^^^^^^^^^^ - # --- Step 4: Create analyzer for the slicing method --- - analyzer = SlicingTrajectoryAnalyzer( - parser=parser, - atom_indices=oxygen_indices, - droplet_geometry="spherical", # Fitting model - delta_gamma=20, # Azimuthal step (deg) for spherical slicing - ) +Replace ``batch_size=1`` with ``batch_size=N`` to pool +:math:`N` consecutive frames per fit — fewer batches, more atoms +per fit, less per-angle noise but no within-batch time resolution. +``batch_size=-1`` pools all requested frames into a single batch +(one angle for the whole trajectory). - # --- Step 5: Run analysis --- - results = analyzer.analyze([1]) # Analyze frame 1 +.. code-block:: python - # --- Step 6: Display results --- - print("Mean contact angle (°):", results.mean_angle) - print("Std contact angle (°):", results.std_angle) + temporal_aggregator = TemporalAggregator(batch_size=5) + +For physical context on the trade-off see +:doc:`../introduction/theoretical_foundations` section 9. + +6.4 Grid alternative +^^^^^^^^^^^^^^^^^^^^ + +The grid extractors (:meth:`InterfaceExtractor.grid_gaussian` and +:meth:`grid_binning`) pair with the slicing fitter exactly the same +way and are covered in :doc:`grid_method_tuto`. Use them when +ray-fan sampling is too sparse to resolve the interface. diff --git a/docs/source/tutorials/visualization_evolution_density.rst b/docs/source/tutorials/visualization_evolution_density.rst new file mode 100644 index 0000000..67f1399 --- /dev/null +++ b/docs/source/tutorials/visualization_evolution_density.rst @@ -0,0 +1,148 @@ +Visualisation Tutorial — Angle Evolution and Density Contour +============================================================= + +Two trajectory-level plotters cover the most common visual outputs: + +* :class:`AngleEvolutionPlotter` — per-batch contact angle vs time, + with an optional ``±σ`` band and a cumulative running mean + overlay. Works on any results object that exposes ``.batches`` + with ``.angle`` and ``.frames``. +* :class:`DensityContourPlotter` — 2D density field with the fitted + spherical cap arc and wall line overlaid. Accepts a single batch + or a full results object (averaged density); 3D results are + azimuthally collapsed to the same 2D plane. + +---- + +1. Angle evolution plot +------------------------ + +The plotter takes a results object directly and exposes a +``.plot()`` method that returns a Plotly figure. The two key toggles +are ``per_frame_std`` (draws the inter-batch ``±σ`` band from +``angle_std``) and ``running_mean`` (overlays the cumulative running +mean with its own cumulative ``±σ`` band). + +.. code-block:: python + + from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, + ) + from wetting_angle_kit.analysis.temporal import TemporalAggregator + from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder + from wetting_angle_kit.visualization import AngleEvolutionPlotter + + filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + oxygen_indices = LammpsDumpWaterFinder( + filename, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(frame_index=0) + + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_azimuthal=20.0, delta_polar=8.0 + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + results = analyzer.analyze(range(0, 50)) + + plotter = AngleEvolutionPlotter( + results, + label="spherical_4k", + timestep=0.5, # 0.5 time units per dumped frame + time_unit="ps", + ) + fig = plotter.plot(per_frame_std=True, running_mean=True) + fig.show() # or fig.write_html("angle_evolution.html") + +The figure has up to four traces per trajectory: + +* the per-batch line (solid), +* the per-batch ``±σ`` band (filled, semi-transparent), +* the cumulative running mean (dashed), +* the cumulative ``±σ`` band of the running mean (filled). + +Coupled-binning result objects don't carry ``angle_std`` per batch, so +the per-batch band is omitted; the running mean band is always +available. + +The plotter also implements :class:`BaseTrajectoryPlotter` so +``plotter.summary()`` returns a list of :class:`TrajectoryStats` +with the mean angle, std, sample count, and a per-method surface area +(shoelace polygon area for slicing batches; spherical-cap segment +area for whole / coupled-binning batches). + +---- + +2. Density contour plot +----------------------- + +For a coupled-binning analysis, :class:`DensityContourPlotter` draws +the 2D density grid with the fitted spherical cap arc (dashed) and +wall line (dotted) overlaid. Pass either a single batch result or a +full results object: + +.. code-block:: python + + from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer + from wetting_angle_kit.analysis.temporal import TemporalAggregator + from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder + from wetting_angle_kit.visualization import DensityContourPlotter + + filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + oxygen_indices = LammpsDumpWaterFinder( + filename, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(frame_index=0) + + binning = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params={ + "xi_0": 0.0, + "xi_f": 70.0, + "nbins_xi": 30, + "zi_0": 0.0, + "zi_f": 70.0, + "nbins_zi": 30, + }, + temporal_aggregator=TemporalAggregator(batch_size=10), + ) + results = binning.analyze(range(0, 100)) + + # One batch: + DensityContourPlotter(results.batches[0], label="spherical_4k").plot().show() + + # Averaged across batches: + DensityContourPlotter(results, label="spherical_4k").plot().show() + +3D-results inputs are azimuthally averaged onto the same ``(r, z)`` +plane before contouring, so the same plotter works for +:class:`CoupledBinning3DResults` and +:class:`CoupledBinning3DBatchResult`. The default title indicates the +azimuthal collapse so plots are unambiguous. + +---- + +3. Tips +------- + +- Pass ``title=...`` on either ``.plot()`` to override the default. + The default density plot drops the list of frame indices from the + title (which can be very long for pooled batches); pass a custom + title if you want batch identification displayed. +- Use ``stat="median"`` on the constructor of + :class:`AngleEvolutionPlotter` to plot the per-batch median across + slices instead of the mean (slicing results only; ignored for + other result types). +- The legacy ``BinningTrajectoryPlotter`` and + ``SlicingTrajectoryPlotter`` have been replaced by these two + classes; the new pattern is one plotter per concern rather than + per analyzer. diff --git a/docs/source/tutorials/visualization_slicing_droplet.rst b/docs/source/tutorials/visualization_slicing_droplet.rst index ee0c01e..3c8bf86 100644 --- a/docs/source/tutorials/visualization_slicing_droplet.rst +++ b/docs/source/tutorials/visualization_slicing_droplet.rst @@ -1,19 +1,29 @@ -Visualization Tutorial — Droplet Surface and Contact Angle -=========================================================== +Visualisation Tutorial — Per-Frame Droplet Snapshot +==================================================== -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. +This tutorial uses :class:`DropletSlicePlotter` to draw a single-frame +snapshot of a droplet, overlaying the recovered interface contour, +the fitted Kasa circle, the tangent at the contact point, and the +wall atom positions. The plotter takes raw arrays +(atom positions, surface points, fit parameters), so it works on the +output of the slicing pipeline once you pull the corresponding fields +off the result object. ---- 1. Overview ----------- -The visualization workflow involves the following steps: +The workflow: -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. +1. Identify water molecules and read their positions for the frame. +2. Read the wall atom positions for the same frame. +3. Run the slicing pipeline (:class:`TrajectoryAnalyzer` with + :class:`SurfaceFitter.slicing()`) on that frame; pull the + ``slice_surfaces`` / ``slice_popts`` / ``per_slice_angles`` arrays + off the resulting :class:`SlicingBatchResult`. +4. Pick one slice index and pass the corresponding entries to + :class:`DropletSlicePlotter`. ---- @@ -23,12 +33,19 @@ The visualization workflow involves the following steps: .. code-block:: python import numpy as np + + from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, + ) + from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import ( LammpsDumpParser, - LammpsDumpWaterFinder, LammpsDumpWallParser, + LammpsDumpWaterFinder, ) - from wetting_angle_kit.analysis.slicing import SlicingFrameFitter from wetting_angle_kit.visualization import DropletSlicePlotter ---- @@ -38,9 +55,8 @@ The visualization workflow involves the following steps: .. code-block:: python - filename = ( - "../wetting_angle_kit/tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" - ) + filename = "../../tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj" + frame_index = 10 ---- @@ -49,70 +65,74 @@ The visualization workflow involves the following steps: .. code-block:: python - 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)) ---- -5. Parse Atomic Coordinates ----------------------------- +5. Read Atom and Wall Positions +-------------------------------- .. code-block:: python parser = LammpsDumpParser(filepath=filename) - oxygen_position = parser.parse(frame_index=10, indices=oxygen_indices) + oxygen_position = parser.parse(frame_index=frame_index, indices=oxygen_indices) - # Wall parser: ``liquid_particle_types`` lists everything the parser - # should *exclude* (so that what remains are the wall atoms). - coord_wall = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) - wall_coords = coord_wall.parse(frame_index=10) + # Wall parser: ``liquid_particle_types`` lists what to EXCLUDE + # (the liquid), leaving the wall atoms. + wall_parser = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) + wall_coords = wall_parser.parse(frame_index=frame_index) ---- -6. Compute Contact Angles --------------------------- +6. Run the Slicing Pipeline +---------------------------- .. code-block:: python - processor = SlicingFrameFitter( - liquid_coordinates=oxygen_position, - liquid_geom_center=np.mean(oxygen_position, axis=0), + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - delta_cylinder=5, - max_dist=100, + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_cylinder=5.0, + delta_polar=8.0, + ), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + temporal_aggregator=TemporalAggregator(batch_size=1), ) - - list_angles, array_surfaces, array_popt = processor.predict_contact_angle() - print("Mean contact angles (°):", list_angles) + batch = analyzer.analyze([frame_index]).batches[0] + print("Per-slice contact angles (°):", batch.per_slice_angles) ---- -7. Visualize the Droplet +7. Visualise the Droplet ------------------------- +The plotter takes a single slice's data; pick a slice index and +pull the corresponding entries off the batch: + .. code-block:: python plotter = DropletSlicePlotter(center=True) - # ``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 + slice_idx = 0 # pick a slice — all three arrays are parallel fig = plotter.plot_surface_points( oxygen_position=oxygen_position, - surface_data=[array_surfaces[slice_idx]], - popt=array_popt[slice_idx], + surface_data=[batch.slice_surfaces[slice_idx]], + popt=batch.slice_popts[slice_idx], wall_coords=wall_coords, - alpha=list_angles[slice_idx], + alpha=float(batch.per_slice_angles[slice_idx]), ) # Interactive view in a notebook fig.show() - # Or save a standalone HTML page fig.write_html("droplet_plot.html") - print("Plot saved as 'droplet_plot.html'") + +The figure overlays four layers: the raw water-oxygen positions, the +recovered interface contour for the chosen slice, the fitted Kasa +circle, and the wall atoms. ``DropletSlicePlotter`` accepts +``center=True`` (default) to centre the plot on the droplet COM. diff --git a/docs/source/tutorials/whole_fit_tuto.rst b/docs/source/tutorials/whole_fit_tuto.rst new file mode 100644 index 0000000..dac747a --- /dev/null +++ b/docs/source/tutorials/whole_fit_tuto.rst @@ -0,0 +1,238 @@ +Tutorial: Whole-Shape Fit with Bootstrap Uncertainty +===================================================== + +This tutorial covers the **whole-fit pipeline**: an algebraic +sphere or cylinder fit to the entire interface shell at once, with +optional bootstrap resampling for an angle uncertainty. Pair it with +:class:`WallDetector.explicit` or :class:`WallDetector.from_atoms` +when you have a known wall position from the simulation setup. + +---- + +1. Overview +----------- + +The whole-fit pipeline differs from the slicing pipeline in two +places: + +1. The :class:`InterfaceExtractor` returns a single ``(N, 3)`` shell + array — every ray's interface point pooled into one cloud rather + than divided into per-slice 2D sub-clouds. +2. The :class:`SurfaceFitter.whole()` runs **one** algebraic Kasa fit + on that shell — sphere fit for spherical droplets, cylinder fit + (algebraic circle in ``(x, z)`` with translational invariance + along ``y``) for cylindrical droplets. The contact angle follows + from the cap geometry ``cos θ = (z_wall - z_center) / R``. + +If ``bootstrap_samples > 0`` the fit is repeated on that many +bootstrap resamples of the shell, and the resulting standard +deviation of the angles is reported as +:attr:`WholeBatchResult.angle_std`. + +---- + +2. Wall detector pairing +------------------------- + +The full-sphere Fibonacci ray fan +(:meth:`InterfaceExtractor.rays_gaussian` with ``n_rays_sphere=...``) +emits rays from the droplet COM in all directions, including +downward. Those downward rays hit the wall plane and contribute +interface points right at the wall, so the lowest shell point lands +at ``z ≈ z_wall``. As a result, :meth:`WallDetector.min_plus_offset` +*does* work with this pipeline. + +The two physical alternatives are usually more reliable: + +* :meth:`WallDetector.from_atoms` reads wall atom positions from the + trajectory and places the wall plane at the mean of the top atomic + layer. Best when the substrate is explicitly modelled. +* :meth:`WallDetector.explicit` lets you supply ``z_wall`` directly + — handy when the wall position is known a priori from the + simulation setup. + +The slicing-mode equivalent of this consideration is much less +sensitive because the wall enters only as a horizontal line in each +slice's 2D fit, but for the whole-shape fit the recovered angle +depends quite linearly on the wall z, so it pays to be honest about +the wall position. + +---- + +3. Example Code +--------------- + +.. code-block:: python + + from wetting_angle_kit.analysis import ( + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, + ) + from wetting_angle_kit.parsers import ( + LammpsDumpParser, + LammpsDumpWallParser, + LammpsDumpWaterFinder, + ) + + filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + + # --- Step 1: Identify water-oxygen and wall-atom indices --- + wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) + oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) + + # Wall parser: ``liquid_particle_types`` lists what to EXCLUDE + # (i.e. the liquid), leaving the wall atoms. + wall_parser = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) + carbon_indices = wall_parser.parse(frame_index=0) # uses internal IDs + + # --- Step 2: Build the whole-fit analyzer --- + # Strategies: full-sphere Fibonacci ray fan + whole-fit + from_atoms wall. + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.rays_gaussian( + n_rays_sphere=400, # 400 rays uniformly over the full sphere + density_sigma=3.0, + ), + surface_fitter=SurfaceFitter.whole( + surface_filter_offset=3.0, + bootstrap_samples=100, # 100 bootstrap resamples → angle_std + ), + wall_detector=WallDetector.from_atoms( + wall_atom_indices=carbon_indices, + method="mean_top_layer", + top_layer_tolerance=1.0, + ), + wall_atom_indices=carbon_indices, # routed to the wall detector + ) + + # --- Step 3: Run the analysis --- + results = analyzer.analyze([1]) + batch = results.batches[0] + print(f"angle = {batch.angle:.2f}° ± {batch.angle_std:.2f}° (bootstrap)") + print(f"R = {batch.popt[3]:.2f} Å, z_wall = {batch.z_wall:.2f} Å") + print(f"rms residual on the shell = {batch.rms_residual:.2f} Å") + +---- + +4. Expected Output +------------------ + +On the water/graphene fixture:: + + angle = 99.13° ± 0.41° (bootstrap) + R = 37.54 Å, z_wall = 4.90 Å + rms residual on the shell = 1.27 Å + +The recovered ``R`` is *not* the physical droplet radius — it's the +sphere that best fits the recovered shell, which sits slightly inside +the actual interface because of the Gaussian-KDE smoothing. The +contact angle reading is unaffected by this; the cap geometry is +self-consistent with whichever ``R`` and ``z_c`` the fit produces. + +---- + +5. Comparing whole vs slicing +----------------------------- + +Both the whole and slicing pipelines on this fixture recover ~95° +when paired with :meth:`WallDetector.min_plus_offset(0)` (the +"natural" interface-derived baseline). When you switch the +wall detector to ``from_atoms`` or ``explicit`` at the actual +graphene top (≈ 4.9 Å), both pipelines move up to ~99°. The two +methods are physically consistent — the slicing pipeline gives you +an inter-slice ``σ`` for free; the whole pipeline gives you a +bootstrap ``σ`` instead. + +The choice between them is mostly about what kind of uncertainty you +want to report: + +* per-slice scatter (slicing): tells you whether the droplet is + symmetric across the chosen slice axis. +* bootstrap σ (whole): tells you how well-determined the fit is given + the shell points; doesn't expose asymmetry. + +---- + +6. Tips +------- + +- **Number of rays** (``n_rays_sphere``): 400 is plenty for a + ~30 Å droplet; you'd push to 800–1600 for larger droplets where + the shell point density per square Å matters. +- **Bootstrap samples**: 100 is enough to get an ``angle_std`` + reliable to two significant figures; 1000 will tighten that but + costs ~10× more. +- **Cylinder droplets** still work — pair the whole fitter with + :meth:`InterfaceExtractor.rays_gaussian` configured with + ``delta_cylinder`` and ``delta_polar`` instead of ``n_rays_sphere``. + The fitter automatically does a 2D circle fit per the cylinder + axis convention. + +---- + +7. Alternative configurations +----------------------------- + +7.1 Cylindrical whole-fit +^^^^^^^^^^^^^^^^^^^^^^^^^ + +For a cylindrical droplet, the whole fitter exploits translational +symmetry along the cylinder axis: it does **one** 2D circle fit in +the ``(x, z)`` plane to the entire shell, treating the +:math:`y`-coordinate as ignorable. The same recipe, with the +cylinder-mode extractor parameters: + +.. code-block:: python + + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(cylinder_fixture), + atom_indices=oxygen_indices, + droplet_geometry="cylinder_y", # or "cylinder_x" + interface_extractor=InterfaceExtractor.rays_gaussian( + delta_cylinder=5.0, + delta_polar=8.0, + density_sigma=3.0, + ), + surface_fitter=SurfaceFitter.whole( + surface_filter_offset=3.0, + bootstrap_samples=100, + ), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + +The recovered ``popt`` for the cylindrical case is a 4-element +array ``[xc, zc, R, z_wall]`` rather than the 5-element spherical +``[xc, yc, zc, R, z_wall]`` — the cylinder axis :math:`y` doesn't +participate in the fit. + +7.2 Explicit wall position +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When the wall plane is known a priori from the simulation setup +(e.g. a 9-3 LJ wall at a specific :math:`z`-coordinate, or you +simply read the top atomic layer's :math:`z` in an external script): + +.. code-block:: python + + wall_detector = WallDetector.explicit(z_wall=5.0) + +No ``wall_atom_indices`` argument needed on the analyzer in this +case — the explicit detector ignores any wall-atom data. Useful +both for whole-fit and slicing pipelines. + +7.3 ``rays_binning`` alternative +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Same Fibonacci-sphere geometry, but the density along each ray is +estimated with a 1D top-hat histogram instead of a Gaussian KDE: + +.. code-block:: python + + interface_extractor = InterfaceExtractor.rays_binning( + n_rays_sphere=400, + bin_width=3.0, + ) From d413fc0008acd5d94d7cee892d76f32225deba8b Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 11 Jun 2026 10:44:44 +0200 Subject: [PATCH 29/53] Moved InterfaceData type from wall to extractors module, makes more sense. --- src/wetting_angle_kit/analysis/extractors/_grid.py | 2 +- src/wetting_angle_kit/analysis/extractors/_rays.py | 2 +- src/wetting_angle_kit/analysis/extractors/base.py | 13 +++++++++++-- src/wetting_angle_kit/analysis/fitters/_slicing.py | 2 +- src/wetting_angle_kit/analysis/fitters/_whole.py | 2 +- src/wetting_angle_kit/analysis/fitters/base.py | 2 +- src/wetting_angle_kit/analysis/wall.py | 10 ++-------- 7 files changed, 18 insertions(+), 15 deletions(-) diff --git a/src/wetting_angle_kit/analysis/extractors/_grid.py b/src/wetting_angle_kit/analysis/extractors/_grid.py index d0547fd..97280fa 100644 --- a/src/wetting_angle_kit/analysis/extractors/_grid.py +++ b/src/wetting_angle_kit/analysis/extractors/_grid.py @@ -6,12 +6,12 @@ import numpy as np from wetting_angle_kit.analysis.extractors.base import ( + InterfaceData, InterfaceExtractor, SamplingKind, SurfaceKind, ) from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.wall import InterfaceData _GRID_KEYS_2D = frozenset({"xi_0", "xi_f", "nbins_xi", "zi_0", "zi_f", "nbins_zi"}) _GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "nbins_yi"} diff --git a/src/wetting_angle_kit/analysis/extractors/_rays.py b/src/wetting_angle_kit/analysis/extractors/_rays.py index 93d8e1e..1619444 100644 --- a/src/wetting_angle_kit/analysis/extractors/_rays.py +++ b/src/wetting_angle_kit/analysis/extractors/_rays.py @@ -16,12 +16,12 @@ _fibonacci_sphere_directions, ) from wetting_angle_kit.analysis.extractors.base import ( + InterfaceData, InterfaceExtractor, SamplingKind, SurfaceKind, ) from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.wall import InterfaceData def _validate_rays_params( diff --git a/src/wetting_angle_kit/analysis/extractors/base.py b/src/wetting_angle_kit/analysis/extractors/base.py index d6bc94c..f206394 100644 --- a/src/wetting_angle_kit/analysis/extractors/base.py +++ b/src/wetting_angle_kit/analysis/extractors/base.py @@ -6,12 +6,11 @@ """ from abc import ABC, abstractmethod -from typing import Any, ClassVar, Literal +from typing import Any, ClassVar, Literal, TypeAlias import numpy as np from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.wall import InterfaceData #: What the downstream :class:`SurfaceFitter` will consume. SurfaceKind = Literal["slicing", "whole"] @@ -19,6 +18,16 @@ #: Sampling strategy used by the extractor. SamplingKind = Literal["rays", "grid"] +#: Interface point set produced by an :class:`InterfaceExtractor` and +#: consumed by :class:`SurfaceFitter` (and, via :class:`WallContext`, +#: by :class:`WallDetector`). +#: +#: - In slicing mode, a list of ``(N_i, 2)`` arrays in the per-slice +#: ``(x, z)`` plane. +#: - In whole mode, a single ``(N, 3)`` array in the internal +#: ``(x, y, z)`` frame. +InterfaceData: TypeAlias = list[np.ndarray] | np.ndarray + class InterfaceExtractor(ABC): """Abstract base for interface point extractors. diff --git a/src/wetting_angle_kit/analysis/fitters/_slicing.py b/src/wetting_angle_kit/analysis/fitters/_slicing.py index 60aa9ca..53484c2 100644 --- a/src/wetting_angle_kit/analysis/fitters/_slicing.py +++ b/src/wetting_angle_kit/analysis/fitters/_slicing.py @@ -5,6 +5,7 @@ import numpy as np +from wetting_angle_kit.analysis.extractors.base import InterfaceData from wetting_angle_kit.analysis.fitters._kasa import _kasa_circle_fit_2d from wetting_angle_kit.analysis.fitters.base import ( SlicingFitOutput, @@ -12,7 +13,6 @@ SurfaceKind, ) from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.wall import InterfaceData @dataclass(frozen=True, eq=False, kw_only=True) diff --git a/src/wetting_angle_kit/analysis/fitters/_whole.py b/src/wetting_angle_kit/analysis/fitters/_whole.py index b19b1e0..a993769 100644 --- a/src/wetting_angle_kit/analysis/fitters/_whole.py +++ b/src/wetting_angle_kit/analysis/fitters/_whole.py @@ -5,6 +5,7 @@ import numpy as np +from wetting_angle_kit.analysis.extractors.base import InterfaceData from wetting_angle_kit.analysis.fitters._kasa import ( _angle_from_cap, _whole_fit_one, @@ -15,7 +16,6 @@ WholeFitOutput, ) from wetting_angle_kit.analysis.geometry import DropletGeometry -from wetting_angle_kit.analysis.wall import InterfaceData @dataclass(frozen=True, eq=False, kw_only=True) diff --git a/src/wetting_angle_kit/analysis/fitters/base.py b/src/wetting_angle_kit/analysis/fitters/base.py index 3161e8f..6c81c1e 100644 --- a/src/wetting_angle_kit/analysis/fitters/base.py +++ b/src/wetting_angle_kit/analysis/fitters/base.py @@ -11,13 +11,13 @@ import numpy as np +from wetting_angle_kit.analysis.extractors.base import InterfaceData from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( BatchResult, SlicingBatchResult, WholeBatchResult, ) -from wetting_angle_kit.analysis.wall import InterfaceData #: Surface-representation kind the fitter consumes. Mirrors #: :data:`wetting_angle_kit.analysis.extractors.SurfaceKind` — the two diff --git a/src/wetting_angle_kit/analysis/wall.py b/src/wetting_angle_kit/analysis/wall.py index 57e0350..25c2b4b 100644 --- a/src/wetting_angle_kit/analysis/wall.py +++ b/src/wetting_angle_kit/analysis/wall.py @@ -23,17 +23,11 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import Literal, TypeAlias +from typing import Literal import numpy as np -#: Interface point set produced by an :class:`InterfaceExtractor`. -#: -#: - In slicing mode, a list of ``(N_i, 2)`` arrays in the per-slice -#: ``(x, z)`` plane. -#: - In whole mode, a single ``(N, 3)`` array in the internal -#: ``(x, y, z)`` frame. -InterfaceData: TypeAlias = list[np.ndarray] | np.ndarray +from wetting_angle_kit.analysis.extractors.base import InterfaceData @dataclass(frozen=True) From aef1091b5b619492c1fc388d2ab0943520bc71f8 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 11 Jun 2026 11:48:37 +0200 Subject: [PATCH 30/53] Updated and optimized parallelism. --- src/wetting_angle_kit/analysis/_base.py | 77 +++++++++++++++++-------- 1 file changed, 54 insertions(+), 23 deletions(-) diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index 637397e..71dc8de 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -227,18 +227,20 @@ def __init__( def analyze( self, frame_range: list[int] | None = None, - n_jobs: int | None = None, + n_jobs: int | None = 1, ) -> Any: - """Run the analyzer in parallel across batches. + """Run the analyzer across batches. Parameters ---------- frame_range : list[int], optional Frame indices to analyse. Defaults to every frame in the trajectory (``range(parser.frame_count())``). - n_jobs : int, optional - Worker process count. ``None`` lets - ``multiprocessing.Pool`` pick the default + n_jobs : int, default 1 + Worker process count. ``1`` (default) runs in-process + with no ``multiprocessing.Pool`` overhead. Set to an + integer ``>= 2`` for parallel execution, or to ``None`` + to let ``multiprocessing.Pool`` pick the default (``os.cpu_count()``). Returns @@ -259,11 +261,51 @@ def analyze( ) init_args = self._init_args() - # ``Any`` here because each concrete subclass has its own - # per-batch result type (BatchResult / CoupledBinning2DBatchResult - # / CoupledBinning3DBatchResult); they share a duck-typed - # ``.frames`` attribute used for ordering, not a nominal base. - batch_results: list[Any] = [] + if n_jobs == 1 or len(batches) == 1: + batch_results = self._run_inline(batches, init_args) + else: + batch_results = self._run_parallel(batches, init_args, n_jobs) + + if not batch_results: + raise RuntimeError( + f"None of the {len(batches)} requested batches produced a " + "result. Check the worker logs above for the underlying " + "parser, geometry, or fit errors." + ) + + # ``imap_unordered`` returns completion-ordered; restore batch + # order using the first frame index in each batch. + batch_results.sort(key=lambda b: min(b.frames) if b.frames else 0) + return self._build_results(batches=batch_results) + + def _run_inline( + self, + batches: list[list[int]], + init_args: tuple, + ) -> list[Any]: + """In-process batch loop; avoids ``Pool`` spawn overhead.""" + self._init_worker(*init_args) + results: list[Any] = [] + with tqdm( + total=len(batches), + desc=self._tqdm_desc(), + unit="batch", + ) as pbar: + for batch in batches: + result = self._process_batch_worker(batch) + if result is not None: + results.append(result) + pbar.update(1) + return results + + def _run_parallel( + self, + batches: list[list[int]], + init_args: tuple, + n_jobs: int | None, + ) -> list[Any]: + """Multi-process batch loop via ``multiprocessing.Pool``.""" + results: list[Any] = [] with ( _MP_CONTEXT.Pool( processes=n_jobs, @@ -278,20 +320,9 @@ def analyze( ): for result in pool.imap_unordered(self._process_batch_worker, batches): if result is not None: - batch_results.append(result) + results.append(result) pbar.update(1) - - if not batch_results: - raise RuntimeError( - f"None of the {len(batches)} requested batches produced a " - "result. Check the worker logs above for the underlying " - "parser, geometry, or fit errors." - ) - - # ``imap_unordered`` returns completion-ordered; restore batch - # order using the first frame index in each batch. - batch_results.sort(key=lambda b: min(b.frames) if b.frames else 0) - return self._build_results(batches=batch_results) + return results def _tqdm_desc(self) -> str: """Progress bar label. Subclasses may override for clarity.""" From 2496991ecb0ac668603d43d0a5773127c7d22cfd Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 11 Jun 2026 12:02:34 +0200 Subject: [PATCH 31/53] Added tests to improve coverage. --- .../test_analysis/test_fitter_error_paths.py | 194 +++++++++++++++ tests/test_analysis/test_geometry.py | 81 ++++++ tests/test_analysis/test_parallel_path.py | 57 +++++ tests/test_analysis/test_temporal.py | 46 ++++ .../test_angle_evolution_helpers.py | 234 ++++++++++++++++++ 5 files changed, 612 insertions(+) create mode 100644 tests/test_analysis/test_fitter_error_paths.py create mode 100644 tests/test_analysis/test_geometry.py create mode 100644 tests/test_analysis/test_parallel_path.py create mode 100644 tests/test_analysis/test_temporal.py create mode 100644 tests/test_visualization/test_angle_evolution_helpers.py diff --git a/tests/test_analysis/test_fitter_error_paths.py b/tests/test_analysis/test_fitter_error_paths.py new file mode 100644 index 0000000..7b33af1 --- /dev/null +++ b/tests/test_analysis/test_fitter_error_paths.py @@ -0,0 +1,194 @@ +"""Direct unit tests for :class:`SurfaceFitter` error paths. + +The :class:`TrajectoryAnalyzer` integration tests cover the happy +path through both fitter kinds; this file targets the explicit +``raise`` branches that the integration fixtures don't hit. +""" + +import numpy as np +import pytest + +from wetting_angle_kit.analysis import SurfaceFitter +from wetting_angle_kit.analysis.geometry import DropletGeometry + + +@pytest.fixture +def spherical() -> DropletGeometry: + return DropletGeometry.coerce("spherical") + + +@pytest.fixture +def cylinder() -> DropletGeometry: + return DropletGeometry.coerce("cylinder_y") + + +# --- Slicing fitter ----------------------------------------------------------- + + +def test_slicing_rejects_non_list_input(spherical: DropletGeometry) -> None: + fitter = SurfaceFitter.slicing() + with pytest.raises(TypeError, match="list of per-slice"): + fitter.fit(np.zeros((10, 3)), z_wall=0.0, droplet_geometry=spherical) + + +def test_slicing_skips_empty_and_thin_slices(spherical: DropletGeometry) -> None: + """Slices with zero / too few points after filtering must be skipped.""" + fitter = SurfaceFitter.slicing(surface_filter_offset=2.0) + # A valid spherical-cap arc to anchor the batch — without it the + # fitter raises "no valid slice", which is a different code path. + theta = np.linspace(np.pi * 0.55, np.pi - 0.1, 50) + R = 20.0 + valid_slice = np.column_stack([R * np.cos(theta), R * np.sin(theta)]) + surfaces = [ + np.empty((0, 2)), # empty slice → skipped + np.array([[0.0, 10.0], [1.0, 10.5]]), # only 2 points → skipped + valid_slice, + ] + out = fitter.fit(surfaces, z_wall=0.0, droplet_geometry=spherical) + # Only the valid slice contributed. + assert len(out.per_slice_angles) == 1 + assert len(out.slice_surfaces) == 1 + + +def test_slicing_skips_circle_outside_wall(spherical: DropletGeometry) -> None: + """Circles whose centre is too far from the wall (|Δz| ≥ R) are skipped.""" + fitter = SurfaceFitter.slicing(surface_filter_offset=0.0) + # A circle that sits high above the wall (centre at z=50, R=5). + # |z_wall - z_c| = 50 > R = 5 → no intersection. + theta = np.linspace(np.pi * 0.1, np.pi * 0.9, 40) + high_slice = np.column_stack([5.0 * np.cos(theta), 50.0 + 5.0 * np.sin(theta)]) + # And a valid spherical-cap arc that intersects z=0. + theta = np.linspace(np.pi * 0.55, np.pi - 0.1, 50) + R = 20.0 + valid_slice = np.column_stack([R * np.cos(theta), R * np.sin(theta)]) + out = fitter.fit([high_slice, valid_slice], z_wall=0.0, droplet_geometry=spherical) + # Only the valid slice produces an angle. + assert len(out.per_slice_angles) == 1 + + +def test_slicing_raises_when_no_valid_slice(spherical: DropletGeometry) -> None: + """If every slice is dropped, the fitter raises rather than averaging zero.""" + fitter = SurfaceFitter.slicing(surface_filter_offset=2.0) + # Every slice below the filter → all skipped. + bad_surfaces = [ + np.array([[0.0, -5.0], [1.0, -4.5]]), + np.array([[0.0, -3.0], [1.0, -2.5]]), + ] + with pytest.raises(RuntimeError, match="no slice produced"): + fitter.fit(bad_surfaces, z_wall=0.0, droplet_geometry=spherical) + + +# --- Whole fitter ------------------------------------------------------------- + + +def test_whole_rejects_negative_bootstrap() -> None: + with pytest.raises(ValueError, match="bootstrap_samples must be"): + SurfaceFitter.whole(bootstrap_samples=-1) + + +def test_whole_rejects_non_ndarray_input(spherical: DropletGeometry) -> None: + fitter = SurfaceFitter.whole() + with pytest.raises(TypeError, match="\\(N, 3\\) ndarray shell"): + fitter.fit([np.zeros((4, 2))], z_wall=0.0, droplet_geometry=spherical) + + +def test_whole_rejects_wrong_shell_shape(spherical: DropletGeometry) -> None: + fitter = SurfaceFitter.whole() + with pytest.raises(ValueError, match="\\(N, 3\\) ndarray shell"): + fitter.fit(np.zeros((10, 2)), z_wall=0.0, droplet_geometry=spherical) + + +def test_whole_rejects_insufficient_points(spherical: DropletGeometry) -> None: + """Below the geometric-fit minimum, the fitter raises a clear RuntimeError.""" + fitter = SurfaceFitter.whole(surface_filter_offset=0.0) + # Only two points above z_wall=0; need 4 for a sphere fit. + shell = np.array( + [ + [1.0, 1.0, 1.0], + [2.0, 1.0, 1.0], + [1.0, 2.0, -1.0], # below filter + [1.0, 3.0, -1.0], # below filter + ] + ) + with pytest.raises(RuntimeError, match="sphere fit"): + fitter.fit(shell, z_wall=0.0, droplet_geometry=spherical) + + +def test_whole_raises_when_cap_does_not_intersect_wall( + spherical: DropletGeometry, +) -> None: + """A sphere far from the wall (|Δz| ≥ R) yields no contact angle.""" + fitter = SurfaceFitter.whole(surface_filter_offset=0.0) + # A sphere centred at (0, 0, 100) with R ≈ 1. Wall at z=0 → no intersect. + n_phi, n_theta = 12, 6 + phi = np.linspace(0, 2 * np.pi, n_phi, endpoint=False) + theta = np.linspace(0.05, np.pi - 0.05, n_theta) + P, T = np.meshgrid(phi, theta, indexing="ij") + R_sphere = 1.0 + shell = np.column_stack( + [ + (R_sphere * np.sin(T) * np.cos(P)).ravel(), + (R_sphere * np.sin(T) * np.sin(P)).ravel(), + 100.0 + (R_sphere * np.cos(T)).ravel(), + ] + ) + with pytest.raises(RuntimeError, match="does not intersect"): + fitter.fit(shell, z_wall=0.0, droplet_geometry=spherical) + + +def test_whole_cylinder_geometry_uses_circle_fit( + cylinder: DropletGeometry, +) -> None: + """Cylinder droplet → 2D circle fit; popt has 4 entries (xc, zc, R, z_wall).""" + fitter = SurfaceFitter.whole(surface_filter_offset=0.0) + # A cylinder of radius 10 along y, centred at (x=0, z=8) so it + # crosses the wall at z=0 (|Δz|=8 < R=10 → finite contact angle). + n_phi = 100 + n_y = 20 + phi = np.linspace(0, 2 * np.pi, n_phi, endpoint=False) + ys = np.linspace(-10.0, 10.0, n_y) + R_cyl = 10.0 + z_center = 8.0 + points = [] + for y in ys: + x = R_cyl * np.cos(phi) + z = z_center + R_cyl * np.sin(phi) + points.append(np.column_stack([x, np.full(n_phi, y), z])) + shell = np.concatenate(points, axis=0) + out = fitter.fit(shell, z_wall=0.0, droplet_geometry=cylinder) + # popt = [xc, zc, R, z_wall] for cylinder; the centre is at (0, 8). + assert out.popt.shape == (4,) + np.testing.assert_allclose(out.popt[0], 0.0, atol=0.1) + np.testing.assert_allclose(out.popt[1], z_center, atol=0.1) + np.testing.assert_allclose(out.popt[2], R_cyl, atol=0.1) + # cos θ = (0 - 8) / 10 = -0.8 → θ = arccos(-0.8) ≈ 143.13°. + expected = float(np.degrees(np.arccos(-0.8))) + np.testing.assert_allclose(out.angle, expected, atol=1.0) + + +def test_whole_bootstrap_populates_angle_std( + spherical: DropletGeometry, +) -> None: + """``bootstrap_samples=20`` returns a finite angle_std.""" + fitter = SurfaceFitter.whole(surface_filter_offset=0.0, bootstrap_samples=20) + # A noisy partial sphere with cap intersecting z=0. + rng = np.random.default_rng(0) + n_phi, n_theta = 40, 12 + phi = np.linspace(0, 2 * np.pi, n_phi, endpoint=False) + theta = np.linspace(0.05, np.pi * 0.6, n_theta) + P, T = np.meshgrid(phi, theta, indexing="ij") + R_sphere = 10.0 + noise = rng.normal(0, 0.1, size=(n_phi * n_theta, 3)) + shell = ( + np.column_stack( + [ + (R_sphere * np.sin(T) * np.cos(P)).ravel(), + (R_sphere * np.sin(T) * np.sin(P)).ravel(), + (R_sphere * np.cos(T)).ravel(), + ] + ) + + noise + ) + out = fitter.fit(shell, z_wall=0.0, droplet_geometry=spherical) + assert out.angle_std is not None + assert 0.0 < out.angle_std < 5.0 diff --git a/tests/test_analysis/test_geometry.py b/tests/test_analysis/test_geometry.py new file mode 100644 index 0000000..98c0174 --- /dev/null +++ b/tests/test_analysis/test_geometry.py @@ -0,0 +1,81 @@ +"""Unit tests for :class:`DropletGeometry`.""" + +import numpy as np +import pytest + +from wetting_angle_kit.analysis.geometry import DropletGeometry + +# --- Construction & validation ---------------------------------------------- + + +def test_rejects_invalid_name() -> None: + with pytest.raises(ValueError, match="droplet_geometry must be one of"): + DropletGeometry(name="bogus") # type: ignore[arg-type] + + +@pytest.mark.parametrize("name", ["spherical", "cylinder_x", "cylinder_y"]) +def test_accepts_valid_names(name: str) -> None: + geom = DropletGeometry(name=name) # type: ignore[arg-type] + assert geom.name == name + + +# --- coerce() --------------------------------------------------------------- + + +def test_coerce_returns_instance_unchanged() -> None: + g = DropletGeometry.coerce("spherical") + assert DropletGeometry.coerce(g) is g + + +def test_coerce_from_string() -> None: + g = DropletGeometry.coerce("cylinder_y") + assert isinstance(g, DropletGeometry) + assert g.name == "cylinder_y" + + +# --- Predicates ------------------------------------------------------------- + + +def test_is_spherical_and_is_cylinder() -> None: + sph = DropletGeometry.coerce("spherical") + cyx = DropletGeometry.coerce("cylinder_x") + cyy = DropletGeometry.coerce("cylinder_y") + assert sph.is_spherical and not sph.is_cylinder + assert cyx.is_cylinder and not cyx.is_spherical + assert cyy.is_cylinder and not cyy.is_spherical + + +# --- cylinder_axis ---------------------------------------------------------- + + +def test_cylinder_axis() -> None: + assert DropletGeometry.coerce("cylinder_x").cylinder_axis == "x" + assert DropletGeometry.coerce("cylinder_y").cylinder_axis == "y" + assert DropletGeometry.coerce("spherical").cylinder_axis is None + + +# --- Coordinate-frame swaps ------------------------------------------------- + + +def test_to_internal_coords_swaps_for_cylinder_x() -> None: + """``cylinder_x`` swaps x↔y so the cylinder axis ends up on y.""" + coords = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + out = DropletGeometry.coerce("cylinder_x").to_internal_coords(coords) + np.testing.assert_array_equal(out, [[2.0, 1.0, 3.0], [5.0, 4.0, 6.0]]) + + +def test_to_internal_coords_is_identity_for_spherical_and_cylinder_y() -> None: + coords = np.array([[1.0, 2.0, 3.0]]) + for name in ("spherical", "cylinder_y"): + out = DropletGeometry.coerce(name).to_internal_coords(coords) + np.testing.assert_array_equal(out, coords) + + +def test_to_user_coords_is_inverse_of_to_internal() -> None: + """The swap is an involution: roundtrip restores the input.""" + coords = np.array([[1.0, 2.0, 3.0], [4.0, 5.0, 6.0]]) + for name in ("spherical", "cylinder_x", "cylinder_y"): + g = DropletGeometry.coerce(name) + np.testing.assert_array_equal( + g.to_user_coords(g.to_internal_coords(coords)), coords + ) diff --git a/tests/test_analysis/test_parallel_path.py b/tests/test_analysis/test_parallel_path.py new file mode 100644 index 0000000..d9c4c05 --- /dev/null +++ b/tests/test_analysis/test_parallel_path.py @@ -0,0 +1,57 @@ +"""Exercise the ``_run_parallel`` (``multiprocessing.Pool``) path. + +The fast-path optimisation makes single-batch and ``n_jobs=1`` calls +go inline, so the parallel branch needs an explicit kick to cover it. +""" + +import pathlib + +import numpy as np +import pytest + +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer # noqa: E402 +from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) + + +@pytest.mark.integration +def test_run_parallel_path_executes_with_n_jobs_2() -> None: + """Multi-batch + ``n_jobs=2`` forces the ``_run_parallel`` branch. + + Coupled binning is the cheapest analyzer; using it here keeps the + test under ~5 seconds even with two real worker processes. + """ + oxygen_indices = LammpsDumpWaterFinder( + _FIXTURE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params={ + "xi_0": 0.0, + "xi_f": 40.0, + "nbins_xi": 30, + "zi_0": 0.0, + "zi_f": 40.0, + "nbins_zi": 30, + }, + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + # Multi-batch (len > 1) AND n_jobs > 1 → parallel path. + results = analyzer.analyze([0, 1, 2], n_jobs=2) + assert len(results) == 3 + for batch in results.batches: + assert np.isfinite(batch.angle) diff --git a/tests/test_analysis/test_temporal.py b/tests/test_analysis/test_temporal.py new file mode 100644 index 0000000..c3e9cac --- /dev/null +++ b/tests/test_analysis/test_temporal.py @@ -0,0 +1,46 @@ +"""Unit tests for :class:`TemporalAggregator`.""" + +import pytest + +from wetting_angle_kit.analysis.temporal import TemporalAggregator + + +def test_iter_batches_per_frame() -> None: + agg = TemporalAggregator(batch_size=1) + assert list(agg.iter_batches([0, 1, 2])) == [[0], [1], [2]] + + +def test_iter_batches_pooled() -> None: + agg = TemporalAggregator(batch_size=2) + assert list(agg.iter_batches([0, 1, 2, 3, 4])) == [[0, 1], [2, 3], [4]] + + +def test_iter_batches_fully_pooled() -> None: + agg = TemporalAggregator(batch_size=-1) + assert list(agg.iter_batches([0, 1, 2, 3])) == [[0, 1, 2, 3]] + + +def test_iter_batches_empty_returns_nothing() -> None: + agg = TemporalAggregator(batch_size=1) + assert list(agg.iter_batches([])) == [] + + +@pytest.mark.parametrize("bad", [0, -2, -10]) +def test_rejects_zero_or_invalid_negative(bad: int) -> None: + with pytest.raises(ValueError, match="batch_size must be"): + TemporalAggregator(batch_size=bad) + + +@pytest.mark.parametrize( + "n_frames,batch_size,expected", + [ + (10, 1, 10), + (10, 3, 4), + (10, 10, 1), + (10, -1, 1), + (0, 1, 0), + (0, -1, 0), + ], +) +def test_n_batches(n_frames: int, batch_size: int, expected: int) -> None: + assert TemporalAggregator(batch_size=batch_size).n_batches(n_frames) == expected diff --git a/tests/test_visualization/test_angle_evolution_helpers.py b/tests/test_visualization/test_angle_evolution_helpers.py new file mode 100644 index 0000000..6ba9d5a --- /dev/null +++ b/tests/test_visualization/test_angle_evolution_helpers.py @@ -0,0 +1,234 @@ +"""Unit tests for the helpers behind :class:`AngleEvolutionPlotter`. + +The plotter's main `.plot()` path is covered by the smoke tests in +``test_angle_evolution_plotter.py``; this file targets the internal +helpers that the smoke tests don't fully exercise — in particular +``_circular_segment_area`` over all its piecewise branches and +``_batch_surface_area`` over each result-type dispatch arm. +""" + +import math + +import numpy as np +import pytest + +from wetting_angle_kit.analysis.results import ( + CoupledBinning2DBatchResult, + CoupledBinning3DBatchResult, + SlicingBatchResult, + TrajectoryResults, + WholeBatchResult, +) +from wetting_angle_kit.visualization.angle_evolution_plotter import ( + AngleEvolutionPlotter, + _batch_surface_area, + _circular_segment_area, + _shoelace_area, +) + +# --- _shoelace_area ---------------------------------------------------------- + + +def test_shoelace_empty_input_returns_zero() -> None: + assert _shoelace_area(np.empty((0, 2))) == 0.0 + + +def test_shoelace_unit_square() -> None: + sq = np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]) + assert _shoelace_area(sq) == pytest.approx(1.0) + + +# --- _circular_segment_area branches ----------------------------------------- + + +def test_circular_segment_h_zero_or_negative_returns_zero() -> None: + # z_center + R = z_cut → h = 0 (boundary). + assert _circular_segment_area(R=5.0, z_center=0.0, z_cut=5.0) == 0.0 + # z_cut above the circle entirely → h < 0. + assert _circular_segment_area(R=5.0, z_center=0.0, z_cut=10.0) == 0.0 + + +def test_circular_segment_h_geq_2R_returns_full_circle() -> None: + # z_cut well below the circle → segment is the whole disc. + R = 3.0 + area = _circular_segment_area(R=R, z_center=10.0, z_cut=-100.0) + assert area == pytest.approx(math.pi * R**2) + + +def test_circular_segment_small_h_branch() -> None: + """``h <= R`` is the "less than half a disc" piecewise formula.""" + R = 1.0 + # z_center + R - z_cut = h. For h=R/2 (a small cap), check + # against the closed-form integral. + h = R / 2.0 + z_center = 0.0 + z_cut = z_center + R - h + expected = R**2 * math.acos((R - h) / R) - (R - h) * math.sqrt(2 * R * h - h**2) + assert _circular_segment_area(R, z_center, z_cut) == pytest.approx(expected) + + +def test_circular_segment_large_h_branch_uses_complement() -> None: + """``R < h < 2R`` should use the "full minus small segment" branch.""" + R = 1.0 + # h = 1.5R (between R and 2R). + h = 1.5 * R + z_center = 0.0 + z_cut = z_center + R - h + # Build the expected by symmetry: full circle minus the small + # segment on the other side. + h_small = 2 * R - h + small_seg = R**2 * math.acos((R - h_small) / R) - (R - h_small) * math.sqrt( + 2 * R * h_small - h_small**2 + ) + expected = math.pi * R**2 - small_seg + assert _circular_segment_area(R, z_center, z_cut) == pytest.approx(expected) + + +# --- _batch_surface_area dispatch -------------------------------------------- + + +def test_batch_surface_area_slicing_uses_shoelace() -> None: + """SlicingBatchResult: mean of per-slice shoelace areas.""" + batch = SlicingBatchResult( + frames=[0], + angle=90.0, + z_wall=0.0, + rms_residual=0.0, + angle_std=0.0, + per_slice_angles=np.array([90.0]), + slice_surfaces=[ + np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), # area 1 + np.array([[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]]), # area 2 + ], + slice_popts=np.zeros((2, 4)), + ) + assert _batch_surface_area(batch) == pytest.approx(1.5) + + +def test_batch_surface_area_slicing_empty_surfaces_returns_zero() -> None: + batch = SlicingBatchResult( + frames=[0], + angle=90.0, + z_wall=0.0, + rms_residual=0.0, + angle_std=0.0, + per_slice_angles=np.array([]), + slice_surfaces=[], + slice_popts=np.zeros((0, 4)), + ) + assert _batch_surface_area(batch) == 0.0 + + +def test_batch_surface_area_whole_spherical_popt() -> None: + """WholeBatchResult with 5-element popt → sphere; uses zc=popt[2], R=popt[3]. + + For a sphere centred at the wall (z_center = z_wall), the segment + above the wall is the upper half-disc, area = π R² / 2. + """ + R = 5.0 + batch = WholeBatchResult( + frames=[0], + angle=90.0, + z_wall=0.0, + rms_residual=0.0, + angle_std=None, + interface_shell=np.zeros((10, 3)), + popt=np.array([0.0, 0.0, 0.0, R, 0.0]), + ) + assert _batch_surface_area(batch) == pytest.approx(math.pi * R**2 / 2) + + +def test_batch_surface_area_whole_cylinder_popt() -> None: + """4-element popt → cylinder; uses zc=popt[1], R=popt[2].""" + R = 3.0 + batch = WholeBatchResult( + frames=[0], + angle=90.0, + z_wall=0.0, + rms_residual=0.0, + angle_std=None, + interface_shell=np.zeros((10, 3)), + popt=np.array([0.0, 0.0, R, 0.0]), + ) + assert _batch_surface_area(batch) == pytest.approx(math.pi * R**2 / 2) + + +def test_batch_surface_area_whole_unknown_popt_returns_nan() -> None: + """Unexpected popt length falls through to NaN.""" + batch = WholeBatchResult( + frames=[0], + angle=90.0, + z_wall=0.0, + rms_residual=0.0, + angle_std=None, + interface_shell=np.zeros((10, 3)), + popt=np.array([1.0, 2.0, 3.0]), + ) + assert math.isnan(_batch_surface_area(batch)) + + +def test_batch_surface_area_coupled_binning_2d_uses_model_params() -> None: + """Both 2D and 3D coupled-binning batches share the dispatch arm.""" + batch = CoupledBinning2DBatchResult( + frames=[0], + angle=90.0, + model_params={ + "rho1": 0.03, + "rho2": 1e-4, + "R_eq": 5.0, + "zi_c": 0.0, + "zi_0": 0.0, + "t1": 1.0, + "t2": 1.0, + }, + xi_grid=np.linspace(0, 10, 5), + zi_grid=np.linspace(0, 10, 5), + density=np.zeros((5, 5)), + ) + # Same hemisphere geometry as above ⇒ area = π R² / 2. + assert _batch_surface_area(batch) == pytest.approx(math.pi * 25.0 / 2) + + +def test_batch_surface_area_coupled_binning_3d_uses_model_params() -> None: + batch = CoupledBinning3DBatchResult( + frames=[0], + angle=90.0, + model_params={ + "rho1": 0.03, + "rho2": 1e-4, + "R_eq": 5.0, + "xi_c": 0.0, + "yi_c": 0.0, + "zi_c": 0.0, + "zi_0": 0.0, + "t1": 1.0, + "t2": 1.0, + }, + xi_grid=np.linspace(-10, 10, 5), + yi_grid=np.linspace(-10, 10, 5), + zi_grid=np.linspace(0, 10, 5), + density=np.zeros((5, 5, 5)), + ) + assert _batch_surface_area(batch) == pytest.approx(math.pi * 25.0 / 2) + + +def test_batch_surface_area_unknown_type_returns_nan() -> None: + """Anything not in the dispatch table falls through to NaN.""" + assert math.isnan(_batch_surface_area("not a batch")) + + +# --- summary() integration --------------------------------------------------- + + +def test_summary_empty_results_returns_nan_stats() -> None: + plotter = AngleEvolutionPlotter( + TrajectoryResults(batches=[], method_metadata={}), + label="empty-traj", + ) + summary = plotter.summary() + assert len(summary) == 1 + stats = summary[0] + assert stats.label == "empty-traj" + assert stats.n_samples == 0 + assert math.isnan(stats.mean_surface_area) + assert math.isnan(stats.mean_contact_angle) From 7df3013023e101f9216434d0531c984ca7625751 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Thu, 11 Jun 2026 12:07:32 +0200 Subject: [PATCH 32/53] Small update of docstring. --- src/wetting_angle_kit/analysis/_base.py | 4 ---- src/wetting_angle_kit/analysis/trajectory.py | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index 71dc8de..4497517 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -21,10 +21,6 @@ computation, run inside a worker; - ``_build_results(batches)`` — packaging the per-batch results into the analyzer's results dataclass. - -Algorithm bodies for the subclass extension points are stubbed at this -skeleton stage. The shared scaffolding (constructor, ``analyze``, coord -gathering) is real because it is small, well-defined, and stable. """ import logging diff --git a/src/wetting_angle_kit/analysis/trajectory.py b/src/wetting_angle_kit/analysis/trajectory.py index 2b953fd..45ca2fc 100644 --- a/src/wetting_angle_kit/analysis/trajectory.py +++ b/src/wetting_angle_kit/analysis/trajectory.py @@ -12,9 +12,7 @@ The class extends the shared :class:`_BatchedTrajectoryAnalyzer` worker-pool scaffolding by implementing the four extension points documented there. The per-batch wiring lives in -:meth:`_process_batch_worker`; algorithm bodies (extraction, fitting, -wall detection beyond :meth:`WallDetector.min_plus_offset`) remain -``NotImplementedError`` until porting begins. +:meth:`_process_batch_worker`. The joint-fit analyzers (:class:`CoupledBinning2DAnalyzer`, :class:`CoupledBinning3DAnalyzer`) live in their own modules and From 90d0d086ea8d206798cf075dfd150c228395cd95 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 12 Jun 2026 11:47:52 +0200 Subject: [PATCH 33/53] Many small things but forgot to commit in between, my bad. Includes modifying default grid and binning params, computing those that can be computed from the data, and mainly modifying grid based extractors so that the gaussian KDE is actually used similarly to the ray case. The grid_binning is better defined as well. Grid mode is correctly handling slicing mode. Better parallelization message and doc. --- README.md | 2 +- docs/examples/binning_ca.py | 4 +- .../visualisation_evolution_density.py | 4 +- docs/source/introduction/introduction.rst | 45 ++ .../introduction/theoretical_foundations.rst | 53 +- docs/source/tutorials/binning_method_tuto.rst | 24 +- .../tutorials/coupled_binning_3d_tuto.rst | 16 +- docs/source/tutorials/grid_method_tuto.rst | 137 ++-- docs/source/tutorials/slicing_method_tuto.rst | 37 +- .../visualization_evolution_density.rst | 4 +- src/wetting_angle_kit/analysis/_base.py | 105 ++- src/wetting_angle_kit/analysis/_density.py | 17 +- .../analysis/coupled_binning/_models.py | 127 +-- .../analysis/coupled_binning/analyzer_2d.py | 35 +- .../analysis/coupled_binning/analyzer_3d.py | 32 +- .../analysis/extractors/_grid.py | 722 ++++++++++++------ .../analysis/extractors/_rays.py | 40 +- .../analysis/extractors/base.py | 99 ++- .../analysis/fitters/_slicing.py | 7 +- .../analysis/fitters/_whole.py | 5 +- src/wetting_angle_kit/analysis/geometry.py | 31 + src/wetting_angle_kit/analysis/trajectory.py | 7 +- src/wetting_angle_kit/analysis/wall.py | 2 +- tests/test_analysis/test_binning_method.py | 9 +- .../test_analysis/test_coupled_binning_3d.py | 16 +- tests/test_analysis/test_cylinder_coverage.py | 313 ++++++++ .../test_analysis/test_default_grid_params.py | 145 ++++ tests/test_analysis/test_grid_extractors.py | 187 ++--- .../test_grid_extractors_whole.py | 90 ++- tests/test_analysis/test_parallel_path.py | 37 +- .../test_rays_binning_extractor.py | 4 - .../test_rays_gaussian_extractor.py | 2 - tests/test_analysis/test_whole_fitter.py | 1 - 33 files changed, 1727 insertions(+), 632 deletions(-) create mode 100644 tests/test_analysis/test_cylinder_coverage.py create mode 100644 tests/test_analysis/test_default_grid_params.py diff --git a/README.md b/README.md index debbce9..b2d7909 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ wetting-angle-kit parses MD trajectories (LAMMPS dump, XYZ, ASE) and computes th The liquid-vapor interface isn't a sharp surface in an MD simulation — the density drops smoothly over ~1 Å. Two extraction strategies recover a clean set of interface points from the noisy atom cloud: - **Ray-fan extractors** emit a fan of rays from the droplet centre of mass and locate the interface along each ray as the half-density point of a 1D tanh fit. The fan is azimuthal slices in the `(x, z)` plane (for a per-slice fit) or a Fibonacci sphere of directions (for a whole-shape fit). The density along each ray comes from either a Gaussian KDE (`rays_gaussian`) or a 1D histogram (`rays_binning`); both produce interface points robust to thermal noise. -- **Grid extractors** build a 2D or 3D density grid by histogramming the liquid atoms, then trace the iso-density contour at the half-bulk level via marching squares (`grid_*` in slicing mode) or marching cubes (`grid_*` in whole mode). Closer to the "average over many frames" intuition; works well when atom statistics are limited per frame. +- **Grid extractors** build a fixed-cell grid in space, compute a density value at each cell, and then trace the iso-density contour at the half-bulk level via marching squares (`grid_*` in slicing mode) or marching cubes (`grid_*` in whole mode). Two ways to fill the cells: `grid_gaussian` evaluates a Gaussian KDE (sum of Gaussians centred on atoms) at each cell centre — the same density estimator `rays_gaussian` uses, just sampled on a grid; `grid_binning` is a histogram where each cell counts the atoms inside it. In slicing mode both extractors iterate per slice (per azimuthal angle for spherical droplets, per axial step for cylinder droplets), so the slicing fit sees one `(s, z)` contour per slice and can expose per-slice asymmetry. Closer to the "average over many frames" intuition than ray fans; works well when atom statistics are limited per frame. ### Surface fitting: what geometric shape do we fit to those points? diff --git a/docs/examples/binning_ca.py b/docs/examples/binning_ca.py index 4aa7af2..ab55eb8 100644 --- a/docs/examples/binning_ca.py +++ b/docs/examples/binning_ca.py @@ -25,10 +25,10 @@ binning_params = { "xi_0": 0.0, "xi_f": 70.0, - "nbins_xi": 30, + "bin_width_x": 2.0, "zi_0": 0.0, "zi_f": 70.0, - "nbins_zi": 30, + "bin_width_z": 2.0, } # --- Step 4: Build the analyzer --- diff --git a/docs/examples/visualisation_evolution_density.py b/docs/examples/visualisation_evolution_density.py index 05c3433..9804baf 100644 --- a/docs/examples/visualisation_evolution_density.py +++ b/docs/examples/visualisation_evolution_density.py @@ -58,10 +58,10 @@ binning_params={ "xi_0": 0.0, "xi_f": 70.0, - "nbins_xi": 30, + "bin_width_x": 2.0, "zi_0": 0.0, "zi_f": 70.0, - "nbins_zi": 30, + "bin_width_z": 2.0, }, temporal_aggregator=TemporalAggregator(batch_size=10), ) diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index 0ae9e7d..081da91 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -155,6 +155,51 @@ Three visualisation classes cover the most common needs: Examples for each plot live in the :doc:`../tutorials/index` section. +4. Parallelisation and progress reporting +----------------------------------------- + +Every analyzer (:class:`TrajectoryAnalyzer`, +:class:`CoupledBinning2DAnalyzer`, :class:`CoupledBinning3DAnalyzer`) +accepts an ``n_jobs`` argument on :meth:`analyze` for worker-process +parallelism, plus a ``temporal_aggregator`` constructor argument that +controls how the requested frame range is partitioned into batches. +The two interact in three regimes: + +* **Per-frame analysis** (``batch_size=1``, the default for + :class:`TrajectoryAnalyzer`): each frame is its own batch, so + ``n_jobs > 1`` distributes batches over a + :class:`multiprocessing.Pool`. This is the right combination for + long trajectories where you want a time-resolved angle trace and + CPU cores are the limiting resource. + +* **Bucketed batches** (``batch_size=N``, ``N > 1``): consecutive + groups of ``N`` frames are pooled into batches; ``n_jobs > 1`` + distributes those batches across workers. Each batch gives one + pooled-density fit and ``angle_std`` reports spatial asymmetry of + the pooled cloud (see the note on pooled-batch slicing in the + :doc:`../tutorials/slicing_method_tuto`). + +* **Fully pooled** (``batch_size=-1``, the default for the + coupled-binning analyzers): every frame goes into one batch and one + fit. Because there's only one unit of work, ``n_jobs`` is silently + irrelevant — :meth:`analyze` always runs inline, and passing + ``n_jobs > 1`` emits a ``UserWarning`` to flag the wasted + expectation. Reach for ``batch_size=-1`` when you want one + maximally-noise-reduced angle over a steady-state window. + +The :class:`multiprocessing.Pool` uses the ``spawn`` start method, so +trajectory parsers are reconstructed in each worker from the file +path captured at :class:`TrajectoryAnalyzer.__init__`. Keep parser +construction cheap (just a path string and a few light flags) — the +spawn cost shows up once per worker per :meth:`analyze` call. + +Progress is reported in **frames**, not batches, so the tqdm meter +stays informative regardless of ``batch_size``. Under +``batch_size=-1`` the meter still updates frame-by-frame while the +per-frame parse loop runs at the start of the batch; the subsequent +extract/fit stage on the pooled cloud is opaque to the meter (a +single long-running computation that the workers can't subdivide). + Troubleshooting --------------- diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 2947d70..f4cacd4 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -151,29 +151,42 @@ equal-area coverage with no clustering anywhere. ^^^^^^^^^^^^^^^^^^^^^^ The :meth:`InterfaceExtractor.grid_gaussian` / -:meth:`grid_binning` factories build a 2D or 3D density grid by -histogramming the atom positions and (optionally) smoothing with a -Gaussian. The interface is then recovered as the iso-density -contour at the half-bulk level, via +:meth:`grid_binning` factories build a fixed-cell grid in space and +compute a density value at each cell, then recover the interface as +the iso-density contour at the half-bulk level via :func:`skimage.measure.find_contours` in 2D (marching squares) or :func:`skimage.measure.marching_cubes` in 3D. -Closer to the "average over many frames" intuition than ray fans; -works well when atom statistics are limited per frame. - -For slicing-mode grid extraction the atoms are projected onto -2D ``(r, z)`` via the droplet's symmetry (radial for spherical, -``|x - x_c|`` for cylinder). The density grid is normalised by - -.. math:: - - dV_{\rm cell} \;=\; - \begin{cases} - 2\pi\,r\,d r\,d z & \text{spherical} \\ - d r\,d z & \text{cylinder} - \end{cases} - -so the annular volume of each cell is accounted for. +The two estimators differ only in how the per-cell density is +computed: + +* ``grid_gaussian`` evaluates the same Gaussian KDE described in + §3 at each cell centre — exactly the estimator + :meth:`rays_gaussian` uses, just sampled on a grid rather than + along rays. No additional smoothing step. +* ``grid_binning`` is a histogram: for slicing mode, atoms within + ``±bin_width_x/2`` of the slice plane contribute to the 2D + histogram in ``(s, z)`` (the slab cut); for whole mode, atoms are + binned directly into 3D ``(x, y, z)`` cells. Density is + ``counts / cell_volume``. + +In slicing mode both grid extractors iterate **per slice** — +azimuthal angles ``γ ∈ [0°, 180°)`` for spherical droplets, axial +steps along ``y`` for cylinder droplets — exactly like the rays +variants. Each slice yields an ``(s, z)`` density field and one +iso-contour; the downstream :class:`SurfaceFitter.slicing` averages +the per-slice angles and reports the inter-slice scatter, which is +how the slicing method exposes droplet asymmetry. + +Two volume-normalisation notes: + +* ``grid_gaussian`` returns 3D density per ų directly from the KDE + evaluation; no extra volume normalisation needed. +* ``grid_binning``'s slab-cut histogram divides by + ``ds × dz × bin_width_x`` so the recovered field is also in + atoms/ų. The slab thickness equals ``bin_width_x`` (the in-plane + horizontal cell width), which keeps the bin's cross-section in the + ``(s, perpendicular)`` directions square. 5. Fitting the cap: algebraic Kasa fits --------------------------------------- diff --git a/docs/source/tutorials/binning_method_tuto.rst b/docs/source/tutorials/binning_method_tuto.rst index 28f4ce7..ef5cbde 100644 --- a/docs/source/tutorials/binning_method_tuto.rst +++ b/docs/source/tutorials/binning_method_tuto.rst @@ -77,10 +77,10 @@ Example trajectory:: binning_params = { "xi_0": 0.0, "xi_f": 100.0, - "nbins_xi": 50, + "bin_width_x": 2.0, "zi_0": 0.0, "zi_f": 100.0, - "nbins_zi": 25, + "bin_width_z": 4.0, } # --- Step 4: Build the analyzer --- @@ -134,11 +134,17 @@ Example printed output:: 5. Tips ------- -- **Grid bounds and bin counts**: pick ``xi_f`` and ``zi_f`` so the - droplet sits well inside the grid; pick ``nbins_xi`` and - ``nbins_zi`` so each cell receives many atoms when pooling. As a - rule of thumb, aim for at least 20 atoms per occupied cell after - pooling across the batch. +- **Grid bounds and cell width**: pick ``xi_f`` and ``zi_f`` so the + droplet sits well inside the grid; pick ``bin_width_x`` and + ``bin_width_z`` so each cell receives many atoms when pooling. As + a rule of thumb, aim for at least 20 atoms per occupied cell after + pooling across the batch. The range bounds are honoured exactly; + the effective cell width is rounded so an integer number of cells + fits, and may differ from the requested value by a few percent. +- **No ``binning_params``?** Leaving it ``None`` uses an atom-derived + default: lateral half-box for ``xi``/``zi``, ``bin_width = 0.5 Å`` + (half the model's default interface thickness ``t1``). A warning + is emitted to flag that the user didn't tune the grid. - **Batch size**: the joint fit benefits from statistics, so pool as many frames as your time-resolution needs allow. ``batch_size=-1`` (the default) pools everything into one batch and returns a single @@ -182,10 +188,10 @@ along the cylinder axis): binning_params={ "xi_0": 0.0, "xi_f": 100.0, - "nbins_xi": 50, + "bin_width_x": 2.0, "zi_0": 0.0, "zi_f": 100.0, - "nbins_zi": 25, + "bin_width_z": 4.0, }, ) diff --git a/docs/source/tutorials/coupled_binning_3d_tuto.rst b/docs/source/tutorials/coupled_binning_3d_tuto.rst index 5312212..80ae119 100644 --- a/docs/source/tutorials/coupled_binning_3d_tuto.rst +++ b/docs/source/tutorials/coupled_binning_3d_tuto.rst @@ -72,13 +72,13 @@ wasting work. binning_params = { "xi_0": -40.0, "xi_f": 40.0, - "nbins_xi": 25, + "bin_width_x": 3.2, "yi_0": -40.0, "yi_f": 40.0, - "nbins_yi": 25, + "bin_width_y": 3.2, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 25, + "bin_width_z": 4.0, } analyzer = CoupledBinning3DAnalyzer( @@ -172,21 +172,21 @@ the same angle within a few degrees. It's a useful sanity check: binning_2d = { "xi_0": 0.0, "xi_f": 40.0, - "nbins_xi": 40, + "bin_width_x": 1.0, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 40, + "bin_width_z": 1.0, } binning_3d = { "xi_0": -40.0, "xi_f": 40.0, - "nbins_xi": 25, + "bin_width_x": 3.2, "yi_0": -40.0, "yi_f": 40.0, - "nbins_yi": 25, + "bin_width_y": 3.2, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 25, + "bin_width_z": 4.0, } a2d = ( diff --git a/docs/source/tutorials/grid_method_tuto.rst b/docs/source/tutorials/grid_method_tuto.rst index 2d6761a..06648f3 100644 --- a/docs/source/tutorials/grid_method_tuto.rst +++ b/docs/source/tutorials/grid_method_tuto.rst @@ -7,9 +7,15 @@ This tutorial covers the **grid-based interface extractors** — the ray-fan extractors used in the :doc:`slicing_method_tuto` and :doc:`whole_fit_tuto`: instead of locating the interface as the -half-density point of a 1D tanh fit along each ray, they build a -2D or 3D density grid and recover the interface as the iso-density -contour at the half-bulk level. +half-density point of a 1D tanh fit along each ray, they evaluate a +density at each cell of a fixed grid and recover the interface as +the iso-density contour at the half-bulk level. + +In slicing mode both grid extractors iterate per slice — per +azimuthal angle for spherical droplets, per axial step for cylinder +droplets — so the downstream :class:`SurfaceFitter.slicing` sees one +contour per slice and can expose per-slice asymmetry, exactly like +the rays variants. ---- @@ -22,14 +28,11 @@ about how the noise/cost trade-off lands on your system: * **Ray fans** sample density along a small number of well-chosen directions; each ray's 1D tanh fit is cheap. Best when atom - statistics per frame are high and the droplet has well-defined - symmetry. + statistics per frame are high and you want sub-frame resolution. * **Grids** estimate density on every cell of a fixed mesh, then trace an iso-contour. Closer to the "average over many frames" intuition; the per-cell density gets smoother as more frames are - pooled. Robust when individual frames are sparse, but it scales - with the number of cells rather than the number of rays so the - grid resolution matters more than in the ray case. + pooled. The grid extractors require ``scikit-image`` for the iso-contour tracing (marching squares in 2D, marching cubes in 3D). Install via @@ -42,11 +45,9 @@ the ``grid3d`` extra:: 2. Worked example: ``grid_gaussian`` + slicing fit --------------------------------------------------- -A spherical droplet, with a 2D density grid in the -:math:`(r, z)` plane (the slicing-mode grid extractor projects atoms -to ``(r, z)`` via the droplet's radial symmetry — see -:doc:`../introduction/theoretical_foundations` section 4.2 for the -volume normalisation): +A spherical droplet, with per-azimuthal-slice 2D density grids in the +``(s, z)`` plane — same density estimator as ``rays_gaussian``, just +sampled on a fixed grid rather than along rays: .. code-block:: python @@ -64,17 +65,16 @@ volume normalisation): filename, oxygen_type=1, hydrogen_type=2 ).get_water_oxygen_ids(frame_index=0) - # 2D grid for slicing-mode extraction: (xi, zi) cells. - # Aim for cells small enough to resolve the interface (~1 Å is plenty - # for a Gaussian-smoothed grid) but large enough that occupied cells - # carry many atoms. + # 2D grid for each slice plane: ``s`` (in-plane radial) spans + # ``[xi_0, xi_f]`` symmetrically around the slice centre to cover + # the full diameter; ``z`` stays in the lab frame. grid_params = { - "xi_0": 0.0, + "xi_0": -40.0, "xi_f": 40.0, - "nbins_xi": 26, + "bin_width_x": 3.0, # 3 Å cells in s "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 26, + "bin_width_z": 1.6, # 1.6 Å cells in z } analyzer = TrajectoryAnalyzer( @@ -83,7 +83,8 @@ volume normalisation): droplet_geometry="spherical", interface_extractor=InterfaceExtractor.grid_gaussian( grid_params=grid_params, - density_sigma=3.0, + delta_azimuthal=20.0, # 9 azimuthal slices + density_sigma=2.0, ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=3.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -91,7 +92,8 @@ volume normalisation): ) batch = analyzer.analyze([1]).batches[0] print( - f"Angle (grid_gaussian + slicing): {batch.angle:.2f}° " f"± {batch.angle_std:.2f}°" + f"Angle (grid_gaussian + slicing): {batch.angle:.2f}° " + f"± {batch.angle_std:.2f}° across {len(batch.per_slice_angles)} slices" ) ---- @@ -99,30 +101,34 @@ volume normalisation): 3. Histogram alternative: ``grid_binning`` ------------------------------------------ -Same flow but the density estimator is a 1D top-hat -(``rho = counts / dV``) rather than a Gaussian KDE. Numerically -cheaper but noisier per cell. Use a smaller grid to keep enough -atoms per cell: +Same per-slice iteration, but the density estimator is a top-hat +histogram of atoms within the slab ``|perp| ≤ bin_width_x / 2`` of +the slice plane. Numerically cheaper than the KDE; intrinsically +noisier because only atoms in the slab contribute (not all atoms +along the slice direction the way they do for ``rays_binning``). +Use coarser cells (thicker slab) than for ``grid_gaussian``: .. code-block:: python from wetting_angle_kit.analysis import InterfaceExtractor grid_params = { - "xi_0": 0.0, + "xi_0": -40.0, "xi_f": 40.0, - "nbins_xi": 16, # coarser + "bin_width_x": 8.0, # thick slab "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 16, + "bin_width_z": 3.0, } + extractor = InterfaceExtractor.grid_binning( + grid_params=grid_params, + delta_azimuthal=60.0, # fewer slices → more atoms per slab + ) - extractor = InterfaceExtractor.grid_binning(grid_params=grid_params) - # ... plug into TrajectoryAnalyzer exactly as above. - -The slicing fitter's ``surface_filter_offset`` is a useful knob -here: histogram-based grids tend to have an iso-contour "floor" -just above the wall, which the filter drops out of the circle fit. +The slab thickness perpendicular to each slice plane is +``bin_width_x``, so refining the in-plane grid also thins the slab. +For systems with limited atom statistics per slab, the answer is +either coarser cells or fewer slices, not a finer grid. ---- @@ -131,20 +137,21 @@ just above the wall, which the filter drops out of the circle fit. The grid extractors also work in whole-fit mode for spherical droplets — the 2D density grid is replaced by a 3D one, and the -half-bulk iso-surface is traced via marching cubes: +half-bulk iso-surface is traced via marching cubes. Whole mode +takes no ``delta_azimuthal`` / ``delta_cylinder``: .. code-block:: python grid_params_3d = { "xi_0": -30.0, "xi_f": 30.0, - "nbins_xi": 26, + "bin_width_x": 2.5, "yi_0": -30.0, "yi_f": 30.0, - "nbins_yi": 26, + "bin_width_y": 2.5, "zi_0": 0.0, "zi_f": 35.0, - "nbins_zi": 26, + "bin_width_z": 2.0, } analyzer = TrajectoryAnalyzer( @@ -172,35 +179,37 @@ Three notes on the 3D case: * ``xi/yi`` are in the **droplet-centred frame** (the per-frame COM is subtracted before binning); ``zi`` stays in the lab frame so the wall position keeps its physical meaning. -* ``grid + whole-fit`` is currently spherical-only — cylindrical - droplets need the ray-fan extractor because the centred-grid - convention doesn't accommodate the cylinder axis spanning the - full box. For cylinder whole fits use - :meth:`InterfaceExtractor.rays_gaussian` with ``delta_cylinder``. +* ``grid + whole-fit`` works for both spherical and cylinder + droplets. For a cylinder, the user must pick ``yi_0`` / ``yi_f`` + to span the full cylinder axis (typically ``[-box_y / 2, +box_y / + 2]``); the centred-grid convention puts the droplet COM at the + midpoint along ``y``, so a symmetric range covers the whole + ridge. The fitter projects the 3D shell onto the ``(x, z)`` plane + and does a 2D circle fit by translational invariance along + ``y``. * Marching cubes can be slow on dense 3D grids; if performance - matters, start with 20–30 bins per axis and only refine if the - recovered angle is grid-resolution-limited. + matters, start with 2–3 Å cells and only refine if the recovered + angle is grid-resolution-limited. ---- 5. Tips ------- -- **Grid bounds**: always pick ``xi_f``, ``yi_f``, ``zi_f`` so the - full droplet fits comfortably inside the grid; the iso-contour - tracer can't extrapolate. -- **Smoothing**: ``density_sigma`` on the Gaussian variant - controls cell smoothing; values around the interface thickness - (~1–3 Å for water) work well. The histogram variant exposes no - smoothing knob — choose the cell size accordingly. -- **Cell size vs ``surface_filter_offset``**: rows of the 2D grid - closest to the wall are normalised by a narrow annular volume - (``2π r dr dz``) which inflates noise. The slicing fitter's - ``surface_filter_offset=3.0`` (instead of the default 2.0) - reliably drops the noisy floor. -- **Comparison plot**: it's often useful to run the same trajectory - through both ``rays_gaussian`` and ``grid_gaussian`` and check - the two angles agree within method-dependent tolerance (a few - degrees on 4k-atom droplets). If they diverge more than ~5°, one - of them is misconfigured (most often the grid bounds are too - tight or ``surface_filter_offset`` is too small). +- **Grid bounds**: pick ``xi_f``, ``yi_f``, ``zi_f`` so the full + droplet fits comfortably inside the grid (signed ``xi_0`` for the + slicing case so the slice spans the full diameter). The + iso-contour tracer can't extrapolate. +- **Cell sizes**: ``bin_width_x`` controls in-plane horizontal + resolution; ``bin_width_z`` controls vertical. The range bounds + are honoured exactly and the cell width is rounded to fit, so the + effective cell size may differ slightly from the value you pass. +- **Comparison plot**: run the same trajectory through both + ``rays_gaussian`` and ``grid_gaussian`` and check the two angles + agree within method-dependent tolerance (a few degrees on + 4k-atom droplets). If they diverge by more than ~8°, one of them + is misconfigured (most often the grid bounds are too tight or + ``surface_filter_offset`` is too small). +- **grid_binning slab thickness**: the slab perpendicular to each + slice equals ``bin_width_x``. If you see a noisy iso-contour, + thicken it (larger ``bin_width_x``) before reaching for ``grid_gaussian``. diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index bea5244..ed8d5e9 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -171,7 +171,16 @@ circle, see :doc:`visualization_slicing_droplet`. For a cylindrical droplet (e.g. water on a periodic stripe), swap ``delta_azimuthal`` for ``delta_cylinder`` (the step along the cylinder axis) and tell the analyzer which axis the cylinder runs -along: +along. Pick ``"cylinder_y"`` if the periodic ridge spans the box +along the lab-frame ``y`` axis; pick ``"cylinder_x"`` if it spans +along ``x``. The package handles ``cylinder_x`` by applying a +self-inverse ``x↔y`` column swap at the parser/analyzer boundary so +all downstream code can assume the cylinder axis is ``y`` — +analysis logic isn't duplicated between the two cases. Picking the +wrong axis is the cylinder analogue of confusing the in-plane +radial direction with the symmetry axis; symptoms are slicing +planes that go across the ridge (almost no atoms per slice) and a +fitter that either NaNs out or returns a non-physical angle: .. code-block:: python @@ -231,6 +240,32 @@ per fit, less per-angle noise but no within-batch time resolution. temporal_aggregator = TemporalAggregator(batch_size=5) +.. note:: + + With ``batch_size > 1``, the temporal aggregator pools + **atom positions** across frames (after per-frame PBC recentring) + before the extractor runs. The slicing pipeline then operates on + a single density field built from the union of frames, giving one + angle per batch with ``angle_std`` reflecting the spatial + asymmetry of the *pooled* density — not per-frame variability. + This is the right tool if you want a robust single angle over a + steady-state window, with the per-slice scatter as an asymmetry + diagnostic. + + If you want per-frame angles plus their across-frame mean and + standard error, use ``batch_size=1`` and aggregate the angles + yourself from the returned ``per_batch_angles`` array. The two + modes are statistically different: pooled-atoms averages the + density before measuring; pooled-angles measures each frame and + then averages. + + Two subtle caveats of pooled-atoms mode: translational drift + across the batch is handled (per-frame PBC recentring), but + rotational drift and shape oscillations are smeared together + with the spatial asymmetry. For steady-state droplets this is + harmless; for transient regimes (wetting, dewetting, vibration) + ``batch_size=1`` is the correct choice. + For physical context on the trade-off see :doc:`../introduction/theoretical_foundations` section 9. diff --git a/docs/source/tutorials/visualization_evolution_density.rst b/docs/source/tutorials/visualization_evolution_density.rst index 67f1399..0922220 100644 --- a/docs/source/tutorials/visualization_evolution_density.rst +++ b/docs/source/tutorials/visualization_evolution_density.rst @@ -108,10 +108,10 @@ full results object: binning_params={ "xi_0": 0.0, "xi_f": 70.0, - "nbins_xi": 30, + "bin_width_x": 2.0, "zi_0": 0.0, "zi_f": 70.0, - "nbins_zi": 30, + "bin_width_z": 2.0, }, temporal_aggregator=TemporalAggregator(batch_size=10), ) diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index 4497517..fb02a95 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -25,7 +25,9 @@ import logging import multiprocessing as mp +import warnings from abc import abstractmethod +from collections.abc import Callable from typing import Any, ClassVar import numpy as np @@ -75,7 +77,8 @@ def gather_batch_coords( atom_indices: np.ndarray, droplet_geometry: DropletGeometry, precentered: bool, -) -> tuple[np.ndarray, np.ndarray, float]: + progress_callback: Callable[[int], None] | None = None, +) -> tuple[np.ndarray, np.ndarray]: """Pool liquid-atom coordinates across one batch. Each frame's atoms are recentered (per-frame circular-mean PBC @@ -98,6 +101,12 @@ def gather_batch_coords( precentered : bool If True, skip the circular-mean PBC recentering and use the plain arithmetic-mean centre. + progress_callback : callable, optional + Called once with ``1`` after each frame is parsed. Used by + the inline ``analyze`` path to drive a per-frame tqdm meter + even when ``batch_size > 1`` makes the meaningful unit of + work a fraction of a batch. Pass ``None`` (the default) to + skip reporting. Returns ------- @@ -106,13 +115,9 @@ def gather_batch_coords( avg_center : ndarray, shape (3,) Mean of the per-frame liquid centres; used as the ray-fan origin by extractors. - max_dist : float - Maximum in-plane box half-extent across the batch, used as - the radial-sampling envelope. """ liquid_chunks: list[np.ndarray] = [] centres: list[np.ndarray] = [] - max_box = 0.0 for frame_num in frame_indices: positions = parser.parse(frame_index=frame_num, indices=atom_indices) if precentered: @@ -132,14 +137,13 @@ def gather_batch_coords( mean_pos = droplet_geometry.to_internal_coords(mean_pos) liquid_chunks.append(positions) centres.append(mean_pos) - box_x = parser.box_size_x(frame_index=frame_num) - box_y = parser.box_size_y(frame_index=frame_num) - max_box = max(max_box, float(box_x), float(box_y)) + if progress_callback is not None: + progress_callback(1) pooled = ( np.concatenate(liquid_chunks, axis=0) if liquid_chunks else np.empty((0, 3)) ) avg_center = np.mean(np.stack(centres, axis=0), axis=0) if centres else np.zeros(3) - return pooled, avg_center, max_box / 2.0 + return pooled, avg_center def gather_wall_coords( @@ -251,11 +255,29 @@ def analyze( if not batches: return self._build_results(batches=[]) + total_frames = sum(len(b) for b in batches) logger.info( - f"Processing {len(batches)} batches " + f"Processing {total_frames} frames across {len(batches)} batches " f"(batch_size={self.temporal_aggregator.batch_size}, n_jobs={n_jobs})." ) + # ``batch_size=-1`` pools every frame into one batch, so + # there's nothing to parallelise across — n_jobs is ignored. + # Warn loudly because the user is probably expecting speedup. + if ( + n_jobs is not None + and n_jobs > 1 + and self.temporal_aggregator.batch_size == -1 + ): + warnings.warn( + f"n_jobs={n_jobs} was requested but batch_size=-1 pools all " + f"frames into a single batch — there is no parallelism " + f"available and the analysis will run inline. Use a finite " + f"batch_size (or remove n_jobs) to silence this warning.", + UserWarning, + stacklevel=2, + ) + init_args = self._init_args() if n_jobs == 1 or len(batches) == 1: batch_results = self._run_inline(batches, init_args) @@ -279,19 +301,44 @@ def _run_inline( batches: list[list[int]], init_args: tuple, ) -> list[Any]: - """In-process batch loop; avoids ``Pool`` spawn overhead.""" + """In-process batch loop; avoids ``Pool`` spawn overhead. + + Progress is reported in frames rather than batches. To keep + the meter informative even when one batch contains many + frames (e.g. ``batch_size=-1`` would otherwise show no + progress until the entire batch completes), we publish a + progress callback into the per-class ``_WORKER_STATE`` dict. + Workers that read it (``gather_batch_coords`` and the + coupled-binning per-frame loops) call it once per frame; the + callback advances the same tqdm bar. The callback lives only + for the duration of this inline run — it's not picklable and + wouldn't survive a ``Pool.imap`` round-trip anyway. + """ self._init_worker(*init_args) results: list[Any] = [] + total_frames = sum(len(b) for b in batches) + worker_state = type(self)._WORKER_STATE with tqdm( - total=len(batches), + total=total_frames, desc=self._tqdm_desc(), - unit="batch", + unit="frame", ) as pbar: - for batch in batches: - result = self._process_batch_worker(batch) - if result is not None: - results.append(result) - pbar.update(1) + worker_state["progress_callback"] = pbar.update + try: + for batch in batches: + pre = pbar.n + result = self._process_batch_worker(batch) + if result is not None: + results.append(result) + # Workers that honour the callback have already + # advanced the bar one frame at a time; workers + # that don't (or batches that errored early) + # leave it behind, so we close the gap here. + deficit = len(batch) - (pbar.n - pre) + if deficit > 0: + pbar.update(deficit) + finally: + worker_state.pop("progress_callback", None) return results def _run_parallel( @@ -300,8 +347,16 @@ def _run_parallel( init_args: tuple, n_jobs: int | None, ) -> list[Any]: - """Multi-process batch loop via ``multiprocessing.Pool``.""" + """Multi-process batch loop via ``multiprocessing.Pool``. + + Uses ordered :meth:`Pool.imap` so each completion can be + zipped with its input batch — that lets the progress meter + advance by the batch's frame count (informative for any + batch size) without requiring workers to return their frame + count alongside the result. + """ results: list[Any] = [] + total_frames = sum(len(b) for b in batches) with ( _MP_CONTEXT.Pool( processes=n_jobs, @@ -309,15 +364,19 @@ def _run_parallel( initargs=init_args, ) as pool, tqdm( - total=len(batches), + total=total_frames, desc=self._tqdm_desc(), - unit="batch", + unit="frame", ) as pbar, ): - for result in pool.imap_unordered(self._process_batch_worker, batches): + for batch, result in zip( + batches, + pool.imap(self._process_batch_worker, batches), + strict=True, + ): if result is not None: results.append(result) - pbar.update(1) + pbar.update(len(batch)) return results def _tqdm_desc(self) -> str: diff --git a/src/wetting_angle_kit/analysis/_density.py b/src/wetting_angle_kit/analysis/_density.py index 339f1d5..77e8d2e 100644 --- a/src/wetting_angle_kit/analysis/_density.py +++ b/src/wetting_angle_kit/analysis/_density.py @@ -207,7 +207,6 @@ def fit_tanh_profiles_batched( distances: np.ndarray, densities: np.ndarray, *, - max_dist: float, max_iter: int = 25, tol: float = 1e-9, ) -> np.ndarray: @@ -225,18 +224,17 @@ def fit_tanh_profiles_batched( 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 the initial - guess. + guess. The recovered ``zd`` is clipped to ``[0, distances[-1]]`` + to keep ill-fit rays from escaping the sampling envelope. Parameters ---------- distances : ndarray, shape (M,) Sample distances along the ray (same for every ray of a slice). + Must be monotonically increasing; the last entry sets the + clip bound on the recovered interface position. densities : ndarray, shape (R, M) Density values per ray. - max_dist : float - Upper bound on the fitted interface position; the returned - ``zd`` is clipped to ``[0, max_dist]`` to keep ill-fit rays - from escaping the sampling envelope. max_iter : int, default 25 Hard cap on Gauss–Newton iterations. tol : float, default 1e-9 @@ -247,18 +245,19 @@ def fit_tanh_profiles_batched( ------- ndarray, shape (R,) Fitted ``zd`` (interface position) per ray, clipped to - ``[0, max_dist]``. + ``[0, distances[-1]]``. """ z = np.ascontiguousarray(distances, dtype=np.float64) y = np.ascontiguousarray(densities, dtype=np.float64) n_rays, n_samples = y.shape + max_dist = float(z[-1]) 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(max_dist)) + zd0 = np.clip(zd0, 0.0, max_dist) params = np.stack([zd0, d0, h0], axis=1) params_init = params.copy() @@ -300,4 +299,4 @@ def fit_tanh_profiles_batched( if np.max(np.abs(step)) < tol: break - return np.clip(params[:, 0], 0.0, float(max_dist)) + return np.clip(params[:, 0], 0.0, max_dist) diff --git a/src/wetting_angle_kit/analysis/coupled_binning/_models.py b/src/wetting_angle_kit/analysis/coupled_binning/_models.py index 8fb8c3c..391fd6c 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning/_models.py +++ b/src/wetting_angle_kit/analysis/coupled_binning/_models.py @@ -271,74 +271,105 @@ def compute_contact_angle(self) -> float: # ---------------------------------------------------------------------- -def _heuristic_binning_params(parser: Any) -> dict[str, Any]: - """Build the legacy heuristic binning grid: 50×50 cells over a third - of the largest in-plane box dimension. +#: Default cell width in 2D coupled binning (Å). Matches ``t1 / 2`` from +#: :class:`_HyperbolicTangentModel2D.DEFAULT_INITIAL_PARAMS` so the +#: per-bin density resolves the tanh interface profile. +_DEFAULT_BIN_WIDTH_2D = 0.5 + +#: Default cell width in 3D coupled binning (Å). Coarser than the 2D +#: default to keep the total cell count tractable for the 9-parameter +#: NLLS fit (3D grids at 0.5 Å cells would give ~1.7M cells for a +#: typical box). +_DEFAULT_BIN_WIDTH_3D = 1.0 + + +def edges_from_bin_width(lo: float, hi: float, bin_width: float) -> np.ndarray: + """Bin edges spanning ``[lo, hi]`` with cells of approximately ``bin_width``. + + The number of cells is rounded to the nearest integer; the range + bounds are honoured exactly, so the effective cell width is + ``(hi - lo) / n_cells`` which may differ slightly from + ``bin_width``. Always returns at least one cell. """ - max_dist = int( - np.max( - np.array( - [ - parser.box_size_y(frame_index=0), - parser.box_size_x(frame_index=0), - ] - ) + n = max(int(round((float(hi) - float(lo)) / float(bin_width))), 1) + return np.linspace(float(lo), float(hi), n + 1) + + +def _default_binning_params(parser: Any) -> dict[str, Any]: + """Atom-derived default 2D binning grid. + + Range: in-plane radial (``xi``) and vertical (``zi``) both span + ``[0, max(box_x, box_y) / 2]``. The radial half-box is the largest + possible distance from the droplet COM to any atom that fits + inside the simulation box (precondition: droplet doesn't interact + with its periodic image). Vertical half-box is the same value as + a safe upper bound on a typical sessile droplet's apex height. + + Cell width: ``_DEFAULT_BIN_WIDTH_2D = 0.5 Å`` — half the model's + default interface thickness ``t1 = 1 Å``, so the tanh profile is + resolved by two cells. + """ + half_lateral = ( + max( + float(parser.box_size_x(frame_index=0)), + float(parser.box_size_y(frame_index=0)), ) - / 3 + / 2.0 ) warnings.warn( - "binning_params was not supplied; using a heuristic default " - f"(xi_0=0, xi_f={max_dist}, zi_0=0, zi_f={max_dist}, 50x50 bins) " - "derived from one third of the largest in-plane box dimension. " - "For accurate density fields, supply system-specific " - "binning_params matching your droplet size and per-frame sampling.", + "binning_params was not supplied; using a default " + f"(xi/zi in [0, {half_lateral:.1f}], bin_width = {_DEFAULT_BIN_WIDTH_2D} Å) " + "derived from half the largest in-plane box dimension. " + "For accurate density fields on a specific system, supply " + "binning_params matching the droplet size and the desired " + "interface resolution.", UserWarning, stacklevel=3, ) return { - "xi_0": 0, - "xi_f": max_dist, - "nbins_xi": 50, + "xi_0": 0.0, + "xi_f": half_lateral, + "bin_width_x": _DEFAULT_BIN_WIDTH_2D, "zi_0": 0.0, - "zi_f": max_dist, - "nbins_zi": 50, + "zi_f": half_lateral, + "bin_width_z": _DEFAULT_BIN_WIDTH_2D, } -def _heuristic_binning_params_3d(parser: Any) -> dict[str, Any]: - """Build a heuristic 3D binning grid centred on the droplet COM. +def _default_binning_params_3d(parser: Any) -> dict[str, Any]: + """Atom-derived default 3D binning grid. - Same one-third-of-box heuristic as the 2D version but tripled - along all three axes. Emits a warning because the user almost - always wants to override this. + Same lateral half-box rule as :func:`_default_binning_params` but + ``xi`` and ``yi`` are signed (the droplet-centred frame spans + both halves of the diameter), and the default cell width is + coarser (``_DEFAULT_BIN_WIDTH_3D = 1 Å``) so the 9-parameter NLLS + fit stays tractable. """ - half = int( - np.max( - np.array( - [ - parser.box_size_y(frame_index=0), - parser.box_size_x(frame_index=0), - ] - ) + half_lateral = ( + max( + float(parser.box_size_x(frame_index=0)), + float(parser.box_size_y(frame_index=0)), ) - / 6 + / 2.0 ) warnings.warn( - "binning_params was not supplied; using a heuristic default " - f"(xi/yi in [-{half}, {half}], zi in [0, {2 * half}], 30^3 bins). " - "For accurate density fields, supply system-specific " - "binning_params matching your droplet size and per-frame sampling.", + "binning_params was not supplied; using a default " + f"(xi/yi in [-{half_lateral:.1f}, {half_lateral:.1f}], zi in " + f"[0, {half_lateral:.1f}], bin_width = {_DEFAULT_BIN_WIDTH_3D} Å). " + "For accurate density fields on a specific system, supply " + "binning_params matching the droplet size and the desired " + "interface resolution.", UserWarning, stacklevel=3, ) return { - "xi_0": -half, - "xi_f": half, - "nbins_xi": 30, - "yi_0": -half, - "yi_f": half, - "nbins_yi": 30, + "xi_0": -half_lateral, + "xi_f": half_lateral, + "bin_width_x": _DEFAULT_BIN_WIDTH_3D, + "yi_0": -half_lateral, + "yi_f": half_lateral, + "bin_width_y": _DEFAULT_BIN_WIDTH_3D, "zi_0": 0.0, - "zi_f": 2 * half, - "nbins_zi": 30, + "zi_f": half_lateral, + "bin_width_z": _DEFAULT_BIN_WIDTH_3D, } diff --git a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py index 1cf4dcf..10784ed 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py @@ -33,8 +33,9 @@ ) from wetting_angle_kit.analysis.coupled_binning._models import ( _PARAM_NAMES, - _heuristic_binning_params, + _default_binning_params, _HyperbolicTangentModel2D, + edges_from_bin_width, ) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( @@ -65,11 +66,14 @@ class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): ``xi = sqrt(x^2 + y^2)``; cylindrical droplets use the coordinate perpendicular to the cylinder axis. binning_params : dict, optional - 2D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"nbins_xi"``, - ``"zi_0"``, ``"zi_f"``, ``"nbins_zi"``. If ``None``, a - heuristic default is used (a third of the largest in-plane box - dimension; 50 × 50 bins). The heuristic is rarely optimal for - a specific system and emits a warning when used. + 2D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"bin_width_x"``, + ``"zi_0"``, ``"zi_f"``, ``"bin_width_z"``. The range bounds + are honoured exactly; the effective cell width is rounded to + fit and may differ slightly from the requested ``bin_width_*``. + If ``None``, an atom-derived default is used: ``xi/zi`` span + half the largest in-plane box dimension, with ``bin_width = + 0.5 Å`` (half the model's default interface thickness ``t1``). + A warning is emitted when the default is used. initial_params : list[float], optional Initial guess for the seven tanh-model parameters ``[rho1, rho2, R_eq, zi_c, zi_0, t1, t2]``. Defaults to the @@ -110,7 +114,7 @@ def __init__( precentered=precentered, ) if binning_params is None: - binning_params = _heuristic_binning_params(parser) + binning_params = _default_binning_params(parser) self.binning_params = binning_params self.initial_params = initial_params # Cylinder dV normalisation needs the box length along the @@ -176,6 +180,9 @@ def _process_batch_worker( initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] box_dimension: float | None = state["box_dimension"] + # Per-frame progress callback (inline mode only); see + # :meth:`_BatchedTrajectoryAnalyzer._run_inline`. + progress_callback = state.get("progress_callback") try: # Per-frame ``(xi, zi)`` projection, matching the legacy # ``BinningBatchFitter.get_profile_coordinates`` so the @@ -195,21 +202,25 @@ def _process_batch_worker( ) r_chunks.append(r_frame) z_chunks.append(z_frame) + if progress_callback is not None: + progress_callback(1) r_values = np.concatenate(r_chunks) if r_chunks else np.empty(0) z_values = np.concatenate(z_chunks) if z_chunks else np.empty(0) n_frames = len(frame_indices) # Build the 2D density grid + apply geometry-aware dV - # normalisation, mirroring ``BinningBatchFitter.binning``. - xi_edges = np.linspace( + # normalisation. Cell width comes from binning_params; + # the range bounds are honoured exactly and the effective + # cell width may differ by a few percent. + xi_edges = edges_from_bin_width( binning_params["xi_0"], binning_params["xi_f"], - int(binning_params["nbins_xi"]), + binning_params["bin_width_x"], ) - zi_edges = np.linspace( + zi_edges = edges_from_bin_width( binning_params["zi_0"], binning_params["zi_f"], - int(binning_params["nbins_zi"]), + binning_params["bin_width_z"], ) counts, _, _ = np.histogram2d(r_values, z_values, bins=(xi_edges, zi_edges)) dxi = float(xi_edges[1] - xi_edges[0]) diff --git a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py index e0d9ca2..cd9f092 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py +++ b/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py @@ -31,8 +31,9 @@ ) from wetting_angle_kit.analysis.coupled_binning._models import ( _PARAM_NAMES_3D, - _heuristic_binning_params_3d, + _default_binning_params_3d, _HyperbolicTangentModel3D, + edges_from_bin_width, ) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( @@ -62,9 +63,13 @@ class CoupledBinning3DAnalyzer(_BatchedTrajectoryAnalyzer): collapses the 3D problem onto the 2D one solved by :class:`CoupledBinning2DAnalyzer`. binning_params : dict, optional - 3D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"nbins_xi"``, - ``"yi_0"``, ``"yi_f"``, ``"nbins_yi"``, ``"zi_0"``, ``"zi_f"``, - ``"nbins_zi"``. ``xi``/``yi`` are in the droplet-centred frame + 3D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"bin_width_x"``, + ``"yi_0"``, ``"yi_f"``, ``"bin_width_y"``, ``"zi_0"``, ``"zi_f"``, + ``"bin_width_z"``. The range bounds are honoured exactly; the + effective cell width is rounded to fit. If ``None``, an + atom-derived default is used (lateral half-box for all axes, + ``bin_width = 1 Å`` to keep the 9-parameter NLLS tractable). + ``xi``/``yi`` are in the droplet-centred frame (atoms are recentred on the per-frame COM before binning); ``zi`` is in the lab frame so the wall position retains physical meaning. If ``None``, a heuristic default is used. @@ -111,7 +116,7 @@ def __init__( "symmetry along the cylinder axis." ) if binning_params is None: - binning_params = _heuristic_binning_params_3d(parser) + binning_params = _default_binning_params_3d(parser) self.binning_params = binning_params self.initial_params = initial_params @@ -163,6 +168,9 @@ def _process_batch_worker( binning_params: dict[str, Any] = state["binning_params"] initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] + # Per-frame progress callback (inline mode only); see + # :meth:`_BatchedTrajectoryAnalyzer._run_inline`. + progress_callback = state.get("progress_callback") try: # Per-frame PBC recentering, then drop each frame's atoms # in the droplet-centred ``(x, y)`` frame (z stays in the @@ -182,6 +190,8 @@ def _process_batch_worker( ) positions_centered = positions - np.array([com[0], com[1], 0.0]) coord_chunks.append(positions_centered) + if progress_callback is not None: + progress_callback(1) coords = ( np.concatenate(coord_chunks, axis=0) if coord_chunks @@ -189,20 +199,20 @@ def _process_batch_worker( ) n_frames = len(frame_indices) - xi_edges = np.linspace( + xi_edges = edges_from_bin_width( binning_params["xi_0"], binning_params["xi_f"], - int(binning_params["nbins_xi"]), + binning_params["bin_width_x"], ) - yi_edges = np.linspace( + yi_edges = edges_from_bin_width( binning_params["yi_0"], binning_params["yi_f"], - int(binning_params["nbins_yi"]), + binning_params["bin_width_y"], ) - zi_edges = np.linspace( + zi_edges = edges_from_bin_width( binning_params["zi_0"], binning_params["zi_f"], - int(binning_params["nbins_zi"]), + binning_params["bin_width_z"], ) counts, _ = np.histogramdd(coords, bins=(xi_edges, yi_edges, zi_edges)) dxi = float(xi_edges[1] - xi_edges[0]) diff --git a/src/wetting_angle_kit/analysis/extractors/_grid.py b/src/wetting_angle_kit/analysis/extractors/_grid.py index 97280fa..473c1a3 100644 --- a/src/wetting_angle_kit/analysis/extractors/_grid.py +++ b/src/wetting_angle_kit/analysis/extractors/_grid.py @@ -1,10 +1,29 @@ -"""Grid-based extractor implementations + shared density/isocontour helpers.""" - +"""Grid-based extractor implementations. + +Both extractors evaluate a density field at fixed-cell grid points and +trace the half-bulk iso-contour (slicing mode) or iso-surface (whole +mode). For slicing mode, both iterate per-slice — azimuthal angles for +spherical droplets, axial steps for cylindrical droplets — so the +downstream :class:`SurfaceFitter.slicing` sees one ``(s, z)`` contour +per slice and can report per-slice scatter. + +Density estimators: + +* ``grid_gaussian`` — 3D Gaussian KDE (sum of Gaussians centred on + atoms), evaluated at each cell centre. Uses the same + :class:`GaussianDensityField` machinery as ``rays_gaussian``. +* ``grid_binning`` — top-hat per cell. For slicing mode, atoms within + ``bin_width_x / 2`` of the slice plane contribute. For whole mode, + atoms binned directly into 3D cells. +""" + +from collections.abc import Iterator from dataclasses import dataclass from typing import Any, ClassVar import numpy as np +from wetting_angle_kit.analysis._density import GaussianDensityField from wetting_angle_kit.analysis.extractors.base import ( InterfaceData, InterfaceExtractor, @@ -13,8 +32,114 @@ ) from wetting_angle_kit.analysis.geometry import DropletGeometry -_GRID_KEYS_2D = frozenset({"xi_0", "xi_f", "nbins_xi", "zi_0", "zi_f", "nbins_zi"}) -_GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "nbins_yi"} +_GRID_KEYS_2D = frozenset( + {"xi_0", "xi_f", "bin_width_x", "zi_0", "zi_f", "bin_width_z"} +) +_GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "bin_width_y"} + +#: Default cell width for ``grid_binning`` (Å). The histogram estimator +#: has no smoothing scale to anchor to, so a flat default is used. +#: +#: 2 Å is the compromise for *pooled-batch* analyses; for +#: per-frame slicing-mode use the slab cut leaves too few atoms +#: per cell regardless of ``bin_width``, and the user should either +#: pool multiple frames per batch or supply a hand-tuned +#: ``grid_params`` explicitly. +_DEFAULT_BIN_WIDTH_BINNING = 2.0 + +#: Buffer (Å) added to the atom bounding box when auto-deriving grid +#: range bounds. Matches the buffer used by ``_compute_max_dist`` for +#: the ray extractors, keeping the spatial-envelope rule consistent. +_DEFAULT_GRID_BUFFER = 5.0 + + +def _default_grid_params( + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + *, + surface_kind: SurfaceKind, + bin_width: float, + buffer: float = _DEFAULT_GRID_BUFFER, +) -> dict[str, Any]: + """Atom-derived default ``grid_params``. + + Range bounds come from the atom bounding box (in the droplet-centred + frame for spherical, lab-frame for the cylinder axis) plus a + fixed buffer. Cell widths are uniform across the three axes and + set by the caller — typically ``density_sigma / 2`` for the + Gaussian KDE estimator or :data:`_DEFAULT_BIN_WIDTH_BINNING` for + the histogram. + """ + if liquid_coordinates.size == 0: + # Empty batch: degenerate grid, the iso-contour will be empty. + return ( + { + "xi_0": -buffer, + "xi_f": buffer, + "bin_width_x": bin_width, + "zi_0": 0.0, + "zi_f": buffer, + "bin_width_z": bin_width, + } + if surface_kind == "slicing" + else { + "xi_0": -buffer, + "xi_f": buffer, + "bin_width_x": bin_width, + "yi_0": -buffer, + "yi_f": buffer, + "bin_width_y": bin_width, + "zi_0": 0.0, + "zi_f": buffer, + "bin_width_z": bin_width, + } + ) + # Atom extent. For slicing-spherical, the slice plane's ``s`` axis + # is the radial direction in ``(x, y)``, so the natural envelope is + # ``max(hypot(dx, dy))``. For slicing-cylinder, the slice plane's + # ``s`` axis is purely ``x`` (the in-plane direction perpendicular + # to the cylinder axis ``y``), so only the radial x-extent matters + # — using ``hypot`` would oversize the grid with the cylinder + # length contribution. + dx = liquid_coordinates[:, 0] - float(center_geom[0]) + dy = liquid_coordinates[:, 1] - float(center_geom[1]) + z_max = float(liquid_coordinates[:, 2].max()) + buffer + if surface_kind == "slicing": + if droplet_geometry.is_spherical: + in_plane_max = float(np.max(np.hypot(dx, dy))) + buffer + else: + in_plane_max = float(np.max(np.abs(dx))) + buffer + return { + "xi_0": -in_plane_max, + "xi_f": in_plane_max, + "bin_width_x": bin_width, + "zi_0": 0.0, + "zi_f": z_max, + "bin_width_z": bin_width, + } + # Whole-mode 3D grid. For cylindrical droplets the ``y`` axis is + # the cylinder axis and atoms span the full box; the bounding box + # (with buffer) captures that. + y_min = float(dy.min()) - buffer + y_max = float(dy.max()) + buffer + x_max = float(np.max(np.abs(dx))) + buffer + return { + "xi_0": -x_max, + "xi_f": x_max, + "bin_width_x": bin_width, + "yi_0": y_min, + "yi_f": y_max, + "bin_width_y": bin_width, + "zi_0": 0.0, + "zi_f": z_max, + "bin_width_z": bin_width, + } + + +# --------------------------------------------------------------------------- +# Validation +# --------------------------------------------------------------------------- def _validate_grid_params( @@ -23,12 +148,7 @@ def _validate_grid_params( grid_params: dict[str, Any], surface_kind: SurfaceKind, ) -> None: - """Shared validation for the two grid-based extractors. - - Checks that ``grid_params`` has the right dimensionality for - ``surface_kind`` and, for whole-kind, that ``scikit-image`` is - importable (it is the marching-cubes backend). - """ + """Check ``grid_params`` carries the right keys + scikit-image for whole-mode.""" if surface_kind == "slicing": missing = _GRID_KEYS_2D - grid_params.keys() if missing: @@ -54,97 +174,240 @@ def _validate_grid_params( ) -def _project_atoms_to_rz( - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, +def _validate_per_slice_params( + *, + name: str, + delta_azimuthal: float | None, + delta_cylinder: float | None, droplet_geometry: DropletGeometry, -) -> tuple[np.ndarray, np.ndarray]: - """Collapse 3D atom coordinates to 2D ``(r, z)`` via droplet symmetry. +) -> None: + """For slicing-mode grid extractors: require the right slice-step param.""" + if droplet_geometry.is_spherical and delta_azimuthal is None: + raise ValueError(f"{name} for slicing+spherical requires delta_azimuthal.") + if droplet_geometry.is_cylinder and delta_cylinder is None: + raise ValueError( + f"{name} for slicing+{droplet_geometry.name} requires delta_cylinder." + ) + - For spherical droplets, ``r = sqrt((x - cx)² + (y - cy)²)``. - For cylinder droplets (axis along ``y`` in the internal frame after - the ``cylinder_x`` axis swap), ``r = |x - cx|``. ``z`` is kept in - the lab frame so the wall position retains physical meaning. +# --------------------------------------------------------------------------- +# Edge helpers (cell-width-based) +# --------------------------------------------------------------------------- + + +def _edges_from_bin_width(lo: float, hi: float, bin_width: float) -> np.ndarray: + """Bin edges spanning ``[lo, hi]`` with cells of approximately ``bin_width``. + + The number of cells is rounded to the nearest integer; the range + bounds are honoured exactly, so the effective cell width is + ``(hi - lo) / n_cells`` which may differ slightly from + ``bin_width``. Always returns at least one cell. """ - dx = liquid_coordinates[:, 0] - center_geom[0] - if droplet_geometry.is_spherical: - dy = liquid_coordinates[:, 1] - center_geom[1] - r = np.hypot(dx, dy) - else: - r = np.abs(dx) - return r, liquid_coordinates[:, 2] + n = max(int(round((float(hi) - float(lo)) / float(bin_width))), 1) + return np.linspace(float(lo), float(hi), n + 1) + + +def _slice_grid_edges( + grid_params: dict[str, Any], +) -> tuple[np.ndarray, np.ndarray]: + s_edges = _edges_from_bin_width( + grid_params["xi_0"], grid_params["xi_f"], grid_params["bin_width_x"] + ) + z_edges = _edges_from_bin_width( + grid_params["zi_0"], grid_params["zi_f"], grid_params["bin_width_z"] + ) + return s_edges, z_edges + + +def _slice_grid_centres( + grid_params: dict[str, Any], +) -> tuple[np.ndarray, np.ndarray]: + s_edges, z_edges = _slice_grid_edges(grid_params) + return ( + 0.5 * (s_edges[:-1] + s_edges[1:]), + 0.5 * (z_edges[:-1] + z_edges[1:]), + ) -def _build_2d_density_grid( - atoms_r: np.ndarray, - atoms_z: np.ndarray, +def _whole_grid_edges( grid_params: dict[str, Any], +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + x_edges = _edges_from_bin_width( + grid_params["xi_0"], grid_params["xi_f"], grid_params["bin_width_x"] + ) + y_edges = _edges_from_bin_width( + grid_params["yi_0"], grid_params["yi_f"], grid_params["bin_width_y"] + ) + z_edges = _edges_from_bin_width( + grid_params["zi_0"], grid_params["zi_f"], grid_params["bin_width_z"] + ) + return x_edges, y_edges, z_edges + + +def _whole_grid_centres( + grid_params: dict[str, Any], +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + x_edges, y_edges, z_edges = _whole_grid_edges(grid_params) + return ( + 0.5 * (x_edges[:-1] + x_edges[1:]), + 0.5 * (y_edges[:-1] + y_edges[1:]), + 0.5 * (z_edges[:-1] + z_edges[1:]), + ) + + +# --------------------------------------------------------------------------- +# Slice iteration +# --------------------------------------------------------------------------- + + +def _iter_slice_planes( + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, droplet_geometry: DropletGeometry, *, - smooth_sigma: float | None, -) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - """Build a 2D density grid in ``(r, z)``. + delta_azimuthal: float | None, + delta_cylinder: float | None, +) -> Iterator[tuple[np.ndarray, np.ndarray]]: + """Yield ``(slice_center, in_plane_axis)`` for each slicing plane. + + ``in_plane_axis`` is the unit vector defining the in-plane radial + coordinate ``s``. The perpendicular-to-plane axis is recovered as + ``(-in_plane_axis[1], in_plane_axis[0], 0)`` by callers that need + it (e.g. the slab cut in :func:`_histogram_on_slice`). + """ + if droplet_geometry.is_spherical: + assert delta_azimuthal is not None + n_slices = int(180 / delta_azimuthal) + for gamma_deg in np.linspace(0.0, 180.0, n_slices, endpoint=False): + gamma_rad = float(np.deg2rad(gamma_deg)) + in_plane = np.array([np.cos(gamma_rad), np.sin(gamma_rad), 0.0]) + yield np.asarray(center_geom, dtype=float), in_plane + return + # cylinder + assert delta_cylinder is not None + y_vals = liquid_coordinates[:, 1] + ys = np.arange(float(y_vals.min()), float(y_vals.max()), delta_cylinder) + in_plane = np.array([1.0, 0.0, 0.0]) + for y in ys: + slice_center = np.array( + [float(center_geom[0]), float(y), float(center_geom[2])] + ) + yield slice_center, in_plane - Volume normalisation: - - **Spherical:** ``dV = 2π r dxi dzi`` per cell (annular shell). - Required so the recovered isocontour isn't pulled toward - smaller ``r``. - - **Cylinder:** ``dV = dxi dzi`` per cell (constant axial extent). - The cylinder-axis length cancels out for an isocontour at a - fraction of the max, so the simpler normalisation is used. +# --------------------------------------------------------------------------- +# Density estimation per slice +# --------------------------------------------------------------------------- - Returns ``(r_centers, z_centers, density)`` with - ``density.shape == (n_r_cells, n_z_cells)``. + +def _evaluate_kde_on_slice( + field: GaussianDensityField, + slice_center: np.ndarray, + in_plane_axis: np.ndarray, + s_centers: np.ndarray, + z_centers: np.ndarray, +) -> np.ndarray: + """3D KDE evaluated at ``(s, z)`` cell centres on a slice plane. + + Cell centre ``(s, z)`` maps to the 3D point + ``(sc.x + s · ax.x, sc.y + s · ax.y, z)`` for a horizontal + ``in_plane_axis``. """ - r_edges = np.linspace( - float(grid_params["xi_0"]), - float(grid_params["xi_f"]), - int(grid_params["nbins_xi"]), + S, Z = np.meshgrid(s_centers, z_centers, indexing="ij") + positions = np.column_stack( + [ + slice_center[0] + S.ravel() * in_plane_axis[0], + slice_center[1] + S.ravel() * in_plane_axis[1], + Z.ravel(), + ] ) - z_edges = np.linspace( - float(grid_params["zi_0"]), - float(grid_params["zi_f"]), - int(grid_params["nbins_zi"]), + return field.evaluate(positions).reshape(S.shape) + + +def _histogram_on_slice( + atoms: np.ndarray, + slice_center: np.ndarray, + in_plane_axis: np.ndarray, + s_edges: np.ndarray, + z_edges: np.ndarray, + slab_thickness: float, +) -> np.ndarray: + """2D histogram of atoms inside the slab ``|perp| ≤ slab_thickness/2``. + + Each cell's bin is a ``ds × dz × slab_thickness`` box; density is + ``counts / (ds × dz × slab_thickness)``. + """ + perp_axis = np.array([-in_plane_axis[1], in_plane_axis[0], 0.0]) + rel = atoms - slice_center[None, :] + s_coord = rel @ in_plane_axis + perp_coord = rel @ perp_axis + mask = np.abs(perp_coord) <= 0.5 * slab_thickness + counts, _, _ = np.histogram2d( + s_coord[mask], atoms[mask, 2], bins=(s_edges, z_edges) ) - counts, _, _ = np.histogram2d(atoms_r, atoms_z, bins=(r_edges, z_edges)) - r_centers = 0.5 * (r_edges[:-1] + r_edges[1:]) - z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) - dr = float(r_edges[1] - r_edges[0]) + ds = float(s_edges[1] - s_edges[0]) dz = float(z_edges[1] - z_edges[0]) + return counts / (ds * dz * slab_thickness) - if droplet_geometry.is_spherical: - # Avoid division by zero at the innermost row (r=0); the contour - # at fraction-of-max never traverses that point anyway. - dV_per_row = 2.0 * np.pi * np.maximum(r_centers, 0.5 * dr) * dr * dz - density = counts / dV_per_row[:, None] - else: - density = counts / (dr * dz) - if smooth_sigma is not None and smooth_sigma > 0.0: - from scipy.ndimage import gaussian_filter +# --------------------------------------------------------------------------- +# 3D density helpers (whole mode) +# --------------------------------------------------------------------------- - sigma_cells = (smooth_sigma / dr, smooth_sigma / dz) - density = gaussian_filter(density, sigma=sigma_cells) - return r_centers, z_centers, density + +def _evaluate_kde_on_3d_grid( + field: GaussianDensityField, + x_centers: np.ndarray, + y_centers: np.ndarray, + z_centers: np.ndarray, + *, + x_offset: float, + y_offset: float, +) -> np.ndarray: + """3D KDE at cell centres of a droplet-centred grid.""" + X, Y, Z = np.meshgrid(x_centers, y_centers, z_centers, indexing="ij") + positions = np.column_stack( + [ + (X + x_offset).ravel(), + (Y + y_offset).ravel(), + Z.ravel(), + ] + ) + return field.evaluate(positions).reshape(X.shape) + + +def _histogram_3d( + atoms: np.ndarray, + center_geom: np.ndarray, + x_edges: np.ndarray, + y_edges: np.ndarray, + z_edges: np.ndarray, +) -> np.ndarray: + """3D histogram in the droplet-centred ``(x, y, z)`` frame.""" + atoms_centered = atoms - np.array( + [float(center_geom[0]), float(center_geom[1]), 0.0] + ) + counts, _ = np.histogramdd(atoms_centered, bins=(x_edges, y_edges, z_edges)) + dx = float(x_edges[1] - x_edges[0]) + dy = float(y_edges[1] - y_edges[0]) + dz = float(z_edges[1] - z_edges[0]) + return counts / (dx * dy * dz) + + +# --------------------------------------------------------------------------- +# Iso-contour / iso-surface extraction +# --------------------------------------------------------------------------- def _extract_isocontour_2d( - r_centers: np.ndarray, + s_centers: np.ndarray, z_centers: np.ndarray, density: np.ndarray, *, fraction_of_bulk: float = 0.5, bulk_percentile: float = 95.0, ) -> np.ndarray: - """Return the longest density iso-line as ``(M, 2)`` ``(r, z)`` points. - - The contour level is ``fraction_of_bulk * percentile(density, - bulk_percentile)`` — using a high percentile rather than ``max`` - makes the bulk estimate robust to the Poisson spikes that the - ``dV_per_row ∝ 1/r`` normalisation can introduce in small-``r`` - bins. - """ + """Longest density iso-line as ``(M, 2)`` ``(s, z)`` points.""" from skimage.measure import find_contours if density.size == 0 or float(density.max()) <= 0: @@ -153,78 +416,15 @@ def _extract_isocontour_2d( if bulk <= 0: return np.empty((0, 2)) level = fraction_of_bulk * bulk - # ``no-untyped-call`` fires only when scikit-image is not installed - # in the type-check env; ``unused-ignore`` keeps the comment tolerant - # when it IS installed and the call resolves to a typed function. contours = find_contours(density, level) # type: ignore[no-untyped-call,unused-ignore] if not contours: return np.empty((0, 2)) longest = max(contours, key=len) - # find_contours returns (row, col) fractional pixel indices, where - # row indexes axis 0 (= r) and col indexes axis 1 (= z). - dr = (float(r_centers[-1]) - float(r_centers[0])) / max(len(r_centers) - 1, 1) + ds = (float(s_centers[-1]) - float(s_centers[0])) / max(len(s_centers) - 1, 1) dz = (float(z_centers[-1]) - float(z_centers[0])) / max(len(z_centers) - 1, 1) - r_phys = float(r_centers[0]) + dr * longest[:, 0] + s_phys = float(s_centers[0]) + ds * longest[:, 0] z_phys = float(z_centers[0]) + dz * longest[:, 1] - return np.column_stack([r_phys, z_phys]) - - -def _build_3d_density_grid( - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - grid_params: dict[str, Any], - *, - smooth_sigma: float | None, -) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: - """Build a 3D density grid centered laterally on the droplet COM. - - The ``(x, y)`` coordinates are recentered on ``(center_geom[0], - center_geom[1])`` so the user can specify symmetric grid bounds - (e.g. ``xi_0 = -30, xi_f = 30``) regardless of where the droplet - sits in the box after PBC recentering. ``z`` stays in the lab - frame so the wall position retains physical meaning. - - Returns ``(x_centers, y_centers, z_centers, density)`` with - ``density.shape == (n_x_cells, n_y_cells, n_z_cells)``. - """ - x_edges = np.linspace( - float(grid_params["xi_0"]), - float(grid_params["xi_f"]), - int(grid_params["nbins_xi"]), - ) - y_edges = np.linspace( - float(grid_params["yi_0"]), - float(grid_params["yi_f"]), - int(grid_params["nbins_yi"]), - ) - z_edges = np.linspace( - float(grid_params["zi_0"]), - float(grid_params["zi_f"]), - int(grid_params["nbins_zi"]), - ) - - atoms_centered = liquid_coordinates - np.array( - [center_geom[0], center_geom[1], 0.0] - ) - counts, _ = np.histogramdd(atoms_centered, bins=(x_edges, y_edges, z_edges)) - dx = float(x_edges[1] - x_edges[0]) - dy = float(y_edges[1] - y_edges[0]) - dz = float(z_edges[1] - z_edges[0]) - x_centers = 0.5 * (x_edges[:-1] + x_edges[1:]) - y_centers = 0.5 * (y_edges[:-1] + y_edges[1:]) - z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) - density = counts / (dx * dy * dz) - - if smooth_sigma is not None and smooth_sigma > 0.0: - from scipy.ndimage import gaussian_filter - - sigma_cells = ( - smooth_sigma / dx, - smooth_sigma / dy, - smooth_sigma / dz, - ) - density = gaussian_filter(density, sigma=sigma_cells) - return x_centers, y_centers, z_centers, density + return np.column_stack([s_phys, z_phys]) def _extract_isosurface_3d( @@ -237,12 +437,7 @@ def _extract_isosurface_3d( fraction_of_bulk: float = 0.5, bulk_percentile: float = 95.0, ) -> np.ndarray: - """Run marching cubes and return ``(N, 3)`` shell points in the internal frame. - - The shell is shifted back by ``(center_geom[0], center_geom[1], 0)`` - so its coordinates match the convention used by the rays-whole - extractor: absolute internal-frame positions, not droplet-centered. - """ + """Marching-cubes shell shifted back to absolute lab coords.""" from skimage.measure import marching_cubes if density.size == 0 or float(density.max()) <= 0: @@ -252,9 +447,6 @@ def _extract_isosurface_3d( return np.empty((0, 3)) level = fraction_of_bulk * bulk try: - # ``no-untyped-call`` fires when scikit-image is not installed - # in the type-check env; ``unused-ignore`` keeps the comment - # tolerant when it is. verts, _faces, _normals, _values = marching_cubes( # type: ignore[no-untyped-call,unused-ignore] density, level ) @@ -270,105 +462,104 @@ def _extract_isosurface_3d( return np.column_stack([x_phys, y_phys, z_phys]) -def _extract_grid_whole( - *, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - grid_params: dict[str, Any], - smooth_sigma: float | None, -) -> np.ndarray: - """Build a 3D density grid + marching-cubes shell for whole-kind grid extractors. - - Currently spherical only — for cylindrical droplets the cylinder - axis would need to span the full simulation box, which the - centered-grid convention doesn't accommodate naturally. Use a - ``rays_*`` extractor for cylinder whole-fits. - """ - if droplet_geometry.is_cylinder: - raise NotImplementedError( - "grid + whole-kind extraction is currently spherical-only. " - "For cylinder droplets use InterfaceExtractor.rays_gaussian " - "or rays_binning with surface_kind='whole'." - ) - x_centers, y_centers, z_centers, density = _build_3d_density_grid( - liquid_coordinates, - center_geom, - grid_params, - smooth_sigma=smooth_sigma, - ) - return _extract_isosurface_3d( - x_centers, y_centers, z_centers, density, center_geom=center_geom - ) - - -def _extract_grid_slicing( - *, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - grid_params: dict[str, Any], - smooth_sigma: float | None, -) -> list[np.ndarray]: - """Build a ``(r, z)`` density map + isocontour for a slicing-mode grid extractor. - - Returns a single-element list since the symmetry-collapsed - ``(r, z)`` reduction makes the slice axis disappear; the downstream - :class:`SlicingFitter` runs one Kasa circle fit on that contour. - """ - r, z = _project_atoms_to_rz(liquid_coordinates, center_geom, droplet_geometry) - r_centers, z_centers, density = _build_2d_density_grid( - r, z, grid_params, droplet_geometry, smooth_sigma=smooth_sigma - ) - contour = _extract_isocontour_2d(r_centers, z_centers, density) - return [contour] +# --------------------------------------------------------------------------- +# Extractor classes +# --------------------------------------------------------------------------- -# eq=False avoids the auto __eq__ tripping on the dict field; equality -# between extractor instances is not a use case the package needs. @dataclass(frozen=True, eq=False, kw_only=True) class _GridGaussianExtractor(InterfaceExtractor): """Concrete extractor for :meth:`InterfaceExtractor.grid_gaussian`.""" sampling: ClassVar[SamplingKind] = "grid" - grid_params: dict[str, Any] + grid_params: dict[str, Any] | None density_sigma: float cutoff_sigma: float + delta_azimuthal: float | None + delta_cylinder: float | None def validate_compatibility( self, surface_kind: SurfaceKind, droplet_geometry: DropletGeometry, ) -> None: - _validate_grid_params( - name="grid_gaussian", - grid_params=self.grid_params, - surface_kind=surface_kind, - ) + # Key-presence check is skipped when grid_params is None + # (auto-derived in extract); the scikit-image import check for + # whole-mode still runs so the user gets the error at + # construction. + if self.grid_params is not None: + _validate_grid_params( + name="grid_gaussian", + grid_params=self.grid_params, + surface_kind=surface_kind, + ) + elif surface_kind == "whole": + try: + import skimage.measure # noqa: F401 + except ImportError as e: + raise ImportError( + "grid_gaussian for whole-kind extraction requires " + "scikit-image (used for marching_cubes). Install with: " + "pip install 'wetting-angle-kit[grid3d]'." + ) from e + if surface_kind == "slicing": + _validate_per_slice_params( + name="grid_gaussian", + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + droplet_geometry=droplet_geometry, + ) def extract( self, liquid_coordinates: np.ndarray, center_geom: np.ndarray, droplet_geometry: DropletGeometry, - max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: + field = GaussianDensityField( + atom_coords=liquid_coordinates, + density_sigma=self.density_sigma, + cutoff_sigma=self.cutoff_sigma, + ) + grid_params = self.grid_params or _default_grid_params( + liquid_coordinates, + center_geom, + droplet_geometry, + surface_kind=surface_kind, + bin_width=self.density_sigma / 2.0, + ) if surface_kind == "slicing": - return _extract_grid_slicing( - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=self.density_sigma, - ) - return _extract_grid_whole( - liquid_coordinates=liquid_coordinates, + s_centers, z_centers = _slice_grid_centres(grid_params) + contours: list[np.ndarray] = [] + for slice_center, in_plane_axis in _iter_slice_planes( + liquid_coordinates, + center_geom, + droplet_geometry, + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + ): + density = _evaluate_kde_on_slice( + field, slice_center, in_plane_axis, s_centers, z_centers + ) + contours.append(_extract_isocontour_2d(s_centers, z_centers, density)) + return contours + x_centers, y_centers, z_centers = _whole_grid_centres(grid_params) + density3d = _evaluate_kde_on_3d_grid( + field, + x_centers, + y_centers, + z_centers, + x_offset=float(center_geom[0]), + y_offset=float(center_geom[1]), + ) + return _extract_isosurface_3d( + x_centers, + y_centers, + z_centers, + density3d, center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=self.density_sigma, ) @@ -378,39 +569,88 @@ class _GridBinningExtractor(InterfaceExtractor): sampling: ClassVar[SamplingKind] = "grid" - grid_params: dict[str, Any] + grid_params: dict[str, Any] | None + delta_azimuthal: float | None + delta_cylinder: float | None def validate_compatibility( self, surface_kind: SurfaceKind, droplet_geometry: DropletGeometry, ) -> None: - _validate_grid_params( - name="grid_binning", - grid_params=self.grid_params, - surface_kind=surface_kind, - ) + if self.grid_params is not None: + _validate_grid_params( + name="grid_binning", + grid_params=self.grid_params, + surface_kind=surface_kind, + ) + elif surface_kind == "whole": + try: + import skimage.measure # noqa: F401 + except ImportError as e: + raise ImportError( + "grid_binning for whole-kind extraction requires " + "scikit-image (used for marching_cubes). Install with: " + "pip install 'wetting-angle-kit[grid3d]'." + ) from e + if surface_kind == "slicing": + _validate_per_slice_params( + name="grid_binning", + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + droplet_geometry=droplet_geometry, + ) def extract( self, liquid_coordinates: np.ndarray, center_geom: np.ndarray, droplet_geometry: DropletGeometry, - max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: + grid_params = self.grid_params or _default_grid_params( + liquid_coordinates, + center_geom, + droplet_geometry, + surface_kind=surface_kind, + bin_width=_DEFAULT_BIN_WIDTH_BINNING, + ) if surface_kind == "slicing": - return _extract_grid_slicing( - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=None, - ) - return _extract_grid_whole( - liquid_coordinates=liquid_coordinates, + s_edges, z_edges = _slice_grid_edges(grid_params) + s_centers = 0.5 * (s_edges[:-1] + s_edges[1:]) + z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) + # Slab thickness perpendicular to the slice plane equals + # the in-plane horizontal cell width, so each cell's bin + # is a ``ds × dz × ds`` box (square cross-section in the + # ``(s, perp)`` plane). + slab = float(s_edges[1] - s_edges[0]) + contours: list[np.ndarray] = [] + for slice_center, in_plane_axis in _iter_slice_planes( + liquid_coordinates, + center_geom, + droplet_geometry, + delta_azimuthal=self.delta_azimuthal, + delta_cylinder=self.delta_cylinder, + ): + density = _histogram_on_slice( + liquid_coordinates, + slice_center, + in_plane_axis, + s_edges, + z_edges, + slab, + ) + contours.append(_extract_isocontour_2d(s_centers, z_centers, density)) + return contours + x_edges, y_edges, z_edges = _whole_grid_edges(grid_params) + x_centers, y_centers, z_centers = _whole_grid_centres(grid_params) + density3d = _histogram_3d( + liquid_coordinates, center_geom, x_edges, y_edges, z_edges + ) + return _extract_isosurface_3d( + x_centers, + y_centers, + z_centers, + density3d, center_geom=center_geom, - droplet_geometry=droplet_geometry, - grid_params=self.grid_params, - smooth_sigma=None, ) diff --git a/src/wetting_angle_kit/analysis/extractors/_rays.py b/src/wetting_angle_kit/analysis/extractors/_rays.py index 1619444..d69eb7b 100644 --- a/src/wetting_angle_kit/analysis/extractors/_rays.py +++ b/src/wetting_angle_kit/analysis/extractors/_rays.py @@ -58,7 +58,6 @@ def _ray_slice_in_plane( field: DensityFieldProtocol, center: np.ndarray, azimuthal: float, - max_dist: float, distances: np.ndarray, delta_polar: float, ) -> np.ndarray: @@ -80,12 +79,31 @@ def _ray_slice_in_plane( ) density_flat = field.evaluate(positions_rm.reshape(-1, 3)) densities = density_flat.reshape(len(polar), len(distances)) - interface_re = fit_tanh_profiles_batched(distances, densities, max_dist=max_dist) + interface_re = fit_tanh_profiles_batched(distances, densities) x_proj = cos_polar * interface_re + center[0] z_proj = sin_polar * interface_re + center[2] return np.column_stack([x_proj, z_proj]) +def _compute_max_dist( + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + *, + buffer: float = 10.0, +) -> float: + """Ray-sampling envelope derived from atom positions. + + Returns the maximum distance from any atom to ``center_geom`` plus + a small ``buffer`` (Å) so the tanh fit has headroom past the + interface (a few smoothing-σ worth, for typical density_sigma ≈ 3). + Defaults to ``buffer`` alone when the atom set is empty. + """ + if liquid_coordinates.size == 0: + return float(buffer) + distances = np.linalg.norm(liquid_coordinates - center_geom[None, :], axis=1) + return float(np.max(distances) + buffer) + + def _extract_rays( *, field: DensityFieldProtocol, @@ -117,7 +135,7 @@ def _extract_rays( azimuthals = np.linspace(0.0, 180.0, n_slices) return [ _ray_slice_in_plane( - field, center_geom, float(g), max_dist, distances, delta_polar + field, center_geom, float(g), distances, delta_polar ) for g in azimuthals ] @@ -129,9 +147,7 @@ def _extract_rays( for y in ys: slice_center = np.array([center_geom[0], float(y), center_geom[2]]) slices.append( - _ray_slice_in_plane( - field, slice_center, 0.0, max_dist, distances, delta_polar - ) + _ray_slice_in_plane(field, slice_center, 0.0, distances, delta_polar) ) return slices @@ -145,9 +161,7 @@ def _extract_rays( ) density_flat = field.evaluate(positions_rm.reshape(-1, 3)) densities = density_flat.reshape(len(directions), len(distances)) - interface_re = fit_tanh_profiles_batched( - distances, densities, max_dist=max_dist - ) + interface_re = fit_tanh_profiles_batched(distances, densities) return center_geom[None, :] + interface_re[:, None] * directions # whole + cylinder_*: pool a per-y ray fan into a 3D shell. @@ -167,9 +181,7 @@ def _extract_rays( ) density_flat = field.evaluate(positions_rm.reshape(-1, 3)) densities = density_flat.reshape(len(polar), len(distances)) - interface_re = fit_tanh_profiles_batched( - distances, densities, max_dist=max_dist - ) + interface_re = fit_tanh_profiles_batched(distances, densities) points = np.column_stack( [ cos_polar * interface_re + slice_center[0], @@ -214,7 +226,6 @@ def extract( liquid_coordinates: np.ndarray, center_geom: np.ndarray, droplet_geometry: DropletGeometry, - max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: field = GaussianDensityField( @@ -222,6 +233,7 @@ def extract( density_sigma=self.density_sigma, cutoff_sigma=self.cutoff_sigma, ) + max_dist = _compute_max_dist(liquid_coordinates, center_geom) return _extract_rays( field=field, liquid_coordinates=liquid_coordinates, @@ -269,13 +281,13 @@ def extract( liquid_coordinates: np.ndarray, center_geom: np.ndarray, droplet_geometry: DropletGeometry, - max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: field = HistogramDensityField( atom_coords=liquid_coordinates, bin_width=self.bin_width, ) + max_dist = _compute_max_dist(liquid_coordinates, center_geom) return _extract_rays( field=field, liquid_coordinates=liquid_coordinates, diff --git a/src/wetting_angle_kit/analysis/extractors/base.py b/src/wetting_angle_kit/analysis/extractors/base.py index f206394..7978084 100644 --- a/src/wetting_angle_kit/analysis/extractors/base.py +++ b/src/wetting_angle_kit/analysis/extractors/base.py @@ -48,7 +48,6 @@ def extract( liquid_coordinates: np.ndarray, center_geom: np.ndarray, droplet_geometry: DropletGeometry, - max_dist: float, surface_kind: SurfaceKind, ) -> InterfaceData: """Build the interface point set for one batch. @@ -62,8 +61,6 @@ def extract( droplet_geometry : DropletGeometry Droplet symmetry; drives the per-slice axis choice for slicing modes and the ray-fan layout for whole modes. - max_dist : float - Maximum radial distance sampled along each ray (Å). surface_kind : {"slicing", "whole"} What the downstream :class:`SurfaceFitter` will consume. Determines the output shape (per-slice 2D points vs 3D @@ -214,33 +211,48 @@ def rays_binning( def grid_gaussian( cls, *, - grid_params: dict[str, Any], + grid_params: dict[str, Any] | None = None, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, density_sigma: float = 3.0, cutoff_sigma: float = 5.0, ) -> "InterfaceExtractor": - """Gaussian-KDE density grid + isocontour interface extraction. + """3D Gaussian-KDE density on a fixed grid + isocontour extraction. - Supports both slicing and whole fitters: + Same density estimator as :meth:`rays_gaussian`, evaluated at + grid cell centres rather than along rays. - - For ``surface_kind="slicing"``, the grid is 2D in the slice - ``(x, z)`` plane and a marching-squares-style isocontour gives - one ``(M, 2)`` interface curve per slice. - - For ``surface_kind="whole"``, the grid is 3D in - ``(x, y, z)`` and the interface shell is recovered by - :func:`skimage.measure.marching_cubes`. This requires the - optional ``grid3d`` extra (``scikit-image``); construction - via :class:`TrajectoryAnalyzer` raises a clear - :class:`ImportError` if it is missing. + Per-slice in slicing mode: spherical droplets iterate over + azimuthal angles ``γ ∈ [0°, 180°)`` controlled by + ``delta_azimuthal``; cylindrical droplets iterate over axial + steps controlled by ``delta_cylinder``. Each slice produces an + ``(s, z)`` density grid and one iso-contour. Whole mode builds + a 3D ``(x, y, z)`` grid centred laterally on the droplet COM + and runs marching cubes. Parameters ---------- - grid_params : dict + grid_params : dict, optional Grid spec. For slicing, six keys: ``"xi_0"``, ``"xi_f"``, - ``"nbins_xi"``, ``"zi_0"``, ``"zi_f"``, ``"nbins_zi"``. - For whole, add three more: ``"yi_0"``, ``"yi_f"``, - ``"nbins_yi"``. + ``"bin_width_x"``, ``"zi_0"``, ``"zi_f"``, + ``"bin_width_z"``. ``xi_0`` should be negative for a + centred slice that spans both halves of the diameter. For + whole, add three more: ``"yi_0"``, ``"yi_f"``, + ``"bin_width_y"`` (xi/yi grids are in the droplet-centred + lateral frame; zi stays in the lab frame). If ``None`` + (default), the grid is auto-derived per batch from the + atom bounding box plus a 5 Å buffer, with cell width set + to ``density_sigma / 2``. + delta_azimuthal : float, optional + Azimuthal step (degrees) between slicing planes for + ``slicing + spherical``. Required for that case; ignored + otherwise. + delta_cylinder : float, optional + Step (Å) along the cylinder axis between slicing planes + for ``slicing + cylinder``. Required for that case; + ignored otherwise. density_sigma : float, default 3.0 - Gaussian kernel width (Å) for the density smoothing. + Gaussian kernel width (Å) for the KDE. cutoff_sigma : float, default 5.0 Per-atom kernel truncation in units of ``density_sigma``. """ @@ -249,7 +261,9 @@ def grid_gaussian( ) return _GridGaussianExtractor( - grid_params=dict(grid_params), + grid_params=dict(grid_params) if grid_params is not None else None, + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, density_sigma=density_sigma, cutoff_sigma=cutoff_sigma, ) @@ -258,21 +272,48 @@ def grid_gaussian( def grid_binning( cls, *, - grid_params: dict[str, Any], + grid_params: dict[str, Any] | None = None, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, ) -> "InterfaceExtractor": - """Histogram density grid + isocontour interface extraction. + """Histogram density on a fixed grid + isocontour extraction. + + Same per-slice iteration scheme as :meth:`grid_gaussian` in + slicing mode (``delta_azimuthal`` for spherical, + ``delta_cylinder`` for cylinder), but the per-cell density is + a top-hat histogram count divided by the cell volume. + + For slicing mode, the bin attached to each ``(s, z)`` cell is a + ``ds × dz × bin_width_x`` box: atoms within + ``±bin_width_x/2`` of the slice plane contribute. The + perpendicular slab thickness re-uses ``grid_params["bin_width_x"]`` + — refining the in-plane grid also thins the slab, so coarser + grids reduce per-bin Poisson noise. - Same dimensionality + dependency rules as - :meth:`grid_gaussian`: 2D grid for slicing, 3D grid + marching - cubes (via optional ``scikit-image``) for whole. + For whole mode, the 3D cells defined by ``grid_params`` are the + bins directly (``bin_width_x`` is then unused). Parameters ---------- - grid_params : dict - Grid spec; see :meth:`grid_gaussian` for the required keys. + grid_params : dict, optional + Same shape as :meth:`grid_gaussian`'s ``grid_params``. + If ``None`` (default), the grid is auto-derived per batch + from the atom bounding box plus a 5 Å buffer, with a flat + ``2 Å`` cell width. The histogram estimator is + intrinsically noisy for single-frame slicing-mode + analyses (the slab cut leaves few atoms per cell); for + that case pool multiple frames per batch or supply a + hand-tuned ``grid_params`` rather than relying on the + default. + delta_azimuthal, delta_cylinder : float, optional + See :meth:`grid_gaussian`. """ from wetting_angle_kit.analysis.extractors._grid import ( _GridBinningExtractor, ) - return _GridBinningExtractor(grid_params=dict(grid_params)) + return _GridBinningExtractor( + grid_params=dict(grid_params) if grid_params is not None else None, + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, + ) diff --git a/src/wetting_angle_kit/analysis/fitters/_slicing.py b/src/wetting_angle_kit/analysis/fitters/_slicing.py index 53484c2..2e0f6bc 100644 --- a/src/wetting_angle_kit/analysis/fitters/_slicing.py +++ b/src/wetting_angle_kit/analysis/fitters/_slicing.py @@ -23,7 +23,10 @@ class _SlicingFitter(SurfaceFitter): surface_filter_offset: float - def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + def validate_compatibility( + self, + droplet_geometry: DropletGeometry, # noqa: ARG002 — ABC contract + ) -> None: # Slicing handles all three geometries (spherical and both # cylinder orientations); nothing geometry-specific to reject. return None @@ -32,7 +35,7 @@ def fit( self, interface_data: InterfaceData, z_wall: float, - droplet_geometry: DropletGeometry, + droplet_geometry: DropletGeometry, # noqa: ARG002 — ABC contract ) -> SlicingFitOutput: if not isinstance(interface_data, list): raise TypeError( diff --git a/src/wetting_angle_kit/analysis/fitters/_whole.py b/src/wetting_angle_kit/analysis/fitters/_whole.py index a993769..86cb913 100644 --- a/src/wetting_angle_kit/analysis/fitters/_whole.py +++ b/src/wetting_angle_kit/analysis/fitters/_whole.py @@ -33,7 +33,10 @@ def __post_init__(self) -> None: f"bootstrap_samples must be >= 0; got {self.bootstrap_samples}." ) - def validate_compatibility(self, droplet_geometry: DropletGeometry) -> None: + def validate_compatibility( + self, + droplet_geometry: DropletGeometry, # noqa: ARG002 — ABC contract + ) -> None: # Whole-fit covers spherical (sphere fit) and both cylinder # orientations (cylinder fit with the standard axis swap); # nothing geometry-specific to reject. diff --git a/src/wetting_angle_kit/analysis/geometry.py b/src/wetting_angle_kit/analysis/geometry.py index e23098c..b6a402a 100644 --- a/src/wetting_angle_kit/analysis/geometry.py +++ b/src/wetting_angle_kit/analysis/geometry.py @@ -36,6 +36,37 @@ class DropletGeometry: boundary so every downstream routine can assume the cylinder axis is ``y`` internally. The swap is self-inverse, so the same helper maps internal coordinates back to user coordinates. + + Picking ``cylinder_x`` vs ``cylinder_y`` + ---------------------------------------- + + Pick the one whose name matches your **trajectory's lab-frame axis** + along which the ridge is invariant: + + * If your dump file's atoms are uniformly distributed along ``y`` + (i.e. the simulation box's ``y`` direction is the periodic + cylinder axis), pass ``"cylinder_y"``. + * If the same situation holds along ``x`` instead, pass + ``"cylinder_x"``. + + The two are not interchangeable — picking the wrong one is the + cylinder analogue of confusing the in-plane radial axis with the + cylinder axis. Symptoms of a mismatch: the slicing fitter + iterates over the wrong axis (slicing planes go *across* the + ridge instead of along it), so each "slice" sees almost no atoms + and the per-slice circle fit either NaNs out or recovers a + non-physical angle. + + Internally everything happens in the ``cylinder_y`` frame: + ``cylinder_x`` simply applies a self-inverse ``x↔y`` column swap + at the parser/analyzer boundary so all downstream extractors, + fitters, and visualisers can assume the cylinder axis is ``y``. + No analysis logic is duplicated between the two cases — they're + distinguished only by where the swap is (or isn't) applied. + + If you're not sure which axis your trajectory uses, the safe + diagnostic is to load one frame, plot atom positions, and look at + which lateral coordinate the droplet spans the full box. """ _VALID_NAMES: ClassVar[tuple[DropletGeometryName, ...]] = ( diff --git a/src/wetting_angle_kit/analysis/trajectory.py b/src/wetting_angle_kit/analysis/trajectory.py index 45ca2fc..1856326 100644 --- a/src/wetting_angle_kit/analysis/trajectory.py +++ b/src/wetting_angle_kit/analysis/trajectory.py @@ -180,13 +180,17 @@ def _process_batch_worker(frame_indices: list[int]) -> BatchResult | None: fitter: SurfaceFitter = state["surface_fitter"] detector: WallDetector = state["wall_detector"] precentered: bool = state["precentered"] + # Optional per-frame progress callback published by the + # inline-mode runner; absent in parallel mode (not picklable). + progress_callback = state.get("progress_callback") try: - coords, center, max_dist = gather_batch_coords( + coords, center = gather_batch_coords( parser=parser, frame_indices=frame_indices, atom_indices=atom_indices, droplet_geometry=droplet_geometry, precentered=precentered, + progress_callback=progress_callback, ) wall_coords = ( gather_wall_coords( @@ -202,7 +206,6 @@ def _process_batch_worker(frame_indices: list[int]) -> BatchResult | None: liquid_coordinates=coords, center_geom=center, droplet_geometry=droplet_geometry, - max_dist=max_dist, surface_kind=fitter.kind, ) z_wall = detector.detect( diff --git a/src/wetting_angle_kit/analysis/wall.py b/src/wetting_angle_kit/analysis/wall.py index 25c2b4b..adf83dc 100644 --- a/src/wetting_angle_kit/analysis/wall.py +++ b/src/wetting_angle_kit/analysis/wall.py @@ -166,7 +166,7 @@ class _ExplicitDetector(WallDetector): z_wall: float - def detect(self, ctx: WallContext) -> float: + def detect(self, ctx: WallContext) -> float: # noqa: ARG002 — ABC contract return self.z_wall diff --git a/tests/test_analysis/test_binning_method.py b/tests/test_analysis/test_binning_method.py index 73fb823..cf3b354 100644 --- a/tests/test_analysis/test_binning_method.py +++ b/tests/test_analysis/test_binning_method.py @@ -38,13 +38,18 @@ def oxygen_indices(filename: pathlib.Path) -> np.ndarray: @pytest.fixture def binning_params() -> dict: + # ``bin_width_*`` values are picked so the edge construction + # rounds to the same cell counts (49 / 24) as the legacy + # ``nbins_xi=50, nbins_zi=25`` spec — the per-frame tanh NLLS is + # sensitive to the grid layout on this fixture, so matching the + # legacy grid keeps the angle anchors meaningful. return { "xi_0": 0, "xi_f": 100.0, - "nbins_xi": 50, + "bin_width_x": 100.0 / 49.0, "zi_0": 0.0, "zi_f": 100.0, - "nbins_zi": 25, + "bin_width_z": 100.0 / 24.0, } diff --git a/tests/test_analysis/test_coupled_binning_3d.py b/tests/test_analysis/test_coupled_binning_3d.py index 6519d9b..e944e96 100644 --- a/tests/test_analysis/test_coupled_binning_3d.py +++ b/tests/test_analysis/test_coupled_binning_3d.py @@ -97,13 +97,13 @@ def frame_count(self) -> int: binning_params={ "xi_0": -30, "xi_f": 30, - "nbins_xi": 10, + "bin_width_x": 6.0, "yi_0": -30, "yi_f": 30, - "nbins_yi": 10, + "bin_width_y": 6.0, "zi_0": 0, "zi_f": 30, - "nbins_zi": 10, + "bin_width_z": 3.0, }, ) @@ -134,10 +134,10 @@ def test_coupled_binning_3d_close_to_2d_on_lammps_fixture() -> None: binning_params_2d = { "xi_0": 0, "xi_f": 40, - "nbins_xi": 40, + "bin_width_x": 1.0, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 40, + "bin_width_z": 1.0, } legacy_2d = CoupledBinning2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), @@ -151,13 +151,13 @@ def test_coupled_binning_3d_close_to_2d_on_lammps_fixture() -> None: binning_params_3d = { "xi_0": -40, "xi_f": 40, - "nbins_xi": 25, + "bin_width_x": 3.3, "yi_0": -40, "yi_f": 40, - "nbins_yi": 25, + "bin_width_y": 3.3, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 25, + "bin_width_z": 1.6, } new_3d = CoupledBinning3DAnalyzer( parser=LammpsDumpParser(_FIXTURE), diff --git a/tests/test_analysis/test_cylinder_coverage.py b/tests/test_analysis/test_cylinder_coverage.py new file mode 100644 index 0000000..5a04bbc --- /dev/null +++ b/tests/test_analysis/test_cylinder_coverage.py @@ -0,0 +1,313 @@ +"""End-to-end coverage for cylinder droplet × every extractor combination. + +The slicing / whole / coupled-binning paths are all designed to handle +``cylinder_y`` droplets, but several extractor × surface_kind cells +gained cylinder support without dedicated tests — this file fills the +gap. Each test runs the full pipeline on the LAMMPS cylinder fixture +(real water/graphene droplet) and asserts the recovered angle sits in +the physically-plausible band. + +Fixture geometry (frame 1, after PBC recentring): + box x ≈ 200 Å, y ≈ 21 Å (cylinder axis along y) + atoms x ∈ [43, 159] (centred on ~100, radial extent ~±58 Å) + y ∈ [ 0, 21] (spans the full y-box) + z ∈ [ 8, 72] (apex at ~72) +Reference angle from rays_gaussian + slicing: ~95-100°. + +Slicing-mode grid extractors evaluate at ``(s, z)`` cell centres +where ``s`` is relative to the cylinder axis at ``center_geom.x``, +so the grid's ``xi_*`` range is symmetric about zero with magnitude +covering the radial extent (here ~±70 Å). Whole-mode grids place +``xi`` in the droplet-centred frame too, while ``yi`` is also +droplet-centred (centre at y≈11 Å) so the cylinder spans roughly +``[-11, +11]``. +""" + +import pathlib + +import numpy as np +import pytest + +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import ( # noqa: E402 + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +_CYL_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_10_3_330w_nve_4k_reajust.lammpstrj" +) + + +@pytest.fixture +def oxygen_indices() -> np.ndarray: + return LammpsDumpWaterFinder( + _CYL_FIXTURE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +#: Known wall position on the LAMMPS cylinder fixture (z of the +#: graphene plane). Pinning this isolates the extractor/fitter chain +#: from wall-detection robustness — some extractors (notably +#: ``rays_binning`` whole-mode for cylinder) produce shell points +#: with outlier z values that fool ``min_plus_offset``. +_WALL_Z = 5.0 + + +def _make_analyzer( + extractor: InterfaceExtractor, + fitter: SurfaceFitter, + oxygen_indices: np.ndarray, + *, + wall_detector: WallDetector | None = None, +) -> TrajectoryAnalyzer: + return TrajectoryAnalyzer( + parser=LammpsDumpParser(_CYL_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="cylinder_y", + interface_extractor=extractor, + surface_fitter=fitter, + wall_detector=wall_detector or WallDetector.explicit(z_wall=_WALL_Z), + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + + +# --- rays_binning ------------------------------------------------------------ + + +@pytest.mark.integration +def test_rays_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``rays_binning`` + slicing on the cylinder fixture.""" + analyzer = _make_analyzer( + InterfaceExtractor.rays_binning( + delta_cylinder=5.0, delta_polar=8.0, bin_width=3.0 + ), + SurfaceFitter.slicing(surface_filter_offset=2.0), + oxygen_indices, + ) + batch = analyzer.analyze([1]).batches[0] + assert 80.0 < batch.angle < 115.0 + + +@pytest.mark.integration +@pytest.mark.xfail( + reason=( + "rays_binning + whole + cylinder is a known-fragile combination. " + "Per-y-slice ray fans pointing into vacuum (polar angles in " + "[180°, 360°] below the wall) produce outlier shell points that " + "spread z over ~150 Å (vs ~65 Å for the physical droplet), and " + "the 2D circle fit in (x, z) converges to a non-intersecting " + "sphere. rays_gaussian + whole + cylinder works because the KDE " + "density gives the tanh fit something physical to converge to " + "even on rays into vacuum. Documented as a method limitation " + "rather than a code bug." + ), + strict=True, +) +def test_rays_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``rays_binning`` + whole-fit on the cylinder fixture (xfail). + + See the ``xfail`` reason for why this combination doesn't produce + a usable angle on the LAMMPS fixture. + """ + analyzer = _make_analyzer( + InterfaceExtractor.rays_binning( + delta_cylinder=5.0, delta_polar=8.0, bin_width=3.0 + ), + SurfaceFitter.whole(surface_filter_offset=2.0), + oxygen_indices, + ) + batch = analyzer.analyze([1]).batches[0] + assert 80.0 < batch.angle < 115.0 + assert batch.popt.shape == (4,) + + +# --- grid_gaussian ----------------------------------------------------------- + + +@pytest.mark.integration +def test_grid_gaussian_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid_gaussian`` + slicing on the cylinder fixture.""" + pytest.importorskip("skimage") + grid_params = { + "xi_0": -70.0, + "xi_f": 70.0, + "bin_width_x": 3.0, + "zi_0": 0.0, + "zi_f": 80.0, + "bin_width_z": 1.5, + } + analyzer = _make_analyzer( + InterfaceExtractor.grid_gaussian( + grid_params=grid_params, delta_cylinder=5.0, density_sigma=2.0 + ), + SurfaceFitter.slicing(surface_filter_offset=3.0), + oxygen_indices, + ) + batch = analyzer.analyze([1]).batches[0] + assert 80.0 < batch.angle < 115.0 + + +@pytest.mark.integration +def test_grid_gaussian_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid_gaussian`` + whole-fit on the cylinder fixture. + + The cylinder spans ~21 Å along ``y`` and is droplet-centred at + ``y ≈ 11`` Å, so a ``yi_0``/``yi_f`` range of ±12 Å covers it. + The grid is droplet-centred in ``x`` too (atoms span ±58 Å), so + ``xi`` spans ±70 Å with a margin. + + The grid whole-mode on a cylinder is method-biased on this + fixture: marching cubes traces the iso-surface through the + coarse 3D grid (~10⁵ cells), and the recovered angle is ~10° + higher than the rays_gaussian reference. That's a known bias of + grid-resolution-limited iso-surfaces, not a fit failure — the + test accepts up to ~140°. + """ + pytest.importorskip("skimage") + grid_params = { + "xi_0": -70.0, + "xi_f": 70.0, + "bin_width_x": 2.5, + "yi_0": -12.0, + "yi_f": 12.0, + "bin_width_y": 2.0, + "zi_0": 0.0, + "zi_f": 80.0, + "bin_width_z": 2.0, + } + analyzer = _make_analyzer( + InterfaceExtractor.grid_gaussian(grid_params=grid_params, density_sigma=2.5), + SurfaceFitter.whole(surface_filter_offset=3.0), + oxygen_indices, + ) + batch = analyzer.analyze([1]).batches[0] + assert 80.0 < batch.angle < 140.0 + # Cylinder whole-fit popt is [xc, zc, R, z_wall]. + assert batch.popt.shape == (4,) + + +# --- grid_binning ------------------------------------------------------------ + + +@pytest.mark.integration +def test_grid_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid_binning`` + slicing on the cylinder fixture. + + Coarser cells than ``grid_gaussian`` because the histogram has no + smoothing — the slab cut also needs to be thick enough to give + enough atoms per cell on a per-frame basis. + """ + pytest.importorskip("skimage") + grid_params = { + "xi_0": -70.0, + "xi_f": 70.0, + "bin_width_x": 8.0, + "zi_0": 0.0, + "zi_f": 80.0, + "bin_width_z": 3.0, + } + analyzer = _make_analyzer( + InterfaceExtractor.grid_binning(grid_params=grid_params, delta_cylinder=10.0), + SurfaceFitter.slicing(surface_filter_offset=3.0), + oxygen_indices, + ) + batch = analyzer.analyze([1]).batches[0] + # Wider band for the histogram variant. + assert 75.0 < batch.angle < 125.0 + + +@pytest.mark.integration +def test_grid_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid_binning`` + whole-fit on the cylinder fixture.""" + pytest.importorskip("skimage") + grid_params = { + "xi_0": -70.0, + "xi_f": 70.0, + "bin_width_x": 3.0, + "yi_0": -12.0, + "yi_f": 12.0, + "bin_width_y": 3.0, + "zi_0": 0.0, + "zi_f": 80.0, + "bin_width_z": 2.5, + } + analyzer = _make_analyzer( + InterfaceExtractor.grid_binning(grid_params=grid_params), + SurfaceFitter.whole(surface_filter_offset=3.0), + oxygen_indices, + ) + batch = analyzer.analyze([1]).batches[0] + # Histogram-iso whole-mode on a coarse 3D grid has a similar + # method bias to ``grid_gaussian`` whole + cylinder; band widened + # accordingly. The popt shape is the important contract here. + assert 75.0 < batch.angle < 145.0 + assert batch.popt.shape == (4,) + + +# --- cylinder_x end-to-end smoke --------------------------------------------- + + +@pytest.mark.integration +def test_cylinder_x_end_to_end_runs_through_axis_swap( + oxygen_indices: np.ndarray, +) -> None: + """A ``cylinder_x`` analysis runs end-to-end without breaking. + + Internally, ``DropletGeometry("cylinder_x")`` swaps the ``x``/``y`` + columns before the rest of the pipeline runs (so the cylinder + axis is always ``y`` in the internal frame). This test verifies + the swap propagates correctly through the extractor, fitter, and + wall detector — a refactor that misses re-applying the swap + somewhere would cause a fit failure or a wildly off angle. + + The LAMMPS fixture's cylinder actually runs along ``y``, so + ``cylinder_x`` is geometrically incorrect for this data: the + pipeline runs end-to-end (the axis-swap code path executes + without raising) but the resulting fit will either NaN out or + fall outside the physical band. That's the right behaviour — a + swap that propagated incorrectly would surface as a pipeline- + level exception, not a silently-wrong angle. + """ + extractor = InterfaceExtractor.rays_gaussian( + delta_cylinder=5.0, delta_polar=8.0, density_sigma=3.0 + ) + fitter = SurfaceFitter.slicing(surface_filter_offset=2.0) + wall = WallDetector.explicit(z_wall=_WALL_Z) + analyzer_x = TrajectoryAnalyzer( + parser=LammpsDumpParser(_CYL_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="cylinder_x", + interface_extractor=extractor, + surface_fitter=fitter, + wall_detector=wall, + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + # The swap is the unit under test: we expect it to execute + # without exception and either produce a (likely non-physical) + # finite angle or a NaN. Both outcomes prove the axis-swap + # propagated correctly through the pipeline. + try: + result = analyzer_x.analyze([1]) + # If batches came back, the swap reached the fitter and the + # fit either converged (finite) or returned NaN. + if len(result.batches) > 0: + angle = float(result.per_batch_angles[0]) + assert np.isnan(angle) or 0.0 < angle < 180.0 + except RuntimeError as e: + # "No batches produced a result" is the third valid outcome + # — every individual fit raised because the geometry is + # wrong, but the swap itself didn't break the pipeline at + # the parser/extractor layer. + assert "no" in str(e).lower() or "batch" in str(e).lower() diff --git a/tests/test_analysis/test_default_grid_params.py b/tests/test_analysis/test_default_grid_params.py new file mode 100644 index 0000000..ff1c9fc --- /dev/null +++ b/tests/test_analysis/test_default_grid_params.py @@ -0,0 +1,145 @@ +"""Auto-derived ``grid_params`` / ``binning_params`` defaults. + +When the user constructs a grid extractor or a coupled-binning +analyzer without specifying the spatial grid spec, the package picks +one from the atom bounding box (extractors) or the box dimensions +(analyzers). These tests verify that the auto-derived defaults +produce physically reasonable angles on the bundled LAMMPS fixture. + +The reference angle on the water/graphene fixture is ~95° (see the +slicing/whole pipeline tests in this directory). The bounds here are +±5° around that — wide enough that the per-method bias is absorbed, +tight enough that a real regression in the default-derivation logic +gets flagged. +""" + +import pathlib + +import numpy as np +import pytest + +pytest.importorskip("ovito") +pytest.importorskip("skimage") + +from wetting_angle_kit.analysis import ( # noqa: E402 + CoupledBinning2DAnalyzer, + CoupledBinning3DAnalyzer, + InterfaceExtractor, + SurfaceFitter, + TrajectoryAnalyzer, + WallDetector, +) +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +_FIXTURE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) + + +@pytest.fixture +def oxygen_indices() -> np.ndarray: + return LammpsDumpWaterFinder( + _FIXTURE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +@pytest.mark.integration +def test_coupled_binning_2d_auto_default(oxygen_indices: np.ndarray) -> None: + """``CoupledBinning2DAnalyzer`` with no ``binning_params`` lands at ~95°.""" + with pytest.warns(UserWarning, match="binning_params was not supplied"): + analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + ) + batch = analyzer.analyze([1]).batches[0] + assert 90.0 < batch.angle < 100.0 + # Sensible model params, not degenerate. + assert 20.0 < batch.model_params["R_eq"] < 60.0 + + +@pytest.mark.integration +@pytest.mark.slow +def test_coupled_binning_3d_auto_default(oxygen_indices: np.ndarray) -> None: + """``CoupledBinning3DAnalyzer`` with no ``binning_params`` lands at ~95°.""" + with pytest.warns(UserWarning, match="binning_params was not supplied"): + analyzer = CoupledBinning3DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + ) + batch = analyzer.analyze([1]).batches[0] + assert 90.0 < batch.angle < 100.0 + + +@pytest.mark.integration +def test_grid_gaussian_slicing_auto_default( + oxygen_indices: np.ndarray, +) -> None: + """``grid_gaussian`` slicing pipeline with no ``grid_params`` lands at ~95°.""" + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.grid_gaussian(delta_azimuthal=20.0), + surface_fitter=SurfaceFitter.slicing(surface_filter_offset=3.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + batch = analyzer.analyze([1]).batches[0] + assert 90.0 < batch.angle < 100.0 + + +@pytest.mark.integration +def test_grid_gaussian_whole_auto_default( + oxygen_indices: np.ndarray, +) -> None: + """``grid_gaussian`` whole-fit pipeline with no ``grid_params`` lands at ~95°.""" + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.grid_gaussian(), + surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + batch = analyzer.analyze([1]).batches[0] + assert 90.0 < batch.angle < 100.0 + + +@pytest.mark.integration +@pytest.mark.slow +def test_grid_binning_whole_auto_default( + oxygen_indices: np.ndarray, +) -> None: + """``grid_binning`` whole-fit pipeline with no ``grid_params``. + + Whole mode bins the full 3D density (no slab cut), so the + auto-default holds up where per-frame slicing-mode + ``grid_binning`` doesn't. The recovered angle should sit in the + physically-acceptable band. + + Note: ``grid_binning`` + slicing-mode + ``grid_params=None`` is + intrinsically unreliable for single-frame analyses (the slab cut + leaves few atoms per cell); that combination has no dedicated + test because there is no robust default for it. + """ + analyzer = TrajectoryAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + interface_extractor=InterfaceExtractor.grid_binning(), + surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), + wall_detector=WallDetector.min_plus_offset(offset=0.0), + ) + batch = analyzer.analyze([1]).batches[0] + # Wide band because the histogram iso-surface is noisier than + # the KDE one; on this fixture it recovers ~116° vs ~96° for the + # KDE variant. Both are in the "physically plausible" range, + # just biased by the per-cell shot noise. + assert 85.0 < batch.angle < 125.0 diff --git a/tests/test_analysis/test_grid_extractors.py b/tests/test_analysis/test_grid_extractors.py index 982f19d..3377dcb 100644 --- a/tests/test_analysis/test_grid_extractors.py +++ b/tests/test_analysis/test_grid_extractors.py @@ -1,17 +1,22 @@ -"""Phase 7 quantification: grid extractors (slicing-only). +"""Grid extractors (slicing mode). Three flavors: -- **Synthetic spherical droplet → recovered angle ≈ truth.** Generates - atoms inside a spherical cap of known truth angle, runs the - grid extractor + slicing fitter pipeline, and checks the recovered - angle. +- **Synthetic spherical droplet → recovered angle ≈ truth.** Atoms + inside a spherical cap of known truth angle; the grid extractor + + slicing fitter pipeline should recover the angle within a few + degrees. - **grid_binning vs grid_gaussian on the same atoms.** Both should - recover a similar interface; the Gaussian-smoothed variant is - cleaner (lower per-slice RMS) than the bare histogram. + recover similar interfaces; the KDE-smoothed variant produces + cleaner (lower per-slice RMS) contours than the bare histogram. - **End-to-end on the LAMMPS water/graphene fixture.** Grid extractors paired with the slicing fitter should produce angles within a few degrees of ``rays_gaussian`` on the same fixture/frame. + +All grid-mode slicing extractors take ``delta_azimuthal`` (spherical) +or ``delta_cylinder`` (cylinder) and iterate per-slice — the +returned contour list has one entry per azimuthal slice for +spherical, one per y-step for cylinder. """ import pathlib @@ -19,8 +24,8 @@ import numpy as np import pytest -# Skip Phase 7 entirely if the optional scikit-image extra isn't -# installed — both grid extractors depend on it. +# Skip entirely if scikit-image isn't installed — both grid extractors +# depend on it. pytest.importorskip("skimage") from wetting_angle_kit.analysis import ( # noqa: E402 @@ -39,11 +44,7 @@ def _spherical_cap_atoms( n_atoms: int, seed: int = 0, ) -> np.ndarray: - """Atoms uniformly filling a spherical cap. - - Sphere of radius ``R`` centered at ``(0, 0, zc)``; only atoms with - ``z > z_wall`` are kept (the visible cap above the wall). - """ + """Atoms uniformly filling a spherical cap above ``z_wall``.""" rng = np.random.default_rng(seed) pts: list[np.ndarray] = [] while sum(p.shape[0] for p in pts) < n_atoms: @@ -56,25 +57,27 @@ def _spherical_cap_atoms( return np.concatenate(pts, axis=0)[:n_atoms] -def _default_grid_params(max_dist: float) -> dict[str, object]: +def _default_grid_params(half: float) -> dict[str, object]: + """A symmetric ``(s, z)`` slice grid: ``s ∈ [-half, half]``, ``z ∈ [0, half]``.""" return { - "xi_0": 0.0, - "xi_f": max_dist, - "nbins_xi": 50, + "xi_0": -half, + "xi_f": half, + "bin_width_x": half / 25.0, "zi_0": 0.0, - "zi_f": max_dist, - "nbins_zi": 50, + "zi_f": half, + "bin_width_z": half / 25.0, } def test_grid_gaussian_recovers_known_spherical_cap_angle() -> None: - """Synthetic spherical cap → ``grid_gaussian`` recovers truth within ~1°.""" + """Per-azimuthal-slice ``grid_gaussian`` recovers a known cap angle.""" R, zc, z_wall = 25.0, 0.0, 5.0 truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=15000, seed=0) extractor = InterfaceExtractor.grid_gaussian( - grid_params=_default_grid_params(max_dist=35.0), + grid_params=_default_grid_params(half=35.0), + delta_azimuthal=30.0, density_sigma=2.0, ) geom = DropletGeometry.coerce("spherical") @@ -83,115 +86,112 @@ def test_grid_gaussian_recovers_known_spherical_cap_angle() -> None: liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=35.0, surface_kind="slicing", ) assert isinstance(contours, list) - assert len(contours) == 1 - contour = contours[0] - assert contour.ndim == 2 and contour.shape[1] == 2 - assert len(contour) >= 10 - - # ``find_contours`` traces the iso-line all the way around the - # droplet — including a thin "floor" segment near ``z = z_wall`` - # that arises from histogram discretisation across the wall. - # ``SurfaceFitter.slicing.surface_filter_offset`` is the designed - # mechanism for dropping that floor before the Kasa circle fit. + # delta_azimuthal=30° → 6 slices. + assert len(contours) == 6 + for contour in contours: + assert contour.ndim == 2 and contour.shape[1] == 2 + assert len(contour) >= 10 + fitter = SurfaceFitter.slicing(surface_filter_offset=3.0) out = fitter.fit(interface_data=contours, z_wall=z_wall, droplet_geometry=geom) drift = abs(out.angle - truth_angle) print( - f"\ngrid_gaussian cap recovery: truth = {truth_angle:.3f}°, " + f"\ngrid_gaussian (6 slices) cap recovery: truth = {truth_angle:.3f}°, " f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " - f"R_fit = {out.slice_popts[0][2]:.3f}, " - f"zc_fit = {out.slice_popts[0][1]:.3f}, " - f"contour_points = {len(contour)}, " + f"per_slice σ = {out.angle_std:.3f}°, " f"rms_residual = {out.rms_residual:.3f} Å" ) - # Grid resolution 0.71 Å + Gaussian smoothing σ=2 ⇒ a fraction of - # a degree on this dense fixture. assert drift < 2.0 + # Axisymmetric truth ⇒ per-slice scatter should be sub-degree. + assert out.angle_std < 2.0 def test_grid_binning_recovers_known_spherical_cap_with_coarse_bins() -> None: - """``grid_binning`` (no smoothing) needs coarser bins to give a usable contour. + """``grid_binning`` (no smoothing) needs coarser cells to give a usable contour. - On finer grids the Poisson noise per bin dominates and the - iso-line jitter spoils the Kasa fit; the coarse-bins case shows - the tool produces sensible answers with the right configuration. + On finer grids the Poisson noise per bin dominates and the slab + cut becomes thin; the coarse-cells case shows the tool produces + sensible answers with the right configuration. """ R, zc, z_wall = 25.0, 0.0, 5.0 truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=50000, seed=0) - # 24 cells span the 35-Å envelope → dxi ≈ 1.5 Å, giving ~10× - # more atoms per bin than the nbins=50 default at this density. + # Per-slice grid_binning has fewer atoms per cell than the old + # axisymmetric collapse (only atoms within the slab contribute). + # 4 Å in-plane bin + 4 Å slab thickness keeps the per-cell atom + # count high enough that the Poisson noise doesn't dominate the + # iso-contour at the 95th-percentile bulk estimator. grid_params: dict[str, object] = { - "xi_0": 0.0, + "xi_0": -35.0, "xi_f": 35.0, - "nbins_xi": 25, + "bin_width_x": 4.0, "zi_0": 0.0, "zi_f": 35.0, - "nbins_zi": 25, + "bin_width_z": 2.0, } - extractor = InterfaceExtractor.grid_binning(grid_params=grid_params) + extractor = InterfaceExtractor.grid_binning( + grid_params=grid_params, + delta_azimuthal=60.0, # 3 slices + ) geom = DropletGeometry.coerce("spherical") contours = extractor.extract( liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=35.0, surface_kind="slicing", ) + assert len(contours) == 3 fitter = SurfaceFitter.slicing(surface_filter_offset=3.0) out = fitter.fit(interface_data=contours, z_wall=z_wall, droplet_geometry=geom) drift = abs(out.angle - truth_angle) print( - f"\ngrid_binning (coarse) cap recovery: truth = {truth_angle:.3f}°, " - f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " - f"R_fit = {out.slice_popts[0][2]:.3f}, " - f"rms_residual = {out.rms_residual:.3f} Å" + f"\ngrid_binning (coarse, 3 slices) cap recovery: " + f"truth = {truth_angle:.3f}°, recovered = {out.angle:.3f}°, " + f"|drift| = {drift:.3f}°, rms_residual = {out.rms_residual:.3f} Å" ) assert drift < 5.0 def test_grid_gaussian_smoother_than_grid_binning() -> None: - """At equal grid spec, ``grid_gaussian`` gives a smoother contour - than ``grid_binning``. - - Same atoms, same grid, equal-shape contours; the smoothed variant - should have a lower per-point Kasa-fit residual. - """ + """At equal grid spec, ``grid_gaussian`` gives a smoother contour.""" R, zc, z_wall = 25.0, 0.0, 5.0 atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=50000, seed=1) - # Coarse-enough grid for both estimators to give usable contours. + # Coarse grid with thick slab so grid_binning isn't dominated by + # Poisson noise; same spec for both estimators so the comparison + # is apples-to-apples. grid_params: dict[str, object] = { - "xi_0": 0.0, + "xi_0": -35.0, "xi_f": 35.0, - "nbins_xi": 25, + "bin_width_x": 4.0, "zi_0": 0.0, "zi_f": 35.0, - "nbins_zi": 25, + "bin_width_z": 2.0, } geom = DropletGeometry.coerce("spherical") - b = InterfaceExtractor.grid_binning(grid_params=grid_params) - g = InterfaceExtractor.grid_gaussian(grid_params=grid_params, density_sigma=2.0) + b = InterfaceExtractor.grid_binning(grid_params=grid_params, delta_azimuthal=60.0) + g = InterfaceExtractor.grid_gaussian( + grid_params=grid_params, + delta_azimuthal=60.0, + density_sigma=2.0, + ) binning_contours = b.extract( liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=35.0, surface_kind="slicing", ) gaussian_contours = g.extract( liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=35.0, surface_kind="slicing", ) @@ -212,6 +212,18 @@ def test_grid_gaussian_smoother_than_grid_binning() -> None: assert abs(out_g.angle - truth_angle) < 5.0 +def test_grid_gaussian_rejects_missing_delta_azimuthal_for_spherical() -> None: + """slicing+spherical without ``delta_azimuthal`` must fail at validation.""" + extractor = InterfaceExtractor.grid_gaussian( + grid_params=_default_grid_params(half=35.0), density_sigma=2.0 + ) + with pytest.raises(ValueError, match="delta_azimuthal"): + extractor.validate_compatibility( + surface_kind="slicing", + droplet_geometry=DropletGeometry.coerce("spherical"), + ) + + @pytest.mark.integration @pytest.mark.slow def test_grid_extractors_end_to_end_close_to_rays_gaussian() -> None: @@ -232,25 +244,24 @@ def test_grid_extractors_end_to_end_close_to_rays_gaussian() -> None: finder = LammpsDumpWaterFinder(fixture, oxygen_type=1, hydrogen_type=2) oxygen_indices = finder.get_water_oxygen_ids(0) - # ``grid_gaussian`` works on the 4k-oxygen fixture with the same - # grid as the synthetic test. ``grid_binning`` (no smoothing) needs - # coarser bins to avoid noisy iso-contour points; we evaluate each - # at its own well-suited bin count. grid_params_gauss = { - "xi_0": 0.0, + "xi_0": -40.0, "xi_f": 40.0, - "nbins_xi": 26, + "bin_width_x": 3.0, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 26, + "bin_width_z": 1.6, } + # grid_binning per-slice has fewer atoms per cell than rays_binning + # (only atoms in the slab contribute, not all atoms along a ray): + # need a thick slab AND few slices to keep per-bin counts reasonable. grid_params_bin = { - "xi_0": 0.0, + "xi_0": -40.0, "xi_f": 40.0, - "nbins_xi": 16, + "bin_width_x": 8.0, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 16, + "bin_width_z": 3.0, } def _angle(extractor: InterfaceExtractor) -> float: @@ -270,23 +281,25 @@ def _angle(extractor: InterfaceExtractor) -> float: ) ) angle_grid_bin = _angle( - InterfaceExtractor.grid_binning(grid_params=grid_params_bin) + InterfaceExtractor.grid_binning( + grid_params=grid_params_bin, delta_azimuthal=60.0 + ) ) angle_grid_gauss = _angle( InterfaceExtractor.grid_gaussian( - grid_params=grid_params_gauss, density_sigma=2.0 + grid_params=grid_params_gauss, + delta_azimuthal=20.0, + density_sigma=2.0, ) ) print( - f"\nrays_gaussian angle = {angle_rays:.3f}°" - f"\ngrid_binning (nbins=16) angle = {angle_grid_bin:.3f}° " + f"\nrays_gaussian angle = {angle_rays:.3f}°" + f"\ngrid_binning (slab=5 Å) angle = {angle_grid_bin:.3f}° " f"|drift| = {abs(angle_grid_bin - angle_rays):.3f}°" - f"\ngrid_gaussian (nbins=26) angle = {angle_grid_gauss:.3f}° " + f"\ngrid_gaussian (slab=3 Å σ) angle = {angle_grid_gauss:.3f}° " f"|drift| = {abs(angle_grid_gauss - angle_rays):.3f}°" ) for angle in (angle_rays, angle_grid_bin, angle_grid_gauss): assert 70.0 < angle < 110.0 - # ``grid_gaussian`` smooths the density → close to ``rays_gaussian``. - assert abs(angle_grid_gauss - angle_rays) < 5.0 - # ``grid_binning`` is intrinsically noisier; allow a wider band. - assert abs(angle_grid_bin - angle_rays) < 12.0 + assert abs(angle_grid_gauss - angle_rays) < 8.0 + assert abs(angle_grid_bin - angle_rays) < 14.0 diff --git a/tests/test_analysis/test_grid_extractors_whole.py b/tests/test_analysis/test_grid_extractors_whole.py index d463999..68c8d7b 100644 --- a/tests/test_analysis/test_grid_extractors_whole.py +++ b/tests/test_analysis/test_grid_extractors_whole.py @@ -10,9 +10,9 @@ ``grid_gaussian`` whole extractor with ``SurfaceFitter.whole`` — the angle should land in the same physically plausible band as ``rays_gaussian``. -- **Cylinder geometry is rejected.** Whole + grid + cylinder raises - ``NotImplementedError`` with a clear pointer to the ``rays_*`` - fallback. +- **Cylinder geometry recovers a known horizontal ridge.** The 3D + grid extracts a tube-like shell whose 2D ``(x, z)`` projection is + a circle of the known cylinder radius. """ import pathlib @@ -51,16 +51,19 @@ def _spherical_cap_atoms( def _whole_grid_params(half_xy: float, z_lo: float, z_hi: float, nbins: int) -> dict: + """3D grid with cell sizes derived to give ``nbins`` cells per axis.""" + bw_xy = 2.0 * half_xy / nbins + bw_z = (z_hi - z_lo) / nbins return { "xi_0": -half_xy, "xi_f": half_xy, - "nbins_xi": nbins, + "bin_width_x": bw_xy, "yi_0": -half_xy, "yi_f": half_xy, - "nbins_yi": nbins, + "bin_width_y": bw_xy, "zi_0": z_lo, "zi_f": z_hi, - "nbins_zi": nbins, + "bin_width_z": bw_z, } @@ -80,7 +83,6 @@ def test_grid_gaussian_whole_recovers_known_spherical_cap() -> None: liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=35.0, surface_kind="whole", ) assert isinstance(shell, np.ndarray) @@ -118,7 +120,6 @@ def test_grid_binning_whole_recovers_known_spherical_cap() -> None: liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=35.0, surface_kind="whole", ) fitter = SurfaceFitter.whole(surface_filter_offset=3.0) @@ -133,25 +134,66 @@ def test_grid_binning_whole_recovers_known_spherical_cap() -> None: assert drift < 5.0 -def test_grid_whole_cylinder_raises_not_implemented() -> None: - """Whole + grid + cylinder is intentionally unsupported.""" - grid_params = _whole_grid_params(half_xy=20.0, z_lo=0.0, z_hi=20.0, nbins=15) +def test_grid_gaussian_whole_cylinder_recovers_horizontal_ridge() -> None: + """``grid_gaussian`` + whole + cylinder recovers a known cylindrical ridge. + + A uniformly-filled cylinder of radius ``R_truth`` running along the + ``y`` axis is binned on a 3D grid whose ``y`` extent spans the + full cylinder. The recovered shell's ``(x, z)`` projection should + be a circle of radius ``R_truth``. + """ + R_truth = 12.0 + y_extent = 30.0 + n_atoms = 20000 + rng = np.random.default_rng(0) + cross = [] + while sum(c.shape[0] for c in cross) < n_atoms: + cand = rng.uniform(-R_truth, R_truth, size=(2 * n_atoms, 2)) + inside = np.hypot(cand[:, 0], cand[:, 1]) < R_truth + cross.append(cand[inside]) + xz = np.concatenate(cross, axis=0)[:n_atoms] + y = rng.uniform(-y_extent / 2, y_extent / 2, size=n_atoms) + # Atoms occupy the cylinder centred on (x=0, z=R_truth) so the + # ridge sits above z=0 (no atoms below z=0). + atoms = np.column_stack([xz[:, 0], y, xz[:, 1] + R_truth]) + + # 3D grid: y span covers the whole cylinder; x, z span the + # cross-section with some margin. + grid_params = { + "xi_0": -1.5 * R_truth, + "xi_f": 1.5 * R_truth, + "bin_width_x": 1.0, + "yi_0": -y_extent / 2, + "yi_f": y_extent / 2, + "bin_width_y": 1.5, + "zi_0": 0.0, + "zi_f": 2.5 * R_truth, + "bin_width_z": 1.0, + } extractor = InterfaceExtractor.grid_gaussian( - grid_params=grid_params, density_sigma=2.0 + grid_params=grid_params, density_sigma=1.5 ) geom = DropletGeometry.coerce("cylinder_y") - # validate_compatibility itself accepts the pairing (grid_params - # have the 9 keys); the NotImplementedError fires inside - # ``extract`` once the geometry is observed. - atoms = np.random.default_rng(0).uniform(-10, 10, size=(100, 3)) - with pytest.raises(NotImplementedError, match="cylinder"): - extractor.extract( - liquid_coordinates=atoms, - center_geom=np.zeros(3), - droplet_geometry=geom, - max_dist=20.0, - surface_kind="whole", - ) + extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) + shell = extractor.extract( + liquid_coordinates=atoms, + center_geom=np.array([0.0, 0.0, R_truth]), + droplet_geometry=geom, + surface_kind="whole", + ) + assert isinstance(shell, np.ndarray) + assert shell.ndim == 2 and shell.shape[1] == 3 + + # In-plane radius (x, z relative to the ridge axis at (0, R_truth)). + in_plane_r = np.hypot(shell[:, 0], shell[:, 2] - R_truth) + mean_r = float(np.mean(in_plane_r)) + print( + f"\ngrid_gaussian whole+cylinder: n_points = {shell.shape[0]}, " + f"R_truth = {R_truth}, R_mean = {mean_r:.3f} Å" + ) + # Gaussian smoothing (σ=1.5) places the iso-contour slightly inside + # the geometric edge; allow ±2 Å. + assert abs(mean_r - R_truth) < 2.0 @pytest.mark.integration diff --git a/tests/test_analysis/test_parallel_path.py b/tests/test_analysis/test_parallel_path.py index d9c4c05..27950f1 100644 --- a/tests/test_analysis/test_parallel_path.py +++ b/tests/test_analysis/test_parallel_path.py @@ -43,10 +43,10 @@ def test_run_parallel_path_executes_with_n_jobs_2() -> None: binning_params={ "xi_0": 0.0, "xi_f": 40.0, - "nbins_xi": 30, + "bin_width_x": 1.4, "zi_0": 0.0, "zi_f": 40.0, - "nbins_zi": 30, + "bin_width_z": 1.4, }, temporal_aggregator=TemporalAggregator(batch_size=1), ) @@ -55,3 +55,36 @@ def test_run_parallel_path_executes_with_n_jobs_2() -> None: assert len(results) == 3 for batch in results.batches: assert np.isfinite(batch.angle) + + +@pytest.mark.integration +def test_n_jobs_gt_1_with_batch_size_minus_1_warns_and_runs_inline() -> None: + """``batch_size=-1`` + ``n_jobs > 1`` warns: no parallelism possible. + + With ``batch_size=-1`` every frame is pooled into one batch, so + ``n_jobs`` can't subdivide the work. The analyzer falls back to + inline execution and emits a ``UserWarning`` to flag the + mis-configuration. + """ + oxygen_indices = LammpsDumpWaterFinder( + _FIXTURE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + analyzer = CoupledBinning2DAnalyzer( + parser=LammpsDumpParser(_FIXTURE), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params={ + "xi_0": 0.0, + "xi_f": 40.0, + "bin_width_x": 1.4, + "zi_0": 0.0, + "zi_f": 40.0, + "bin_width_z": 1.4, + }, + temporal_aggregator=TemporalAggregator(batch_size=-1), + ) + with pytest.warns(UserWarning, match="batch_size=-1"): + results = analyzer.analyze([0, 1, 2], n_jobs=4) + # One pooled batch ⇒ one result regardless of n_jobs request. + assert len(results) == 1 + assert np.isfinite(results.batches[0].angle) diff --git a/tests/test_analysis/test_rays_binning_extractor.py b/tests/test_analysis/test_rays_binning_extractor.py index ba951fb..f1f1acc 100644 --- a/tests/test_analysis/test_rays_binning_extractor.py +++ b/tests/test_analysis/test_rays_binning_extractor.py @@ -93,7 +93,6 @@ def test_rays_binning_whole_spherical_recovers_sphere() -> None: liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=radius + 10.0, surface_kind="whole", ) assert isinstance(shell, np.ndarray) @@ -122,7 +121,6 @@ def test_rays_binning_matches_rays_gaussian_slicing_spherical() -> None: bin_width = sigma * float(np.sqrt(12.0)) # variance-matched top-hat delta_azimuthal = 30.0 delta_polar = 8.0 - max_dist = 30.0 geom = DropletGeometry.coerce("spherical") g = InterfaceExtractor.rays_gaussian( @@ -141,14 +139,12 @@ def test_rays_binning_matches_rays_gaussian_slicing_spherical() -> None: liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=max_dist, surface_kind="slicing", ) bin_slices = b.extract( liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=max_dist, surface_kind="slicing", ) assert isinstance(gauss_slices, list) diff --git a/tests/test_analysis/test_rays_gaussian_extractor.py b/tests/test_analysis/test_rays_gaussian_extractor.py index dea8002..b640c74 100644 --- a/tests/test_analysis/test_rays_gaussian_extractor.py +++ b/tests/test_analysis/test_rays_gaussian_extractor.py @@ -52,7 +52,6 @@ def test_whole_spherical_recovers_known_sphere_radius() -> None: liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=radius + 10.0, surface_kind="whole", ) assert isinstance(shell, np.ndarray) @@ -118,7 +117,6 @@ def test_whole_cylinder_recovers_horizontal_ridge() -> None: shell = extractor.extract( liquid_coordinates=atoms, center_geom=np.array([0.0, 0.0, R_truth]), - max_dist=R_truth + 10.0, droplet_geometry=geom, surface_kind="whole", ) diff --git a/tests/test_analysis/test_whole_fitter.py b/tests/test_analysis/test_whole_fitter.py index 1ad8bcc..76e5498 100644 --- a/tests/test_analysis/test_whole_fitter.py +++ b/tests/test_analysis/test_whole_fitter.py @@ -123,7 +123,6 @@ def test_whole_fitter_end_to_end_atom_sphere() -> None: liquid_coordinates=atoms, center_geom=np.zeros(3), droplet_geometry=geom, - max_dist=R_truth + 10.0, surface_kind="whole", ) From 6f53661bc2e4392762550295c81c0abe302c0138 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Fri, 12 Jun 2026 15:36:35 +0200 Subject: [PATCH 34/53] Update to allow using a Gaussian KDE density with the CoupledFit (renamed) methodologies. --- README.md | 23 +- docs/examples/binning_ca.py | 56 ---- docs/examples/coupled_fit_ca.py | 80 +++++ .../visualisation_evolution_density.py | 21 +- docs/source/API/index.rst | 2 +- docs/source/examples/index.rst | 10 +- docs/source/introduction/introduction.rst | 4 +- .../introduction/theoretical_foundations.rst | 4 +- ...ethod_tuto.rst => coupled_fit_2d_tuto.rst} | 83 ++++-- ...ng_3d_tuto.rst => coupled_fit_3d_tuto.rst} | 26 +- docs/source/tutorials/index.rst | 4 +- .../visualization_evolution_density.rst | 25 +- src/wetting_angle_kit/analysis/__init__.py | 42 +-- src/wetting_angle_kit/analysis/_base.py | 6 +- .../analysis/coupled_binning/__init__.py | 28 -- .../analysis/coupled_fit/__init__.py | 40 +++ .../coupled_fit/_density_estimator.py | 277 ++++++++++++++++++ .../_models.py | 4 +- .../analyzer_2d.py | 116 +++++--- .../analyzer_3d.py | 62 ++-- src/wetting_angle_kit/analysis/results.py | 32 +- src/wetting_angle_kit/analysis/trajectory.py | 4 +- .../visualization/angle_evolution_plotter.py | 14 +- .../visualization/density_contour_plotter.py | 30 +- tests/test_analysis/test_binning_method.py | 14 +- ...d_binning_3d.py => test_coupled_fit_3d.py} | 24 +- .../test_analysis/test_default_grid_params.py | 16 +- tests/test_analysis/test_density_estimator.py | 212 ++++++++++++++ tests/test_analysis/test_parallel_path.py | 6 +- .../test_angle_evolution_helpers.py | 12 +- .../test_angle_evolution_plotter.py | 10 +- .../test_density_contour_plotter.py | 22 +- 32 files changed, 967 insertions(+), 342 deletions(-) delete mode 100644 docs/examples/binning_ca.py create mode 100644 docs/examples/coupled_fit_ca.py rename docs/source/tutorials/{binning_method_tuto.rst => coupled_fit_2d_tuto.rst} (71%) rename docs/source/tutorials/{coupled_binning_3d_tuto.rst => coupled_fit_3d_tuto.rst} (92%) delete mode 100644 src/wetting_angle_kit/analysis/coupled_binning/__init__.py create mode 100644 src/wetting_angle_kit/analysis/coupled_fit/__init__.py create mode 100644 src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py rename src/wetting_angle_kit/analysis/{coupled_binning => coupled_fit}/_models.py (99%) rename src/wetting_angle_kit/analysis/{coupled_binning => coupled_fit}/analyzer_2d.py (73%) rename src/wetting_angle_kit/analysis/{coupled_binning => coupled_fit}/analyzer_3d.py (85%) rename tests/test_analysis/{test_coupled_binning_3d.py => test_coupled_fit_3d.py} (88%) create mode 100644 tests/test_analysis/test_density_estimator.py diff --git a/README.md b/README.md index b2d7909..634912e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ The liquid-vapor interface isn't a sharp surface in an MD simulation — the den - **Slicing fit** — independently fits an algebraic circle in each slice's `(x, z)` plane, then averages the per-slice contact angles. Good when the droplet might be slightly non-spherical: the per-slice scatter naturally reports a `±σ` band. - **Whole fit** — fits a single sphere (spherical droplet) or cylinder (cylindrical droplet) to the entire 3D interface shell. Uses the algebraic Kasa method, plus optional bootstrap resampling to put an uncertainty on the recovered angle. -- **Coupled-binning fit** (joint approach) — a 7-parameter (2D) or 9-parameter (3D) hyperbolic-tangent density model that solves "where is the interface", "where is the wall plane", and "what's the cap geometry" in one nonlinear least-squares fit on the binned density field. Statistically efficient when you pool many frames per batch. +- **Coupled fit** (joint approach) — a 7-parameter (2D) or 9-parameter (3D) hyperbolic-tangent density model that solves "where is the interface", "where is the wall plane", and "what's the cap geometry" in one nonlinear least-squares fit on a density field. The per-cell density is computed by a pluggable `DensityEstimator` strategy: a top-hat histogram (`DensityEstimator.binning()`, default) or a 3D Gaussian KDE evaluated at the cell centres (`DensityEstimator.gaussian(density_sigma=…)`); the KDE variant trades a small constant cost for a smooth, Poisson-noise-free density. Statistically efficient when you pool many frames per batch. ### Wall detection: where is the wall plane? @@ -40,7 +40,7 @@ The `TemporalAggregator` groups trajectory frames into batches before fitting. ` ## Two top-level entry points 1. **`TrajectoryAnalyzer`** — composes the four strategies above (`InterfaceExtractor` × `SurfaceFitter` × `WallDetector` × `TemporalAggregator`). Use it when you want per-frame time resolution or when you want to mix-and-match approaches (e.g. ray-fan extractor + whole-fit + explicit wall + 5-frame batches). -2. **`CoupledBinning2DAnalyzer` / `CoupledBinning3DAnalyzer`** — the joint-fit alternative. One robust angle per pooled batch via the hyperbolic-tangent density model. Best when you have many frames and don't need per-frame time resolution. +2. **`CoupledFit2DAnalyzer` / `CoupledFit3DAnalyzer`** — the joint-fit alternative. One robust angle per pooled batch via the hyperbolic-tangent density model. The per-cell density estimator is pluggable (`DensityEstimator.binning()` or `DensityEstimator.gaussian(...)`). Best when you have many frames and don't need per-frame time resolution. The documentation is available [here](https://matgenix.github.io/wetting-angle-kit), with worked examples and tutorials. @@ -85,7 +85,8 @@ conda install --strict-channel-priority -c https://conda.ovito.org -c conda-forg ```python from wetting_angle_kit.analysis import ( - CoupledBinning2DAnalyzer, + CoupledFit2DAnalyzer, + DensityEstimator, InterfaceExtractor, SurfaceFitter, TrajectoryAnalyzer, @@ -119,16 +120,20 @@ slicing = TrajectoryAnalyzer( results = slicing.analyze(range(0, 50)) print(results.mean_angle, results.std_angle) -# --- Joint coupled-binning fit (one robust angle over a pooled batch) --- -binning = CoupledBinning2DAnalyzer( +# --- Joint coupled-fit (one robust angle over a pooled batch) --- +coupled_fit = CoupledFit2DAnalyzer( parser=parser, atom_indices=oxygen_ids, droplet_geometry="spherical", binning_params={ - "xi_0": 0, "xi_f": 70.0, "nbins_xi": 50, - "zi_0": 0.0, "zi_f": 70.0, "nbins_zi": 25, + "xi_0": 0.0, "xi_f": 70.0, "bin_width_x": 2.0, + "zi_0": 0.0, "zi_f": 70.0, "bin_width_z": 2.0, }, + # Default: histogram density. Swap in `DensityEstimator.gaussian( + # density_sigma=2.5)` for a smooth Gaussian-KDE density field — + # useful on per-frame batches or sparse systems. + density_estimator=DensityEstimator.binning(), ) -results_binning = binning.analyze(range(0, 200)) -print(results_binning.mean_angle, results_binning.std_angle) +results_coupled_fit = coupled_fit.analyze(range(0, 200)) +print(results_coupled_fit.mean_angle, results_coupled_fit.std_angle) ``` diff --git a/docs/examples/binning_ca.py b/docs/examples/binning_ca.py deleted file mode 100644 index ab55eb8..0000000 --- a/docs/examples/binning_ca.py +++ /dev/null @@ -1,56 +0,0 @@ -"""Coupled-binning contact-angle example. - -Runs the joint hyperbolic-tangent fit on a 2D binned density grid via -:class:`CoupledBinning2DAnalyzer`. One angle per pooled batch — best -when you have many frames per batch. -""" - -from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer -from wetting_angle_kit.analysis.temporal import TemporalAggregator -from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder - -# --- Step 1: Define the trajectory file --- -filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" - -# --- Step 2: Identify water-oxygen atoms --- -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:", len(oxygen_indices)) - -# --- Step 3: Define the binning grid --- -binning_params = { - "xi_0": 0.0, - "xi_f": 70.0, - "bin_width_x": 2.0, - "zi_0": 0.0, - "zi_f": 70.0, - "bin_width_z": 2.0, -} - -# --- Step 4: Build the analyzer --- -analyzer = CoupledBinning2DAnalyzer( - parser=LammpsDumpParser(filename), - atom_indices=oxygen_indices, - droplet_geometry="spherical", - binning_params=binning_params, - # Pool 10 frames per batch (legacy split_factor=10 analog). - temporal_aggregator=TemporalAggregator(batch_size=10), -) - -# --- Step 5: Run analysis on a frame range --- -results = analyzer.analyze([1]) -print("Mean contact angle (°):", results.mean_angle) -print("Std across batches (°):", results.std_angle) - -# Per-batch detail: -batch = results.batches[0] -print( - f"Frames {batch.frames[0]}–{batch.frames[-1]}: " - f"angle = {batch.angle:.2f}°, " - f"R_eq = {batch.model_params['R_eq']:.2f} Å, " - f"z_wall = {batch.model_params['zi_0']:.2f} Å" -) diff --git a/docs/examples/coupled_fit_ca.py b/docs/examples/coupled_fit_ca.py new file mode 100644 index 0000000..f5c18c6 --- /dev/null +++ b/docs/examples/coupled_fit_ca.py @@ -0,0 +1,80 @@ +"""Coupled-fit contact-angle example. + +Runs the joint hyperbolic-tangent fit on a 2D density grid via +:class:`CoupledFit2DAnalyzer`. The analyzer solves interface extraction, +wall detection, and surface fit jointly — one robust angle per pooled +batch. + +Two density estimators are shown: + +- :meth:`DensityEstimator.binning` (the default) — top-hat histogram + with geometry-aware ``dV`` normalisation. Fast and exact; intrinsically + noisy at low per-cell counts. +- :meth:`DensityEstimator.gaussian` — 3D Gaussian KDE on the cell + centres. Smooth density field with no per-cell Poisson noise; the + estimator of choice when running per-frame analyses or on systems + with low atom density per cell. +""" + +from wetting_angle_kit.analysis import ( + CoupledFit2DAnalyzer, + DensityEstimator, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder + +# --- Step 1: Define the trajectory file --- +filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + +# --- Step 2: Identify water-oxygen atoms --- +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:", len(oxygen_indices)) + +# --- Step 3: Define the grid --- +binning_params = { + "xi_0": 0.0, + "xi_f": 70.0, + "bin_width_x": 2.0, + "zi_0": 0.0, + "zi_f": 70.0, + "bin_width_z": 2.0, +} + +# --- Step 4: Pick a density estimator --- +# The histogram is the default and matches the legacy numerics: +estimator = DensityEstimator.binning() +# Swap in the Gaussian KDE for smoother per-cell density. Picks the +# same ``density_sigma`` you would for ``rays_gaussian`` on the same +# system (3 Å is a sensible default for room-temperature water): +# estimator = DensityEstimator.gaussian(density_sigma=2.5) + +# --- Step 5: Build the analyzer --- +analyzer = CoupledFit2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + density_estimator=estimator, + # Pool 10 frames per batch — the joint fit benefits from + # statistics; ``batch_size=-1`` pools the entire trajectory. + temporal_aggregator=TemporalAggregator(batch_size=10), +) + +# --- Step 6: Run analysis on a frame range --- +results = analyzer.analyze([1]) +print("Mean contact angle (°):", results.mean_angle) +print("Std across batches (°):", results.std_angle) + +# Per-batch detail: +batch = results.batches[0] +print( + f"Frames {batch.frames[0]}–{batch.frames[-1]}: " + f"angle = {batch.angle:.2f}°, " + f"R_eq = {batch.model_params['R_eq']:.2f} Å, " + f"z_wall = {batch.model_params['zi_0']:.2f} Å" +) diff --git a/docs/examples/visualisation_evolution_density.py b/docs/examples/visualisation_evolution_density.py index 9804baf..f56a579 100644 --- a/docs/examples/visualisation_evolution_density.py +++ b/docs/examples/visualisation_evolution_density.py @@ -1,13 +1,17 @@ """End-to-end example: angle evolution + density contour plots. -Runs both the per-frame slicing pipeline and the coupled-binning +Runs both the per-frame slicing pipeline and the coupled-fit analyzer on the same trajectory, then renders the two trajectory-level plots: the angle evolution curve (with per-batch ±σ band and running mean) and the density contour with the fitted spherical cap overlaid. + +The coupled-fit analyzer is built with the default histogram +estimator; pass ``density_estimator=DensityEstimator.gaussian(...)`` +to render the contour over a smoothed density field instead. """ from wetting_angle_kit.analysis import ( - CoupledBinning2DAnalyzer, + CoupledFit2DAnalyzer, InterfaceExtractor, SurfaceFitter, TrajectoryAnalyzer, @@ -50,8 +54,8 @@ fig_evolution.write_html("angle_evolution.html") print("Saved angle_evolution.html") -# --- 2. Coupled-binning analyzer → density contour figure --- -binning = CoupledBinning2DAnalyzer( +# --- 2. Coupled-fit analyzer → density contour figure --- +coupled_fit = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -63,13 +67,14 @@ "zi_f": 70.0, "bin_width_z": 2.0, }, + # density_estimator=DensityEstimator.gaussian(density_sigma=2.5), temporal_aggregator=TemporalAggregator(batch_size=10), ) -binning_results = binning.analyze(range(0, 100)) +coupled_fit_results = coupled_fit.analyze(range(0, 100)) -# Pick the first batch (or pass ``binning_results`` directly to average -# the density across all batches before contouring). -bplot = DensityContourPlotter(binning_results.batches[0], label="spherical_4k") +# Pick the first batch (or pass ``coupled_fit_results`` directly to +# average the density across all batches before contouring). +bplot = DensityContourPlotter(coupled_fit_results.batches[0], label="spherical_4k") fig_density = bplot.plot() fig_density.write_html("density_contour.html") print("Saved density_contour.html") diff --git a/docs/source/API/index.rst b/docs/source/API/index.rst index 087dc5f..ae358dd 100644 --- a/docs/source/API/index.rst +++ b/docs/source/API/index.rst @@ -25,7 +25,7 @@ Top-level analyzers :members: :show-inheritance: -.. automodule:: wetting_angle_kit.analysis.coupled_binning +.. automodule:: wetting_angle_kit.analysis.coupled_fit :members: :show-inheritance: diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index f7fa33c..0a4ac96 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -39,13 +39,15 @@ substrate atoms and a bootstrap uncertainty. ---- -Coupled-Binning Contact Angle ------------------------------- +Coupled-Fit Contact Angle +-------------------------- Joint hyperbolic-tangent density-model fit via -:class:`CoupledBinning2DAnalyzer` — one angle per pooled batch. +:class:`CoupledFit2DAnalyzer` — one angle per pooled batch. The +example shows both density estimators (histogram default vs Gaussian +KDE). -.. literalinclude:: ../../examples/binning_ca.py +.. literalinclude:: ../../examples/coupled_fit_ca.py :language: python :linenos: diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index 081da91..e4d13cd 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -104,7 +104,7 @@ combinations: batches — interface from a 2D density iso-contour, wall from the actual substrate atoms. -:class:`CoupledBinning2DAnalyzer` and :class:`CoupledBinning3DAnalyzer` +:class:`CoupledFit2DAnalyzer` and :class:`CoupledFit3DAnalyzer` are the **joint-fit alternative**. They skip the extractor/wall/fitter decomposition and fit a seven-parameter (2D) or nine-parameter (3D) hyperbolic-tangent density model directly to the @@ -159,7 +159,7 @@ Examples for each plot live in the :doc:`../tutorials/index` section. ----------------------------------------- Every analyzer (:class:`TrajectoryAnalyzer`, -:class:`CoupledBinning2DAnalyzer`, :class:`CoupledBinning3DAnalyzer`) +:class:`CoupledFit2DAnalyzer`, :class:`CoupledFit3DAnalyzer`) accepts an ``n_jobs`` argument on :meth:`analyze` for worker-process parallelism, plus a ``temporal_aggregator`` constructor argument that controls how the requested frame range is partitioned into batches. diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index f4cacd4..85615ea 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -262,8 +262,8 @@ for two choices to make the dependence visible. 7. Joint coupled-binning fit ---------------------------- -The :class:`CoupledBinning2DAnalyzer` and -:class:`CoupledBinning3DAnalyzer` skip the +The :class:`CoupledFit2DAnalyzer` and +:class:`CoupledFit3DAnalyzer` skip the extractor/wall/fitter decomposition and fit a multi-parameter density model directly. diff --git a/docs/source/tutorials/binning_method_tuto.rst b/docs/source/tutorials/coupled_fit_2d_tuto.rst similarity index 71% rename from docs/source/tutorials/binning_method_tuto.rst rename to docs/source/tutorials/coupled_fit_2d_tuto.rst index ef5cbde..224ca9c 100644 --- a/docs/source/tutorials/binning_method_tuto.rst +++ b/docs/source/tutorials/coupled_fit_2d_tuto.rst @@ -1,13 +1,22 @@ -Tutorial: Contact Angle Analysis (Coupled Binning) +Tutorial: Contact Angle Analysis (Coupled Fit, 2D) =================================================== -This tutorial covers :class:`CoupledBinning2DAnalyzer`, the +This tutorial covers :class:`CoupledFit2DAnalyzer`, the joint-fit alternative to the composable -:class:`TrajectoryAnalyzer` pipeline. The binning analyzer solves -interface extraction, wall detection, and surface fit together by -fitting a seven-parameter hyperbolic-tangent density model directly to -a binned 2D density field. One robust angle per pooled batch — ideal -when you have many frames and don't need per-frame time resolution. +:class:`TrajectoryAnalyzer` pipeline. The analyzer solves interface +extraction, wall detection, and surface fit together by fitting a +seven-parameter hyperbolic-tangent density model directly to a 2D +density field on a fixed grid. One robust angle per pooled batch — +ideal when you have many frames and don't need per-frame time +resolution. + +The per-cell density is computed by a pluggable +:class:`DensityEstimator` strategy: the default +:meth:`DensityEstimator.binning` is a top-hat histogram (legacy +behaviour); :meth:`DensityEstimator.gaussian` evaluates a 3D Gaussian +KDE at the cell centres for a smooth, Poisson-noise-free density — +useful for per-frame analyses where the histogram occasionally +collapses to a degenerate fit. See §6.2 for a worked example. ---- @@ -61,7 +70,7 @@ Example trajectory:: .. code-block:: python - from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer + from wetting_angle_kit.analysis import CoupledFit2DAnalyzer from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder @@ -84,7 +93,7 @@ Example trajectory:: } # --- Step 4: Build the analyzer --- - analyzer = CoupledBinning2DAnalyzer( + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", @@ -110,11 +119,11 @@ Example trajectory:: 4. Output --------- -The returned :class:`CoupledBinning2DResults` object exposes: +The returned :class:`CoupledFit2DResults` object exposes: * ``mean_angle`` / ``std_angle`` — mean and std across batches. * ``per_batch_angles`` — array of one angle per batch. -* ``batches`` — list of :class:`CoupledBinning2DBatchResult` entries. +* ``batches`` — list of :class:`CoupledFit2DBatchResult` entries. Each batch carries ``angle``, ``model_params`` (the seven tanh parameters), and ``xi_grid`` / ``zi_grid`` / ``density`` — the binned density field used for the fit. Feed any batch (or the @@ -155,9 +164,9 @@ Example printed output:: explicitly if you see the fit's ``rho1`` or ``rho2`` pegged at the zero bound (the analyzer warns when this happens). - **3D extension**: if you suspect significant deviation from - axisymmetry, swap in :class:`CoupledBinning3DAnalyzer`. Spherical + axisymmetry, swap in :class:`CoupledFit3DAnalyzer`. Spherical droplets only; same API plus extra ``yi_*`` bin keys. The full - tutorial is :doc:`coupled_binning_3d_tuto`. + tutorial is :doc:`coupled_fit_3d_tuto`. For a side-by-side density contour plot with the fitted cap overlaid, see :doc:`visualization_evolution_density`. @@ -181,7 +190,7 @@ along the cylinder axis): .. code-block:: python - analyzer = CoupledBinning2DAnalyzer( + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(cylinder_fixture), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", @@ -199,7 +208,45 @@ The seven-parameter model and the cap-angle formula are identical to the spherical case; the geometry change is fully absorbed into the projection and the volume normalisation. -6.2 Custom initial parameters +6.2 Gaussian KDE density (smoother than the histogram) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +The default per-cell density is a top-hat histogram with +geometry-aware ``dV`` normalisation — fast, exact, and intrinsically +noisy at small per-cell atom counts. The seven-parameter tanh fit +will fail or converge to a degenerate minimum on per-frame batches +where the histogram density has too many empty cells. + +Pass a :meth:`DensityEstimator.gaussian` instance to switch the +per-cell density to a 3D Gaussian KDE evaluated at the grid cell +centres — the same kernel ``rays_gaussian`` and ``grid_gaussian`` +use. The density field becomes smooth; per-cell Poisson noise +disappears at the cost of a small constant per-fit overhead: + +.. code-block:: python + + from wetting_angle_kit.analysis import CoupledFit2DAnalyzer, DensityEstimator + + analyzer = CoupledFit2DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + binning_params=binning_params, + density_estimator=DensityEstimator.gaussian(density_sigma=2.5), + # batch_size=1 now becomes viable — the KDE density is smooth + # enough that per-frame fits don't fall into the degenerate + # ``t1`` minimum the histogram occasionally produces. + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + +Pick the same ``density_sigma`` you would for ``rays_gaussian`` on +the same system (3 Å is the default; smaller for finer features, +larger for sparser systems). The recovered angle differs from the +binning variant by at most ~1° on well-pooled batches, but the +Gaussian variant is far more robust on small batches and on +systems with low atom density per cell. + +6.3 Custom initial parameters ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The default initial guess @@ -211,7 +258,7 @@ If your simulation uses different units or a different liquid, pass .. code-block:: python - analyzer = CoupledBinning2DAnalyzer( + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -224,7 +271,7 @@ pinned at the physical lower bound (densities at 0, lengths at :math:`10^{-6}`) — that's the usual sign your initial guess is far from the true minimum or your grid bounds are wrong. -6.3 Single fully-pooled batch +6.4 Single fully-pooled batch ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Drop the ``temporal_aggregator`` argument (or set @@ -232,7 +279,7 @@ Drop the ``temporal_aggregator`` argument (or set .. code-block:: python - results = CoupledBinning2DAnalyzer( + results = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", diff --git a/docs/source/tutorials/coupled_binning_3d_tuto.rst b/docs/source/tutorials/coupled_fit_3d_tuto.rst similarity index 92% rename from docs/source/tutorials/coupled_binning_3d_tuto.rst rename to docs/source/tutorials/coupled_fit_3d_tuto.rst index 80ae119..549c9c2 100644 --- a/docs/source/tutorials/coupled_binning_3d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_3d_tuto.rst @@ -1,9 +1,9 @@ -Tutorial: 3D Coupled-Binning Analyzer -====================================== +Tutorial: 3D Coupled-Fit Analyzer +================================== -:class:`CoupledBinning3DAnalyzer` is the 3D extension of -:class:`CoupledBinning2DAnalyzer`. Instead of projecting atoms to a -2D ``(xi, zi)`` plane and exploiting radial symmetry, it bins the +:class:`CoupledFit3DAnalyzer` is the 3D extension of +:class:`CoupledFit2DAnalyzer`. Instead of projecting atoms to a +2D ``(xi, zi)`` plane and exploiting radial symmetry, it builds the full 3D density ``rho(xi, yi, zi)`` and fits a nine-parameter hyperbolic-tangent density model directly: @@ -58,7 +58,7 @@ wasting work. .. code-block:: python - from wetting_angle_kit.analysis import CoupledBinning3DAnalyzer + from wetting_angle_kit.analysis import CoupledFit3DAnalyzer from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder @@ -81,7 +81,7 @@ wasting work. "bin_width_z": 4.0, } - analyzer = CoupledBinning3DAnalyzer( + analyzer = CoupledFit3DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -164,8 +164,8 @@ the same angle within a few degrees. It's a useful sanity check: .. code-block:: python from wetting_angle_kit.analysis import ( - CoupledBinning2DAnalyzer, - CoupledBinning3DAnalyzer, + CoupledFit2DAnalyzer, + CoupledFit3DAnalyzer, ) # Same trajectory, same frames; pick comparable grids. @@ -190,7 +190,7 @@ the same angle within a few degrees. It's a useful sanity check: } a2d = ( - CoupledBinning2DAnalyzer( + CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -200,7 +200,7 @@ the same angle within a few degrees. It's a useful sanity check: .batches[0] ) a3d = ( - CoupledBinning3DAnalyzer( + CoupledFit3DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -233,6 +233,6 @@ On the test fixture this gives 94.46° (2D) vs 95.42° (3D), drift yi_c = 0``. Override via ``initial_params`` if you have a strong prior on the cap geometry. - **No cylinder support**: pass ``droplet_geometry="cylinder_y"`` - to ``CoupledBinning3DAnalyzer`` and you'll get a ``ValueError`` + to ``CoupledFit3DAnalyzer`` and you'll get a ``ValueError`` at construction explaining the design choice; route cylindrical - droplets through :class:`CoupledBinning2DAnalyzer` instead. + droplets through :class:`CoupledFit2DAnalyzer` instead. diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 09f57b0..3074ea7 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -24,8 +24,8 @@ math. :maxdepth: 1 :caption: Joint-fit analyzers: - binning_method_tuto - coupled_binning_3d_tuto + coupled_fit_2d_tuto + coupled_fit_3d_tuto .. toctree:: :maxdepth: 1 diff --git a/docs/source/tutorials/visualization_evolution_density.rst b/docs/source/tutorials/visualization_evolution_density.rst index 0922220..f20af85 100644 --- a/docs/source/tutorials/visualization_evolution_density.rst +++ b/docs/source/tutorials/visualization_evolution_density.rst @@ -69,7 +69,7 @@ The figure has up to four traces per trajectory: * the cumulative running mean (dashed), * the cumulative ``±σ`` band of the running mean (filled). -Coupled-binning result objects don't carry ``angle_std`` per batch, so +Coupled-fit result objects don't carry ``angle_std`` per batch, so the per-batch band is omitted; the running mean band is always available. @@ -77,21 +77,24 @@ The plotter also implements :class:`BaseTrajectoryPlotter` so ``plotter.summary()`` returns a list of :class:`TrajectoryStats` with the mean angle, std, sample count, and a per-method surface area (shoelace polygon area for slicing batches; spherical-cap segment -area for whole / coupled-binning batches). +area for whole / coupled-fit batches). ---- 2. Density contour plot ----------------------- -For a coupled-binning analysis, :class:`DensityContourPlotter` draws -the 2D density grid with the fitted spherical cap arc (dashed) and -wall line (dotted) overlaid. Pass either a single batch result or a -full results object: +For a coupled-fit analysis, :class:`DensityContourPlotter` draws the +2D density grid with the fitted spherical cap arc (dashed) and wall +line (dotted) overlaid. Pass either a single batch result or a full +results object. The example below uses the default histogram +estimator; passing ``density_estimator=DensityEstimator.gaussian(...)`` +on the analyzer constructor renders the contour over a smoothed +density field without touching anything else here: .. code-block:: python - from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer + from wetting_angle_kit.analysis import CoupledFit2DAnalyzer from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder from wetting_angle_kit.visualization import DensityContourPlotter @@ -101,7 +104,7 @@ full results object: filename, oxygen_type=1, hydrogen_type=2 ).get_water_oxygen_ids(frame_index=0) - binning = CoupledBinning2DAnalyzer( + coupled_fit = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -115,7 +118,7 @@ full results object: }, temporal_aggregator=TemporalAggregator(batch_size=10), ) - results = binning.analyze(range(0, 100)) + results = coupled_fit.analyze(range(0, 100)) # One batch: DensityContourPlotter(results.batches[0], label="spherical_4k").plot().show() @@ -125,8 +128,8 @@ full results object: 3D-results inputs are azimuthally averaged onto the same ``(r, z)`` plane before contouring, so the same plotter works for -:class:`CoupledBinning3DResults` and -:class:`CoupledBinning3DBatchResult`. The default title indicates the +:class:`CoupledFit3DResults` and +:class:`CoupledFit3DBatchResult`. The default title indicates the azimuthal collapse so plots are unambiguous. ---- diff --git a/src/wetting_angle_kit/analysis/__init__.py b/src/wetting_angle_kit/analysis/__init__.py index cf6b476..d08b809 100644 --- a/src/wetting_angle_kit/analysis/__init__.py +++ b/src/wetting_angle_kit/analysis/__init__.py @@ -6,8 +6,8 @@ Top-level analyzers (call ``analyze()`` to run a study):: TrajectoryAnalyzer # decomposed pipeline: extractor → wall → fitter - CoupledBinning2DAnalyzer # joint fit on a 2D density grid - CoupledBinning3DAnalyzer # joint fit on a 3D density grid (spherical only) + CoupledFit2DAnalyzer # joint fit on a 2D density grid + CoupledFit3DAnalyzer # joint fit on a 3D density grid (spherical only) Strategy components (compose into :class:`TrajectoryAnalyzer`):: @@ -17,21 +17,26 @@ SurfaceFitter # slicing / whole WallDetector # min_plus_offset / explicit / from_atoms +Strategy components (compose into the coupled-fit analyzers):: + + DensityEstimator # binning / gaussian + Results dataclasses returned by ``analyze()``:: TrajectoryResults, BatchResult, SlicingBatchResult, WholeBatchResult - CoupledBinning2DResults, CoupledBinning2DBatchResult - CoupledBinning3DResults, CoupledBinning3DBatchResult + CoupledFit2DResults, CoupledFit2DBatchResult + CoupledFit3DResults, CoupledFit3DBatchResult """ from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer # Top-level analyzers. -from wetting_angle_kit.analysis.coupled_binning.analyzer_2d import ( - CoupledBinning2DAnalyzer, +from wetting_angle_kit.analysis.coupled_fit import DensityEstimator +from wetting_angle_kit.analysis.coupled_fit.analyzer_2d import ( + CoupledFit2DAnalyzer, ) -from wetting_angle_kit.analysis.coupled_binning.analyzer_3d import ( - CoupledBinning3DAnalyzer, +from wetting_angle_kit.analysis.coupled_fit.analyzer_3d import ( + CoupledFit3DAnalyzer, ) # Strategy components. @@ -47,10 +52,10 @@ # Results dataclasses. from wetting_angle_kit.analysis.results import ( BatchResult, - CoupledBinning2DBatchResult, - CoupledBinning2DResults, - CoupledBinning3DBatchResult, - CoupledBinning3DResults, + CoupledFit2DBatchResult, + CoupledFit2DResults, + CoupledFit3DBatchResult, + CoupledFit3DResults, SlicingBatchResult, TrajectoryResults, WholeBatchResult, @@ -63,9 +68,10 @@ # Top-level analyzers. "BaseTrajectoryAnalyzer", "TrajectoryAnalyzer", - "CoupledBinning2DAnalyzer", - "CoupledBinning3DAnalyzer", + "CoupledFit2DAnalyzer", + "CoupledFit3DAnalyzer", # Strategy components. + "DensityEstimator", "DropletGeometry", "TemporalAggregator", "InterfaceExtractor", @@ -79,8 +85,8 @@ "SlicingBatchResult", "WholeBatchResult", "TrajectoryResults", - "CoupledBinning2DBatchResult", - "CoupledBinning2DResults", - "CoupledBinning3DBatchResult", - "CoupledBinning3DResults", + "CoupledFit2DBatchResult", + "CoupledFit2DResults", + "CoupledFit3DBatchResult", + "CoupledFit3DResults", ] diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index fb02a95..fc42bb2 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -2,7 +2,7 @@ :class:`_BatchedTrajectoryAnalyzer` is the private base from which both :class:`TrajectoryAnalyzer` and the two coupled-fit analyzers -(:class:`CoupledBinning2DAnalyzer`, :class:`CoupledBinning3DAnalyzer`) +(:class:`CoupledFit2DAnalyzer`, :class:`CoupledFit3DAnalyzer`) inherit. It centralises: - the common constructor (parser + atom_indices + droplet_geometry + @@ -188,7 +188,7 @@ class _BatchedTrajectoryAnalyzer(BaseTrajectoryAnalyzer): """Shared scaffolding for batched trajectory analyzers. Not user-facing. Concrete analyzers (:class:`TrajectoryAnalyzer`, - :class:`CoupledBinning2DAnalyzer`, :class:`CoupledBinning3DAnalyzer`) + :class:`CoupledFit2DAnalyzer`, :class:`CoupledFit3DAnalyzer`) inherit from this and provide the four extension points listed in the module docstring. """ @@ -413,7 +413,7 @@ def _process_batch_worker(frame_indices: list[int]) -> Any: Reads from the subclass's ``_WORKER_STATE`` populated by :meth:`_init_worker`. Returns the per-batch result (a :class:`BatchResult` subclass for :class:`TrajectoryAnalyzer`, - a :class:`CoupledBinning2DBatchResult` for the 2D coupled fit, + a :class:`CoupledFit2DBatchResult` for the 2D coupled fit, etc.) — or ``None`` on a per-batch failure. The parent process logs and skips ``None`` results, raising only if every batch fails. diff --git a/src/wetting_angle_kit/analysis/coupled_binning/__init__.py b/src/wetting_angle_kit/analysis/coupled_binning/__init__.py deleted file mode 100644 index 119d5f7..0000000 --- a/src/wetting_angle_kit/analysis/coupled_binning/__init__.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Coupled-binning joint contact-angle analyzers. - -Two top-level analyzers that solve interface extraction, wall -detection, and surface fit jointly via a hyperbolic-tangent density -model: - -- :class:`CoupledBinning2DAnalyzer` — seven-parameter fit on a 2D - ``(xi, zi)`` density grid (radial symmetry assumption). -- :class:`CoupledBinning3DAnalyzer` — nine-parameter fit on a full 3D - ``(xi, yi, zi)`` density grid (no symmetry assumption; spherical - droplets only — cylinder droplets are rejected at construction). - -Use these when you have many frames per batch and want a single robust -estimate; use :class:`TrajectoryAnalyzer` with separable strategies -for per-frame time resolution. -""" - -from wetting_angle_kit.analysis.coupled_binning.analyzer_2d import ( - CoupledBinning2DAnalyzer, -) -from wetting_angle_kit.analysis.coupled_binning.analyzer_3d import ( - CoupledBinning3DAnalyzer, -) - -__all__ = [ - "CoupledBinning2DAnalyzer", - "CoupledBinning3DAnalyzer", -] diff --git a/src/wetting_angle_kit/analysis/coupled_fit/__init__.py b/src/wetting_angle_kit/analysis/coupled_fit/__init__.py new file mode 100644 index 0000000..bb9b0c5 --- /dev/null +++ b/src/wetting_angle_kit/analysis/coupled_fit/__init__.py @@ -0,0 +1,40 @@ +"""Coupled-fit joint contact-angle analyzers. + +Two top-level analyzers that solve interface extraction, wall +detection, and surface fit jointly via a hyperbolic-tangent density +model: + +- :class:`CoupledFit2DAnalyzer` — seven-parameter fit on a 2D + ``(xi, zi)`` density grid (radial symmetry assumption). +- :class:`CoupledFit3DAnalyzer` — nine-parameter fit on a full 3D + ``(xi, yi, zi)`` density grid (no symmetry assumption; spherical + droplets only — cylinder droplets are rejected at construction). + +Both analyzers accept a :class:`DensityEstimator` strategy that +controls how the per-cell density is computed from pooled atom +positions: :meth:`DensityEstimator.binning` (top-hat histogram, the +default) or :meth:`DensityEstimator.gaussian` (3D Gaussian KDE on +the cell centres). Switching to the Gaussian variant trades a small +constant cost per fit for a smooth density field with no per-cell +Poisson noise. + +Use these analyzers when you have many frames per batch and want a +single robust estimate; use :class:`TrajectoryAnalyzer` with +separable strategies for per-frame time resolution. +""" + +from wetting_angle_kit.analysis.coupled_fit._density_estimator import ( + DensityEstimator, +) +from wetting_angle_kit.analysis.coupled_fit.analyzer_2d import ( + CoupledFit2DAnalyzer, +) +from wetting_angle_kit.analysis.coupled_fit.analyzer_3d import ( + CoupledFit3DAnalyzer, +) + +__all__ = [ + "CoupledFit2DAnalyzer", + "CoupledFit3DAnalyzer", + "DensityEstimator", +] diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py b/src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py new file mode 100644 index 0000000..7e942a0 --- /dev/null +++ b/src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py @@ -0,0 +1,277 @@ +"""Density-estimator strategies for the coupled-fit analyzers. + +The coupled-fit analyzers +(:class:`CoupledFit2DAnalyzer`, :class:`CoupledFit3DAnalyzer`) build a +cell-centred density field on a fixed grid and fit a hyperbolic-tangent +model to it jointly. The estimator strategy controls how the per-cell +density is computed from the pooled atom positions: + +- :meth:`DensityEstimator.binning` — top-hat histogram with + geometry-aware volume normalisation. The historical method + (`legacy: BinningBatchFitter.binning`). +- :meth:`DensityEstimator.gaussian` — 3D Gaussian KDE evaluated at the + cell centres. Same density estimator the ``rays_gaussian`` and + ``grid_gaussian`` extractors use, so the joint tanh fit sees a + smooth density field with no per-cell Poisson noise. + +The input to the estimator is pooled atom positions in a standard +*droplet-centred internal frame*: ``(x, y)`` are recentered on the +batch-averaged droplet COM (with PBC unwrapping) and ``z`` stays in +the lab frame. That convention lets the estimator pick the right +projection for each ``DropletGeometry`` without needing a separate +pre-projection step. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np + +from wetting_angle_kit.analysis._density import GaussianDensityField +from wetting_angle_kit.analysis.geometry import DropletGeometry + + +@dataclass(frozen=True) +class DensityEstimator(ABC): + """Strategy interface for the coupled-fit per-cell density. + + Concrete instances come from one of the classmethod factories + :meth:`binning` or :meth:`gaussian`; the abstract ``evaluate_*`` + methods are dispatched by the analyzer worker after pooling + droplet-centred atom positions across the batch. + """ + + #: Human-readable kind tag (used in tqdm labels). Set by each + #: concrete subclass. + kind: ClassVar[str] + + @abstractmethod + def evaluate_2d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + zi_edges: np.ndarray, + box_dimension: float | None, + ) -> np.ndarray: + """2D density on the ``(xi_cc, zi_cc)`` grid. + + Returns a ``(n_xi, n_zi)`` array in atoms/ų, averaged across + the ``n_frames`` pooled into the batch. + + Parameters + ---------- + atoms_pooled : ndarray, shape (N, 3) + Pooled atom positions in the droplet-centred internal + frame (``x``/``y`` recentered on COM; ``z`` in lab frame). + n_frames : int + Number of frames pooled. Used to divide the cumulative + density by frame count. + droplet_geometry : DropletGeometry + ``spherical`` projects atoms to radial ``xi = hypot(x, y)``; + ``cylinder_*`` uses ``xi = |x|`` and folds across the + cylinder axis. + xi_edges, zi_edges : ndarray + 1D cell-edge arrays. + box_dimension : float, optional + Cylinder axis length, only needed for the binning + estimator's geometry-aware ``dV`` factor on + ``cylinder_*`` droplets. + """ + + @abstractmethod + def evaluate_3d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + yi_edges: np.ndarray, + zi_edges: np.ndarray, + ) -> np.ndarray: + """3D density on the ``(xi_cc, yi_cc, zi_cc)`` grid. + + Returns a ``(n_xi, n_yi, n_zi)`` array in atoms/ų, averaged + across the ``n_frames`` pooled into the batch. + + Only ``spherical`` is currently exercised — the 3D coupled-fit + analyzer rejects cylinder droplets at construction. + """ + + # ------------------------------------------------------------------ + # Factories. + # ------------------------------------------------------------------ + + @classmethod + def binning(cls) -> DensityEstimator: + """Top-hat histogram density with geometry-aware ``dV``. + + Atoms in each cell contribute ``1 / dV / n_frames`` to the + density. ``dV`` is the cell's annular volume for + ``spherical``, ``2 · box_dimension · dxi · dzi`` for + ``cylinder_*`` (the binning's |x| folding combined with the + box-length integral along the cylinder axis), and the plain + ``dxi · dyi · dzi`` in 3D. + + This is the legacy estimator; the coupled-fit analyzers + default to it for backwards-compatible numerics. + """ + return _BinningDensityEstimator() + + @classmethod + def gaussian( + cls, + *, + density_sigma: float = 3.0, + cutoff_sigma: float = 5.0, + ) -> DensityEstimator: + """3D Gaussian KDE evaluated at the cell centres. + + Same density estimator as ``rays_gaussian`` and + ``grid_gaussian``. For ``spherical`` droplets the per-cell + evaluation point is ``(xi_cc, 0, zi_cc)``: by axisymmetry the + single annular point is a representative for the whole + annulus, so no annular integration is needed. + + Parameters + ---------- + density_sigma : float, default 3.0 + Gaussian kernel width (Å). + cutoff_sigma : float, default 5.0 + Per-atom kernel truncation in units of ``density_sigma``. + Larger values are slower but more accurate in the + kernel's tails. + """ + return _GaussianDensityEstimator( + density_sigma=density_sigma, cutoff_sigma=cutoff_sigma + ) + + +@dataclass(frozen=True) +class _BinningDensityEstimator(DensityEstimator): + """Concrete estimator for :meth:`DensityEstimator.binning`.""" + + kind: ClassVar[str] = "binning" + + def evaluate_2d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + zi_edges: np.ndarray, + box_dimension: float | None, + ) -> np.ndarray: + if droplet_geometry.is_spherical: + xi_vals = np.hypot(atoms_pooled[:, 0], atoms_pooled[:, 1]) + else: + xi_vals = np.abs(atoms_pooled[:, 0]) + zi_vals = atoms_pooled[:, 2] + counts, _, _ = np.histogram2d(xi_vals, zi_vals, bins=(xi_edges, zi_edges)) + dxi = float(xi_edges[1] - xi_edges[0]) + dzi = float(zi_edges[1] - zi_edges[0]) + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + if droplet_geometry.is_cylinder: + assert box_dimension is not None + dV = 2.0 * box_dimension * dxi * dzi + rho_cc = counts / dV + else: + dV_per_row = 2.0 * np.pi * xi_cc * dxi * dzi + rho_cc = counts / dV_per_row[:, np.newaxis] + if n_frames > 0: + rho_cc = rho_cc / n_frames + return rho_cc + + def evaluate_3d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + yi_edges: np.ndarray, + zi_edges: np.ndarray, + ) -> np.ndarray: + counts, _ = np.histogramdd(atoms_pooled, bins=(xi_edges, yi_edges, zi_edges)) + dxi = float(xi_edges[1] - xi_edges[0]) + dyi = float(yi_edges[1] - yi_edges[0]) + dzi = float(zi_edges[1] - zi_edges[0]) + rho = counts / (dxi * dyi * dzi) + if n_frames > 0: + rho = rho / n_frames + return rho + + +@dataclass(frozen=True) +class _GaussianDensityEstimator(DensityEstimator): + """Concrete estimator for :meth:`DensityEstimator.gaussian`.""" + + kind: ClassVar[str] = "gaussian" + + density_sigma: float + cutoff_sigma: float + + def _build_field(self, atoms_pooled: np.ndarray) -> GaussianDensityField: + return GaussianDensityField( + atom_coords=atoms_pooled, + density_sigma=self.density_sigma, + cutoff_sigma=self.cutoff_sigma, + ) + + def evaluate_2d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + zi_edges: np.ndarray, + box_dimension: float | None, + ) -> np.ndarray: + field = self._build_field(atoms_pooled) + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) + xi_mesh, zi_mesh = np.meshgrid(xi_cc, zi_cc, indexing="ij") + # Evaluation plane: y=0 for both geometries. + # - spherical: by axisymmetry the (xi, 0, zi) point is a + # representative for the whole annulus at radius xi. + # - cylinder: atoms are droplet-centred in y, so y=0 is the + # cylinder midpoint. Translational invariance along y means + # any y cross-section gives the same density. + positions = np.column_stack( + [xi_mesh.ravel(), np.zeros(xi_mesh.size), zi_mesh.ravel()] + ) + rho_flat = field.evaluate(positions) + rho_cc = rho_flat.reshape(xi_mesh.shape) + if n_frames > 0: + rho_cc = rho_cc / n_frames + return rho_cc + + def evaluate_3d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + yi_edges: np.ndarray, + zi_edges: np.ndarray, + ) -> np.ndarray: + field = self._build_field(atoms_pooled) + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + yi_cc = 0.5 * (yi_edges[:-1] + yi_edges[1:]) + zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) + xi_mesh, yi_mesh, zi_mesh = np.meshgrid(xi_cc, yi_cc, zi_cc, indexing="ij") + positions = np.column_stack([xi_mesh.ravel(), yi_mesh.ravel(), zi_mesh.ravel()]) + rho_flat = field.evaluate(positions) + rho = rho_flat.reshape(xi_mesh.shape) + if n_frames > 0: + rho = rho / n_frames + return rho diff --git a/src/wetting_angle_kit/analysis/coupled_binning/_models.py b/src/wetting_angle_kit/analysis/coupled_fit/_models.py similarity index 99% rename from src/wetting_angle_kit/analysis/coupled_binning/_models.py rename to src/wetting_angle_kit/analysis/coupled_fit/_models.py index 391fd6c..63f7bb5 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning/_models.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/_models.py @@ -3,7 +3,7 @@ Both the 2D (seven-parameter) and 3D (nine-parameter) joint density models are kept in this module so the shared bounds / warning / cap-angle formula sit side by side. Public access goes through -:class:`CoupledBinning2DAnalyzer` and :class:`CoupledBinning3DAnalyzer`. +:class:`CoupledFit2DAnalyzer` and :class:`CoupledFit3DAnalyzer`. """ import warnings @@ -47,7 +47,7 @@ class _HyperbolicTangentModel2D: r = sqrt(xi^2 + (zi - zi_c)^2). Seven free parameters fitted by bounded NLLS. Private (the public - entry point is :class:`CoupledBinning2DAnalyzer`); the 3D + entry point is :class:`CoupledFit2DAnalyzer`); the 3D counterpart :class:`_HyperbolicTangentModel3D` lives in the same module. """ diff --git a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py similarity index 73% rename from src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py rename to src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py index 10784ed..298247e 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py @@ -1,6 +1,6 @@ """Coupled 2D-binning joint contact-angle analyzer. -:class:`CoupledBinning2DAnalyzer` is the modern incarnation of the +:class:`CoupledFit2DAnalyzer` is the modern incarnation of the package's original binning method. Unlike :class:`TrajectoryAnalyzer` it does not separate interface extraction, wall detection, and surface fit — a seven-parameter hyperbolic-tangent model (rho1, rho2, R_eq, @@ -19,7 +19,7 @@ For per-frame analysis with separable strategies use :class:`TrajectoryAnalyzer` instead. For the 3D extension of this analyzer (relaxing the radial symmetry assumption) see -:class:`CoupledBinning3DAnalyzer`. +:class:`CoupledFit3DAnalyzer`. """ import logging @@ -31,7 +31,10 @@ _BatchedTrajectoryAnalyzer, build_parser, ) -from wetting_angle_kit.analysis.coupled_binning._models import ( +from wetting_angle_kit.analysis.coupled_fit._density_estimator import ( + DensityEstimator, +) +from wetting_angle_kit.analysis.coupled_fit._models import ( _PARAM_NAMES, _default_binning_params, _HyperbolicTangentModel2D, @@ -39,16 +42,16 @@ ) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( - CoupledBinning2DBatchResult, - CoupledBinning2DResults, + CoupledFit2DBatchResult, + CoupledFit2DResults, ) from wetting_angle_kit.analysis.temporal import TemporalAggregator -from wetting_angle_kit.io_utils import project_to_profile +from wetting_angle_kit.io_utils import recenter_droplet_pbc logger = logging.getLogger(__name__) -class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): +class CoupledFit2DAnalyzer(_BatchedTrajectoryAnalyzer): """Joint contact-angle fit on a 2D binned density grid. Parameters @@ -74,6 +77,16 @@ class CoupledBinning2DAnalyzer(_BatchedTrajectoryAnalyzer): half the largest in-plane box dimension, with ``bin_width = 0.5 Å`` (half the model's default interface thickness ``t1``). A warning is emitted when the default is used. + density_estimator : DensityEstimator, optional + How the per-cell density is computed from the pooled atom + positions. Built via :meth:`DensityEstimator.binning` (the + default, top-hat histogram with geometry-aware ``dV`` + normalisation) or :meth:`DensityEstimator.gaussian` + (3D Gaussian KDE evaluated at the cell centres; same kernel + the ``rays_gaussian`` / ``grid_gaussian`` extractors use). + Switching to the Gaussian variant smooths out per-cell + Poisson noise — useful on per-frame / small-batch analyses + where the histogram density is degenerate. initial_params : list[float], optional Initial guess for the seven tanh-model parameters ``[rho1, rho2, R_eq, zi_c, zi_0, t1, t2]``. Defaults to the @@ -101,6 +114,7 @@ def __init__( droplet_geometry: DropletGeometry | str = "spherical", *, binning_params: dict[str, Any] | None = None, + density_estimator: DensityEstimator | None = None, initial_params: list[float] | None = None, temporal_aggregator: TemporalAggregator | None = None, precentered: bool = False, @@ -116,6 +130,7 @@ def __init__( if binning_params is None: binning_params = _default_binning_params(parser) self.binning_params = binning_params + self.density_estimator = density_estimator or DensityEstimator.binning() self.initial_params = initial_params # Cylinder dV normalisation needs the box length along the # cylinder axis; read it once at construction (per legacy). @@ -133,7 +148,10 @@ def __init__( # ------------------------------------------------------------------ def _tqdm_desc(self) -> str: - return f"CoupledBinning2DAnalyzer ({self.droplet_geometry.name})" + return ( + f"CoupledFit2DAnalyzer " + f"({self.droplet_geometry.name} / {self.density_estimator.kind})" + ) def _init_args(self) -> tuple: return ( @@ -141,6 +159,7 @@ def _init_args(self) -> tuple: self.atom_indices, self.droplet_geometry, self.binning_params, + self.density_estimator, self.initial_params, self.precentered, self.box_dimension, @@ -152,17 +171,19 @@ def _init_worker( atom_indices: np.ndarray, droplet_geometry: DropletGeometry, binning_params: dict[str, Any], + density_estimator: DensityEstimator, initial_params: list[float] | None, precentered: bool, box_dimension: float | None, ) -> None: - cls = CoupledBinning2DAnalyzer + cls = CoupledFit2DAnalyzer cls._WORKER_STATE.clear() cls._WORKER_STATE.update( parser=build_parser(filename), atom_indices=atom_indices, droplet_geometry=droplet_geometry, binning_params=binning_params, + density_estimator=density_estimator, initial_params=initial_params, precentered=precentered, box_dimension=box_dimension, @@ -171,12 +192,13 @@ def _init_worker( @staticmethod def _process_batch_worker( frame_indices: list[int], - ) -> CoupledBinning2DBatchResult | None: - state = CoupledBinning2DAnalyzer._WORKER_STATE + ) -> CoupledFit2DBatchResult | None: + state = CoupledFit2DAnalyzer._WORKER_STATE parser = state["parser"] atom_indices: np.ndarray = state["atom_indices"] droplet_geometry: DropletGeometry = state["droplet_geometry"] binning_params: dict[str, Any] = state["binning_params"] + density_estimator: DensityEstimator = state["density_estimator"] initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] box_dimension: float | None = state["box_dimension"] @@ -184,34 +206,38 @@ def _process_batch_worker( # :meth:`_BatchedTrajectoryAnalyzer._run_inline`. progress_callback = state.get("progress_callback") try: - # Per-frame ``(xi, zi)`` projection, matching the legacy - # ``BinningBatchFitter.get_profile_coordinates`` so the - # joint fit sees the same projected coordinates. - r_chunks: list[np.ndarray] = [] - z_chunks: list[np.ndarray] = [] + # Per-frame PBC recentering + droplet-centring in (x, y). + # ``z`` stays in the lab frame so wall position retains + # physical meaning. The pooled 3D positions are then + # handed to the density estimator strategy, which picks + # its own projection (radial for spherical, |x| for + # cylinder) and density rule (histogram vs Gaussian KDE). + coord_chunks: list[np.ndarray] = [] for frame_idx in frame_indices: positions = parser.parse(frame_index=frame_idx, indices=atom_indices) - box_size: tuple[float, float] | None = None - if not precentered: - box_size = ( + if precentered: + com = np.mean(positions, axis=0) + else: + box_xy = ( parser.box_size_x(frame_index=frame_idx), parser.box_size_y(frame_index=frame_idx), ) - r_frame, z_frame = project_to_profile( - positions, droplet_geometry.name, box_size=box_size - ) - r_chunks.append(r_frame) - z_chunks.append(z_frame) + positions, com = recenter_droplet_pbc( + positions, droplet_geometry.name, box_size=box_xy + ) + positions = droplet_geometry.to_internal_coords(positions) + com = droplet_geometry.to_internal_coords(com) + positions_centered = positions - np.array([com[0], com[1], 0.0]) + coord_chunks.append(positions_centered) if progress_callback is not None: progress_callback(1) - r_values = np.concatenate(r_chunks) if r_chunks else np.empty(0) - z_values = np.concatenate(z_chunks) if z_chunks else np.empty(0) + atoms_pooled = ( + np.concatenate(coord_chunks, axis=0) + if coord_chunks + else np.empty((0, 3)) + ) n_frames = len(frame_indices) - # Build the 2D density grid + apply geometry-aware dV - # normalisation. Cell width comes from binning_params; - # the range bounds are honoured exactly and the effective - # cell width may differ by a few percent. xi_edges = edges_from_bin_width( binning_params["xi_0"], binning_params["xi_f"], @@ -222,20 +248,16 @@ def _process_batch_worker( binning_params["zi_f"], binning_params["bin_width_z"], ) - counts, _, _ = np.histogram2d(r_values, z_values, bins=(xi_edges, zi_edges)) - dxi = float(xi_edges[1] - xi_edges[0]) - dzi = float(zi_edges[1] - zi_edges[0]) xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) - if droplet_geometry.is_cylinder: - assert box_dimension is not None - dV = 2.0 * box_dimension * dxi * dzi - rho_cc = counts / dV - else: - dV_per_row = 2.0 * np.pi * xi_cc * dxi * dzi - rho_cc = counts / dV_per_row[:, np.newaxis] - if n_frames > 0: - rho_cc /= n_frames + rho_cc = density_estimator.evaluate_2d( + atoms_pooled=atoms_pooled, + n_frames=n_frames, + droplet_geometry=droplet_geometry, + xi_edges=xi_edges, + zi_edges=zi_edges, + box_dimension=box_dimension, + ) # Joint tanh fit. ``_HyperbolicTangentModel2D`` expects the # density and grid axes flattened in Fortran order — same @@ -252,13 +274,13 @@ def _process_batch_worker( if params is None: raise RuntimeError( "_HyperbolicTangentModel2D did not set model parameters; " - "cannot build CoupledBinning2DBatchResult." + "cannot build CoupledFit2DBatchResult." ) model_params = { name: float(value) for name, value in zip(_PARAM_NAMES, params, strict=False) } - return CoupledBinning2DBatchResult( + return CoupledFit2DBatchResult( frames=list(frame_indices), angle=angle, model_params=model_params, @@ -271,9 +293,9 @@ def _process_batch_worker( return None def _build_results( - self, batches: list[CoupledBinning2DBatchResult] - ) -> CoupledBinning2DResults: - return CoupledBinning2DResults( + self, batches: list[CoupledFit2DBatchResult] + ) -> CoupledFit2DResults: + return CoupledFit2DResults( batches=batches, method_metadata={ "droplet_geometry": self.droplet_geometry.name, diff --git a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py similarity index 85% rename from src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py rename to src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py index cd9f092..4076cec 100644 --- a/src/wetting_angle_kit/analysis/coupled_binning/analyzer_3d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py @@ -1,7 +1,7 @@ """Coupled 3D-binning joint contact-angle analyzer. -:class:`CoupledBinning3DAnalyzer` is the 3D extension of the joint -binning fit (:class:`CoupledBinning2DAnalyzer`). Instead of projecting +:class:`CoupledFit3DAnalyzer` is the 3D extension of the joint +binning fit (:class:`CoupledFit2DAnalyzer`). Instead of projecting atoms onto a 2D ``(xi, zi)`` plane and exploiting radial symmetry, it bins the full 3D density ``rho(xi, yi, zi)`` and fits a nine-parameter hyperbolic-tangent model (``rho1, rho2, R_eq, xi_c, yi_c, zi_c, zi_0, @@ -17,7 +17,7 @@ Cylindrical droplets are rejected at construction: their translational symmetry along the cylinder axis means the 3D fit reduces to the 2D -fit already implemented by :class:`CoupledBinning2DAnalyzer`. +fit already implemented by :class:`CoupledFit2DAnalyzer`. """ import logging @@ -29,7 +29,10 @@ _BatchedTrajectoryAnalyzer, build_parser, ) -from wetting_angle_kit.analysis.coupled_binning._models import ( +from wetting_angle_kit.analysis.coupled_fit._density_estimator import ( + DensityEstimator, +) +from wetting_angle_kit.analysis.coupled_fit._models import ( _PARAM_NAMES_3D, _default_binning_params_3d, _HyperbolicTangentModel3D, @@ -37,8 +40,8 @@ ) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( - CoupledBinning3DBatchResult, - CoupledBinning3DResults, + CoupledFit3DBatchResult, + CoupledFit3DResults, ) from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.io_utils import recenter_droplet_pbc @@ -46,7 +49,7 @@ logger = logging.getLogger(__name__) -class CoupledBinning3DAnalyzer(_BatchedTrajectoryAnalyzer): +class CoupledFit3DAnalyzer(_BatchedTrajectoryAnalyzer): """Joint contact-angle fit on a 3D binned density grid. Parameters @@ -61,7 +64,7 @@ class CoupledBinning3DAnalyzer(_BatchedTrajectoryAnalyzer): Must be spherical. Cylindrical droplets are rejected at construction because their translational symmetry already collapses the 3D problem onto the 2D one solved by - :class:`CoupledBinning2DAnalyzer`. + :class:`CoupledFit2DAnalyzer`. binning_params : dict, optional 3D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"bin_width_x"``, ``"yi_0"``, ``"yi_f"``, ``"bin_width_y"``, ``"zi_0"``, ``"zi_f"``, @@ -95,6 +98,7 @@ def __init__( droplet_geometry: DropletGeometry | str = "spherical", *, binning_params: dict[str, Any] | None = None, + density_estimator: DensityEstimator | None = None, initial_params: list[float] | None = None, temporal_aggregator: TemporalAggregator | None = None, precentered: bool = False, @@ -109,15 +113,16 @@ def __init__( ) if not self.droplet_geometry.is_spherical: raise ValueError( - "CoupledBinning3DAnalyzer only supports spherical droplets; " + "CoupledFit3DAnalyzer only supports spherical droplets; " f"got droplet_geometry={self.droplet_geometry.name!r}. " - "For cylindrical droplets use CoupledBinning2DAnalyzer — " + "For cylindrical droplets use CoupledFit2DAnalyzer — " "the 3D fit collapses onto the 2D one by translational " "symmetry along the cylinder axis." ) if binning_params is None: binning_params = _default_binning_params_3d(parser) self.binning_params = binning_params + self.density_estimator = density_estimator or DensityEstimator.binning() self.initial_params = initial_params # ------------------------------------------------------------------ @@ -125,7 +130,7 @@ def __init__( # ------------------------------------------------------------------ def _tqdm_desc(self) -> str: - return "CoupledBinning3DAnalyzer (spherical)" + return f"CoupledFit3DAnalyzer (spherical / {self.density_estimator.kind})" def _init_args(self) -> tuple: return ( @@ -133,6 +138,7 @@ def _init_args(self) -> tuple: self.atom_indices, self.droplet_geometry, self.binning_params, + self.density_estimator, self.initial_params, self.precentered, ) @@ -143,16 +149,18 @@ def _init_worker( atom_indices: np.ndarray, droplet_geometry: DropletGeometry, binning_params: dict[str, Any], + density_estimator: DensityEstimator, initial_params: list[float] | None, precentered: bool, ) -> None: - cls = CoupledBinning3DAnalyzer + cls = CoupledFit3DAnalyzer cls._WORKER_STATE.clear() cls._WORKER_STATE.update( parser=build_parser(filename), atom_indices=atom_indices, droplet_geometry=droplet_geometry, binning_params=binning_params, + density_estimator=density_estimator, initial_params=initial_params, precentered=precentered, ) @@ -160,12 +168,13 @@ def _init_worker( @staticmethod def _process_batch_worker( frame_indices: list[int], - ) -> CoupledBinning3DBatchResult | None: - state = CoupledBinning3DAnalyzer._WORKER_STATE + ) -> CoupledFit3DBatchResult | None: + state = CoupledFit3DAnalyzer._WORKER_STATE parser = state["parser"] atom_indices: np.ndarray = state["atom_indices"] droplet_geometry: DropletGeometry = state["droplet_geometry"] binning_params: dict[str, Any] = state["binning_params"] + density_estimator: DensityEstimator = state["density_estimator"] initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] # Per-frame progress callback (inline mode only); see @@ -214,13 +223,14 @@ def _process_batch_worker( binning_params["zi_f"], binning_params["bin_width_z"], ) - counts, _ = np.histogramdd(coords, bins=(xi_edges, yi_edges, zi_edges)) - dxi = float(xi_edges[1] - xi_edges[0]) - dyi = float(yi_edges[1] - yi_edges[0]) - dzi = float(zi_edges[1] - zi_edges[0]) - rho = counts / (dxi * dyi * dzi) - if n_frames > 0: - rho /= n_frames + rho = density_estimator.evaluate_3d( + atoms_pooled=coords, + n_frames=n_frames, + droplet_geometry=droplet_geometry, + xi_edges=xi_edges, + yi_edges=yi_edges, + zi_edges=zi_edges, + ) xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) yi_cc = 0.5 * (yi_edges[:-1] + yi_edges[1:]) @@ -243,13 +253,13 @@ def _process_batch_worker( if params is None: raise RuntimeError( "_HyperbolicTangentModel3D did not set parameters; " - "cannot build CoupledBinning3DBatchResult." + "cannot build CoupledFit3DBatchResult." ) model_params = { name: float(value) for name, value in zip(_PARAM_NAMES_3D, params, strict=False) } - return CoupledBinning3DBatchResult( + return CoupledFit3DBatchResult( frames=list(frame_indices), angle=float(angle), model_params=model_params, @@ -263,9 +273,9 @@ def _process_batch_worker( return None def _build_results( - self, batches: list[CoupledBinning3DBatchResult] - ) -> CoupledBinning3DResults: - return CoupledBinning3DResults( + self, batches: list[CoupledFit3DBatchResult] + ) -> CoupledFit3DResults: + return CoupledFit3DResults( batches=batches, method_metadata={ "droplet_geometry": self.droplet_geometry.name, diff --git a/src/wetting_angle_kit/analysis/results.py b/src/wetting_angle_kit/analysis/results.py index e8bf291..2f41f9f 100644 --- a/src/wetting_angle_kit/analysis/results.py +++ b/src/wetting_angle_kit/analysis/results.py @@ -8,9 +8,9 @@ - slicing fitters → :class:`SlicingBatchResult` - whole fitters → :class:`WholeBatchResult` -The two joint-fit analyzers — :class:`CoupledBinning2DAnalyzer` and -:class:`CoupledBinning3DAnalyzer` — each return their own results type -(:class:`CoupledBinning2DResults`, :class:`CoupledBinning3DResults`). +The two joint-fit analyzers — :class:`CoupledFit2DAnalyzer` and +:class:`CoupledFit3DAnalyzer` — each return their own results type +(:class:`CoupledFit2DResults`, :class:`CoupledFit3DResults`). They carry density grids plus joint-fit parameters and are therefore not part of the :class:`TrajectoryResults` hierarchy. """ @@ -107,8 +107,8 @@ class WholeBatchResult(BatchResult): @dataclass(frozen=True, eq=False) -class CoupledBinning2DBatchResult: - """Per-batch result from :class:`CoupledBinning2DAnalyzer`. +class CoupledFit2DBatchResult: + """Per-batch result from :class:`CoupledFit2DAnalyzer`. Attributes ---------- @@ -137,12 +137,12 @@ class CoupledBinning2DBatchResult: @dataclass(frozen=True, eq=False) -class CoupledBinning3DBatchResult: - """Per-batch result from :class:`CoupledBinning3DAnalyzer`. +class CoupledFit3DBatchResult: + """Per-batch result from :class:`CoupledFit3DAnalyzer`. Only meaningful for spherical droplets; cylindrical droplets carry a translational symmetry along the cylinder axis that the 2D - analyzer already exploits, so :class:`CoupledBinning3DAnalyzer` + analyzer already exploits, so :class:`CoupledFit3DAnalyzer` rejects non-spherical geometries at construction. Attributes @@ -224,19 +224,19 @@ def std_angle(self) -> float: @dataclass -class CoupledBinning2DResults: - """In-memory results of a :class:`CoupledBinning2DAnalyzer.analyze` run. +class CoupledFit2DResults: + """In-memory results of a :class:`CoupledFit2DAnalyzer.analyze` run. Attributes ---------- - batches : list[CoupledBinning2DBatchResult] + batches : list[CoupledFit2DBatchResult] Per-batch results, in the order produced by the aggregator. method_metadata : dict Free-form descriptor (droplet geometry, binning params, initial parameters, batch size). """ - batches: list[CoupledBinning2DBatchResult] + batches: list[CoupledFit2DBatchResult] method_metadata: dict[str, Any] = field(default_factory=dict) def __len__(self) -> int: @@ -263,19 +263,19 @@ def std_angle(self) -> float: @dataclass -class CoupledBinning3DResults: - """In-memory results of a :class:`CoupledBinning3DAnalyzer.analyze` run. +class CoupledFit3DResults: + """In-memory results of a :class:`CoupledFit3DAnalyzer.analyze` run. Attributes ---------- - batches : list[CoupledBinning3DBatchResult] + batches : list[CoupledFit3DBatchResult] Per-batch results, in the order produced by the aggregator. method_metadata : dict Free-form descriptor (droplet geometry — always spherical for this analyzer, binning params, initial parameters, batch size). """ - batches: list[CoupledBinning3DBatchResult] + batches: list[CoupledFit3DBatchResult] method_metadata: dict[str, Any] = field(default_factory=dict) def __len__(self) -> int: diff --git a/src/wetting_angle_kit/analysis/trajectory.py b/src/wetting_angle_kit/analysis/trajectory.py index 1856326..6af8c8a 100644 --- a/src/wetting_angle_kit/analysis/trajectory.py +++ b/src/wetting_angle_kit/analysis/trajectory.py @@ -14,8 +14,8 @@ documented there. The per-batch wiring lives in :meth:`_process_batch_worker`. -The joint-fit analyzers (:class:`CoupledBinning2DAnalyzer`, -:class:`CoupledBinning3DAnalyzer`) live in their own modules and +The joint-fit analyzers (:class:`CoupledFit2DAnalyzer`, +:class:`CoupledFit3DAnalyzer`) live in their own modules and share only the worker-pool scaffolding, not this strategy pipeline. """ diff --git a/src/wetting_angle_kit/visualization/angle_evolution_plotter.py b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py index 1333472..a9f80f8 100644 --- a/src/wetting_angle_kit/visualization/angle_evolution_plotter.py +++ b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py @@ -4,8 +4,8 @@ ``SlicingTrajectoryPlotter.plot_angle_evolution`` — a per-batch contact-angle line with an optional inter-batch ``±σ`` band and a cumulative running mean overlay — but consumes the new -:class:`TrajectoryResults` / :class:`CoupledBinning2DResults` / -:class:`CoupledBinning3DResults` shapes. +:class:`TrajectoryResults` / :class:`CoupledFit2DResults` / +:class:`CoupledFit3DResults` shapes. The plotter implements :class:`BaseTrajectoryPlotter`, so callers can also fetch a :class:`TrajectoryStats` summary alongside the figure. @@ -17,8 +17,8 @@ import plotly.graph_objects as go from wetting_angle_kit.analysis.results import ( - CoupledBinning2DBatchResult, - CoupledBinning3DBatchResult, + CoupledFit2DBatchResult, + CoupledFit3DBatchResult, SlicingBatchResult, WholeBatchResult, ) @@ -73,7 +73,7 @@ def _batch_surface_area(batch: Any) -> float: else: return float("nan") return _circular_segment_area(R, zc, float(batch.z_wall)) - if isinstance(batch, (CoupledBinning2DBatchResult, CoupledBinning3DBatchResult)): + if isinstance(batch, (CoupledFit2DBatchResult, CoupledFit3DBatchResult)): params = batch.model_params return _circular_segment_area( float(params["R_eq"]), @@ -106,8 +106,8 @@ class AngleEvolutionPlotter(BaseTrajectoryPlotter): results A ``*Results`` object exposing ``.batches`` with ``.angle`` and ``.frames`` (i.e. :class:`TrajectoryResults`, - :class:`CoupledBinning2DResults`, or - :class:`CoupledBinning3DResults`). For per-frame analyses use + :class:`CoupledFit2DResults`, or + :class:`CoupledFit3DResults`). For per-frame analyses use :class:`TemporalAggregator` with ``batch_size=1``; for pooled batches the evolution is shown per batch with each x-value at the pooled-frames midpoint. diff --git a/src/wetting_angle_kit/visualization/density_contour_plotter.py b/src/wetting_angle_kit/visualization/density_contour_plotter.py index ddf9d7f..1e43b14 100644 --- a/src/wetting_angle_kit/visualization/density_contour_plotter.py +++ b/src/wetting_angle_kit/visualization/density_contour_plotter.py @@ -5,11 +5,11 @@ dashed cap arc, dotted wall line, equal x/y aspect) while accepting the new coupled-binning result shapes: -- :class:`CoupledBinning2DBatchResult` — single batch, plotted directly. -- :class:`CoupledBinning2DResults` — densities averaged across batches. -- :class:`CoupledBinning3DBatchResult` — 3D density azimuthally +- :class:`CoupledFit2DBatchResult` — single batch, plotted directly. +- :class:`CoupledFit2DResults` — densities averaged across batches. +- :class:`CoupledFit3DBatchResult` — 3D density azimuthally averaged on the ``(xi, yi)`` plane to a 2D ``(r, zi)`` field. -- :class:`CoupledBinning3DResults` — averaged across batches first, +- :class:`CoupledFit3DResults` — averaged across batches first, then azimuthally collapsed. """ @@ -19,10 +19,10 @@ import plotly.graph_objects as go from wetting_angle_kit.analysis.results import ( - CoupledBinning2DBatchResult, - CoupledBinning2DResults, - CoupledBinning3DBatchResult, - CoupledBinning3DResults, + CoupledFit2DBatchResult, + CoupledFit2DResults, + CoupledFit3DBatchResult, + CoupledFit3DResults, ) @@ -164,7 +164,7 @@ def plot( def _extract( self, source: Any ) -> tuple[np.ndarray, np.ndarray, np.ndarray, dict, str]: - if isinstance(source, CoupledBinning2DBatchResult): + if isinstance(source, CoupledFit2DBatchResult): return ( source.xi_grid, source.zi_grid, @@ -172,9 +172,9 @@ def _extract( source.model_params, "", ) - if isinstance(source, CoupledBinning2DResults): + if isinstance(source, CoupledFit2DResults): if not source.batches: - raise ValueError("CoupledBinning2DResults has no batches to plot.") + raise ValueError("CoupledFit2DResults has no batches to plot.") ref2d = source.batches[0] densities = np.stack([b.density for b in source.batches], axis=0) mean_density = densities.mean(axis=0) @@ -185,7 +185,7 @@ def _extract( ref2d.model_params, f"averaged over {len(source.batches)} batches", ) - if isinstance(source, CoupledBinning3DBatchResult): + if isinstance(source, CoupledFit3DBatchResult): xi, zi, density2d = self._azimuthal_average_3d( source.xi_grid, source.yi_grid, @@ -199,10 +199,10 @@ def _extract( source.model_params, "azimuthally averaged", ) - if isinstance(source, CoupledBinning3DResults): + if isinstance(source, CoupledFit3DResults): if not source.batches: - raise ValueError("CoupledBinning3DResults has no batches to plot.") - ref3d: CoupledBinning3DBatchResult = source.batches[0] + raise ValueError("CoupledFit3DResults has no batches to plot.") + ref3d: CoupledFit3DBatchResult = source.batches[0] densities = np.stack([b.density for b in source.batches], axis=0) mean_density = densities.mean(axis=0) xi, zi, density2d = self._azimuthal_average_3d( diff --git a/tests/test_analysis/test_binning_method.py b/tests/test_analysis/test_binning_method.py index cf3b354..4bdf6e6 100644 --- a/tests/test_analysis/test_binning_method.py +++ b/tests/test_analysis/test_binning_method.py @@ -1,6 +1,6 @@ """Binning-method integration tests on a LAMMPS cylinder-droplet fixture. -End-to-end ``CoupledBinning2DAnalyzer`` runs on the cylinder droplet +End-to-end ``CoupledFit2DAnalyzer`` runs on the cylinder droplet fixture, both single-batch and per-frame batching. """ @@ -11,7 +11,7 @@ pytest.importorskip("ovito") -from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer # noqa: E402 +from wetting_angle_kit.analysis import CoupledFit2DAnalyzer # noqa: E402 from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 from wetting_angle_kit.parsers import ( # noqa: E402 LammpsDumpParser, @@ -54,13 +54,13 @@ def binning_params() -> dict: @pytest.mark.integration -def test_coupled_binning_2d_with_cylinder_fixture( +def test_coupled_fit_2d_with_cylinder_fixture( filename: pathlib.Path, oxygen_indices: np.ndarray, binning_params: dict, ) -> None: - """End-to-end ``CoupledBinning2DAnalyzer`` on the cylinder droplet.""" - analyzer = CoupledBinning2DAnalyzer( + """End-to-end ``CoupledFit2DAnalyzer`` on the cylinder droplet.""" + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", @@ -78,14 +78,14 @@ def test_coupled_binning_2d_with_cylinder_fixture( @pytest.mark.integration -def test_coupled_binning_2d_per_frame_batches( +def test_coupled_fit_2d_per_frame_batches( filename: pathlib.Path, oxygen_indices: np.ndarray, binning_params: dict, ) -> None: """``batch_size=1``: one fit per frame.""" frames = [1, 2, 3] - analyzer = CoupledBinning2DAnalyzer( + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", diff --git a/tests/test_analysis/test_coupled_binning_3d.py b/tests/test_analysis/test_coupled_fit_3d.py similarity index 88% rename from tests/test_analysis/test_coupled_binning_3d.py rename to tests/test_analysis/test_coupled_fit_3d.py index e944e96..c1012c0 100644 --- a/tests/test_analysis/test_coupled_binning_3d.py +++ b/tests/test_analysis/test_coupled_fit_3d.py @@ -1,4 +1,4 @@ -"""Phase 10 quantification: ``CoupledBinning3DAnalyzer``. +"""Phase 10 quantification: ``CoupledFit3DAnalyzer``. Three flavors: @@ -7,7 +7,7 @@ recovered contact angle matches truth to ≤ 0.1°. - **Cylinder rejection.** The analyzer refuses cylindrical droplets at construction with the documented pointer to the 2D variant. -- **End-to-end vs ``CoupledBinning2DAnalyzer`` on the LAMMPS fixture.** +- **End-to-end vs ``CoupledFit2DAnalyzer`` on the LAMMPS fixture.** The 2D analyzer collapses the droplet via radial symmetry; the 3D one keeps the full 3D density. For an approximately axisymmetric droplet the two should agree within a few degrees. @@ -18,8 +18,8 @@ import numpy as np import pytest -from wetting_angle_kit.analysis.coupled_binning.analyzer_3d import ( # noqa: E402 - CoupledBinning3DAnalyzer, +from wetting_angle_kit.analysis.coupled_fit.analyzer_3d import ( # noqa: E402 + CoupledFit3DAnalyzer, _HyperbolicTangentModel3D, ) @@ -69,7 +69,7 @@ def test_3d_tanh_model_recovers_known_cap_angle_on_clean_grid() -> None: assert drift < 0.1 -def test_coupled_binning_3d_rejects_cylinder() -> None: +def test_coupled_fit_3d_rejects_cylinder() -> None: """Constructing the analyzer with a cylinder droplet raises clearly.""" class _MockParser: @@ -91,7 +91,7 @@ def frame_count(self) -> int: # rejection sits **after** super().__init__, so we expect a # ValueError from the cylinder check. with pytest.raises(ValueError): - CoupledBinning3DAnalyzer( + CoupledFit3DAnalyzer( parser=_MockParser(), droplet_geometry="cylinder_y", binning_params={ @@ -118,10 +118,10 @@ def frame_count(self) -> int: @pytest.mark.integration @pytest.mark.slow -def test_coupled_binning_3d_close_to_2d_on_lammps_fixture() -> None: +def test_coupled_fit_3d_close_to_2d_on_lammps_fixture() -> None: """On an axisymmetric droplet the 3D and 2D fits should agree within ~few°.""" pytest.importorskip("ovito") - from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer + from wetting_angle_kit.analysis import CoupledFit2DAnalyzer from wetting_angle_kit.parsers import ( LammpsDumpParser, LammpsDumpWaterFinder, @@ -139,7 +139,7 @@ def test_coupled_binning_3d_close_to_2d_on_lammps_fixture() -> None: "zi_f": 40.0, "bin_width_z": 1.0, } - legacy_2d = CoupledBinning2DAnalyzer( + legacy_2d = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -159,7 +159,7 @@ def test_coupled_binning_3d_close_to_2d_on_lammps_fixture() -> None: "zi_f": 40.0, "bin_width_z": 1.6, } - new_3d = CoupledBinning3DAnalyzer( + new_3d = CoupledFit3DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -169,8 +169,8 @@ def test_coupled_binning_3d_close_to_2d_on_lammps_fixture() -> None: drift = abs(angle_2d - angle_3d) print( - f"\nCoupledBinning2DAnalyzer angle = {angle_2d:.3f}°" - f"\nCoupledBinning3DAnalyzer angle = {angle_3d:.3f}°" + f"\nCoupledFit2DAnalyzer angle = {angle_2d:.3f}°" + f"\nCoupledFit3DAnalyzer angle = {angle_3d:.3f}°" f"\n|drift| = {drift:.3f}°" ) # Both should land in the physically plausible band. diff --git a/tests/test_analysis/test_default_grid_params.py b/tests/test_analysis/test_default_grid_params.py index ff1c9fc..b965005 100644 --- a/tests/test_analysis/test_default_grid_params.py +++ b/tests/test_analysis/test_default_grid_params.py @@ -22,8 +22,8 @@ pytest.importorskip("skimage") from wetting_angle_kit.analysis import ( # noqa: E402 - CoupledBinning2DAnalyzer, - CoupledBinning3DAnalyzer, + CoupledFit2DAnalyzer, + CoupledFit3DAnalyzer, InterfaceExtractor, SurfaceFitter, TrajectoryAnalyzer, @@ -50,10 +50,10 @@ def oxygen_indices() -> np.ndarray: @pytest.mark.integration -def test_coupled_binning_2d_auto_default(oxygen_indices: np.ndarray) -> None: - """``CoupledBinning2DAnalyzer`` with no ``binning_params`` lands at ~95°.""" +def test_coupled_fit_2d_auto_default(oxygen_indices: np.ndarray) -> None: + """``CoupledFit2DAnalyzer`` with no ``binning_params`` lands at ~95°.""" with pytest.warns(UserWarning, match="binning_params was not supplied"): - analyzer = CoupledBinning2DAnalyzer( + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -66,10 +66,10 @@ def test_coupled_binning_2d_auto_default(oxygen_indices: np.ndarray) -> None: @pytest.mark.integration @pytest.mark.slow -def test_coupled_binning_3d_auto_default(oxygen_indices: np.ndarray) -> None: - """``CoupledBinning3DAnalyzer`` with no ``binning_params`` lands at ~95°.""" +def test_coupled_fit_3d_auto_default(oxygen_indices: np.ndarray) -> None: + """``CoupledFit3DAnalyzer`` with no ``binning_params`` lands at ~95°.""" with pytest.warns(UserWarning, match="binning_params was not supplied"): - analyzer = CoupledBinning3DAnalyzer( + analyzer = CoupledFit3DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", diff --git a/tests/test_analysis/test_density_estimator.py b/tests/test_analysis/test_density_estimator.py new file mode 100644 index 0000000..42cad2f --- /dev/null +++ b/tests/test_analysis/test_density_estimator.py @@ -0,0 +1,212 @@ +"""Density-estimator strategy: binning vs gaussian. + +The coupled-fit analyzers accept either +:meth:`DensityEstimator.binning` (the default — top-hat histogram) or +:meth:`DensityEstimator.gaussian` (3D Gaussian KDE on cell centres). +These tests verify both code paths on the LAMMPS fixtures and check +that the Gaussian variant produces a smoother density field than the +histogram on the same grid. +""" + +import pathlib + +import numpy as np +import pytest + +pytest.importorskip("ovito") + +from wetting_angle_kit.analysis import ( # noqa: E402 + CoupledFit2DAnalyzer, + CoupledFit3DAnalyzer, + DensityEstimator, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 +from wetting_angle_kit.parsers import ( # noqa: E402 + LammpsDumpParser, + LammpsDumpWaterFinder, +) + +_SPHERE = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_spherical_drop_4k.lammpstrj" +) +_CYL = ( + pathlib.Path(__file__).parent + / ".." + / "trajectories" + / "traj_10_3_330w_nve_4k_reajust.lammpstrj" +) + + +@pytest.fixture +def oxygen_indices_sphere() -> np.ndarray: + return LammpsDumpWaterFinder( + _SPHERE, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +@pytest.fixture +def oxygen_indices_cyl() -> np.ndarray: + return LammpsDumpWaterFinder( + _CYL, oxygen_type=1, hydrogen_type=2 + ).get_water_oxygen_ids(0) + + +@pytest.mark.integration +def test_coupled_fit_2d_gaussian_estimator_recovers_known_angle( + oxygen_indices_sphere: np.ndarray, +) -> None: + """``CoupledFit2DAnalyzer`` with the Gaussian estimator lands at ~95°. + + Same grid spec as the binning variant; only the per-cell density + estimator changes. The Gaussian smoothing reduces Poisson noise + relative to the histogram, so this combination is usable on + per-frame batches where the binning variant has occasionally + landed in degenerate fits. + """ + binning_params = { + "xi_0": 0.0, + "xi_f": 40.0, + "bin_width_x": 1.0, + "zi_0": 0.0, + "zi_f": 40.0, + "bin_width_z": 1.0, + } + analyzer = CoupledFit2DAnalyzer( + parser=LammpsDumpParser(_SPHERE), + atom_indices=oxygen_indices_sphere, + droplet_geometry="spherical", + binning_params=binning_params, + density_estimator=DensityEstimator.gaussian(density_sigma=2.5), + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + batch = analyzer.analyze([1]).batches[0] + assert 90.0 < batch.angle < 100.0 + assert 25.0 < batch.model_params["R_eq"] < 50.0 + + +@pytest.mark.integration +def test_coupled_fit_2d_gaussian_estimator_smoother_than_binning( + oxygen_indices_sphere: np.ndarray, +) -> None: + """At equal grid spec, Gaussian KDE → smoother density than histogram. + + Quantified as the inter-cell coefficient of variation across the + occupied bulk region: the Gaussian density's CoV is strictly + lower than the binning density's CoV on the same fixture and grid. + """ + binning_params = { + "xi_0": 0.0, + "xi_f": 40.0, + "bin_width_x": 1.0, + "zi_0": 0.0, + "zi_f": 40.0, + "bin_width_z": 1.0, + } + + def _density(estimator: DensityEstimator) -> np.ndarray: + analyzer = CoupledFit2DAnalyzer( + parser=LammpsDumpParser(_SPHERE), + atom_indices=oxygen_indices_sphere, + droplet_geometry="spherical", + binning_params=binning_params, + density_estimator=estimator, + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + return analyzer.analyze([1]).batches[0].density + + rho_bin = _density(DensityEstimator.binning()) + rho_gauss = _density(DensityEstimator.gaussian(density_sigma=2.5)) + + # Use the Gaussian density to define the bulk region (it's smooth + # enough that "above half the bulk" picks out a stable band of + # cells), then compute the coefficient of variation of both + # densities on that same set of cells. A smoother density gives a + # lower CoV across a roughly-uniform bulk region; the binning's + # Poisson noise should make its CoV strictly larger. + mask_bulk = rho_gauss > 0.5 * float(rho_gauss.max()) + assert mask_bulk.sum() > 50, "bulk mask too small for the test to mean anything" + cov_bin = float(np.std(rho_bin[mask_bulk]) / np.mean(rho_bin[mask_bulk])) + cov_gauss = float(np.std(rho_gauss[mask_bulk]) / np.mean(rho_gauss[mask_bulk])) + print( + f"\nbulk CoV (mask = gaussian > 0.5 max): " + f"binning = {cov_bin:.4f}, gaussian = {cov_gauss:.4f}" + ) + assert cov_gauss < cov_bin + + +@pytest.mark.integration +def test_coupled_fit_2d_gaussian_estimator_works_on_cylinder( + oxygen_indices_cyl: np.ndarray, +) -> None: + """Gaussian estimator + cylinder geometry lands at ~97-103°. + + Reference: binning estimator on the same fixture recovers ~95-99° + across frames 0-4 with per-frame std ~3.6°. The Gaussian variant + shifts the angle ~2-3° higher (the KDE iso-level sits slightly + inside the geometric edge) and gives similar per-frame std. + """ + binning_params = { + "xi_0": 0.0, + "xi_f": 70.0, + "bin_width_x": 1.0, + "zi_0": 0.0, + "zi_f": 80.0, + "bin_width_z": 1.0, + } + analyzer = CoupledFit2DAnalyzer( + parser=LammpsDumpParser(_CYL), + atom_indices=oxygen_indices_cyl, + droplet_geometry="cylinder_y", + binning_params=binning_params, + density_estimator=DensityEstimator.gaussian(density_sigma=2.5), + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + batch = analyzer.analyze([1]).batches[0] + assert 95.0 < batch.angle < 110.0 + + +@pytest.mark.integration +@pytest.mark.slow +def test_coupled_fit_3d_gaussian_estimator_recovers_known_angle( + oxygen_indices_sphere: np.ndarray, +) -> None: + """``CoupledFit3DAnalyzer`` with the Gaussian estimator lands at ~96°. + + The 3D coupled fit is much more constrained than the 2D per-frame + case: per-frame std across frames 0-4 is ~0.9° for the Gaussian + variant and ~0.7° for the binning variant. Mean angles agree + within ~0.5° (gaussian ~96.5°, binning ~95.6°), so the band + here is narrow on purpose — a regression in the estimator + plumbing or volume normalisation would push the angle well + outside it. + """ + binning_params = { + "xi_0": -40.0, + "xi_f": 40.0, + "bin_width_x": 3.3, + "yi_0": -40.0, + "yi_f": 40.0, + "bin_width_y": 3.3, + "zi_0": 0.0, + "zi_f": 40.0, + "bin_width_z": 1.6, + } + analyzer = CoupledFit3DAnalyzer( + parser=LammpsDumpParser(_SPHERE), + atom_indices=oxygen_indices_sphere, + droplet_geometry="spherical", + binning_params=binning_params, + density_estimator=DensityEstimator.gaussian(density_sigma=3.0), + temporal_aggregator=TemporalAggregator(batch_size=1), + ) + batch = analyzer.analyze([1]).batches[0] + assert 93.0 < batch.angle < 100.0 + + +def test_density_estimator_kind_tags_are_distinct() -> None: + """The two strategies expose distinct ``kind`` tags for the tqdm label.""" + assert DensityEstimator.binning().kind == "binning" + assert DensityEstimator.gaussian().kind == "gaussian" diff --git a/tests/test_analysis/test_parallel_path.py b/tests/test_analysis/test_parallel_path.py index 27950f1..ebe03e2 100644 --- a/tests/test_analysis/test_parallel_path.py +++ b/tests/test_analysis/test_parallel_path.py @@ -11,7 +11,7 @@ pytest.importorskip("ovito") -from wetting_angle_kit.analysis import CoupledBinning2DAnalyzer # noqa: E402 +from wetting_angle_kit.analysis import CoupledFit2DAnalyzer # noqa: E402 from wetting_angle_kit.analysis.temporal import TemporalAggregator # noqa: E402 from wetting_angle_kit.parsers import ( # noqa: E402 LammpsDumpParser, @@ -36,7 +36,7 @@ def test_run_parallel_path_executes_with_n_jobs_2() -> None: oxygen_indices = LammpsDumpWaterFinder( _FIXTURE, oxygen_type=1, hydrogen_type=2 ).get_water_oxygen_ids(0) - analyzer = CoupledBinning2DAnalyzer( + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", @@ -69,7 +69,7 @@ def test_n_jobs_gt_1_with_batch_size_minus_1_warns_and_runs_inline() -> None: oxygen_indices = LammpsDumpWaterFinder( _FIXTURE, oxygen_type=1, hydrogen_type=2 ).get_water_oxygen_ids(0) - analyzer = CoupledBinning2DAnalyzer( + analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", diff --git a/tests/test_visualization/test_angle_evolution_helpers.py b/tests/test_visualization/test_angle_evolution_helpers.py index 6ba9d5a..e83ff78 100644 --- a/tests/test_visualization/test_angle_evolution_helpers.py +++ b/tests/test_visualization/test_angle_evolution_helpers.py @@ -13,8 +13,8 @@ import pytest from wetting_angle_kit.analysis.results import ( - CoupledBinning2DBatchResult, - CoupledBinning3DBatchResult, + CoupledFit2DBatchResult, + CoupledFit3DBatchResult, SlicingBatchResult, TrajectoryResults, WholeBatchResult, @@ -167,9 +167,9 @@ def test_batch_surface_area_whole_unknown_popt_returns_nan() -> None: assert math.isnan(_batch_surface_area(batch)) -def test_batch_surface_area_coupled_binning_2d_uses_model_params() -> None: +def test_batch_surface_area_coupled_fit_2d_uses_model_params() -> None: """Both 2D and 3D coupled-binning batches share the dispatch arm.""" - batch = CoupledBinning2DBatchResult( + batch = CoupledFit2DBatchResult( frames=[0], angle=90.0, model_params={ @@ -189,8 +189,8 @@ def test_batch_surface_area_coupled_binning_2d_uses_model_params() -> None: assert _batch_surface_area(batch) == pytest.approx(math.pi * 25.0 / 2) -def test_batch_surface_area_coupled_binning_3d_uses_model_params() -> None: - batch = CoupledBinning3DBatchResult( +def test_batch_surface_area_coupled_fit_3d_uses_model_params() -> None: + batch = CoupledFit3DBatchResult( frames=[0], angle=90.0, model_params={ diff --git a/tests/test_visualization/test_angle_evolution_plotter.py b/tests/test_visualization/test_angle_evolution_plotter.py index 0d4a9f6..8233868 100644 --- a/tests/test_visualization/test_angle_evolution_plotter.py +++ b/tests/test_visualization/test_angle_evolution_plotter.py @@ -5,8 +5,8 @@ import pytest from wetting_angle_kit.analysis.results import ( - CoupledBinning2DBatchResult, - CoupledBinning2DResults, + CoupledFit2DBatchResult, + CoupledFit2DResults, SlicingBatchResult, TrajectoryResults, WholeBatchResult, @@ -32,9 +32,9 @@ def _slicing_results() -> TrajectoryResults: return TrajectoryResults(batches=batches, method_metadata={}) -def _coupled_2d_results() -> CoupledBinning2DResults: +def _coupled_2d_results() -> CoupledFit2DResults: batches = [ - CoupledBinning2DBatchResult( + CoupledFit2DBatchResult( frames=[i, i + 1], angle=99.0 - 0.5 * i, model_params={ @@ -52,7 +52,7 @@ def _coupled_2d_results() -> CoupledBinning2DResults: ) for i in range(2) ] - return CoupledBinning2DResults(batches=batches, method_metadata={}) + return CoupledFit2DResults(batches=batches, method_metadata={}) def test_angle_evolution_plotter_slicing_runs_with_all_overlays() -> None: diff --git a/tests/test_visualization/test_density_contour_plotter.py b/tests/test_visualization/test_density_contour_plotter.py index 2235a8a..2998c67 100644 --- a/tests/test_visualization/test_density_contour_plotter.py +++ b/tests/test_visualization/test_density_contour_plotter.py @@ -5,10 +5,10 @@ import pytest from wetting_angle_kit.analysis.results import ( - CoupledBinning2DBatchResult, - CoupledBinning2DResults, - CoupledBinning3DBatchResult, - CoupledBinning3DResults, + CoupledFit2DBatchResult, + CoupledFit2DResults, + CoupledFit3DBatchResult, + CoupledFit3DResults, ) from wetting_angle_kit.visualization import DensityContourPlotter @@ -39,12 +39,12 @@ def _model_params_3d() -> dict: } -def _make_2d_batch(seed: int = 0) -> CoupledBinning2DBatchResult: +def _make_2d_batch(seed: int = 0) -> CoupledFit2DBatchResult: rng = np.random.default_rng(seed) xi = np.linspace(0.0, 40.0, 15) zi = np.linspace(0.0, 40.0, 15) density = rng.uniform(0.0, 0.03, size=(15, 15)) - return CoupledBinning2DBatchResult( + return CoupledFit2DBatchResult( frames=[0, 1], angle=95.0, model_params=_model_params_2d(), @@ -54,7 +54,7 @@ def _make_2d_batch(seed: int = 0) -> CoupledBinning2DBatchResult: ) -def _make_3d_batch() -> CoupledBinning3DBatchResult: +def _make_3d_batch() -> CoupledFit3DBatchResult: xi = np.linspace(-30.0, 30.0, 10) yi = np.linspace(-30.0, 30.0, 10) zi = np.linspace(0.0, 35.0, 12) @@ -66,7 +66,7 @@ def _make_3d_batch() -> CoupledBinning3DBatchResult: * 0.5 * (1.0 + np.tanh(2 * (ZI - 5.0) / 1.0)) ) - return CoupledBinning3DBatchResult( + return CoupledFit3DBatchResult( frames=[0], angle=90.0, model_params=_model_params_3d(), @@ -100,7 +100,7 @@ def test_density_contour_plotter_2d_batch_runs() -> None: def test_density_contour_plotter_2d_results_averages_density() -> None: b1 = _make_2d_batch(seed=0) b2 = _make_2d_batch(seed=1) - results = CoupledBinning2DResults(batches=[b1, b2], method_metadata={}) + results = CoupledFit2DResults(batches=[b1, b2], method_metadata={}) fig = DensityContourPlotter(results).plot() assert isinstance(fig, go.Figure) contour_z = np.array(fig.data[0].z) @@ -110,7 +110,7 @@ def test_density_contour_plotter_2d_results_averages_density() -> None: def test_density_contour_plotter_2d_empty_results_raises() -> None: - results = CoupledBinning2DResults(batches=[], method_metadata={}) + results = CoupledFit2DResults(batches=[], method_metadata={}) with pytest.raises(ValueError, match="no batches"): DensityContourPlotter(results).plot() @@ -143,7 +143,7 @@ def test_density_contour_plotter_3d_batch_runs() -> None: def test_density_contour_plotter_3d_results_runs() -> None: - results = CoupledBinning3DResults( + results = CoupledFit3DResults( batches=[_make_3d_batch(), _make_3d_batch()], method_metadata={} ) fig = DensityContourPlotter(results).plot() From 6567effef50817100902f92d24b5228529d9fa99 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Sat, 13 Jun 2026 09:56:37 +0200 Subject: [PATCH 35/53] Files reordering, doc update, removing the legacy narrative etc. --- README.md | 20 +- docs/examples/coupled_fit_ca.py | 8 +- docs/examples/slicing_ca.py | 11 +- .../visualisation_evolution_density.py | 7 +- docs/examples/visualisation_slicing_traj.py | 8 +- docs/examples/whole_fit_ca.py | 8 +- docs/source/API/index.rst | 2 +- docs/source/introduction/introduction.rst | 6 +- .../introduction/theoretical_foundations.rst | 33 +- docs/source/tutorials/coupled_fit_2d_tuto.rst | 18 +- docs/source/tutorials/grid_method_tuto.rst | 55 ++- docs/source/tutorials/slicing_method_tuto.rst | 40 +- .../visualization_evolution_density.rst | 12 +- .../visualization_slicing_droplet.rst | 6 +- docs/source/tutorials/whole_fit_tuto.rst | 27 +- src/wetting_angle_kit/analysis/__init__.py | 21 +- src/wetting_angle_kit/analysis/_base.py | 21 +- src/wetting_angle_kit/analysis/_density.py | 16 +- src/wetting_angle_kit/analysis/analyzer.py | 13 - .../analysis/coupled_fit/__init__.py | 6 +- .../coupled_fit/_density_estimator.py | 277 ----------- .../analysis/coupled_fit/_models.py | 6 +- .../analysis/coupled_fit/analyzer_2d.py | 14 +- .../analysis/coupled_fit/analyzer_3d.py | 6 +- .../analysis/density_estimator.py | 467 ++++++++++++++++++ .../analysis/extractors/__init__.py | 29 -- .../analysis/extractors/_sampling.py | 41 -- .../analysis/extractors/base.py | 319 ------------ .../analysis/fitters/_slicing.py | 2 +- .../analysis/fitters/_whole.py | 2 +- .../analysis/fitters/base.py | 4 +- .../analysis/interface/__init__.py | 35 ++ .../{extractors => interface}/_grid.py | 281 +++-------- .../{extractors => interface}/_rays.py | 124 ++--- .../analysis/interface/base.py | 309 ++++++++++++ src/wetting_angle_kit/analysis/trajectory.py | 15 +- src/wetting_angle_kit/analysis/wall.py | 2 +- src/wetting_angle_kit/io_utils.py | 6 +- .../visualization/angle_evolution_plotter.py | 11 +- .../visualization/density_contour_plotter.py | 10 +- src/wetting_angle_kit/visualization/stats.py | 7 +- ...nning_method.py => test_coupled_fit_2d.py} | 11 +- tests/test_analysis/test_coupled_fit_3d.py | 6 +- tests/test_analysis/test_cylinder_coverage.py | 86 ++-- .../test_analysis/test_default_grid_params.py | 36 +- ...rid_extractors.py => test_grid_slicing.py} | 110 +++-- ...extractors_whole.py => test_grid_whole.py} | 58 ++- ...extractor.py => test_rays_with_binning.py} | 75 +-- ...xtractor.py => test_rays_with_gaussian.py} | 17 +- .../test_analysis/test_slicing_edge_cases.py | 29 +- tests/test_analysis/test_slicing_method.py | 14 +- .../test_trajectory_analyzer_integration.py | 51 +- .../test_wall_detector_from_atoms_e2e.py | 13 +- tests/test_analysis/test_whole_fitter.py | 18 +- tests/test_geometry_projection.py | 33 +- .../test_angle_evolution_helpers.py | 2 +- .../test_angle_evolution_plotter.py | 2 +- .../test_density_contour_plotter.py | 5 +- 58 files changed, 1468 insertions(+), 1403 deletions(-) delete mode 100644 src/wetting_angle_kit/analysis/analyzer.py delete mode 100644 src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py create mode 100644 src/wetting_angle_kit/analysis/density_estimator.py delete mode 100644 src/wetting_angle_kit/analysis/extractors/__init__.py delete mode 100644 src/wetting_angle_kit/analysis/extractors/_sampling.py delete mode 100644 src/wetting_angle_kit/analysis/extractors/base.py create mode 100644 src/wetting_angle_kit/analysis/interface/__init__.py rename src/wetting_angle_kit/analysis/{extractors => interface}/_grid.py (66%) rename src/wetting_angle_kit/analysis/{extractors => interface}/_rays.py (73%) create mode 100644 src/wetting_angle_kit/analysis/interface/base.py rename tests/test_analysis/{test_binning_method.py => test_coupled_fit_2d.py} (88%) rename tests/test_analysis/{test_grid_extractors.py => test_grid_slicing.py} (70%) rename tests/test_analysis/{test_grid_extractors_whole.py => test_grid_whole.py} (81%) rename tests/test_analysis/{test_rays_binning_extractor.py => test_rays_with_binning.py} (77%) rename tests/test_analysis/{test_rays_gaussian_extractor.py => test_rays_with_gaussian.py} (90%) diff --git a/README.md b/README.md index 634912e..1a1cfb4 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,14 @@ wetting-angle-kit parses MD trajectories (LAMMPS dump, XYZ, ASE) and computes th The liquid-vapor interface isn't a sharp surface in an MD simulation — the density drops smoothly over ~1 Å. Two extraction strategies recover a clean set of interface points from the noisy atom cloud: -- **Ray-fan extractors** emit a fan of rays from the droplet centre of mass and locate the interface along each ray as the half-density point of a 1D tanh fit. The fan is azimuthal slices in the `(x, z)` plane (for a per-slice fit) or a Fibonacci sphere of directions (for a whole-shape fit). The density along each ray comes from either a Gaussian KDE (`rays_gaussian`) or a 1D histogram (`rays_binning`); both produce interface points robust to thermal noise. -- **Grid extractors** build a fixed-cell grid in space, compute a density value at each cell, and then trace the iso-density contour at the half-bulk level via marching squares (`grid_*` in slicing mode) or marching cubes (`grid_*` in whole mode). Two ways to fill the cells: `grid_gaussian` evaluates a Gaussian KDE (sum of Gaussians centred on atoms) at each cell centre — the same density estimator `rays_gaussian` uses, just sampled on a grid; `grid_binning` is a histogram where each cell counts the atoms inside it. In slicing mode both extractors iterate per slice (per azimuthal angle for spherical droplets, per axial step for cylinder droplets), so the slicing fit sees one `(s, z)` contour per slice and can expose per-slice asymmetry. Closer to the "average over many frames" intuition than ray fans; works well when atom statistics are limited per frame. +The package exposes two orthogonal strategy axes for interface extraction. A `SpaceSampling` decides *where* density is evaluated; a `DensityEstimator` decides *how*. An `InterfaceExtractor` composes one of each. + +- **Sampling: `SpaceSampling.rays(...)`** emits a fan of rays from the droplet centre of mass; the interface along each ray is the half-density point of a 1D tanh fit. The fan layout is azimuthal slices in the `(x, z)` plane (for a per-slice fit) or a Fibonacci sphere of directions (for a whole-shape fit). +- **Sampling: `SpaceSampling.grid(...)`** builds a fixed-cell grid in space; the interface is the iso-density contour at the half-bulk level, traced via marching squares (slicing mode) or marching cubes (whole mode). In slicing mode the grid iterates per slice (per azimuthal angle for spherical droplets, per axial step for cylinder droplets), so the slicing fit sees one `(s, z)` contour per slice and can expose per-slice asymmetry. Closer to the "average over many frames" intuition than ray fans; works well when atom statistics are limited per frame. +- **Density: `DensityEstimator.gaussian(density_sigma=…)`** is a 3D Gaussian KDE (smooth, no per-cell Poisson noise). +- **Density: `DensityEstimator.binning(bin_width=…)`** is a 3D top-hat histogram (cheap; bin_width required only for the rays sampling, where it sets the pointwise kernel size). + +Any sampling × any density is a valid extractor. ### Surface fitting: what geometric shape do we fit to those points? @@ -88,6 +94,7 @@ from wetting_angle_kit.analysis import ( CoupledFit2DAnalyzer, DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -109,9 +116,12 @@ slicing = TrajectoryAnalyzer( parser=parser, atom_indices=oxygen_ids, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=5.0, # 5° between slicing planes - delta_polar=8.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=5.0, # 5° between slicing planes + delta_polar=8.0, + ), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), diff --git a/docs/examples/coupled_fit_ca.py b/docs/examples/coupled_fit_ca.py index f5c18c6..fa08e03 100644 --- a/docs/examples/coupled_fit_ca.py +++ b/docs/examples/coupled_fit_ca.py @@ -46,11 +46,11 @@ } # --- Step 4: Pick a density estimator --- -# The histogram is the default and matches the legacy numerics: +# Top-hat histogram on the binning grid (default): estimator = DensityEstimator.binning() -# Swap in the Gaussian KDE for smoother per-cell density. Picks the -# same ``density_sigma`` you would for ``rays_gaussian`` on the same -# system (3 Å is a sensible default for room-temperature water): +# Swap in the Gaussian KDE for smoother per-cell density. ``density_sigma`` +# is the Gaussian kernel width; 3 Å is a sensible default for +# room-temperature water: # estimator = DensityEstimator.gaussian(density_sigma=2.5) # --- Step 5: Build the analyzer --- diff --git a/docs/examples/slicing_ca.py b/docs/examples/slicing_ca.py index 4511f72..c689f54 100644 --- a/docs/examples/slicing_ca.py +++ b/docs/examples/slicing_ca.py @@ -6,7 +6,9 @@ """ from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -33,9 +35,12 @@ parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, # 20° between slicing planes - delta_polar=8.0, # 8° in-plane ray step + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=20.0, # 20° between slicing planes + delta_polar=8.0, # 8° in-plane ray step + ), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), diff --git a/docs/examples/visualisation_evolution_density.py b/docs/examples/visualisation_evolution_density.py index f56a579..1f09762 100644 --- a/docs/examples/visualisation_evolution_density.py +++ b/docs/examples/visualisation_evolution_density.py @@ -12,7 +12,9 @@ from wetting_angle_kit.analysis import ( CoupledFit2DAnalyzer, + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -35,8 +37,9 @@ parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, delta_polar=8.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), diff --git a/docs/examples/visualisation_slicing_traj.py b/docs/examples/visualisation_slicing_traj.py index e88e2a2..1f305fe 100644 --- a/docs/examples/visualisation_slicing_traj.py +++ b/docs/examples/visualisation_slicing_traj.py @@ -6,7 +6,9 @@ """ from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -42,9 +44,9 @@ parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_cylinder=5.0, - delta_polar=8.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=5.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), diff --git a/docs/examples/whole_fit_ca.py b/docs/examples/whole_fit_ca.py index b216c54..0c22c76 100644 --- a/docs/examples/whole_fit_ca.py +++ b/docs/examples/whole_fit_ca.py @@ -6,7 +6,9 @@ """ from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -34,9 +36,9 @@ parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - n_rays_sphere=400, - density_sigma=3.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole( surface_filter_offset=3.0, diff --git a/docs/source/API/index.rst b/docs/source/API/index.rst index ae358dd..0b14768 100644 --- a/docs/source/API/index.rst +++ b/docs/source/API/index.rst @@ -32,7 +32,7 @@ Top-level analyzers Strategy components ^^^^^^^^^^^^^^^^^^^ -.. automodule:: wetting_angle_kit.analysis.extractors +.. automodule:: wetting_angle_kit.analysis.interface :members: :show-inheritance: diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index e4d13cd..f1a66d5 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -95,8 +95,8 @@ aggregator, and the analyzer runs them per batch. Examples of useful combinations: * ray-fan extractor + slicing fit + ``min_plus_offset`` wall + - per-frame batches — the closest analogue of the legacy slicing - method; + per-frame batches — a per-frame angle trace with a per-slice ``±σ`` + band; * ray-fan extractor + whole-fit + ``explicit`` wall + 10-frame pooled batches — a whole-shape sphere fit with the wall position imported from the simulation setup; @@ -180,7 +180,7 @@ The two interact in three regimes: :doc:`../tutorials/slicing_method_tuto`). * **Fully pooled** (``batch_size=-1``, the default for the - coupled-binning analyzers): every frame goes into one batch and one + coupled-fit analyzers): every frame goes into one batch and one fit. Because there's only one unit of work, ``n_jobs`` is silently irrelevant — :meth:`analyze` always runs inline, and passing ``n_jobs > 1`` emits a ``UserWarning`` to flag the wasted diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 85615ea..938bdd5 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -75,7 +75,7 @@ To fit the tanh profile to a sampled density, we first need a local density estimate at each sample point. Two estimators are available, swappable via :class:`InterfaceExtractor`: -**Gaussian KDE** (``rays_gaussian`` / ``grid_gaussian``) +**Gaussian KDE** (``rays`` (Gaussian) / ``grid`` (Gaussian)) Each atom contributes a normalised 3D Gaussian of width :math:`\sigma`: @@ -89,7 +89,7 @@ swappable via :class:`InterfaceExtractor`: which makes it the default choice. For efficiency, a per-atom cut-off at :math:`5\sigma` is applied via a cKDTree. -**3D top-hat** (``rays_binning`` / ``grid_binning``) +**3D top-hat** (``rays`` (binning) / ``grid`` (binning)) Atoms within :math:`{\rm bin\_width}/2` of the sample contribute uniformly: @@ -101,7 +101,6 @@ swappable via :class:`InterfaceExtractor`: Fast and conceptually simple, but the hard cut-off introduces Poisson noise that can interfere with the tanh fit unless the bin width is matched to the smoothing length you'd otherwise pick. - The legacy binning analyzer used this estimator. Both estimators implement the same :class:`DensityFieldProtocol`, so the analysis pipeline can plug @@ -116,8 +115,8 @@ the interface: 4.1 Ray fans ^^^^^^^^^^^^ -The :meth:`InterfaceExtractor.rays_gaussian` / -:meth:`rays_binning` factories emit a fan of rays from the droplet +The :meth:`InterfaceExtractor.rays` (Gaussian) / +:meth:`InterfaceExtractor.rays` (binning) factories emit a fan of rays from the droplet COM, sample the density along each ray, and recover the interface position as the half-density point of a 1D tanh fit on that ray. @@ -150,8 +149,8 @@ equal-area coverage with no clustering anywhere. 4.2 Grid + iso-contour ^^^^^^^^^^^^^^^^^^^^^^ -The :meth:`InterfaceExtractor.grid_gaussian` / -:meth:`grid_binning` factories build a fixed-cell grid in space and +The :meth:`InterfaceExtractor.grid` (Gaussian) / +:meth:`InterfaceExtractor.grid` (binning) factories build a fixed-cell grid in space and compute a density value at each cell, then recover the interface as the iso-density contour at the half-bulk level via :func:`skimage.measure.find_contours` in 2D (marching squares) or @@ -160,11 +159,11 @@ the iso-density contour at the half-bulk level via The two estimators differ only in how the per-cell density is computed: -* ``grid_gaussian`` evaluates the same Gaussian KDE described in +* ``grid`` (Gaussian) evaluates the same Gaussian KDE described in §3 at each cell centre — exactly the estimator - :meth:`rays_gaussian` uses, just sampled on a grid rather than + :meth:`InterfaceExtractor.rays` (Gaussian) uses, just sampled on a grid rather than along rays. No additional smoothing step. -* ``grid_binning`` is a histogram: for slicing mode, atoms within +* ``grid`` (binning) is a histogram: for slicing mode, atoms within ``±bin_width_x/2`` of the slice plane contribute to the 2D histogram in ``(s, z)`` (the slab cut); for whole mode, atoms are binned directly into 3D ``(x, y, z)`` cells. Density is @@ -180,9 +179,9 @@ how the slicing method exposes droplet asymmetry. Two volume-normalisation notes: -* ``grid_gaussian`` returns 3D density per ų directly from the KDE +* ``grid`` (Gaussian) returns 3D density per ų directly from the KDE evaluation; no extra volume normalisation needed. -* ``grid_binning``'s slab-cut histogram divides by +* ``grid`` (binning)'s slab-cut histogram divides by ``ds × dz × bin_width_x`` so the recovered field is also in atoms/ų. The slab thickness equals ``bin_width_x`` (the in-plane horizontal cell width), which keeps the bin's cross-section in the @@ -259,8 +258,8 @@ to roughly a 3° shift in the recovered angle. So either pick the wall detector that matches your trust budget, or report the angle for two choices to make the dependence visible. -7. Joint coupled-binning fit ----------------------------- +7. Joint coupled fit +-------------------- The :class:`CoupledFit2DAnalyzer` and :class:`CoupledFit3DAnalyzer` skip the @@ -391,9 +390,9 @@ batches before the full pipeline runs. Three regimes are useful: ``batch_size=-1`` Pool every requested frame into a single batch — one angle for - the whole trajectory. The default for the coupled-binning - analyzers; useful for the slicing/whole pipeline too when you - only want a representative angle. + the whole trajectory. The default for the coupled-fit analyzers; + useful for the slicing/whole pipeline too when you only want a + representative angle. The trade-off: the per-batch fit cost scales with the number of atoms in the batch (roughly linearly for ray fans, sub-linearly diff --git a/docs/source/tutorials/coupled_fit_2d_tuto.rst b/docs/source/tutorials/coupled_fit_2d_tuto.rst index 224ca9c..295b2f2 100644 --- a/docs/source/tutorials/coupled_fit_2d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_2d_tuto.rst @@ -12,11 +12,11 @@ resolution. The per-cell density is computed by a pluggable :class:`DensityEstimator` strategy: the default -:meth:`DensityEstimator.binning` is a top-hat histogram (legacy -behaviour); :meth:`DensityEstimator.gaussian` evaluates a 3D Gaussian -KDE at the cell centres for a smooth, Poisson-noise-free density — -useful for per-frame analyses where the histogram occasionally -collapses to a degenerate fit. See §6.2 for a worked example. +:meth:`DensityEstimator.binning` is a top-hat histogram; +:meth:`DensityEstimator.gaussian` evaluates a 3D Gaussian KDE at +the cell centres for a smooth, Poisson-noise-free density — useful +for per-frame analyses where the histogram occasionally collapses +to a degenerate fit. See §6.2 for a worked example. ---- @@ -98,7 +98,7 @@ Example trajectory:: atom_indices=oxygen_indices, droplet_geometry="cylinder_y", binning_params=binning_params, - # 10-frame pooled batches (legacy split_factor=10 analog) + # 10-frame pooled batches temporal_aggregator=TemporalAggregator(batch_size=10), ) @@ -179,7 +179,7 @@ overlaid, see :doc:`visualization_evolution_density`. 6.1 Cylindrical droplet ^^^^^^^^^^^^^^^^^^^^^^^ -The 2D coupled-binning analyzer handles cylindrical droplets out of +The 2D coupled-fit analyzer handles cylindrical droplets out of the box — pass ``droplet_geometry="cylinder_y"`` (or ``"cylinder_x"``). The projection switches from radial (:math:`\xi = \sqrt{x^2 + y^2}`) to perpendicular-to-axis @@ -219,7 +219,7 @@ where the histogram density has too many empty cells. Pass a :meth:`DensityEstimator.gaussian` instance to switch the per-cell density to a 3D Gaussian KDE evaluated at the grid cell -centres — the same kernel ``rays_gaussian`` and ``grid_gaussian`` +centres — the same kernel ``rays`` (Gaussian) and ``grid`` (Gaussian) use. The density field becomes smooth; per-cell Poisson noise disappears at the cost of a small constant per-fit overhead: @@ -239,7 +239,7 @@ disappears at the cost of a small constant per-fit overhead: temporal_aggregator=TemporalAggregator(batch_size=1), ) -Pick the same ``density_sigma`` you would for ``rays_gaussian`` on +Pick the same ``density_sigma`` you would for ``rays`` (Gaussian) on the same system (3 Å is the default; smaller for finer features, larger for sparser systems). The recovered angle differs from the binning variant by at most ~1° on well-pooled batches, but the diff --git a/docs/source/tutorials/grid_method_tuto.rst b/docs/source/tutorials/grid_method_tuto.rst index 06648f3..31a9cbd 100644 --- a/docs/source/tutorials/grid_method_tuto.rst +++ b/docs/source/tutorials/grid_method_tuto.rst @@ -2,8 +2,8 @@ Tutorial: Grid-Based Interface Extraction ========================================== This tutorial covers the **grid-based interface extractors** — -:meth:`InterfaceExtractor.grid_gaussian` and -:meth:`InterfaceExtractor.grid_binning`. They are an alternative to +:meth:`InterfaceExtractor.grid` (Gaussian) and +:meth:`InterfaceExtractor.grid` (binning). They are an alternative to the ray-fan extractors used in the :doc:`slicing_method_tuto` and :doc:`whole_fit_tuto`: instead of locating the interface as the @@ -42,11 +42,11 @@ the ``grid3d`` extra:: ---- -2. Worked example: ``grid_gaussian`` + slicing fit +2. Worked example: ``grid`` (Gaussian) + slicing fit --------------------------------------------------- A spherical droplet, with per-azimuthal-slice 2D density grids in the -``(s, z)`` plane — same density estimator as ``rays_gaussian``, just +``(s, z)`` plane — same density estimator as ``rays`` (Gaussian), just sampled on a fixed grid rather than along rays: .. code-block:: python @@ -81,10 +81,12 @@ sampled on a fixed grid rather than along rays: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.grid_gaussian( - grid_params=grid_params, - delta_azimuthal=20.0, # 9 azimuthal slices - density_sigma=2.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.grid( + grid_params=grid_params, + delta_azimuthal=20.0, # 9 azimuthal slices + ), + density=DensityEstimator.gaussian(density_sigma=2.0), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=3.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -92,25 +94,29 @@ sampled on a fixed grid rather than along rays: ) batch = analyzer.analyze([1]).batches[0] print( - f"Angle (grid_gaussian + slicing): {batch.angle:.2f}° " + f"Angle (grid (Gaussian) + slicing): {batch.angle:.2f}° " f"± {batch.angle_std:.2f}° across {len(batch.per_slice_angles)} slices" ) ---- -3. Histogram alternative: ``grid_binning`` +3. Histogram alternative: ``grid`` (binning) ------------------------------------------ Same per-slice iteration, but the density estimator is a top-hat histogram of atoms within the slab ``|perp| ≤ bin_width_x / 2`` of the slice plane. Numerically cheaper than the KDE; intrinsically noisier because only atoms in the slab contribute (not all atoms -along the slice direction the way they do for ``rays_binning``). -Use coarser cells (thicker slab) than for ``grid_gaussian``: +along the slice direction the way they do for ``rays`` (binning)). +Use coarser cells (thicker slab) than for ``grid`` (Gaussian): .. code-block:: python - from wetting_angle_kit.analysis import InterfaceExtractor + from wetting_angle_kit.analysis import ( + DensityEstimator, + InterfaceExtractor, + SpaceSampling, + ) grid_params = { "xi_0": -40.0, @@ -120,9 +126,12 @@ Use coarser cells (thicker slab) than for ``grid_gaussian``: "zi_f": 40.0, "bin_width_z": 3.0, } - extractor = InterfaceExtractor.grid_binning( - grid_params=grid_params, - delta_azimuthal=60.0, # fewer slices → more atoms per slab + extractor = InterfaceExtractor( + sampling=SpaceSampling.grid( + grid_params=grid_params, + delta_azimuthal=60.0, # fewer slices → more atoms per slab + ), + density=DensityEstimator.binning(), ) The slab thickness perpendicular to each slice plane is @@ -158,9 +167,9 @@ takes no ``delta_azimuthal`` / ``delta_cylinder``: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.grid_gaussian( - grid_params=grid_params_3d, - density_sigma=3.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params_3d), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole( surface_filter_offset=3.0, @@ -170,7 +179,7 @@ takes no ``delta_azimuthal`` / ``delta_cylinder``: ) batch = analyzer.analyze([1]).batches[0] print( - f"Angle (grid_gaussian + whole-fit): " + f"Angle (grid (Gaussian) + whole-fit): " f"{batch.angle:.2f}° ± {batch.angle_std:.2f}°" ) @@ -205,11 +214,11 @@ Three notes on the 3D case: are honoured exactly and the cell width is rounded to fit, so the effective cell size may differ slightly from the value you pass. - **Comparison plot**: run the same trajectory through both - ``rays_gaussian`` and ``grid_gaussian`` and check the two angles + ``rays`` (Gaussian) and ``grid`` (Gaussian) and check the two angles agree within method-dependent tolerance (a few degrees on 4k-atom droplets). If they diverge by more than ~8°, one of them is misconfigured (most often the grid bounds are too tight or ``surface_filter_offset`` is too small). -- **grid_binning slab thickness**: the slab perpendicular to each +- **grid + binning slab thickness**: the slab perpendicular to each slice equals ``bin_width_x``. If you see a noisy iso-contour, - thicken it (larger ``bin_width_x``) before reaching for ``grid_gaussian``. + thicken it (larger ``bin_width_x``) before reaching for ``grid`` (Gaussian). diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index ed8d5e9..1526fdc 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -77,15 +77,18 @@ Example trajectory:: print("Number of water molecules:", len(oxygen_indices)) # --- Step 3: Build the trajectory analyzer --- - # Strategies: rays_gaussian extractor + slicing fitter + + # Strategies: rays extractor (Gaussian) + slicing fitter + # interface-derived wall + per-frame batching. analyzer = TrajectoryAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, # 20° between slicing planes - delta_polar=8.0, # 8° in-plane ray step + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=20.0, # 20° between slicing planes + delta_polar=8.0, # 8° in-plane ray step + ), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -188,9 +191,12 @@ fitter that either NaNs out or returns a non-physical angle: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", # or "cylinder_x" - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_cylinder=5.0, # 5 Å between slicing planes - delta_polar=8.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_cylinder=5.0, # 5 Å between slicing planes + delta_polar=8.0, + ), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -204,7 +210,7 @@ fixture ``tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj`` in the repository is a cylindrical-droplet trajectory you can use as a worked example. -6.2 ``rays_binning`` alternative +6.2 ``rays`` (binning) alternative ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The same ray-fan geometry is available with a 1D histogram density @@ -214,18 +220,20 @@ the bin width): .. code-block:: python - interface_extractor = InterfaceExtractor.rays_binning( - delta_azimuthal=20.0, - delta_polar=8.0, - bin_width=3.0, # 3 Å diameter top-hat - points_per_angstrom=1.0, + interface_extractor = InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=20.0, + delta_polar=8.0, + points_per_angstrom=1.0, + ), + density=DensityEstimator.binning(bin_width=3.0), # 3 Å diameter top-hat ) The ``bin_width`` parameter sets the diameter of the 3D top-hat counted at each sample point along the ray; matching it to the interface thickness (~1–3 Å for water) keeps the tanh fit well-conditioned. Numerically the bin width plays the same role -``density_sigma`` plays for ``rays_gaussian``. +``density_sigma`` plays for ``rays`` (Gaussian). 6.3 Pooled batches ^^^^^^^^^^^^^^^^^^ @@ -272,7 +280,7 @@ For physical context on the trade-off see 6.4 Grid alternative ^^^^^^^^^^^^^^^^^^^^ -The grid extractors (:meth:`InterfaceExtractor.grid_gaussian` and -:meth:`grid_binning`) pair with the slicing fitter exactly the same +The grid extractors (:meth:`InterfaceExtractor.grid` (Gaussian) and +:meth:`InterfaceExtractor.grid` (binning)) pair with the slicing fitter exactly the same way and are covered in :doc:`grid_method_tuto`. Use them when ray-fan sampling is too sparse to resolve the interface. diff --git a/docs/source/tutorials/visualization_evolution_density.rst b/docs/source/tutorials/visualization_evolution_density.rst index f20af85..877defa 100644 --- a/docs/source/tutorials/visualization_evolution_density.rst +++ b/docs/source/tutorials/visualization_evolution_density.rst @@ -44,8 +44,9 @@ mean with its own cumulative ``±σ`` band). parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, delta_polar=8.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -145,7 +146,6 @@ azimuthal collapse so plots are unambiguous. :class:`AngleEvolutionPlotter` to plot the per-batch median across slices instead of the mean (slicing results only; ignored for other result types). -- The legacy ``BinningTrajectoryPlotter`` and - ``SlicingTrajectoryPlotter`` have been replaced by these two - classes; the new pattern is one plotter per concern rather than - per analyzer. +- The package follows a "one plotter per concern" pattern rather + than per analyzer — pass any analyzer's results object to either + plotter, and the plotter dispatches on the result type. diff --git a/docs/source/tutorials/visualization_slicing_droplet.rst b/docs/source/tutorials/visualization_slicing_droplet.rst index 3c8bf86..6e8b2c7 100644 --- a/docs/source/tutorials/visualization_slicing_droplet.rst +++ b/docs/source/tutorials/visualization_slicing_droplet.rst @@ -95,9 +95,9 @@ The workflow: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_cylinder=5.0, - delta_polar=8.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=5.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), diff --git a/docs/source/tutorials/whole_fit_tuto.rst b/docs/source/tutorials/whole_fit_tuto.rst index dac747a..4d70879 100644 --- a/docs/source/tutorials/whole_fit_tuto.rst +++ b/docs/source/tutorials/whole_fit_tuto.rst @@ -35,7 +35,7 @@ deviation of the angles is reported as ------------------------- The full-sphere Fibonacci ray fan -(:meth:`InterfaceExtractor.rays_gaussian` with ``n_rays_sphere=...``) +(:meth:`InterfaceExtractor.rays` (Gaussian) with ``n_rays_sphere=...``) emits rays from the droplet COM in all directions, including downward. Those downward rays hit the wall plane and contribute interface points right at the wall, so the lowest shell point lands @@ -93,9 +93,11 @@ the wall position. parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - n_rays_sphere=400, # 400 rays uniformly over the full sphere - density_sigma=3.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays( + n_rays_sphere=400, # 400 rays uniformly over the full sphere + ), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole( surface_filter_offset=3.0, @@ -167,7 +169,7 @@ want to report: reliable to two significant figures; 1000 will tighten that but costs ~10× more. - **Cylinder droplets** still work — pair the whole fitter with - :meth:`InterfaceExtractor.rays_gaussian` configured with + :meth:`InterfaceExtractor.rays` (Gaussian) configured with ``delta_cylinder`` and ``delta_polar`` instead of ``n_rays_sphere``. The fitter automatically does a 2D circle fit per the cylinder axis convention. @@ -192,10 +194,9 @@ cylinder-mode extractor parameters: parser=LammpsDumpParser(cylinder_fixture), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", # or "cylinder_x" - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_cylinder=5.0, - delta_polar=8.0, - density_sigma=3.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=5.0, delta_polar=8.0), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole( surface_filter_offset=3.0, @@ -224,7 +225,7 @@ No ``wall_atom_indices`` argument needed on the analyzer in this case — the explicit detector ignores any wall-atom data. Useful both for whole-fit and slicing pipelines. -7.3 ``rays_binning`` alternative +7.3 ``rays`` (binning) alternative ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Same Fibonacci-sphere geometry, but the density along each ray is @@ -232,7 +233,7 @@ estimated with a 1D top-hat histogram instead of a Gaussian KDE: .. code-block:: python - interface_extractor = InterfaceExtractor.rays_binning( - n_rays_sphere=400, - bin_width=3.0, + interface_extractor = InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.binning(bin_width=3.0), ) diff --git a/src/wetting_angle_kit/analysis/__init__.py b/src/wetting_angle_kit/analysis/__init__.py index d08b809..de3419f 100644 --- a/src/wetting_angle_kit/analysis/__init__.py +++ b/src/wetting_angle_kit/analysis/__init__.py @@ -13,13 +13,14 @@ DropletGeometry # spherical / cylinder_x / cylinder_y TemporalAggregator # per-frame / pooled batches - InterfaceExtractor # rays / grid × gaussian / binning + InterfaceExtractor # composes (sampling, density) + SpaceSampling # rays / grid + DensityEstimator # gaussian / binning SurfaceFitter # slicing / whole WallDetector # min_plus_offset / explicit / from_atoms -Strategy components (compose into the coupled-fit analyzers):: - - DensityEstimator # binning / gaussian +The coupled-fit analyzers also accept a :class:`DensityEstimator` +directly (they have their own grid / projection logic). Results dataclasses returned by ``analyze()``:: @@ -28,7 +29,7 @@ CoupledFit3DResults, CoupledFit3DBatchResult """ -from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer +from wetting_angle_kit.analysis._base import BaseTrajectoryAnalyzer # Top-level analyzers. from wetting_angle_kit.analysis.coupled_fit import DensityEstimator @@ -38,9 +39,6 @@ from wetting_angle_kit.analysis.coupled_fit.analyzer_3d import ( CoupledFit3DAnalyzer, ) - -# Strategy components. -from wetting_angle_kit.analysis.extractors import InterfaceExtractor from wetting_angle_kit.analysis.fitters import ( FitOutput, SlicingFitOutput, @@ -49,6 +47,12 @@ ) from wetting_angle_kit.analysis.geometry import DropletGeometry +# Strategy components. +from wetting_angle_kit.analysis.interface import ( + InterfaceExtractor, + SpaceSampling, +) + # Results dataclasses. from wetting_angle_kit.analysis.results import ( BatchResult, @@ -75,6 +79,7 @@ "DropletGeometry", "TemporalAggregator", "InterfaceExtractor", + "SpaceSampling", "SurfaceFitter", "FitOutput", "SlicingFitOutput", diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index fc42bb2..85d3e12 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -26,14 +26,13 @@ import logging import multiprocessing as mp import warnings -from abc import abstractmethod +from abc import ABC, abstractmethod from collections.abc import Callable from typing import Any, ClassVar import numpy as np from tqdm.auto import tqdm -from wetting_angle_kit.analysis.analyzer import BaseTrajectoryAnalyzer from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.io_utils import ( @@ -45,6 +44,22 @@ from wetting_angle_kit.parsers.lammps_dump import LammpsDumpParser from wetting_angle_kit.parsers.xyz import XYZParser + +class BaseTrajectoryAnalyzer(ABC): + """Abstract base for contact angle analysis across trajectory files. + + Concrete analyzers are :class:`TrajectoryAnalyzer` and the two + coupled-fit analyzers; all three extend + :class:`_BatchedTrajectoryAnalyzer` below, which provides the + worker-pool / tqdm scaffolding. + """ + + @abstractmethod + def analyze(self, frame_range: list[int] | None = None) -> Any: + """Run the analysis and return a method-specific results object.""" + pass + + # Spawn is required because parser instances may hold un-picklable # handles (OVITO pipelines, ASE Atoms with C extensions). A scoped # context keeps this side-effect-free at import time. @@ -309,7 +324,7 @@ def _run_inline( progress until the entire batch completes), we publish a progress callback into the per-class ``_WORKER_STATE`` dict. Workers that read it (``gather_batch_coords`` and the - coupled-binning per-frame loops) call it once per frame; the + coupled-fit per-frame loops) call it once per frame; the callback advances the same tqdm bar. The callback lives only for the duration of this inline run — it's not picklable and wouldn't survive a ``Pool.imap`` round-trip anyway. diff --git a/src/wetting_angle_kit/analysis/_density.py b/src/wetting_angle_kit/analysis/_density.py index 77e8d2e..0a62596 100644 --- a/src/wetting_angle_kit/analysis/_density.py +++ b/src/wetting_angle_kit/analysis/_density.py @@ -2,15 +2,15 @@ Used by both the slicing method (re-imported by :class:`wetting_angle_kit.analysis.slicing.surface_definition.SurfaceDefinition`) -and the new ``rays_gaussian`` / ``rays_binning`` extractors that fit -a hyperbolic-tangent profile to the density along a ray. +and the rays/grid extractors that fit a hyperbolic-tangent profile +to the density along a ray or sample it on a grid of cell centres. :class:`GaussianDensityField` wraps a ``cKDTree`` over the atom cloud plus the kernel-width parameters. :class:`HistogramDensityField` is -the equivalent for the histogram-style estimator used by -``rays_binning``. Both expose the same ``evaluate(positions)`` method -so the ray-fan geometry helpers in -:mod:`wetting_angle_kit.analysis.extractors` can take either one. +the equivalent for the histogram-style estimator. Both expose the +same ``evaluate(positions)`` method so the ray-fan geometry helpers +in :mod:`wetting_angle_kit.analysis.interface` and the +:class:`DensityEstimator` strategy can take either one. :func:`fit_tanh_profiles_batched` solves the per-ray tanh fit for an entire slice in one batched Gauss–Newton call. """ @@ -119,13 +119,13 @@ class HistogramDensityField: """Top-hat (histogram-style) density evaluator over a fixed atom cloud. The natural counterpart of :class:`GaussianDensityField` for the - ``rays_binning`` extractor. Conceptually a 1D histogram of atoms + ``rays`` extractor with a binning density. Conceptually a 1D histogram of atoms projected onto each ray, implemented as a 3D top-hat kernel: each sample position counts atoms within a sphere of radius ``bin_width / 2`` and divides by the sphere's volume. This shares the ``cKDTree.sparse_distance_matrix`` machinery used by the Gaussian field and exposes the same ``evaluate`` interface so the - ray-fan geometry helpers in :mod:`wetting_angle_kit.analysis.extractors` + ray-fan geometry helpers in :mod:`wetting_angle_kit.analysis.interface` can take either field. Parameters diff --git a/src/wetting_angle_kit/analysis/analyzer.py b/src/wetting_angle_kit/analysis/analyzer.py deleted file mode 100644 index 03b6e84..0000000 --- a/src/wetting_angle_kit/analysis/analyzer.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Abstract base class for contact-angle analyzers.""" - -from abc import ABC, abstractmethod -from typing import Any - - -class BaseTrajectoryAnalyzer(ABC): - """Abstract base for contact angle analysis across trajectory files.""" - - @abstractmethod - def analyze(self, frame_range: list[int] | None = None) -> Any: - """Run the analysis and return a method-specific results object.""" - pass diff --git a/src/wetting_angle_kit/analysis/coupled_fit/__init__.py b/src/wetting_angle_kit/analysis/coupled_fit/__init__.py index bb9b0c5..b981428 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/__init__.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/__init__.py @@ -23,15 +23,15 @@ separable strategies for per-frame time resolution. """ -from wetting_angle_kit.analysis.coupled_fit._density_estimator import ( - DensityEstimator, -) from wetting_angle_kit.analysis.coupled_fit.analyzer_2d import ( CoupledFit2DAnalyzer, ) from wetting_angle_kit.analysis.coupled_fit.analyzer_3d import ( CoupledFit3DAnalyzer, ) +from wetting_angle_kit.analysis.density_estimator import ( + DensityEstimator, +) __all__ = [ "CoupledFit2DAnalyzer", diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py b/src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py deleted file mode 100644 index 7e942a0..0000000 --- a/src/wetting_angle_kit/analysis/coupled_fit/_density_estimator.py +++ /dev/null @@ -1,277 +0,0 @@ -"""Density-estimator strategies for the coupled-fit analyzers. - -The coupled-fit analyzers -(:class:`CoupledFit2DAnalyzer`, :class:`CoupledFit3DAnalyzer`) build a -cell-centred density field on a fixed grid and fit a hyperbolic-tangent -model to it jointly. The estimator strategy controls how the per-cell -density is computed from the pooled atom positions: - -- :meth:`DensityEstimator.binning` — top-hat histogram with - geometry-aware volume normalisation. The historical method - (`legacy: BinningBatchFitter.binning`). -- :meth:`DensityEstimator.gaussian` — 3D Gaussian KDE evaluated at the - cell centres. Same density estimator the ``rays_gaussian`` and - ``grid_gaussian`` extractors use, so the joint tanh fit sees a - smooth density field with no per-cell Poisson noise. - -The input to the estimator is pooled atom positions in a standard -*droplet-centred internal frame*: ``(x, y)`` are recentered on the -batch-averaged droplet COM (with PBC unwrapping) and ``z`` stays in -the lab frame. That convention lets the estimator pick the right -projection for each ``DropletGeometry`` without needing a separate -pre-projection step. -""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import ClassVar - -import numpy as np - -from wetting_angle_kit.analysis._density import GaussianDensityField -from wetting_angle_kit.analysis.geometry import DropletGeometry - - -@dataclass(frozen=True) -class DensityEstimator(ABC): - """Strategy interface for the coupled-fit per-cell density. - - Concrete instances come from one of the classmethod factories - :meth:`binning` or :meth:`gaussian`; the abstract ``evaluate_*`` - methods are dispatched by the analyzer worker after pooling - droplet-centred atom positions across the batch. - """ - - #: Human-readable kind tag (used in tqdm labels). Set by each - #: concrete subclass. - kind: ClassVar[str] - - @abstractmethod - def evaluate_2d( - self, - *, - atoms_pooled: np.ndarray, - n_frames: int, - droplet_geometry: DropletGeometry, - xi_edges: np.ndarray, - zi_edges: np.ndarray, - box_dimension: float | None, - ) -> np.ndarray: - """2D density on the ``(xi_cc, zi_cc)`` grid. - - Returns a ``(n_xi, n_zi)`` array in atoms/ų, averaged across - the ``n_frames`` pooled into the batch. - - Parameters - ---------- - atoms_pooled : ndarray, shape (N, 3) - Pooled atom positions in the droplet-centred internal - frame (``x``/``y`` recentered on COM; ``z`` in lab frame). - n_frames : int - Number of frames pooled. Used to divide the cumulative - density by frame count. - droplet_geometry : DropletGeometry - ``spherical`` projects atoms to radial ``xi = hypot(x, y)``; - ``cylinder_*`` uses ``xi = |x|`` and folds across the - cylinder axis. - xi_edges, zi_edges : ndarray - 1D cell-edge arrays. - box_dimension : float, optional - Cylinder axis length, only needed for the binning - estimator's geometry-aware ``dV`` factor on - ``cylinder_*`` droplets. - """ - - @abstractmethod - def evaluate_3d( - self, - *, - atoms_pooled: np.ndarray, - n_frames: int, - droplet_geometry: DropletGeometry, - xi_edges: np.ndarray, - yi_edges: np.ndarray, - zi_edges: np.ndarray, - ) -> np.ndarray: - """3D density on the ``(xi_cc, yi_cc, zi_cc)`` grid. - - Returns a ``(n_xi, n_yi, n_zi)`` array in atoms/ų, averaged - across the ``n_frames`` pooled into the batch. - - Only ``spherical`` is currently exercised — the 3D coupled-fit - analyzer rejects cylinder droplets at construction. - """ - - # ------------------------------------------------------------------ - # Factories. - # ------------------------------------------------------------------ - - @classmethod - def binning(cls) -> DensityEstimator: - """Top-hat histogram density with geometry-aware ``dV``. - - Atoms in each cell contribute ``1 / dV / n_frames`` to the - density. ``dV`` is the cell's annular volume for - ``spherical``, ``2 · box_dimension · dxi · dzi`` for - ``cylinder_*`` (the binning's |x| folding combined with the - box-length integral along the cylinder axis), and the plain - ``dxi · dyi · dzi`` in 3D. - - This is the legacy estimator; the coupled-fit analyzers - default to it for backwards-compatible numerics. - """ - return _BinningDensityEstimator() - - @classmethod - def gaussian( - cls, - *, - density_sigma: float = 3.0, - cutoff_sigma: float = 5.0, - ) -> DensityEstimator: - """3D Gaussian KDE evaluated at the cell centres. - - Same density estimator as ``rays_gaussian`` and - ``grid_gaussian``. For ``spherical`` droplets the per-cell - evaluation point is ``(xi_cc, 0, zi_cc)``: by axisymmetry the - single annular point is a representative for the whole - annulus, so no annular integration is needed. - - Parameters - ---------- - density_sigma : float, default 3.0 - Gaussian kernel width (Å). - cutoff_sigma : float, default 5.0 - Per-atom kernel truncation in units of ``density_sigma``. - Larger values are slower but more accurate in the - kernel's tails. - """ - return _GaussianDensityEstimator( - density_sigma=density_sigma, cutoff_sigma=cutoff_sigma - ) - - -@dataclass(frozen=True) -class _BinningDensityEstimator(DensityEstimator): - """Concrete estimator for :meth:`DensityEstimator.binning`.""" - - kind: ClassVar[str] = "binning" - - def evaluate_2d( - self, - *, - atoms_pooled: np.ndarray, - n_frames: int, - droplet_geometry: DropletGeometry, - xi_edges: np.ndarray, - zi_edges: np.ndarray, - box_dimension: float | None, - ) -> np.ndarray: - if droplet_geometry.is_spherical: - xi_vals = np.hypot(atoms_pooled[:, 0], atoms_pooled[:, 1]) - else: - xi_vals = np.abs(atoms_pooled[:, 0]) - zi_vals = atoms_pooled[:, 2] - counts, _, _ = np.histogram2d(xi_vals, zi_vals, bins=(xi_edges, zi_edges)) - dxi = float(xi_edges[1] - xi_edges[0]) - dzi = float(zi_edges[1] - zi_edges[0]) - xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) - if droplet_geometry.is_cylinder: - assert box_dimension is not None - dV = 2.0 * box_dimension * dxi * dzi - rho_cc = counts / dV - else: - dV_per_row = 2.0 * np.pi * xi_cc * dxi * dzi - rho_cc = counts / dV_per_row[:, np.newaxis] - if n_frames > 0: - rho_cc = rho_cc / n_frames - return rho_cc - - def evaluate_3d( - self, - *, - atoms_pooled: np.ndarray, - n_frames: int, - droplet_geometry: DropletGeometry, - xi_edges: np.ndarray, - yi_edges: np.ndarray, - zi_edges: np.ndarray, - ) -> np.ndarray: - counts, _ = np.histogramdd(atoms_pooled, bins=(xi_edges, yi_edges, zi_edges)) - dxi = float(xi_edges[1] - xi_edges[0]) - dyi = float(yi_edges[1] - yi_edges[0]) - dzi = float(zi_edges[1] - zi_edges[0]) - rho = counts / (dxi * dyi * dzi) - if n_frames > 0: - rho = rho / n_frames - return rho - - -@dataclass(frozen=True) -class _GaussianDensityEstimator(DensityEstimator): - """Concrete estimator for :meth:`DensityEstimator.gaussian`.""" - - kind: ClassVar[str] = "gaussian" - - density_sigma: float - cutoff_sigma: float - - def _build_field(self, atoms_pooled: np.ndarray) -> GaussianDensityField: - return GaussianDensityField( - atom_coords=atoms_pooled, - density_sigma=self.density_sigma, - cutoff_sigma=self.cutoff_sigma, - ) - - def evaluate_2d( - self, - *, - atoms_pooled: np.ndarray, - n_frames: int, - droplet_geometry: DropletGeometry, - xi_edges: np.ndarray, - zi_edges: np.ndarray, - box_dimension: float | None, - ) -> np.ndarray: - field = self._build_field(atoms_pooled) - xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) - zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) - xi_mesh, zi_mesh = np.meshgrid(xi_cc, zi_cc, indexing="ij") - # Evaluation plane: y=0 for both geometries. - # - spherical: by axisymmetry the (xi, 0, zi) point is a - # representative for the whole annulus at radius xi. - # - cylinder: atoms are droplet-centred in y, so y=0 is the - # cylinder midpoint. Translational invariance along y means - # any y cross-section gives the same density. - positions = np.column_stack( - [xi_mesh.ravel(), np.zeros(xi_mesh.size), zi_mesh.ravel()] - ) - rho_flat = field.evaluate(positions) - rho_cc = rho_flat.reshape(xi_mesh.shape) - if n_frames > 0: - rho_cc = rho_cc / n_frames - return rho_cc - - def evaluate_3d( - self, - *, - atoms_pooled: np.ndarray, - n_frames: int, - droplet_geometry: DropletGeometry, - xi_edges: np.ndarray, - yi_edges: np.ndarray, - zi_edges: np.ndarray, - ) -> np.ndarray: - field = self._build_field(atoms_pooled) - xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) - yi_cc = 0.5 * (yi_edges[:-1] + yi_edges[1:]) - zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) - xi_mesh, yi_mesh, zi_mesh = np.meshgrid(xi_cc, yi_cc, zi_cc, indexing="ij") - positions = np.column_stack([xi_mesh.ravel(), yi_mesh.ravel(), zi_mesh.ravel()]) - rho_flat = field.evaluate(positions) - rho = rho_flat.reshape(xi_mesh.shape) - if n_frames > 0: - rho = rho / n_frames - return rho diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_models.py b/src/wetting_angle_kit/analysis/coupled_fit/_models.py index 63f7bb5..1ff74c0 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/_models.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/_models.py @@ -1,4 +1,4 @@ -"""Hyperbolic-tangent models + heuristic-grid helpers for the coupled-binning analyzers. +"""Hyperbolic-tangent models + grid helpers for the coupled-fit analyzers. Both the 2D (seven-parameter) and 3D (nine-parameter) joint density models are kept in this module so the shared bounds / warning / cap-angle @@ -271,12 +271,12 @@ def compute_contact_angle(self) -> float: # ---------------------------------------------------------------------- -#: Default cell width in 2D coupled binning (Å). Matches ``t1 / 2`` from +#: Default cell width for the 2D coupled fit (Å). Matches ``t1 / 2`` from #: :class:`_HyperbolicTangentModel2D.DEFAULT_INITIAL_PARAMS` so the #: per-bin density resolves the tanh interface profile. _DEFAULT_BIN_WIDTH_2D = 0.5 -#: Default cell width in 3D coupled binning (Å). Coarser than the 2D +#: Default cell width for the 3D coupled fit (Å). Coarser than the 2D #: default to keep the total cell count tractable for the 9-parameter #: NLLS fit (3D grids at 0.5 Å cells would give ~1.7M cells for a #: typical box). diff --git a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py index 298247e..eac1887 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py @@ -31,15 +31,15 @@ _BatchedTrajectoryAnalyzer, build_parser, ) -from wetting_angle_kit.analysis.coupled_fit._density_estimator import ( - DensityEstimator, -) from wetting_angle_kit.analysis.coupled_fit._models import ( _PARAM_NAMES, _default_binning_params, _HyperbolicTangentModel2D, edges_from_bin_width, ) +from wetting_angle_kit.analysis.density_estimator import ( + DensityEstimator, +) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( CoupledFit2DBatchResult, @@ -83,7 +83,7 @@ class CoupledFit2DAnalyzer(_BatchedTrajectoryAnalyzer): default, top-hat histogram with geometry-aware ``dV`` normalisation) or :meth:`DensityEstimator.gaussian` (3D Gaussian KDE evaluated at the cell centres; same kernel - the ``rays_gaussian`` / ``grid_gaussian`` extractors use). + the ``rays`` / ``grid`` with the Gaussian extractors use). Switching to the Gaussian variant smooths out per-cell Poisson noise — useful on per-frame / small-batch analyses where the histogram density is degenerate. @@ -133,7 +133,7 @@ def __init__( self.density_estimator = density_estimator or DensityEstimator.binning() self.initial_params = initial_params # Cylinder dV normalisation needs the box length along the - # cylinder axis; read it once at construction (per legacy). + # cylinder axis; read it once at construction. self.box_dimension: float | None if self.droplet_geometry.is_cylinder: if self.droplet_geometry.cylinder_axis == "x": @@ -260,8 +260,8 @@ def _process_batch_worker( ) # Joint tanh fit. ``_HyperbolicTangentModel2D`` expects the - # density and grid axes flattened in Fortran order — same - # as the legacy ``BinningBatchFitter.process_batch``. + # density and grid axes flattened in Fortran order so the + # ``(xi, zi)`` pairs line up with their density values. model = _HyperbolicTangentModel2D(initial_params=initial_params) msh_zi_grid, msh_xi_grid = np.meshgrid(zi_cc, xi_cc) n_flat = len(xi_cc) * len(zi_cc) diff --git a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py index 4076cec..be7144a 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py @@ -29,15 +29,15 @@ _BatchedTrajectoryAnalyzer, build_parser, ) -from wetting_angle_kit.analysis.coupled_fit._density_estimator import ( - DensityEstimator, -) from wetting_angle_kit.analysis.coupled_fit._models import ( _PARAM_NAMES_3D, _default_binning_params_3d, _HyperbolicTangentModel3D, edges_from_bin_width, ) +from wetting_angle_kit.analysis.density_estimator import ( + DensityEstimator, +) from wetting_angle_kit.analysis.geometry import DropletGeometry from wetting_angle_kit.analysis.results import ( CoupledFit3DBatchResult, diff --git a/src/wetting_angle_kit/analysis/density_estimator.py b/src/wetting_angle_kit/analysis/density_estimator.py new file mode 100644 index 0000000..7a0856b --- /dev/null +++ b/src/wetting_angle_kit/analysis/density_estimator.py @@ -0,0 +1,467 @@ +"""Density-estimator strategies used across the analysis package. + +A :class:`DensityEstimator` answers "how do I compute a density from +a set of atom positions?". The same strategy is consumed in four +distinct evaluation patterns: + +- **Pointwise 3D** — used by :class:`InterfaceExtractor` with + :meth:`SpaceSampling.rays`, via :meth:`build_field`. Returns a + :class:`DensityFieldProtocol` whose ``.evaluate(positions)`` gives + the density at arbitrary 3D query points (the ray sample + positions). +- **Grid + slicing plane** — used by :class:`InterfaceExtractor` + with :meth:`SpaceSampling.grid` in slicing mode, via + :meth:`evaluate_on_slice`. Returns a 2D density on a slice-plane + cell grid. +- **Grid + 3D volume** — used by :class:`InterfaceExtractor` with + :meth:`SpaceSampling.grid` in whole mode, via + :meth:`evaluate_on_3d_grid`. Returns a 3D density on a Cartesian + cell grid. +- **Radial-projected / 3D** for the coupled-fit analyzers — used by + :class:`CoupledFit2DAnalyzer` (:meth:`evaluate_2d`) and + :class:`CoupledFit3DAnalyzer` (:meth:`evaluate_3d`). The 2D path + exploits the spherical droplet's axisymmetry by folding atoms onto + ``(xi = hypot(x, y), zi)`` cells and dividing by the annular + volume; the cylinder path folds with ``xi = |x|`` and divides by + the cylinder-length factor. The 3D path is a plain Cartesian grid. + +Two concrete strategies are exposed via the classmethod factories: + +- :meth:`DensityEstimator.binning` — top-hat histogram. Cheap and + exact, intrinsically noisy at low per-cell counts. +- :meth:`DensityEstimator.gaussian` — 3D Gaussian KDE. Smooth, no + per-cell Poisson noise, slightly more expensive. + +Both factories return frozen dataclass instances that carry their +own parameters (``bin_width`` for binning; ``density_sigma`` and +``cutoff_sigma`` for the Gaussian); the consumers only need to know +about the abstract :class:`DensityEstimator` interface. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import ClassVar + +import numpy as np + +from wetting_angle_kit.analysis._density import ( + DensityFieldProtocol, + GaussianDensityField, + HistogramDensityField, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry + + +@dataclass(frozen=True) +class DensityEstimator(ABC): + """Strategy interface for density estimation. + + Concrete instances come from one of the classmethod factories + :meth:`binning` or :meth:`gaussian`; the abstract methods are + dispatched by the analyzer / extractor that consumes them. + """ + + #: Human-readable kind tag (used in tqdm labels). + kind: ClassVar[str] + + # ------------------------------------------------------------------ + # Pointwise interface (rays extractor). + # ------------------------------------------------------------------ + + @abstractmethod + def build_field(self, atoms: np.ndarray) -> DensityFieldProtocol: + """Pointwise 3D density evaluator on the given atom set. + + Returns an object exposing ``evaluate(positions)`` for + arbitrary ``(N, 3)`` query points. Used by the rays + extractor to sample density along each ray. + + The binning estimator requires ``bin_width`` to have been set + on the factory call; calling :meth:`build_field` without one + raises :class:`ValueError`. + """ + + # ------------------------------------------------------------------ + # Grid interface (grid extractor). + # ------------------------------------------------------------------ + + @abstractmethod + def evaluate_on_slice( + self, + atoms: np.ndarray, + slice_center: np.ndarray, + in_plane_axis: np.ndarray, + s_centers: np.ndarray, + z_centers: np.ndarray, + slab_thickness: float, + ) -> np.ndarray: + """2D density on the cell centres of a slice plane. + + Returns a ``(len(s_centers), len(z_centers))`` array. The + slice plane is defined by ``slice_center`` (a 3D point on + the plane) and ``in_plane_axis`` (a horizontal unit vector + defining the radial coordinate ``s``). + + For the Gaussian estimator, the KDE is evaluated at each + cell-centre 3D point on the plane. For the binning estimator, + atoms inside the slab ``|perp| ≤ slab_thickness / 2`` are + histogrammed in ``(s, z)``; each cell's density is + ``counts / (ds · dz · slab_thickness)``. + """ + + @abstractmethod + def evaluate_on_3d_grid( + self, + atoms: np.ndarray, + x_centers: np.ndarray, + y_centers: np.ndarray, + z_centers: np.ndarray, + *, + x_offset: float, + y_offset: float, + ) -> np.ndarray: + """3D density on the cell centres of a Cartesian grid. + + Returns a ``(len(x_centers), len(y_centers), len(z_centers))`` + array. The grid is laterally droplet-centred (``x_offset``, + ``y_offset`` shift the cell coordinates back to the lab + frame for evaluation against the lab-frame atoms). + """ + + # ------------------------------------------------------------------ + # Coupled-fit interface (radial / 3D box density with dV). + # ------------------------------------------------------------------ + + @abstractmethod + def evaluate_2d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + zi_edges: np.ndarray, + box_dimension: float | None, + ) -> np.ndarray: + """Coupled-fit 2D: radial-projected ``(xi, zi)`` density. + + Returns a ``(n_xi, n_zi)`` array in atoms/ų, averaged across + the ``n_frames`` pooled into the batch. For spherical, atoms + fold onto ``xi = hypot(x, y)`` with annular ``dV``; for + cylinder, atoms fold onto ``xi = |x|`` with cylinder-length + ``dV``. + """ + + @abstractmethod + def evaluate_3d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + yi_edges: np.ndarray, + zi_edges: np.ndarray, + ) -> np.ndarray: + """Coupled-fit 3D: Cartesian ``(xi, yi, zi)`` density. + + Returns a ``(n_xi, n_yi, n_zi)`` array in atoms/ų, averaged + across the ``n_frames`` pooled into the batch. + + Only ``spherical`` is currently exercised — the 3D coupled-fit + analyzer rejects cylinder droplets at construction. + """ + + # ------------------------------------------------------------------ + # Factories. + # ------------------------------------------------------------------ + + @classmethod + def binning(cls, *, bin_width: float | None = None) -> DensityEstimator: + """Top-hat histogram density estimator. + + Parameters + ---------- + bin_width : float, optional + Side length (Å) of the 3D top-hat kernel used by + :meth:`build_field` for pointwise evaluation (the rays + extractor). Ignored by :meth:`evaluate_on_slice`, + :meth:`evaluate_on_3d_grid`, :meth:`evaluate_2d`, and + :meth:`evaluate_3d` — those consumers derive their cell + sizes from the grid spec they're given. Required only + when the estimator is consumed pointwise. + """ + return _BinningDensityEstimator(bin_width=bin_width) + + @classmethod + def gaussian( + cls, + *, + density_sigma: float = 3.0, + cutoff_sigma: float = 5.0, + ) -> DensityEstimator: + """3D Gaussian KDE density estimator. + + Parameters + ---------- + density_sigma : float, default 3.0 + Gaussian kernel width (Å). + cutoff_sigma : float, default 5.0 + Per-atom kernel truncation in units of ``density_sigma``. + Larger values are slower but more accurate in the + kernel's tails. + """ + return _GaussianDensityEstimator( + density_sigma=density_sigma, cutoff_sigma=cutoff_sigma + ) + + +# --------------------------------------------------------------------------- +# Concrete implementations. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class _BinningDensityEstimator(DensityEstimator): + """Concrete estimator for :meth:`DensityEstimator.binning`.""" + + kind: ClassVar[str] = "binning" + + #: 3D top-hat kernel side length for pointwise evaluation. Required + #: only by :meth:`build_field` (the rays extractor); ``None`` is + #: fine when this estimator is consumed by grid or coupled-fit. + bin_width: float | None + + def build_field(self, atoms: np.ndarray) -> DensityFieldProtocol: + if self.bin_width is None: + raise ValueError( + "DensityEstimator.binning() needs bin_width=... for " + "pointwise evaluation (the rays extractor). Either pass " + "bin_width when building the estimator, or use it with the " + "grid / coupled-fit consumers that derive the cell size " + "from their grid spec." + ) + return HistogramDensityField(atom_coords=atoms, bin_width=self.bin_width) + + def evaluate_on_slice( + self, + atoms: np.ndarray, + slice_center: np.ndarray, + in_plane_axis: np.ndarray, + s_centers: np.ndarray, + z_centers: np.ndarray, + slab_thickness: float, + ) -> np.ndarray: + # Slab cut: atoms within ±slab_thickness/2 of the slice plane + # along the perp direction. Then histogram their (s, z) + # projection on the slice plane. + perp_axis = np.array([-in_plane_axis[1], in_plane_axis[0], 0.0]) + rel = atoms - slice_center[None, :] + s_coord = rel @ in_plane_axis + perp_coord = rel @ perp_axis + mask = np.abs(perp_coord) <= 0.5 * slab_thickness + s_edges = _edges_from_centers(s_centers) + z_edges = _edges_from_centers(z_centers) + counts, _, _ = np.histogram2d( + s_coord[mask], atoms[mask, 2], bins=(s_edges, z_edges) + ) + ds = float(s_centers[1] - s_centers[0]) if len(s_centers) > 1 else 1.0 + dz = float(z_centers[1] - z_centers[0]) if len(z_centers) > 1 else 1.0 + return counts / (ds * dz * slab_thickness) + + def evaluate_on_3d_grid( + self, + atoms: np.ndarray, + x_centers: np.ndarray, + y_centers: np.ndarray, + z_centers: np.ndarray, + *, + x_offset: float, + y_offset: float, + ) -> np.ndarray: + # The grid is droplet-centred (cells defined relative to COM). + # Shift atoms to the same frame, then histogram into cells. + atoms_centered = atoms - np.array([x_offset, y_offset, 0.0]) + x_edges = _edges_from_centers(x_centers) + y_edges = _edges_from_centers(y_centers) + z_edges = _edges_from_centers(z_centers) + counts, _ = np.histogramdd(atoms_centered, bins=(x_edges, y_edges, z_edges)) + dx = float(x_centers[1] - x_centers[0]) if len(x_centers) > 1 else 1.0 + dy = float(y_centers[1] - y_centers[0]) if len(y_centers) > 1 else 1.0 + dz = float(z_centers[1] - z_centers[0]) if len(z_centers) > 1 else 1.0 + return counts / (dx * dy * dz) + + def evaluate_2d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + zi_edges: np.ndarray, + box_dimension: float | None, + ) -> np.ndarray: + if droplet_geometry.is_spherical: + xi_vals = np.hypot(atoms_pooled[:, 0], atoms_pooled[:, 1]) + else: + xi_vals = np.abs(atoms_pooled[:, 0]) + zi_vals = atoms_pooled[:, 2] + counts, _, _ = np.histogram2d(xi_vals, zi_vals, bins=(xi_edges, zi_edges)) + dxi = float(xi_edges[1] - xi_edges[0]) + dzi = float(zi_edges[1] - zi_edges[0]) + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + if droplet_geometry.is_cylinder: + assert box_dimension is not None + dV = 2.0 * box_dimension * dxi * dzi + rho_cc = counts / dV + else: + dV_per_row = 2.0 * np.pi * xi_cc * dxi * dzi + rho_cc = counts / dV_per_row[:, np.newaxis] + if n_frames > 0: + rho_cc = rho_cc / n_frames + return rho_cc + + def evaluate_3d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + yi_edges: np.ndarray, + zi_edges: np.ndarray, + ) -> np.ndarray: + counts, _ = np.histogramdd(atoms_pooled, bins=(xi_edges, yi_edges, zi_edges)) + dxi = float(xi_edges[1] - xi_edges[0]) + dyi = float(yi_edges[1] - yi_edges[0]) + dzi = float(zi_edges[1] - zi_edges[0]) + rho = counts / (dxi * dyi * dzi) + if n_frames > 0: + rho = rho / n_frames + return rho + + +@dataclass(frozen=True) +class _GaussianDensityEstimator(DensityEstimator): + """Concrete estimator for :meth:`DensityEstimator.gaussian`.""" + + kind: ClassVar[str] = "gaussian" + + density_sigma: float + cutoff_sigma: float + + def build_field(self, atoms: np.ndarray) -> DensityFieldProtocol: + return GaussianDensityField( + atom_coords=atoms, + density_sigma=self.density_sigma, + cutoff_sigma=self.cutoff_sigma, + ) + + def evaluate_on_slice( + self, + atoms: np.ndarray, + slice_center: np.ndarray, + in_plane_axis: np.ndarray, + s_centers: np.ndarray, + z_centers: np.ndarray, + slab_thickness: float, # noqa: ARG002 — unused; KDE is pointwise + ) -> np.ndarray: + # Evaluate the 3D KDE at each cell-centre point on the slice + # plane. The slab thickness is meaningless for a pointwise + # estimator (kept in the signature for interface symmetry + # with the binning variant). + field = self.build_field(atoms) + s_mesh, z_mesh = np.meshgrid(s_centers, z_centers, indexing="ij") + positions = np.column_stack( + [ + slice_center[0] + s_mesh.ravel() * in_plane_axis[0], + slice_center[1] + s_mesh.ravel() * in_plane_axis[1], + z_mesh.ravel(), + ] + ) + return field.evaluate(positions).reshape(s_mesh.shape) + + def evaluate_on_3d_grid( + self, + atoms: np.ndarray, + x_centers: np.ndarray, + y_centers: np.ndarray, + z_centers: np.ndarray, + *, + x_offset: float, + y_offset: float, + ) -> np.ndarray: + field = self.build_field(atoms) + x_mesh, y_mesh, z_mesh = np.meshgrid( + x_centers, y_centers, z_centers, indexing="ij" + ) + positions = np.column_stack( + [ + (x_mesh + x_offset).ravel(), + (y_mesh + y_offset).ravel(), + z_mesh.ravel(), + ] + ) + return field.evaluate(positions).reshape(x_mesh.shape) + + def evaluate_2d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + zi_edges: np.ndarray, + box_dimension: float | None, # noqa: ARG002 — unused; KDE is pointwise + ) -> np.ndarray: + field = self.build_field(atoms_pooled) + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) + xi_mesh, zi_mesh = np.meshgrid(xi_cc, zi_cc, indexing="ij") + # Evaluation plane: y=0 for both geometries. Spherical: by + # axisymmetry, (xi, 0, zi) is representative of the whole + # annulus. Cylinder: atoms are droplet-centred in y, so y=0 + # is the cylinder midpoint; translational invariance. + positions = np.column_stack( + [xi_mesh.ravel(), np.zeros(xi_mesh.size), zi_mesh.ravel()] + ) + rho_cc = field.evaluate(positions).reshape(xi_mesh.shape) + if n_frames > 0: + rho_cc = rho_cc / n_frames + return rho_cc + + def evaluate_3d( + self, + *, + atoms_pooled: np.ndarray, + n_frames: int, + droplet_geometry: DropletGeometry, + xi_edges: np.ndarray, + yi_edges: np.ndarray, + zi_edges: np.ndarray, + ) -> np.ndarray: + field = self.build_field(atoms_pooled) + xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) + yi_cc = 0.5 * (yi_edges[:-1] + yi_edges[1:]) + zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) + xi_mesh, yi_mesh, zi_mesh = np.meshgrid(xi_cc, yi_cc, zi_cc, indexing="ij") + positions = np.column_stack([xi_mesh.ravel(), yi_mesh.ravel(), zi_mesh.ravel()]) + rho = field.evaluate(positions).reshape(xi_mesh.shape) + if n_frames > 0: + rho = rho / n_frames + return rho + + +# --------------------------------------------------------------------------- +# Helpers. +# --------------------------------------------------------------------------- + + +def _edges_from_centers(centers: np.ndarray) -> np.ndarray: + """Recover cell edges from cell centres assuming uniform spacing.""" + if len(centers) < 2: + return np.array([float(centers[0]) - 0.5, float(centers[0]) + 0.5]) + step = float(centers[1] - centers[0]) + return np.concatenate([centers - 0.5 * step, [centers[-1] + 0.5 * step]]) diff --git a/src/wetting_angle_kit/analysis/extractors/__init__.py b/src/wetting_angle_kit/analysis/extractors/__init__.py deleted file mode 100644 index e8de8c9..0000000 --- a/src/wetting_angle_kit/analysis/extractors/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Interface extractors: build the interface point set from raw atoms. - -An :class:`InterfaceExtractor` converts pooled liquid-atom coordinates -into one of two output shapes, determined by the :class:`SurfaceFitter` -the analyzer is paired with: - -- ``surface_kind="slicing"`` → a list of per-slice ``(M_i, 2)`` arrays - in the slice ``(x, z)`` plane; -- ``surface_kind="whole"`` → a single ``(N, 3)`` shell array in the - internal ``(x, y, z)`` frame. - -Extractors are constructed through classmethod factories on the base -class — each factory configures one sampling + density-kernel -combination:: - - InterfaceExtractor.rays_gaussian(...) # ray fan + Gaussian KDE + tanh - InterfaceExtractor.rays_binning(...) # ray fan + histogram bins + tanh - InterfaceExtractor.grid_gaussian(...) # 2D KDE map + isocontour (slicing only) - InterfaceExtractor.grid_binning(...) # 2D histogram map + isocontour - # (slicing only) - -The pairing between the chosen extractor and the analyzer's -:class:`SurfaceFitter` is validated at :class:`TrajectoryAnalyzer` -construction via :meth:`InterfaceExtractor.validate_compatibility`. -""" - -from wetting_angle_kit.analysis.extractors.base import InterfaceExtractor - -__all__ = ["InterfaceExtractor"] diff --git a/src/wetting_angle_kit/analysis/extractors/_sampling.py b/src/wetting_angle_kit/analysis/extractors/_sampling.py deleted file mode 100644 index 88b7e04..0000000 --- a/src/wetting_angle_kit/analysis/extractors/_sampling.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Direction-generation helpers used by the ray-based extractors.""" - -import numpy as np - - -def _fibonacci_sphere_directions(n: int) -> np.ndarray: - """Equal-area Fibonacci-spiral directions on the full sphere. - - ``cos θ`` is uniformly spaced over ``[-1, 1]`` (so the surface - density is uniform over the whole sphere) and ``φ`` is incremented - by the golden angle for low-discrepancy azimuthal coverage. - ``i = 0`` sits at the south pole (``cos θ = -1``) and - ``i = n - 1`` at the north pole (``cos θ = 1``). - - The full sphere coverage is important for sessile droplets: rays - emitted from the droplet COM in downward directions traverse the - liquid, hit the wall plane, and contribute interface points at the - wall — making :meth:`WallDetector.min_plus_offset` work correctly - in the whole-fit pipeline. (Restricting to the upper hemisphere - misses the wall, so ``min(shell z)`` lands on ``COM_z`` instead.) - - Parameters - ---------- - n : int - Number of directions. - - Returns - ------- - ndarray, shape (n, 3) - Unit direction vectors covering the full sphere. - """ - if n <= 0: - return np.empty((0, 3)) - i = np.arange(n, dtype=np.float64) - cos_theta = 2.0 * i / (n - 1) - 1.0 if n > 1 else np.array([1.0]) - sin_theta = np.sqrt(np.maximum(0.0, 1.0 - cos_theta * cos_theta)) - golden_angle = np.pi * (3.0 - np.sqrt(5.0)) - phi = (i * golden_angle) % (2.0 * np.pi) - return np.column_stack( - [sin_theta * np.cos(phi), sin_theta * np.sin(phi), cos_theta] - ) diff --git a/src/wetting_angle_kit/analysis/extractors/base.py b/src/wetting_angle_kit/analysis/extractors/base.py deleted file mode 100644 index 7978084..0000000 --- a/src/wetting_angle_kit/analysis/extractors/base.py +++ /dev/null @@ -1,319 +0,0 @@ -"""``InterfaceExtractor`` ABC and the four classmethod factories. - -The factories use deferred imports of the concrete extractor classes -to avoid a circular dependency with the sibling ``_rays`` / ``_grid`` -modules (which inherit from :class:`InterfaceExtractor`). -""" - -from abc import ABC, abstractmethod -from typing import Any, ClassVar, Literal, TypeAlias - -import numpy as np - -from wetting_angle_kit.analysis.geometry import DropletGeometry - -#: What the downstream :class:`SurfaceFitter` will consume. -SurfaceKind = Literal["slicing", "whole"] - -#: Sampling strategy used by the extractor. -SamplingKind = Literal["rays", "grid"] - -#: Interface point set produced by an :class:`InterfaceExtractor` and -#: consumed by :class:`SurfaceFitter` (and, via :class:`WallContext`, -#: by :class:`WallDetector`). -#: -#: - In slicing mode, a list of ``(N_i, 2)`` arrays in the per-slice -#: ``(x, z)`` plane. -#: - In whole mode, a single ``(N, 3)`` array in the internal -#: ``(x, y, z)`` frame. -InterfaceData: TypeAlias = list[np.ndarray] | np.ndarray - - -class InterfaceExtractor(ABC): - """Abstract base for interface point extractors. - - Concrete extractors are constructed through the classmethod - factories :meth:`rays_gaussian`, :meth:`rays_binning`, - :meth:`grid_gaussian`, :meth:`grid_binning`. Direct subclassing is - supported for custom strategies; the factories cover the built-in - cases. - """ - - #: Sampling strategy this extractor uses. Set by each concrete subclass. - sampling: ClassVar[SamplingKind] - - @abstractmethod - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - surface_kind: SurfaceKind, - ) -> InterfaceData: - """Build the interface point set for one batch. - - Parameters - ---------- - liquid_coordinates : ndarray, shape (N, 3) - Pooled liquid-atom coordinates in the internal frame. - center_geom : ndarray, shape (3,) - Geometric droplet center. - droplet_geometry : DropletGeometry - Droplet symmetry; drives the per-slice axis choice for - slicing modes and the ray-fan layout for whole modes. - surface_kind : {"slicing", "whole"} - What the downstream :class:`SurfaceFitter` will consume. - Determines the output shape (per-slice 2D points vs 3D - shell). The analyzer enforces ``surface_kind == fitter.kind`` - via :meth:`validate_compatibility` at construction. - - Returns - ------- - InterfaceData - ``list[ndarray]`` of ``(M_i, 2)`` per-slice points when - ``surface_kind="slicing"``; a single ``(N, 3)`` shell when - ``surface_kind="whole"``. - """ - - @abstractmethod - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - """Raise if this extractor cannot serve ``(surface_kind, geometry)``. - - Called by :class:`TrajectoryAnalyzer.__init__` so misconfigurations - fail fast at construction instead of at the first batch. - """ - - @classmethod - def rays_gaussian( - cls, - *, - delta_azimuthal: float | None = None, - delta_cylinder: float | None = None, - n_rays_sphere: int | None = None, - delta_polar: float = 8.0, - points_per_angstrom: float = 1.0, - density_sigma: float = 3.0, - cutoff_sigma: float = 5.0, - ) -> "InterfaceExtractor": - """Ray fan + Gaussian KDE + per-ray tanh interface fit. - - The interface position along each ray is recovered by smoothing - atom positions with a Gaussian kernel and fitting a hyperbolic - tangent profile to the resulting 1D density. - - Required ray-fan parameters depend on the - ``(surface_kind, droplet_geometry)`` the extractor will be - paired with: - - ========================== ========================================= - surface_kind, geometry required ray params - ========================== ========================================= - slicing, spherical ``delta_azimuthal`` (+ ``delta_polar``) - slicing, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) - whole, spherical ``n_rays_sphere`` - whole, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) - ========================== ========================================= - - Parameters - ---------- - delta_azimuthal : float, optional - Azimuthal step (degrees) between slicing planes for the - spherical slicing mode. - delta_cylinder : float, optional - Step (Å) along the cylinder axis between slices for the - cylinder modes (both slicing and whole). - n_rays_sphere : int, optional - Total number of rays covering the **full sphere** for the - spherical whole-fit mode. Rays are placed via an equal-area - Fibonacci ``(cos θ, φ)`` construction so the angular density - is uniform from south to north pole. Full-sphere (rather - than upper-hemisphere) coverage is intentional: downward - rays from the droplet COM traverse the liquid and hit the - wall plane, producing interface points at the wall — that - keeps :meth:`WallDetector.min_plus_offset` consistent with - the physical wall position. - delta_polar : float, default 8.0 - In-plane ray step (degrees) for every mode that emits rays - in the ``(x, z)`` plane (i.e. everything except - whole + spherical). - points_per_angstrom : float, default 1.0 - Sampling density along each ray (samples per Å). - density_sigma : float, default 3.0 - Gaussian kernel width (Å) for the density smoothing. - Default tuned for full-atomistic water at room temperature. - cutoff_sigma : float, default 5.0 - Per-atom kernel truncation in units of ``density_sigma``. - """ - from wetting_angle_kit.analysis.extractors._rays import ( - _RaysGaussianExtractor, - ) - - return _RaysGaussianExtractor( - delta_azimuthal=delta_azimuthal, - delta_cylinder=delta_cylinder, - n_rays_sphere=n_rays_sphere, - delta_polar=delta_polar, - points_per_angstrom=points_per_angstrom, - density_sigma=density_sigma, - cutoff_sigma=cutoff_sigma, - ) - - @classmethod - def rays_binning( - cls, - *, - delta_azimuthal: float | None = None, - delta_cylinder: float | None = None, - n_rays_sphere: int | None = None, - delta_polar: float = 8.0, - bin_width: float = 1.0, - points_per_angstrom: float = 1.0, - ) -> "InterfaceExtractor": - """Ray fan + histogram density + per-ray tanh interface fit. - - Same ray-fan geometry as :meth:`rays_gaussian` (see that method - for the parameter compatibility table) but density along each - ray is estimated via a 1D histogram rather than a Gaussian - kernel. - - Parameters - ---------- - delta_azimuthal, delta_cylinder, n_rays_sphere, delta_polar : - See :meth:`rays_gaussian`. - bin_width : float, default 1.0 - Diameter (Å) of the 3D top-hat kernel used at each sample - position along the ray: atoms within ``bin_width / 2`` of - a sample contribute uniformly to the density, atoms outside - do not. The natural analogue of :meth:`rays_gaussian`'s - ``density_sigma``, but with a hard cutoff instead of a - smooth fall-off. - points_per_angstrom : float, default 1.0 - Sampling density along each ray (samples per Å). - """ - from wetting_angle_kit.analysis.extractors._rays import ( - _RaysBinningExtractor, - ) - - return _RaysBinningExtractor( - delta_azimuthal=delta_azimuthal, - delta_cylinder=delta_cylinder, - n_rays_sphere=n_rays_sphere, - delta_polar=delta_polar, - bin_width=bin_width, - points_per_angstrom=points_per_angstrom, - ) - - @classmethod - def grid_gaussian( - cls, - *, - grid_params: dict[str, Any] | None = None, - delta_azimuthal: float | None = None, - delta_cylinder: float | None = None, - density_sigma: float = 3.0, - cutoff_sigma: float = 5.0, - ) -> "InterfaceExtractor": - """3D Gaussian-KDE density on a fixed grid + isocontour extraction. - - Same density estimator as :meth:`rays_gaussian`, evaluated at - grid cell centres rather than along rays. - - Per-slice in slicing mode: spherical droplets iterate over - azimuthal angles ``γ ∈ [0°, 180°)`` controlled by - ``delta_azimuthal``; cylindrical droplets iterate over axial - steps controlled by ``delta_cylinder``. Each slice produces an - ``(s, z)`` density grid and one iso-contour. Whole mode builds - a 3D ``(x, y, z)`` grid centred laterally on the droplet COM - and runs marching cubes. - - Parameters - ---------- - grid_params : dict, optional - Grid spec. For slicing, six keys: ``"xi_0"``, ``"xi_f"``, - ``"bin_width_x"``, ``"zi_0"``, ``"zi_f"``, - ``"bin_width_z"``. ``xi_0`` should be negative for a - centred slice that spans both halves of the diameter. For - whole, add three more: ``"yi_0"``, ``"yi_f"``, - ``"bin_width_y"`` (xi/yi grids are in the droplet-centred - lateral frame; zi stays in the lab frame). If ``None`` - (default), the grid is auto-derived per batch from the - atom bounding box plus a 5 Å buffer, with cell width set - to ``density_sigma / 2``. - delta_azimuthal : float, optional - Azimuthal step (degrees) between slicing planes for - ``slicing + spherical``. Required for that case; ignored - otherwise. - delta_cylinder : float, optional - Step (Å) along the cylinder axis between slicing planes - for ``slicing + cylinder``. Required for that case; - ignored otherwise. - density_sigma : float, default 3.0 - Gaussian kernel width (Å) for the KDE. - cutoff_sigma : float, default 5.0 - Per-atom kernel truncation in units of ``density_sigma``. - """ - from wetting_angle_kit.analysis.extractors._grid import ( - _GridGaussianExtractor, - ) - - return _GridGaussianExtractor( - grid_params=dict(grid_params) if grid_params is not None else None, - delta_azimuthal=delta_azimuthal, - delta_cylinder=delta_cylinder, - density_sigma=density_sigma, - cutoff_sigma=cutoff_sigma, - ) - - @classmethod - def grid_binning( - cls, - *, - grid_params: dict[str, Any] | None = None, - delta_azimuthal: float | None = None, - delta_cylinder: float | None = None, - ) -> "InterfaceExtractor": - """Histogram density on a fixed grid + isocontour extraction. - - Same per-slice iteration scheme as :meth:`grid_gaussian` in - slicing mode (``delta_azimuthal`` for spherical, - ``delta_cylinder`` for cylinder), but the per-cell density is - a top-hat histogram count divided by the cell volume. - - For slicing mode, the bin attached to each ``(s, z)`` cell is a - ``ds × dz × bin_width_x`` box: atoms within - ``±bin_width_x/2`` of the slice plane contribute. The - perpendicular slab thickness re-uses ``grid_params["bin_width_x"]`` - — refining the in-plane grid also thins the slab, so coarser - grids reduce per-bin Poisson noise. - - For whole mode, the 3D cells defined by ``grid_params`` are the - bins directly (``bin_width_x`` is then unused). - - Parameters - ---------- - grid_params : dict, optional - Same shape as :meth:`grid_gaussian`'s ``grid_params``. - If ``None`` (default), the grid is auto-derived per batch - from the atom bounding box plus a 5 Å buffer, with a flat - ``2 Å`` cell width. The histogram estimator is - intrinsically noisy for single-frame slicing-mode - analyses (the slab cut leaves few atoms per cell); for - that case pool multiple frames per batch or supply a - hand-tuned ``grid_params`` rather than relying on the - default. - delta_azimuthal, delta_cylinder : float, optional - See :meth:`grid_gaussian`. - """ - from wetting_angle_kit.analysis.extractors._grid import ( - _GridBinningExtractor, - ) - - return _GridBinningExtractor( - grid_params=dict(grid_params) if grid_params is not None else None, - delta_azimuthal=delta_azimuthal, - delta_cylinder=delta_cylinder, - ) diff --git a/src/wetting_angle_kit/analysis/fitters/_slicing.py b/src/wetting_angle_kit/analysis/fitters/_slicing.py index 2e0f6bc..2c29f8e 100644 --- a/src/wetting_angle_kit/analysis/fitters/_slicing.py +++ b/src/wetting_angle_kit/analysis/fitters/_slicing.py @@ -5,7 +5,6 @@ import numpy as np -from wetting_angle_kit.analysis.extractors.base import InterfaceData from wetting_angle_kit.analysis.fitters._kasa import _kasa_circle_fit_2d from wetting_angle_kit.analysis.fitters.base import ( SlicingFitOutput, @@ -13,6 +12,7 @@ SurfaceKind, ) from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface.base import InterfaceData @dataclass(frozen=True, eq=False, kw_only=True) diff --git a/src/wetting_angle_kit/analysis/fitters/_whole.py b/src/wetting_angle_kit/analysis/fitters/_whole.py index 86cb913..c6774e4 100644 --- a/src/wetting_angle_kit/analysis/fitters/_whole.py +++ b/src/wetting_angle_kit/analysis/fitters/_whole.py @@ -5,7 +5,6 @@ import numpy as np -from wetting_angle_kit.analysis.extractors.base import InterfaceData from wetting_angle_kit.analysis.fitters._kasa import ( _angle_from_cap, _whole_fit_one, @@ -16,6 +15,7 @@ WholeFitOutput, ) from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface.base import InterfaceData @dataclass(frozen=True, eq=False, kw_only=True) diff --git a/src/wetting_angle_kit/analysis/fitters/base.py b/src/wetting_angle_kit/analysis/fitters/base.py index 6c81c1e..3dc9d63 100644 --- a/src/wetting_angle_kit/analysis/fitters/base.py +++ b/src/wetting_angle_kit/analysis/fitters/base.py @@ -11,8 +11,8 @@ import numpy as np -from wetting_angle_kit.analysis.extractors.base import InterfaceData from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface.base import InterfaceData from wetting_angle_kit.analysis.results import ( BatchResult, SlicingBatchResult, @@ -20,7 +20,7 @@ ) #: Surface-representation kind the fitter consumes. Mirrors -#: :data:`wetting_angle_kit.analysis.extractors.SurfaceKind` — the two +#: :data:`wetting_angle_kit.analysis.interface.SurfaceKind` — the two #: are kept in sync by the analyzer's compatibility check, which raises #: if ``extractor.kind != fitter.kind``. SurfaceKind = Literal["slicing", "whole"] diff --git a/src/wetting_angle_kit/analysis/interface/__init__.py b/src/wetting_angle_kit/analysis/interface/__init__.py new file mode 100644 index 0000000..7edef81 --- /dev/null +++ b/src/wetting_angle_kit/analysis/interface/__init__.py @@ -0,0 +1,35 @@ +"""The interface-finding subsystem. + +The submodule owns everything related to recovering the liquid–vapor +interface from atom positions. An :class:`InterfaceExtractor` composes +two orthogonal strategy objects: + +* a :class:`SpaceSampling` (built via :meth:`SpaceSampling.rays` or + :meth:`SpaceSampling.grid`) that decides *where* density is + evaluated; +* a :class:`DensityEstimator` (built via + :meth:`DensityEstimator.gaussian` or :meth:`DensityEstimator.binning`) + that decides *how* it is computed. + +Both choices are independent — any sampling can be paired with any +density estimator:: + + extractor = InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=20.0, delta_polar=8.0, + ), + density=DensityEstimator.gaussian(density_sigma=3.0), + ) + +The pairing between the chosen extractor and the analyzer's +:class:`SurfaceFitter` is validated at :class:`TrajectoryAnalyzer` +construction via :meth:`InterfaceExtractor.validate_compatibility`, +which forwards to :meth:`SpaceSampling.validate_compatibility`. +""" + +from wetting_angle_kit.analysis.interface.base import ( + InterfaceExtractor, + SpaceSampling, +) + +__all__ = ["InterfaceExtractor", "SpaceSampling"] diff --git a/src/wetting_angle_kit/analysis/extractors/_grid.py b/src/wetting_angle_kit/analysis/interface/_grid.py similarity index 66% rename from src/wetting_angle_kit/analysis/extractors/_grid.py rename to src/wetting_angle_kit/analysis/interface/_grid.py index 473c1a3..27ea392 100644 --- a/src/wetting_angle_kit/analysis/extractors/_grid.py +++ b/src/wetting_angle_kit/analysis/interface/_grid.py @@ -7,14 +7,11 @@ downstream :class:`SurfaceFitter.slicing` sees one ``(s, z)`` contour per slice and can report per-slice scatter. -Density estimators: - -* ``grid_gaussian`` — 3D Gaussian KDE (sum of Gaussians centred on - atoms), evaluated at each cell centre. Uses the same - :class:`GaussianDensityField` machinery as ``rays_gaussian``. -* ``grid_binning`` — top-hat per cell. For slicing mode, atoms within - ``bin_width_x / 2`` of the slice plane contribute. For whole mode, - atoms binned directly into 3D cells. +The per-cell density comes from the :class:`DensityEstimator` +strategy passed via :meth:`SpaceSampling.grid` × :class:`DensityEstimator`. The Gaussian +variant samples the KDE at cell centres; the binning variant +histograms atoms into cells (with a slab cut perpendicular to the +slice plane for slicing mode). """ from collections.abc import Iterator @@ -23,22 +20,26 @@ import numpy as np -from wetting_angle_kit.analysis._density import GaussianDensityField -from wetting_angle_kit.analysis.extractors.base import ( +from wetting_angle_kit.analysis.density_estimator import ( + DensityEstimator, + _GaussianDensityEstimator, +) +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface.base import ( InterfaceData, - InterfaceExtractor, SamplingKind, + SpaceSampling, SurfaceKind, ) -from wetting_angle_kit.analysis.geometry import DropletGeometry _GRID_KEYS_2D = frozenset( {"xi_0", "xi_f", "bin_width_x", "zi_0", "zi_f", "bin_width_z"} ) _GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "bin_width_y"} -#: Default cell width for ``grid_binning`` (Å). The histogram estimator -#: has no smoothing scale to anchor to, so a flat default is used. +#: Default cell width for the grid + binning combination (Å). The +#: histogram estimator has no smoothing scale to anchor to, so a +#: flat default is used. #: #: 2 Å is the compromise for *pooled-batch* analyses; for #: per-frame slicing-mode use the slab cut leaves too few atoms @@ -272,8 +273,8 @@ def _iter_slice_planes( ``in_plane_axis`` is the unit vector defining the in-plane radial coordinate ``s``. The perpendicular-to-plane axis is recovered as - ``(-in_plane_axis[1], in_plane_axis[0], 0)`` by callers that need - it (e.g. the slab cut in :func:`_histogram_on_slice`). + ``(-in_plane_axis[1], in_plane_axis[0], 0)`` by the binning + estimator's slab cut. """ if droplet_geometry.is_spherical: assert delta_azimuthal is not None @@ -295,105 +296,6 @@ def _iter_slice_planes( yield slice_center, in_plane -# --------------------------------------------------------------------------- -# Density estimation per slice -# --------------------------------------------------------------------------- - - -def _evaluate_kde_on_slice( - field: GaussianDensityField, - slice_center: np.ndarray, - in_plane_axis: np.ndarray, - s_centers: np.ndarray, - z_centers: np.ndarray, -) -> np.ndarray: - """3D KDE evaluated at ``(s, z)`` cell centres on a slice plane. - - Cell centre ``(s, z)`` maps to the 3D point - ``(sc.x + s · ax.x, sc.y + s · ax.y, z)`` for a horizontal - ``in_plane_axis``. - """ - S, Z = np.meshgrid(s_centers, z_centers, indexing="ij") - positions = np.column_stack( - [ - slice_center[0] + S.ravel() * in_plane_axis[0], - slice_center[1] + S.ravel() * in_plane_axis[1], - Z.ravel(), - ] - ) - return field.evaluate(positions).reshape(S.shape) - - -def _histogram_on_slice( - atoms: np.ndarray, - slice_center: np.ndarray, - in_plane_axis: np.ndarray, - s_edges: np.ndarray, - z_edges: np.ndarray, - slab_thickness: float, -) -> np.ndarray: - """2D histogram of atoms inside the slab ``|perp| ≤ slab_thickness/2``. - - Each cell's bin is a ``ds × dz × slab_thickness`` box; density is - ``counts / (ds × dz × slab_thickness)``. - """ - perp_axis = np.array([-in_plane_axis[1], in_plane_axis[0], 0.0]) - rel = atoms - slice_center[None, :] - s_coord = rel @ in_plane_axis - perp_coord = rel @ perp_axis - mask = np.abs(perp_coord) <= 0.5 * slab_thickness - counts, _, _ = np.histogram2d( - s_coord[mask], atoms[mask, 2], bins=(s_edges, z_edges) - ) - ds = float(s_edges[1] - s_edges[0]) - dz = float(z_edges[1] - z_edges[0]) - return counts / (ds * dz * slab_thickness) - - -# --------------------------------------------------------------------------- -# 3D density helpers (whole mode) -# --------------------------------------------------------------------------- - - -def _evaluate_kde_on_3d_grid( - field: GaussianDensityField, - x_centers: np.ndarray, - y_centers: np.ndarray, - z_centers: np.ndarray, - *, - x_offset: float, - y_offset: float, -) -> np.ndarray: - """3D KDE at cell centres of a droplet-centred grid.""" - X, Y, Z = np.meshgrid(x_centers, y_centers, z_centers, indexing="ij") - positions = np.column_stack( - [ - (X + x_offset).ravel(), - (Y + y_offset).ravel(), - Z.ravel(), - ] - ) - return field.evaluate(positions).reshape(X.shape) - - -def _histogram_3d( - atoms: np.ndarray, - center_geom: np.ndarray, - x_edges: np.ndarray, - y_edges: np.ndarray, - z_edges: np.ndarray, -) -> np.ndarray: - """3D histogram in the droplet-centred ``(x, y, z)`` frame.""" - atoms_centered = atoms - np.array( - [float(center_geom[0]), float(center_geom[1]), 0.0] - ) - counts, _ = np.histogramdd(atoms_centered, bins=(x_edges, y_edges, z_edges)) - dx = float(x_edges[1] - x_edges[0]) - dy = float(y_edges[1] - y_edges[0]) - dz = float(z_edges[1] - z_edges[0]) - return counts / (dx * dy * dz) - - # --------------------------------------------------------------------------- # Iso-contour / iso-surface extraction # --------------------------------------------------------------------------- @@ -468,14 +370,19 @@ def _extract_isosurface_3d( @dataclass(frozen=True, eq=False, kw_only=True) -class _GridGaussianExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.grid_gaussian`.""" +class _GridSampling(SpaceSampling): + """Concrete sampling for :meth:`SpaceSampling.grid`. + + Dispatches the per-cell density computation to the + :class:`DensityEstimator` strategy received at extract time: the + Gaussian variant samples the KDE at cell centres, the binning + variant histograms atoms into cells (with a slab cut for slicing + mode). + """ - sampling: ClassVar[SamplingKind] = "grid" + kind: ClassVar[SamplingKind] = "grid" grid_params: dict[str, Any] | None - density_sigma: float - cutoff_sigma: float delta_azimuthal: float | None delta_cylinder: float | None @@ -490,7 +397,7 @@ def validate_compatibility( # construction. if self.grid_params is not None: _validate_grid_params( - name="grid_gaussian", + name="grid", grid_params=self.grid_params, surface_kind=surface_kind, ) @@ -499,107 +406,25 @@ def validate_compatibility( import skimage.measure # noqa: F401 except ImportError as e: raise ImportError( - "grid_gaussian for whole-kind extraction requires " + "grid for whole-kind extraction requires " "scikit-image (used for marching_cubes). Install with: " "pip install 'wetting-angle-kit[grid3d]'." ) from e if surface_kind == "slicing": _validate_per_slice_params( - name="grid_gaussian", + name="grid", delta_azimuthal=self.delta_azimuthal, delta_cylinder=self.delta_cylinder, droplet_geometry=droplet_geometry, ) - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - surface_kind: SurfaceKind, - ) -> InterfaceData: - field = GaussianDensityField( - atom_coords=liquid_coordinates, - density_sigma=self.density_sigma, - cutoff_sigma=self.cutoff_sigma, - ) - grid_params = self.grid_params or _default_grid_params( - liquid_coordinates, - center_geom, - droplet_geometry, - surface_kind=surface_kind, - bin_width=self.density_sigma / 2.0, - ) - if surface_kind == "slicing": - s_centers, z_centers = _slice_grid_centres(grid_params) - contours: list[np.ndarray] = [] - for slice_center, in_plane_axis in _iter_slice_planes( - liquid_coordinates, - center_geom, - droplet_geometry, - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - ): - density = _evaluate_kde_on_slice( - field, slice_center, in_plane_axis, s_centers, z_centers - ) - contours.append(_extract_isocontour_2d(s_centers, z_centers, density)) - return contours - x_centers, y_centers, z_centers = _whole_grid_centres(grid_params) - density3d = _evaluate_kde_on_3d_grid( - field, - x_centers, - y_centers, - z_centers, - x_offset=float(center_geom[0]), - y_offset=float(center_geom[1]), - ) - return _extract_isosurface_3d( - x_centers, - y_centers, - z_centers, - density3d, - center_geom=center_geom, - ) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class _GridBinningExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.grid_binning`.""" - - sampling: ClassVar[SamplingKind] = "grid" - - grid_params: dict[str, Any] | None - delta_azimuthal: float | None - delta_cylinder: float | None - - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - if self.grid_params is not None: - _validate_grid_params( - name="grid_binning", - grid_params=self.grid_params, - surface_kind=surface_kind, - ) - elif surface_kind == "whole": - try: - import skimage.measure # noqa: F401 - except ImportError as e: - raise ImportError( - "grid_binning for whole-kind extraction requires " - "scikit-image (used for marching_cubes). Install with: " - "pip install 'wetting-angle-kit[grid3d]'." - ) from e - if surface_kind == "slicing": - _validate_per_slice_params( - name="grid_binning", - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - droplet_geometry=droplet_geometry, - ) + def _auto_grid_bin_width(self, density: DensityEstimator) -> float: + # Pick the auto-derived bin_width that matches the estimator: + # Gaussian uses density_sigma / 2 (Nyquist-ish for the KDE); + # histograms use a flat 2 Å (no smoothing scale to anchor to). + if isinstance(density, _GaussianDensityEstimator): + return density.density_sigma / 2.0 + return _DEFAULT_BIN_WIDTH_BINNING def extract( self, @@ -607,22 +432,24 @@ def extract( center_geom: np.ndarray, droplet_geometry: DropletGeometry, surface_kind: SurfaceKind, + density: DensityEstimator, ) -> InterfaceData: grid_params = self.grid_params or _default_grid_params( liquid_coordinates, center_geom, droplet_geometry, surface_kind=surface_kind, - bin_width=_DEFAULT_BIN_WIDTH_BINNING, + bin_width=self._auto_grid_bin_width(density), ) if surface_kind == "slicing": - s_edges, z_edges = _slice_grid_edges(grid_params) - s_centers = 0.5 * (s_edges[:-1] + s_edges[1:]) - z_centers = 0.5 * (z_edges[:-1] + z_edges[1:]) + s_centers, z_centers = _slice_grid_centres(grid_params) # Slab thickness perpendicular to the slice plane equals # the in-plane horizontal cell width, so each cell's bin # is a ``ds × dz × ds`` box (square cross-section in the - # ``(s, perp)`` plane). + # ``(s, perp)`` plane). The Gaussian estimator ignores + # this parameter — its kernel size is set by + # ``density_sigma`` on the estimator itself. + s_edges, _z_edges = _slice_grid_edges(grid_params) slab = float(s_edges[1] - s_edges[0]) contours: list[np.ndarray] = [] for slice_center, in_plane_axis in _iter_slice_planes( @@ -632,20 +459,26 @@ def extract( delta_azimuthal=self.delta_azimuthal, delta_cylinder=self.delta_cylinder, ): - density = _histogram_on_slice( + slice_density = density.evaluate_on_slice( liquid_coordinates, slice_center, in_plane_axis, - s_edges, - z_edges, + s_centers, + z_centers, slab, ) - contours.append(_extract_isocontour_2d(s_centers, z_centers, density)) + contours.append( + _extract_isocontour_2d(s_centers, z_centers, slice_density) + ) return contours - x_edges, y_edges, z_edges = _whole_grid_edges(grid_params) x_centers, y_centers, z_centers = _whole_grid_centres(grid_params) - density3d = _histogram_3d( - liquid_coordinates, center_geom, x_edges, y_edges, z_edges + density3d = density.evaluate_on_3d_grid( + liquid_coordinates, + x_centers, + y_centers, + z_centers, + x_offset=float(center_geom[0]), + y_offset=float(center_geom[1]), ) return _extract_isosurface_3d( x_centers, diff --git a/src/wetting_angle_kit/analysis/extractors/_rays.py b/src/wetting_angle_kit/analysis/interface/_rays.py similarity index 73% rename from src/wetting_angle_kit/analysis/extractors/_rays.py rename to src/wetting_angle_kit/analysis/interface/_rays.py index d69eb7b..ab6380e 100644 --- a/src/wetting_angle_kit/analysis/extractors/_rays.py +++ b/src/wetting_angle_kit/analysis/interface/_rays.py @@ -1,4 +1,4 @@ -"""Ray-based extractor implementations + shared geometry/validation helpers.""" +"""Ray-based sampling: ``_RaysSampling`` + ray-fan geometry helpers.""" from dataclasses import dataclass from typing import ClassVar @@ -8,20 +8,44 @@ from wetting_angle_kit.analysis._density import ( MIN_POINTS_PER_RAY, DensityFieldProtocol, - GaussianDensityField, - HistogramDensityField, fit_tanh_profiles_batched, ) -from wetting_angle_kit.analysis.extractors._sampling import ( - _fibonacci_sphere_directions, -) -from wetting_angle_kit.analysis.extractors.base import ( +from wetting_angle_kit.analysis.density_estimator import DensityEstimator +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface.base import ( InterfaceData, - InterfaceExtractor, SamplingKind, + SpaceSampling, SurfaceKind, ) -from wetting_angle_kit.analysis.geometry import DropletGeometry + + +def _fibonacci_sphere_directions(n: int) -> np.ndarray: + """Equal-area Fibonacci-spiral directions on the full sphere. + + ``cos θ`` is uniformly spaced over ``[-1, 1]`` (so the surface + density is uniform over the whole sphere) and ``φ`` is incremented + by the golden angle for low-discrepancy azimuthal coverage. + ``i = 0`` sits at the south pole (``cos θ = -1``) and + ``i = n - 1`` at the north pole (``cos θ = 1``). + + The full sphere coverage is important for sessile droplets: rays + emitted from the droplet COM in downward directions traverse the + liquid, hit the wall plane, and contribute interface points at the + wall — making :meth:`WallDetector.min_plus_offset` work correctly + in the whole-fit pipeline. (Restricting to the upper hemisphere + misses the wall, so ``min(shell z)`` lands on ``COM_z`` instead.) + """ + if n <= 0: + return np.empty((0, 3)) + i = np.arange(n, dtype=np.float64) + cos_theta = 2.0 * i / (n - 1) - 1.0 if n > 1 else np.array([1.0]) + sin_theta = np.sqrt(np.maximum(0.0, 1.0 - cos_theta * cos_theta)) + golden_angle = np.pi * (3.0 - np.sqrt(5.0)) + phi = (i * golden_angle) % (2.0 * np.pi) + return np.column_stack( + [sin_theta * np.cos(phi), sin_theta * np.sin(phi), cos_theta] + ) def _validate_rays_params( @@ -35,7 +59,7 @@ def _validate_rays_params( ) -> None: """Shared validation for the two ray-based extractors. - Both ``rays_gaussian`` and ``rays_binning`` use the same ray-fan + Both density estimators share the same ray-fan parameter set; only the density estimator differs. """ if surface_kind == "slicing": @@ -64,7 +88,7 @@ def _ray_slice_in_plane( """Per-slice ``(R, 2)`` interface from a tilted ray fan. Parameterised on a generic :class:`DensityFieldProtocol` so both - ``rays_gaussian`` and ``rays_binning`` can share the geometry. + the Gaussian and binning density paths can share the geometry. """ polar = np.linspace(0, 360, int(360 / delta_polar), endpoint=False) cos_polar = np.cos(np.deg2rad(polar)) @@ -120,10 +144,9 @@ def _extract_rays( ) -> InterfaceData: """Dispatch a ray-fan extraction over the four ``(kind, geometry)`` cells. - Shared by :class:`_RaysGaussianExtractor` and - :class:`_RaysBinningExtractor` — only the density evaluator - differs between them, so the geometry, sampling cadence, and - tanh-fit invocation all live here. + The density evaluator is provided by the caller; the geometry, + sampling cadence, and tanh-fit invocation are shared across all + density estimators. """ n_samples = max(int(max_dist * points_per_angstrom), MIN_POINTS_PER_RAY) distances = np.linspace(0.0, max_dist, n_samples) @@ -194,72 +217,15 @@ def _extract_rays( @dataclass(frozen=True, eq=False, kw_only=True) -class _RaysGaussianExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.rays_gaussian`.""" - - sampling: ClassVar[SamplingKind] = "rays" - - delta_azimuthal: float | None - delta_cylinder: float | None - n_rays_sphere: int | None - delta_polar: float - points_per_angstrom: float - density_sigma: float - cutoff_sigma: float - - def validate_compatibility( - self, - surface_kind: SurfaceKind, - droplet_geometry: DropletGeometry, - ) -> None: - _validate_rays_params( - name="rays_gaussian", - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - n_rays_sphere=self.n_rays_sphere, - surface_kind=surface_kind, - droplet_geometry=droplet_geometry, - ) - - def extract( - self, - liquid_coordinates: np.ndarray, - center_geom: np.ndarray, - droplet_geometry: DropletGeometry, - surface_kind: SurfaceKind, - ) -> InterfaceData: - field = GaussianDensityField( - atom_coords=liquid_coordinates, - density_sigma=self.density_sigma, - cutoff_sigma=self.cutoff_sigma, - ) - max_dist = _compute_max_dist(liquid_coordinates, center_geom) - return _extract_rays( - field=field, - liquid_coordinates=liquid_coordinates, - center_geom=center_geom, - droplet_geometry=droplet_geometry, - max_dist=max_dist, - surface_kind=surface_kind, - points_per_angstrom=self.points_per_angstrom, - delta_azimuthal=self.delta_azimuthal, - delta_cylinder=self.delta_cylinder, - n_rays_sphere=self.n_rays_sphere, - delta_polar=self.delta_polar, - ) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class _RaysBinningExtractor(InterfaceExtractor): - """Concrete extractor for :meth:`InterfaceExtractor.rays_binning`.""" +class _RaysSampling(SpaceSampling): + """Concrete sampling for :meth:`SpaceSampling.rays`.""" - sampling: ClassVar[SamplingKind] = "rays" + kind: ClassVar[SamplingKind] = "rays" delta_azimuthal: float | None delta_cylinder: float | None n_rays_sphere: int | None delta_polar: float - bin_width: float points_per_angstrom: float def validate_compatibility( @@ -268,7 +234,7 @@ def validate_compatibility( droplet_geometry: DropletGeometry, ) -> None: _validate_rays_params( - name="rays_binning", + name="rays", delta_azimuthal=self.delta_azimuthal, delta_cylinder=self.delta_cylinder, n_rays_sphere=self.n_rays_sphere, @@ -282,11 +248,9 @@ def extract( center_geom: np.ndarray, droplet_geometry: DropletGeometry, surface_kind: SurfaceKind, + density: DensityEstimator, ) -> InterfaceData: - field = HistogramDensityField( - atom_coords=liquid_coordinates, - bin_width=self.bin_width, - ) + field = density.build_field(liquid_coordinates) max_dist = _compute_max_dist(liquid_coordinates, center_geom) return _extract_rays( field=field, diff --git a/src/wetting_angle_kit/analysis/interface/base.py b/src/wetting_angle_kit/analysis/interface/base.py new file mode 100644 index 0000000..02e0c37 --- /dev/null +++ b/src/wetting_angle_kit/analysis/interface/base.py @@ -0,0 +1,309 @@ +"""Interface-extraction composer and the space-sampling strategy. + +This module owns three closely coupled pieces of the interface-finding +subsystem: + +- the type aliases :data:`SurfaceKind`, :data:`SamplingKind`, and + :data:`InterfaceData` that flow through the pipeline; +- :class:`SpaceSampling` — the strategy that decides *where* in 3D + space density is evaluated (exposed via the factories + :meth:`SpaceSampling.rays` and :meth:`SpaceSampling.grid`); +- :class:`InterfaceExtractor` — the thin composition layer that pairs + a :class:`SpaceSampling` with a :class:`DensityEstimator` and + produces the interface points consumed by + :class:`SurfaceFitter`. + +The concrete sampling implementations (:class:`_RaysSampling`, +:class:`_GridSampling`) live in sibling private modules and are +constructed via the factories on :class:`SpaceSampling`. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeAlias + +import numpy as np + +from wetting_angle_kit.analysis.density_estimator import DensityEstimator +from wetting_angle_kit.analysis.geometry import DropletGeometry + +if TYPE_CHECKING: + pass + + +# --------------------------------------------------------------------------- +# Type aliases. +# --------------------------------------------------------------------------- + +#: What the downstream :class:`SurfaceFitter` will consume. +SurfaceKind = Literal["slicing", "whole"] + +#: Tag identifying which sampling strategy a :class:`SpaceSampling` +#: instance implements. Used for tqdm labels and result metadata. +SamplingKind = Literal["rays", "grid"] + +#: Interface point set produced by an :class:`InterfaceExtractor` and +#: consumed by :class:`SurfaceFitter` (and, via :class:`WallContext`, +#: by :class:`WallDetector`). +#: +#: - In slicing mode, a list of ``(N_i, 2)`` arrays in the per-slice +#: ``(x, z)`` plane. +#: - In whole mode, a single ``(N, 3)`` array in the internal +#: ``(x, y, z)`` frame. +InterfaceData: TypeAlias = list[np.ndarray] | np.ndarray + + +# --------------------------------------------------------------------------- +# SpaceSampling — strategy. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SpaceSampling(ABC): + """Strategy interface for space-sampling layouts. + + Concrete instances come from one of the classmethod factories + :meth:`rays` or :meth:`grid`; the abstract :meth:`extract` and + :meth:`validate_compatibility` methods are dispatched by the + composing :class:`InterfaceExtractor` after pooling atom positions. + """ + + #: Human-readable kind tag (used in tqdm labels). Set by each + #: concrete subclass. + kind: ClassVar[SamplingKind] + + @abstractmethod + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + """Raise if this sampling cannot serve ``(surface_kind, geometry)``. + + Called by :class:`TrajectoryAnalyzer.__init__` so misconfigurations + fail fast at construction instead of at the first batch. + """ + + @abstractmethod + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + surface_kind: SurfaceKind, + density: DensityEstimator, + ) -> InterfaceData: + """Build the interface point set for one batch. + + Parameters + ---------- + liquid_coordinates : ndarray, shape (N, 3) + Pooled liquid-atom coordinates in the internal frame. + center_geom : ndarray, shape (3,) + Geometric droplet center. + droplet_geometry : DropletGeometry + Droplet symmetry; drives the per-slice axis choice for + slicing modes and the ray-fan / grid layout for whole + modes. + surface_kind : {"slicing", "whole"} + What the downstream :class:`SurfaceFitter` will consume. + density : DensityEstimator + Density-estimation strategy. The sampling delegates per-cell + or per-ray-sample density to this strategy. + + Returns + ------- + InterfaceData + ``list[ndarray]`` of ``(M_i, 2)`` per-slice points when + ``surface_kind="slicing"``; a single ``(N, 3)`` shell when + ``surface_kind="whole"``. + """ + + # ------------------------------------------------------------------ + # Factories. + # ------------------------------------------------------------------ + + @classmethod + def rays( + cls, + *, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, + n_rays_sphere: int | None = None, + delta_polar: float = 8.0, + points_per_angstrom: float = 1.0, + ) -> SpaceSampling: + """Ray-fan sampling layout. + + Required ray-fan parameters depend on the + ``(surface_kind, droplet_geometry)`` the sampling is paired + with: + + ========================== ========================================= + surface_kind, geometry required ray params + ========================== ========================================= + slicing, spherical ``delta_azimuthal`` (+ ``delta_polar``) + slicing, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) + whole, spherical ``n_rays_sphere`` + whole, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) + ========================== ========================================= + + Parameters + ---------- + delta_azimuthal : float, optional + Azimuthal step (degrees) between slicing planes for the + spherical slicing mode. + delta_cylinder : float, optional + Step (Å) along the cylinder axis between slices for the + cylinder modes (both slicing and whole). + n_rays_sphere : int, optional + Total number of rays covering the **full sphere** for the + spherical whole-fit mode. Rays are placed via an equal-area + Fibonacci ``(cos θ, φ)`` construction so the angular density + is uniform from south to north pole. Full-sphere (rather + than upper-hemisphere) coverage is intentional: downward + rays from the droplet COM traverse the liquid and hit the + wall plane, producing interface points at the wall — that + keeps :meth:`WallDetector.min_plus_offset` consistent with + the physical wall position. + delta_polar : float, default 8.0 + In-plane ray step (degrees) for every mode that emits rays + in the ``(x, z)`` plane (i.e. everything except + whole + spherical). + points_per_angstrom : float, default 1.0 + Sampling density along each ray (samples per Å). + """ + from wetting_angle_kit.analysis.interface._rays import _RaysSampling + + return _RaysSampling( + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, + n_rays_sphere=n_rays_sphere, + delta_polar=delta_polar, + points_per_angstrom=points_per_angstrom, + ) + + @classmethod + def grid( + cls, + *, + grid_params: dict[str, Any] | None = None, + delta_azimuthal: float | None = None, + delta_cylinder: float | None = None, + ) -> SpaceSampling: + """Fixed-cell grid sampling layout. + + Per-slice in slicing mode: spherical droplets iterate over + azimuthal angles ``γ ∈ [0°, 180°)`` controlled by + ``delta_azimuthal``; cylindrical droplets iterate over axial + steps controlled by ``delta_cylinder``. Each slice produces + an ``(s, z)`` density grid and one iso-contour. Whole mode + builds a 3D ``(x, y, z)`` grid centred laterally on the + droplet COM and runs marching cubes. + + Parameters + ---------- + grid_params : dict, optional + Grid spec. For slicing, six keys: ``"xi_0"``, ``"xi_f"``, + ``"bin_width_x"``, ``"zi_0"``, ``"zi_f"``, + ``"bin_width_z"``. ``xi_0`` should be negative for a + centred slice that spans both halves of the diameter. For + whole, add three more: ``"yi_0"``, ``"yi_f"``, + ``"bin_width_y"`` (xi/yi grids are in the droplet-centred + lateral frame; zi stays in the lab frame). If ``None`` + (default), the grid is auto-derived per batch from the + atom bounding box plus a 5 Å buffer, with cell width set + to ``density_sigma / 2`` for Gaussian or ``2 Å`` for + binning. + delta_azimuthal : float, optional + Azimuthal step (degrees) between slicing planes for + ``slicing + spherical``. Required for that case; ignored + otherwise. + delta_cylinder : float, optional + Step (Å) along the cylinder axis between slicing planes + for ``slicing + cylinder``. Required for that case; + ignored otherwise. + """ + from wetting_angle_kit.analysis.interface._grid import _GridSampling + + return _GridSampling( + grid_params=dict(grid_params) if grid_params is not None else None, + delta_azimuthal=delta_azimuthal, + delta_cylinder=delta_cylinder, + ) + + +# --------------------------------------------------------------------------- +# InterfaceExtractor — composer. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True, eq=False) +class InterfaceExtractor: + """Composes a sampling layout with a density estimator. + + Parameters + ---------- + sampling : SpaceSampling + Space-sampling strategy. Built via + :meth:`SpaceSampling.rays` or :meth:`SpaceSampling.grid`. + density : DensityEstimator + Density-estimation strategy. Built via + :meth:`DensityEstimator.gaussian` or + :meth:`DensityEstimator.binning`. + + Examples + -------- + >>> from wetting_angle_kit.analysis import ( + ... DensityEstimator, InterfaceExtractor, SpaceSampling, + ... ) + >>> extractor = InterfaceExtractor( + ... sampling=SpaceSampling.rays( + ... delta_azimuthal=20.0, delta_polar=8.0, + ... ), + ... density=DensityEstimator.gaussian(density_sigma=3.0), + ... ) + """ + + sampling: SpaceSampling + density: DensityEstimator + + @property + def sampling_kind(self) -> SamplingKind: + """Tag identifying the sampling layout (``"rays"`` or ``"grid"``).""" + return self.sampling.kind + + def validate_compatibility( + self, + surface_kind: SurfaceKind, + droplet_geometry: DropletGeometry, + ) -> None: + """Raise if this extractor cannot serve ``(surface_kind, geometry)``. + + Forwards to :meth:`SpaceSampling.validate_compatibility`; the + sampling owns the validation rules (e.g. ``delta_azimuthal`` is + required for slicing-spherical rays). + """ + self.sampling.validate_compatibility(surface_kind, droplet_geometry) + + def extract( + self, + liquid_coordinates: np.ndarray, + center_geom: np.ndarray, + droplet_geometry: DropletGeometry, + surface_kind: SurfaceKind, + ) -> InterfaceData: + """Build the interface point set for one batch. + + Delegates to :meth:`SpaceSampling.extract`, threading + ``self.density`` through. + """ + return self.sampling.extract( + liquid_coordinates=liquid_coordinates, + center_geom=center_geom, + droplet_geometry=droplet_geometry, + surface_kind=surface_kind, + density=self.density, + ) diff --git a/src/wetting_angle_kit/analysis/trajectory.py b/src/wetting_angle_kit/analysis/trajectory.py index 6af8c8a..6d94455 100644 --- a/src/wetting_angle_kit/analysis/trajectory.py +++ b/src/wetting_angle_kit/analysis/trajectory.py @@ -30,9 +30,9 @@ gather_batch_coords, gather_wall_coords, ) -from wetting_angle_kit.analysis.extractors import InterfaceExtractor from wetting_angle_kit.analysis.fitters import SurfaceFitter from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface import InterfaceExtractor from wetting_angle_kit.analysis.results import BatchResult, TrajectoryResults from wetting_angle_kit.analysis.temporal import TemporalAggregator from wetting_angle_kit.analysis.wall import WallContext, WallDetector @@ -57,10 +57,11 @@ class TrajectoryAnalyzer(_BatchedTrajectoryAnalyzer): string. Drives the internal axis convention and the per-slice layout used by the extractor. interface_extractor : InterfaceExtractor - Built via one of :meth:`InterfaceExtractor.rays_gaussian` / - :meth:`InterfaceExtractor.rays_binning` / - :meth:`InterfaceExtractor.grid_gaussian` / - :meth:`InterfaceExtractor.grid_binning`. + Composes a :class:`SpaceSampling` (built via + :meth:`SpaceSampling.rays` or :meth:`SpaceSampling.grid`) + with a :class:`DensityEstimator` (built via + :meth:`DensityEstimator.gaussian` or + :meth:`DensityEstimator.binning`). surface_fitter : SurfaceFitter Built via :meth:`SurfaceFitter.slicing` or :meth:`SurfaceFitter.whole`. Its :attr:`kind` must match the @@ -130,7 +131,7 @@ def __init__( def _tqdm_desc(self) -> str: return ( f"TrajectoryAnalyzer ({self.surface_fitter.kind} / " - f"{self.interface_extractor.sampling})" + f"{self.interface_extractor.sampling_kind})" ) def _init_args(self) -> tuple: @@ -229,7 +230,7 @@ def _build_results(self, batches: list[BatchResult]) -> TrajectoryResults: batches=batches, method_metadata={ "kind": self.surface_fitter.kind, - "sampling": self.interface_extractor.sampling, + "sampling": self.interface_extractor.sampling_kind, "droplet_geometry": self.droplet_geometry.name, "batch_size": self.temporal_aggregator.batch_size, }, diff --git a/src/wetting_angle_kit/analysis/wall.py b/src/wetting_angle_kit/analysis/wall.py index adf83dc..9e2e63c 100644 --- a/src/wetting_angle_kit/analysis/wall.py +++ b/src/wetting_angle_kit/analysis/wall.py @@ -27,7 +27,7 @@ import numpy as np -from wetting_angle_kit.analysis.extractors.base import InterfaceData +from wetting_angle_kit.analysis.interface.base import InterfaceData @dataclass(frozen=True) diff --git a/src/wetting_angle_kit/io_utils.py b/src/wetting_angle_kit/io_utils.py index ad9c95d..ccc4f5d 100644 --- a/src/wetting_angle_kit/io_utils.py +++ b/src/wetting_angle_kit/io_utils.py @@ -200,8 +200,8 @@ def project_to_profile( One of ``"spherical"``, ``"cylinder_x"``, ``"cylinder_y"``. box_size : (Lx, Ly), optional Lateral box lengths. If omitted, the arithmetic mean is used and no - PBC handling is applied (legacy behavior: only correct when the - trajectory already recenters the droplet at every frame). + PBC handling is applied — only correct when the trajectory + already recenters the droplet at every frame. Returns ------- @@ -216,7 +216,7 @@ def project_to_profile( return np.empty(0), np.empty(0) if box_size is None: - # Legacy path: arithmetic-mean centering on the confined axes only. + # No PBC info: arithmetic-mean centering on the confined axes only. x_centered = positions.copy() for axis in _confined_lateral_axes(droplet_geometry): x_centered[:, axis] = positions[:, axis] - np.mean(positions[:, axis]) diff --git a/src/wetting_angle_kit/visualization/angle_evolution_plotter.py b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py index a9f80f8..dbb55e1 100644 --- a/src/wetting_angle_kit/visualization/angle_evolution_plotter.py +++ b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py @@ -1,11 +1,10 @@ """Trajectory-level contact-angle evolution plot. -Mirrors the visual conventions of the legacy -``SlicingTrajectoryPlotter.plot_angle_evolution`` — a per-batch -contact-angle line with an optional inter-batch ``±σ`` band and a -cumulative running mean overlay — but consumes the new -:class:`TrajectoryResults` / :class:`CoupledFit2DResults` / -:class:`CoupledFit3DResults` shapes. +Renders a per-batch contact-angle line with an optional inter-batch +``±σ`` band and a cumulative running mean overlay. Consumes any of +the package's per-batch result types +(:class:`TrajectoryResults`, :class:`CoupledFit2DResults`, +:class:`CoupledFit3DResults`). The plotter implements :class:`BaseTrajectoryPlotter`, so callers can also fetch a :class:`TrajectoryStats` summary alongside the figure. diff --git a/src/wetting_angle_kit/visualization/density_contour_plotter.py b/src/wetting_angle_kit/visualization/density_contour_plotter.py index 1e43b14..259fd7b 100644 --- a/src/wetting_angle_kit/visualization/density_contour_plotter.py +++ b/src/wetting_angle_kit/visualization/density_contour_plotter.py @@ -1,9 +1,8 @@ """Density-field contour plot with the fitted spherical cap overlay. -Mirrors the visuals of the legacy -``BinningTrajectoryPlotter.plot_density_contour`` (Jet colormap, -dashed cap arc, dotted wall line, equal x/y aspect) while accepting -the new coupled-binning result shapes: +Renders a 2D density contour with the fitted cap arc (dashed) and +wall line (dotted) overlaid (equal x/y aspect, Jet colormap by +default). Accepts any of the coupled-fit result types: - :class:`CoupledFit2DBatchResult` — single batch, plotted directly. - :class:`CoupledFit2DResults` — densities averaged across batches. @@ -37,8 +36,7 @@ class DensityContourPlotter: label : str, default ``"trajectory"`` Display label used in the figure title. colorscale : str, default ``"Jet"`` - Plotly colorscale for the density contour. The legacy plotter - used ``"Jet"``; the default is kept for visual continuity. + Plotly colorscale for the density contour. """ def __init__( diff --git a/src/wetting_angle_kit/visualization/stats.py b/src/wetting_angle_kit/visualization/stats.py index 549f539..38d7f5f 100644 --- a/src/wetting_angle_kit/visualization/stats.py +++ b/src/wetting_angle_kit/visualization/stats.py @@ -5,10 +5,9 @@ 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. + A plotter's ``.summary()`` method returns this dataclass so callers + can both display the block (``print(stats)``) and reuse the + underlying numbers programmatically. Attributes ---------- diff --git a/tests/test_analysis/test_binning_method.py b/tests/test_analysis/test_coupled_fit_2d.py similarity index 88% rename from tests/test_analysis/test_binning_method.py rename to tests/test_analysis/test_coupled_fit_2d.py index 4bdf6e6..ad927a6 100644 --- a/tests/test_analysis/test_binning_method.py +++ b/tests/test_analysis/test_coupled_fit_2d.py @@ -38,11 +38,10 @@ def oxygen_indices(filename: pathlib.Path) -> np.ndarray: @pytest.fixture def binning_params() -> dict: - # ``bin_width_*`` values are picked so the edge construction - # rounds to the same cell counts (49 / 24) as the legacy - # ``nbins_xi=50, nbins_zi=25`` spec — the per-frame tanh NLLS is - # sensitive to the grid layout on this fixture, so matching the - # legacy grid keeps the angle anchors meaningful. + # The per-frame tanh NLLS is sensitive to the grid layout on + # this fixture: ``bin_width_*`` values are chosen so the edge + # construction rounds to 49 × 24 cells, the grid the angle + # anchors below were calibrated against. return { "xi_0": 0, "xi_f": 100.0, @@ -70,7 +69,7 @@ def test_coupled_fit_2d_with_cylinder_fixture( assert len(results) == 1 angle = float(results.batches[0].angle) - # Coupled-binning angle on this fixture, frame 1: 99.110°. ±3° band. + # Coupled-fit angle on this fixture, frame 1: 99.110°. ±3° band. assert 96.0 <= angle <= 102.0 assert np.isfinite(results.mean_angle) # Single batch → std across batches is 0. diff --git a/tests/test_analysis/test_coupled_fit_3d.py b/tests/test_analysis/test_coupled_fit_3d.py index c1012c0..42ca092 100644 --- a/tests/test_analysis/test_coupled_fit_3d.py +++ b/tests/test_analysis/test_coupled_fit_3d.py @@ -1,4 +1,4 @@ -"""Phase 10 quantification: ``CoupledFit3DAnalyzer``. +"""``CoupledFit3DAnalyzer``. Three flavors: @@ -139,13 +139,13 @@ def test_coupled_fit_3d_close_to_2d_on_lammps_fixture() -> None: "zi_f": 40.0, "bin_width_z": 1.0, } - legacy_2d = CoupledFit2DAnalyzer( + analyzer_2d = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", binning_params=binning_params_2d, ) - angle_2d = float(legacy_2d.analyze([1]).batches[0].angle) + angle_2d = float(analyzer_2d.analyze([1]).batches[0].angle) # 3D analyzer — full (xi, yi, zi). binning_params_3d = { diff --git a/tests/test_analysis/test_cylinder_coverage.py b/tests/test_analysis/test_cylinder_coverage.py index 5a04bbc..6147946 100644 --- a/tests/test_analysis/test_cylinder_coverage.py +++ b/tests/test_analysis/test_cylinder_coverage.py @@ -1,6 +1,6 @@ """End-to-end coverage for cylinder droplet × every extractor combination. -The slicing / whole / coupled-binning paths are all designed to handle +The slicing / whole / coupled-fit paths are all designed to handle ``cylinder_y`` droplets, but several extractor × surface_kind cells gained cylinder support without dedicated tests — this file fills the gap. Each test runs the full pipeline on the LAMMPS cylinder fixture @@ -12,7 +12,7 @@ atoms x ∈ [43, 159] (centred on ~100, radial extent ~±58 Å) y ∈ [ 0, 21] (spans the full y-box) z ∈ [ 8, 72] (apex at ~72) -Reference angle from rays_gaussian + slicing: ~95-100°. +Reference angle from rays + Gaussian + slicing: ~95-100°. Slicing-mode grid extractors evaluate at ``(s, z)`` cell centres where ``s`` is relative to the cylinder axis at ``center_geom.x``, @@ -30,8 +30,11 @@ pytest.importorskip("ovito") -from wetting_angle_kit.analysis import ( # noqa: E402 +from wetting_angle_kit.analysis import ( + DensityEstimator, + # noqa: E402 InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -60,7 +63,7 @@ def oxygen_indices() -> np.ndarray: #: Known wall position on the LAMMPS cylinder fixture (z of the #: graphene plane). Pinning this isolates the extractor/fitter chain #: from wall-detection robustness — some extractors (notably -#: ``rays_binning`` whole-mode for cylinder) produce shell points +#: ``rays`` + binning whole-mode for cylinder) produce shell points #: with outlier z values that fool ``min_plus_offset``. _WALL_Z = 5.0 @@ -83,15 +86,16 @@ def _make_analyzer( ) -# --- rays_binning ------------------------------------------------------------ +# --- rays + binning ------------------------------------------------------------ @pytest.mark.integration -def test_rays_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: - """``rays_binning`` + slicing on the cylinder fixture.""" +def test_rays_with_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``rays`` + binning + slicing on the cylinder fixture.""" analyzer = _make_analyzer( - InterfaceExtractor.rays_binning( - delta_cylinder=5.0, delta_polar=8.0, bin_width=3.0 + InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=5.0, delta_polar=8.0), + density=DensityEstimator.binning(bin_width=3.0), ), SurfaceFitter.slicing(surface_filter_offset=2.0), oxygen_indices, @@ -103,27 +107,28 @@ def test_rays_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: @pytest.mark.integration @pytest.mark.xfail( reason=( - "rays_binning + whole + cylinder is a known-fragile combination. " + "rays + binning + whole + cylinder is a known-fragile combination. " "Per-y-slice ray fans pointing into vacuum (polar angles in " "[180°, 360°] below the wall) produce outlier shell points that " "spread z over ~150 Å (vs ~65 Å for the physical droplet), and " "the 2D circle fit in (x, z) converges to a non-intersecting " - "sphere. rays_gaussian + whole + cylinder works because the KDE " + "sphere. rays + Gaussian + whole + cylinder works because the KDE " "density gives the tanh fit something physical to converge to " "even on rays into vacuum. Documented as a method limitation " "rather than a code bug." ), strict=True, ) -def test_rays_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: - """``rays_binning`` + whole-fit on the cylinder fixture (xfail). +def test_rays_with_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``rays`` + binning + whole-fit on the cylinder fixture (xfail). See the ``xfail`` reason for why this combination doesn't produce a usable angle on the LAMMPS fixture. """ analyzer = _make_analyzer( - InterfaceExtractor.rays_binning( - delta_cylinder=5.0, delta_polar=8.0, bin_width=3.0 + InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=5.0, delta_polar=8.0), + density=DensityEstimator.binning(bin_width=3.0), ), SurfaceFitter.whole(surface_filter_offset=2.0), oxygen_indices, @@ -133,12 +138,12 @@ def test_rays_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: assert batch.popt.shape == (4,) -# --- grid_gaussian ----------------------------------------------------------- +# --- grid + Gaussian ----------------------------------------------------------- @pytest.mark.integration -def test_grid_gaussian_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: - """``grid_gaussian`` + slicing on the cylinder fixture.""" +def test_grid_with_gaussian_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid`` + Gaussian + slicing on the cylinder fixture.""" pytest.importorskip("skimage") grid_params = { "xi_0": -70.0, @@ -149,8 +154,9 @@ def test_grid_gaussian_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: "bin_width_z": 1.5, } analyzer = _make_analyzer( - InterfaceExtractor.grid_gaussian( - grid_params=grid_params, delta_cylinder=5.0, density_sigma=2.0 + InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params, delta_cylinder=5.0), + density=DensityEstimator.gaussian(density_sigma=2.0), ), SurfaceFitter.slicing(surface_filter_offset=3.0), oxygen_indices, @@ -160,8 +166,8 @@ def test_grid_gaussian_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: @pytest.mark.integration -def test_grid_gaussian_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: - """``grid_gaussian`` + whole-fit on the cylinder fixture. +def test_grid_with_gaussian_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid`` + Gaussian + whole-fit on the cylinder fixture. The cylinder spans ~21 Å along ``y`` and is droplet-centred at ``y ≈ 11`` Å, so a ``yi_0``/``yi_f`` range of ±12 Å covers it. @@ -171,7 +177,7 @@ def test_grid_gaussian_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: The grid whole-mode on a cylinder is method-biased on this fixture: marching cubes traces the iso-surface through the coarse 3D grid (~10⁵ cells), and the recovered angle is ~10° - higher than the rays_gaussian reference. That's a known bias of + higher than the rays + Gaussian reference. That's a known bias of grid-resolution-limited iso-surfaces, not a fit failure — the test accepts up to ~140°. """ @@ -188,7 +194,10 @@ def test_grid_gaussian_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: "bin_width_z": 2.0, } analyzer = _make_analyzer( - InterfaceExtractor.grid_gaussian(grid_params=grid_params, density_sigma=2.5), + InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params), + density=DensityEstimator.gaussian(density_sigma=2.5), + ), SurfaceFitter.whole(surface_filter_offset=3.0), oxygen_indices, ) @@ -198,14 +207,14 @@ def test_grid_gaussian_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: assert batch.popt.shape == (4,) -# --- grid_binning ------------------------------------------------------------ +# --- grid + binning ------------------------------------------------------------ @pytest.mark.integration -def test_grid_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: - """``grid_binning`` + slicing on the cylinder fixture. +def test_grid_with_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid`` + binning + slicing on the cylinder fixture. - Coarser cells than ``grid_gaussian`` because the histogram has no + Coarser cells than ``grid`` + Gaussian because the histogram has no smoothing — the slab cut also needs to be thick enough to give enough atoms per cell on a per-frame basis. """ @@ -219,7 +228,10 @@ def test_grid_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: "bin_width_z": 3.0, } analyzer = _make_analyzer( - InterfaceExtractor.grid_binning(grid_params=grid_params, delta_cylinder=10.0), + InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params, delta_cylinder=10.0), + density=DensityEstimator.binning(), + ), SurfaceFitter.slicing(surface_filter_offset=3.0), oxygen_indices, ) @@ -229,8 +241,8 @@ def test_grid_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: @pytest.mark.integration -def test_grid_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: - """``grid_binning`` + whole-fit on the cylinder fixture.""" +def test_grid_with_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: + """``grid`` + binning + whole-fit on the cylinder fixture.""" pytest.importorskip("skimage") grid_params = { "xi_0": -70.0, @@ -244,13 +256,16 @@ def test_grid_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None: "bin_width_z": 2.5, } analyzer = _make_analyzer( - InterfaceExtractor.grid_binning(grid_params=grid_params), + InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params), + density=DensityEstimator.binning(), + ), SurfaceFitter.whole(surface_filter_offset=3.0), oxygen_indices, ) batch = analyzer.analyze([1]).batches[0] # Histogram-iso whole-mode on a coarse 3D grid has a similar - # method bias to ``grid_gaussian`` whole + cylinder; band widened + # method bias to ``grid`` + Gaussian whole + cylinder; band widened # accordingly. The popt shape is the important contract here. assert 75.0 < batch.angle < 145.0 assert batch.popt.shape == (4,) @@ -280,8 +295,9 @@ def test_cylinder_x_end_to_end_runs_through_axis_swap( swap that propagated incorrectly would surface as a pipeline- level exception, not a silently-wrong angle. """ - extractor = InterfaceExtractor.rays_gaussian( - delta_cylinder=5.0, delta_polar=8.0, density_sigma=3.0 + extractor = InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=5.0, delta_polar=8.0), + density=DensityEstimator.gaussian(density_sigma=3.0), ) fitter = SurfaceFitter.slicing(surface_filter_offset=2.0) wall = WallDetector.explicit(z_wall=_WALL_Z) diff --git a/tests/test_analysis/test_default_grid_params.py b/tests/test_analysis/test_default_grid_params.py index b965005..aeb13f5 100644 --- a/tests/test_analysis/test_default_grid_params.py +++ b/tests/test_analysis/test_default_grid_params.py @@ -1,6 +1,6 @@ """Auto-derived ``grid_params`` / ``binning_params`` defaults. -When the user constructs a grid extractor or a coupled-binning +When the user constructs a grid extractor or a coupled-fit analyzer without specifying the spatial grid spec, the package picks one from the atom bounding box (extractors) or the box dimensions (analyzers). These tests verify that the auto-derived defaults @@ -21,10 +21,13 @@ pytest.importorskip("ovito") pytest.importorskip("skimage") -from wetting_angle_kit.analysis import ( # noqa: E402 +from wetting_angle_kit.analysis import ( + # noqa: E402 CoupledFit2DAnalyzer, CoupledFit3DAnalyzer, + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -79,15 +82,18 @@ def test_coupled_fit_3d_auto_default(oxygen_indices: np.ndarray) -> None: @pytest.mark.integration -def test_grid_gaussian_slicing_auto_default( +def test_grid_with_gaussian_slicing_auto_default( oxygen_indices: np.ndarray, ) -> None: - """``grid_gaussian`` slicing pipeline with no ``grid_params`` lands at ~95°.""" + """``grid`` + Gaussian slicing pipeline with no ``grid_params`` lands at ~95°.""" analyzer = TrajectoryAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.grid_gaussian(delta_azimuthal=20.0), + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.grid(delta_azimuthal=20.0), + density=DensityEstimator.gaussian(), + ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=3.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), ) @@ -96,15 +102,17 @@ def test_grid_gaussian_slicing_auto_default( @pytest.mark.integration -def test_grid_gaussian_whole_auto_default( +def test_grid_with_gaussian_whole_auto_default( oxygen_indices: np.ndarray, ) -> None: - """``grid_gaussian`` whole-fit pipeline with no ``grid_params`` lands at ~95°.""" + """``grid`` + Gaussian whole-fit pipeline with no ``grid_params`` lands at ~95°.""" analyzer = TrajectoryAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.grid_gaussian(), + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.grid(), density=DensityEstimator.gaussian() + ), surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), ) @@ -114,17 +122,17 @@ def test_grid_gaussian_whole_auto_default( @pytest.mark.integration @pytest.mark.slow -def test_grid_binning_whole_auto_default( +def test_grid_with_binning_whole_auto_default( oxygen_indices: np.ndarray, ) -> None: - """``grid_binning`` whole-fit pipeline with no ``grid_params``. + """``grid`` + binning whole-fit pipeline with no ``grid_params``. Whole mode bins the full 3D density (no slab cut), so the auto-default holds up where per-frame slicing-mode - ``grid_binning`` doesn't. The recovered angle should sit in the + ``grid`` + binning doesn't. The recovered angle should sit in the physically-acceptable band. - Note: ``grid_binning`` + slicing-mode + ``grid_params=None`` is + Note: ``grid`` + binning + slicing-mode + ``grid_params=None`` is intrinsically unreliable for single-frame analyses (the slab cut leaves few atoms per cell); that combination has no dedicated test because there is no robust default for it. @@ -133,7 +141,9 @@ def test_grid_binning_whole_auto_default( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.grid_binning(), + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.grid(), density=DensityEstimator.binning() + ), surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), ) diff --git a/tests/test_analysis/test_grid_extractors.py b/tests/test_analysis/test_grid_slicing.py similarity index 70% rename from tests/test_analysis/test_grid_extractors.py rename to tests/test_analysis/test_grid_slicing.py index 3377dcb..98adbfc 100644 --- a/tests/test_analysis/test_grid_extractors.py +++ b/tests/test_analysis/test_grid_slicing.py @@ -6,12 +6,12 @@ inside a spherical cap of known truth angle; the grid extractor + slicing fitter pipeline should recover the angle within a few degrees. -- **grid_binning vs grid_gaussian on the same atoms.** Both should +- **grid + binning vs grid + Gaussian on the same atoms.** Both should recover similar interfaces; the KDE-smoothed variant produces cleaner (lower per-slice RMS) contours than the bare histogram. - **End-to-end on the LAMMPS water/graphene fixture.** Grid extractors paired with the slicing fitter should produce angles within a few - degrees of ``rays_gaussian`` on the same fixture/frame. + degrees of ``rays`` + Gaussian on the same fixture/frame. All grid-mode slicing extractors take ``delta_azimuthal`` (spherical) or ``delta_cylinder`` (cylinder) and iterate per-slice — the @@ -28,8 +28,11 @@ # depend on it. pytest.importorskip("skimage") -from wetting_angle_kit.analysis import ( # noqa: E402 +from wetting_angle_kit.analysis import ( + DensityEstimator, + # noqa: E402 InterfaceExtractor, + SpaceSampling, SurfaceFitter, WallDetector, ) @@ -69,16 +72,17 @@ def _default_grid_params(half: float) -> dict[str, object]: } -def test_grid_gaussian_recovers_known_spherical_cap_angle() -> None: - """Per-azimuthal-slice ``grid_gaussian`` recovers a known cap angle.""" +def test_grid_with_gaussian_recovers_known_spherical_cap_angle() -> None: + """Per-azimuthal-slice ``grid`` + Gaussian recovers a known cap angle.""" R, zc, z_wall = 25.0, 0.0, 5.0 truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=15000, seed=0) - extractor = InterfaceExtractor.grid_gaussian( - grid_params=_default_grid_params(half=35.0), - delta_azimuthal=30.0, - density_sigma=2.0, + extractor = InterfaceExtractor( + sampling=SpaceSampling.grid( + grid_params=_default_grid_params(half=35.0), delta_azimuthal=30.0 + ), + density=DensityEstimator.gaussian(density_sigma=2.0), ) geom = DropletGeometry.coerce("spherical") extractor.validate_compatibility(surface_kind="slicing", droplet_geometry=geom) @@ -99,7 +103,7 @@ def test_grid_gaussian_recovers_known_spherical_cap_angle() -> None: out = fitter.fit(interface_data=contours, z_wall=z_wall, droplet_geometry=geom) drift = abs(out.angle - truth_angle) print( - f"\ngrid_gaussian (6 slices) cap recovery: truth = {truth_angle:.3f}°, " + f"\ngrid + Gaussian (6 slices) cap recovery: truth = {truth_angle:.3f}°, " f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " f"per_slice σ = {out.angle_std:.3f}°, " f"rms_residual = {out.rms_residual:.3f} Å" @@ -109,8 +113,8 @@ def test_grid_gaussian_recovers_known_spherical_cap_angle() -> None: assert out.angle_std < 2.0 -def test_grid_binning_recovers_known_spherical_cap_with_coarse_bins() -> None: - """``grid_binning`` (no smoothing) needs coarser cells to give a usable contour. +def test_grid_with_binning_recovers_known_spherical_cap_with_coarse_bins() -> None: + """``grid`` + binning (no smoothing) needs coarser cells to give a usable contour. On finer grids the Poisson noise per bin dominates and the slab cut becomes thin; the coarse-cells case shows the tool produces @@ -120,11 +124,10 @@ def test_grid_binning_recovers_known_spherical_cap_with_coarse_bins() -> None: truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=50000, seed=0) - # Per-slice grid_binning has fewer atoms per cell than the old - # axisymmetric collapse (only atoms within the slab contribute). - # 4 Å in-plane bin + 4 Å slab thickness keeps the per-cell atom - # count high enough that the Poisson noise doesn't dominate the - # iso-contour at the 95th-percentile bulk estimator. + # Per-slice binning sees only the atoms within the slab, so the + # per-cell count is low. A 4 Å in-plane bin + 4 Å slab thickness + # keeps that count high enough that Poisson noise doesn't dominate + # the iso-contour at the 95th-percentile bulk estimator. grid_params: dict[str, object] = { "xi_0": -35.0, "xi_f": 35.0, @@ -133,9 +136,12 @@ def test_grid_binning_recovers_known_spherical_cap_with_coarse_bins() -> None: "zi_f": 35.0, "bin_width_z": 2.0, } - extractor = InterfaceExtractor.grid_binning( - grid_params=grid_params, - delta_azimuthal=60.0, # 3 slices + extractor = InterfaceExtractor( + sampling=SpaceSampling.grid( + grid_params=grid_params, + delta_azimuthal=60.0, # 3 slices + ), + density=DensityEstimator.binning(), ) geom = DropletGeometry.coerce("spherical") contours = extractor.extract( @@ -150,19 +156,19 @@ def test_grid_binning_recovers_known_spherical_cap_with_coarse_bins() -> None: out = fitter.fit(interface_data=contours, z_wall=z_wall, droplet_geometry=geom) drift = abs(out.angle - truth_angle) print( - f"\ngrid_binning (coarse, 3 slices) cap recovery: " + f"\ngrid + binning (coarse, 3 slices) cap recovery: " f"truth = {truth_angle:.3f}°, recovered = {out.angle:.3f}°, " f"|drift| = {drift:.3f}°, rms_residual = {out.rms_residual:.3f} Å" ) assert drift < 5.0 -def test_grid_gaussian_smoother_than_grid_binning() -> None: - """At equal grid spec, ``grid_gaussian`` gives a smoother contour.""" +def test_grid_with_gaussian_smoother_than_grid_with_binning() -> None: + """At equal grid spec, ``grid`` + Gaussian gives a smoother contour.""" R, zc, z_wall = 25.0, 0.0, 5.0 atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=50000, seed=1) - # Coarse grid with thick slab so grid_binning isn't dominated by + # Coarse grid with thick slab so grid + binning isn't dominated by # Poisson noise; same spec for both estimators so the comparison # is apples-to-apples. grid_params: dict[str, object] = { @@ -175,11 +181,13 @@ def test_grid_gaussian_smoother_than_grid_binning() -> None: } geom = DropletGeometry.coerce("spherical") - b = InterfaceExtractor.grid_binning(grid_params=grid_params, delta_azimuthal=60.0) - g = InterfaceExtractor.grid_gaussian( - grid_params=grid_params, - delta_azimuthal=60.0, - density_sigma=2.0, + b = InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params, delta_azimuthal=60.0), + density=DensityEstimator.binning(), + ) + g = InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params, delta_azimuthal=60.0), + density=DensityEstimator.gaussian(density_sigma=2.0), ) binning_contours = b.extract( @@ -203,8 +211,10 @@ def test_grid_gaussian_smoother_than_grid_binning() -> None: interface_data=gaussian_contours, z_wall=z_wall, droplet_geometry=geom ) print( - f"\ngrid_binning rms = {out_b.rms_residual:.3f} Å, angle = {out_b.angle:.3f}°" - f"\ngrid_gaussian rms = {out_g.rms_residual:.3f} Å, angle = {out_g.angle:.3f}°" + f"\ngrid + binning rms = {out_b.rms_residual:.3f} Å, " + f"angle = {out_b.angle:.3f}°" + f"\ngrid + Gaussian rms = {out_g.rms_residual:.3f} Å, " + f"angle = {out_g.angle:.3f}°" ) assert out_g.rms_residual <= out_b.rms_residual truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) @@ -212,10 +222,11 @@ def test_grid_gaussian_smoother_than_grid_binning() -> None: assert abs(out_g.angle - truth_angle) < 5.0 -def test_grid_gaussian_rejects_missing_delta_azimuthal_for_spherical() -> None: +def test_grid_with_gaussian_rejects_missing_delta_azimuthal_for_spherical() -> None: """slicing+spherical without ``delta_azimuthal`` must fail at validation.""" - extractor = InterfaceExtractor.grid_gaussian( - grid_params=_default_grid_params(half=35.0), density_sigma=2.0 + extractor = InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=_default_grid_params(half=35.0)), + density=DensityEstimator.gaussian(density_sigma=2.0), ) with pytest.raises(ValueError, match="delta_azimuthal"): extractor.validate_compatibility( @@ -226,7 +237,7 @@ def test_grid_gaussian_rejects_missing_delta_azimuthal_for_spherical() -> None: @pytest.mark.integration @pytest.mark.slow -def test_grid_extractors_end_to_end_close_to_rays_gaussian() -> None: +def test_grid_extractors_end_to_end_close_to_rays_with_gaussian() -> None: """Grid extractor angles on the LAMMPS fixture sit within a few ° of rays.""" pytest.importorskip("ovito") from wetting_angle_kit.analysis import TrajectoryAnalyzer @@ -252,7 +263,7 @@ def test_grid_extractors_end_to_end_close_to_rays_gaussian() -> None: "zi_f": 40.0, "bin_width_z": 1.6, } - # grid_binning per-slice has fewer atoms per cell than rays_binning + # grid + binning per-slice has fewer atoms per cell than rays + binning # (only atoms in the slab contribute, not all atoms along a ray): # need a thick slab AND few slices to keep per-bin counts reasonable. grid_params_bin = { @@ -276,27 +287,32 @@ def _angle(extractor: InterfaceExtractor) -> float: return float(analyzer.analyze([1]).per_batch_angles[0]) angle_rays = _angle( - InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, delta_polar=8.0, density_sigma=3.0 + InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(density_sigma=3.0), ) ) angle_grid_bin = _angle( - InterfaceExtractor.grid_binning( - grid_params=grid_params_bin, delta_azimuthal=60.0 + InterfaceExtractor( + sampling=SpaceSampling.grid( + grid_params=grid_params_bin, delta_azimuthal=60.0 + ), + density=DensityEstimator.binning(), ) ) angle_grid_gauss = _angle( - InterfaceExtractor.grid_gaussian( - grid_params=grid_params_gauss, - delta_azimuthal=20.0, - density_sigma=2.0, + InterfaceExtractor( + sampling=SpaceSampling.grid( + grid_params=grid_params_gauss, delta_azimuthal=20.0 + ), + density=DensityEstimator.gaussian(density_sigma=2.0), ) ) print( - f"\nrays_gaussian angle = {angle_rays:.3f}°" - f"\ngrid_binning (slab=5 Å) angle = {angle_grid_bin:.3f}° " + f"\nrays + Gaussian angle = {angle_rays:.3f}°" + f"\ngrid + binning (slab=5 Å) angle = {angle_grid_bin:.3f}° " f"|drift| = {abs(angle_grid_bin - angle_rays):.3f}°" - f"\ngrid_gaussian (slab=3 Å σ) angle = {angle_grid_gauss:.3f}° " + f"\ngrid + Gaussian (slab=3 Å σ) angle = {angle_grid_gauss:.3f}° " f"|drift| = {abs(angle_grid_gauss - angle_rays):.3f}°" ) for angle in (angle_rays, angle_grid_bin, angle_grid_gauss): diff --git a/tests/test_analysis/test_grid_extractors_whole.py b/tests/test_analysis/test_grid_whole.py similarity index 81% rename from tests/test_analysis/test_grid_extractors_whole.py rename to tests/test_analysis/test_grid_whole.py index 68c8d7b..21e2e58 100644 --- a/tests/test_analysis/test_grid_extractors_whole.py +++ b/tests/test_analysis/test_grid_whole.py @@ -1,4 +1,4 @@ -"""Phase 8 quantification: 3D grid extractors via marching cubes. +"""3D grid extractors via marching cubes. Three flavors: @@ -7,9 +7,9 @@ ``SurfaceFitter.whole`` should recover the cap angle within a few degrees. - **End-to-end on the LAMMPS fixture.** Smoke test pairing the - ``grid_gaussian`` whole extractor with ``SurfaceFitter.whole`` — + ``grid`` + Gaussian whole extractor with ``SurfaceFitter.whole`` — the angle should land in the same physically plausible band as - ``rays_gaussian``. + ``rays`` + Gaussian. - **Cylinder geometry recovers a known horizontal ridge.** The 3D grid extracts a tube-like shell whose 2D ``(x, z)`` projection is a circle of the known cylinder radius. @@ -22,8 +22,11 @@ pytest.importorskip("skimage") -from wetting_angle_kit.analysis import ( # noqa: E402 +from wetting_angle_kit.analysis import ( + DensityEstimator, + # noqa: E402 InterfaceExtractor, + SpaceSampling, SurfaceFitter, WallDetector, ) @@ -67,15 +70,16 @@ def _whole_grid_params(half_xy: float, z_lo: float, z_hi: float, nbins: int) -> } -def test_grid_gaussian_whole_recovers_known_spherical_cap() -> None: +def test_grid_with_gaussian_whole_recovers_known_spherical_cap() -> None: """3D grid + marching cubes + sphere fit recovers a known cap angle.""" R, zc, z_wall = 25.0, 0.0, 5.0 truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=80000, seed=0) grid_params = _whole_grid_params(half_xy=30.0, z_lo=0.0, z_hi=35.0, nbins=31) - extractor = InterfaceExtractor.grid_gaussian( - grid_params=grid_params, density_sigma=2.0 + extractor = InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params), + density=DensityEstimator.gaussian(density_sigma=2.0), ) geom = DropletGeometry.coerce("spherical") extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) @@ -96,7 +100,7 @@ def test_grid_gaussian_whole_recovers_known_spherical_cap() -> None: out = fitter.fit(interface_data=shell, z_wall=z_wall, droplet_geometry=geom) drift = abs(out.angle - truth_angle) print( - f"\ngrid_gaussian whole cap recovery: truth = {truth_angle:.3f}°, " + f"\ngrid + Gaussian (whole) cap recovery: truth = {truth_angle:.3f}°, " f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " f"R_fit = {out.popt[3]:.3f} (truth {R}), " f"zc_fit = {out.popt[2]:.3f} (truth {zc}), " @@ -107,14 +111,17 @@ def test_grid_gaussian_whole_recovers_known_spherical_cap() -> None: assert drift < 5.0 -def test_grid_binning_whole_recovers_known_spherical_cap() -> None: +def test_grid_with_binning_whole_recovers_known_spherical_cap() -> None: """No-smoothing variant also recovers truth at suitable atom density.""" R, zc, z_wall = 25.0, 0.0, 5.0 truth_angle = float(np.degrees(np.arccos((z_wall - zc) / R))) atoms = _spherical_cap_atoms(R=R, zc=zc, z_wall=z_wall, n_atoms=200000, seed=1) grid_params = _whole_grid_params(half_xy=30.0, z_lo=0.0, z_hi=35.0, nbins=25) - extractor = InterfaceExtractor.grid_binning(grid_params=grid_params) + extractor = InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params), + density=DensityEstimator.binning(), + ) geom = DropletGeometry.coerce("spherical") shell = extractor.extract( liquid_coordinates=atoms, @@ -126,7 +133,7 @@ def test_grid_binning_whole_recovers_known_spherical_cap() -> None: out = fitter.fit(interface_data=shell, z_wall=z_wall, droplet_geometry=geom) drift = abs(out.angle - truth_angle) print( - f"\ngrid_binning whole cap recovery: truth = {truth_angle:.3f}°, " + f"\ngrid + binning (whole) cap recovery: truth = {truth_angle:.3f}°, " f"recovered = {out.angle:.3f}°, |drift| = {drift:.3f}°, " f"R_fit = {out.popt[3]:.3f}, " f"shell_points = {len(shell)}, rms = {out.rms_residual:.3f} Å" @@ -134,8 +141,8 @@ def test_grid_binning_whole_recovers_known_spherical_cap() -> None: assert drift < 5.0 -def test_grid_gaussian_whole_cylinder_recovers_horizontal_ridge() -> None: - """``grid_gaussian`` + whole + cylinder recovers a known cylindrical ridge. +def test_grid_with_gaussian_whole_cylinder_recovers_horizontal_ridge() -> None: + """``grid`` + Gaussian + whole + cylinder recovers a known cylindrical ridge. A uniformly-filled cylinder of radius ``R_truth`` running along the ``y`` axis is binned on a 3D grid whose ``y`` extent spans the @@ -170,8 +177,9 @@ def test_grid_gaussian_whole_cylinder_recovers_horizontal_ridge() -> None: "zi_f": 2.5 * R_truth, "bin_width_z": 1.0, } - extractor = InterfaceExtractor.grid_gaussian( - grid_params=grid_params, density_sigma=1.5 + extractor = InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params), + density=DensityEstimator.gaussian(density_sigma=1.5), ) geom = DropletGeometry.coerce("cylinder_y") extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) @@ -188,7 +196,7 @@ def test_grid_gaussian_whole_cylinder_recovers_horizontal_ridge() -> None: in_plane_r = np.hypot(shell[:, 0], shell[:, 2] - R_truth) mean_r = float(np.mean(in_plane_r)) print( - f"\ngrid_gaussian whole+cylinder: n_points = {shell.shape[0]}, " + f"\ngrid + Gaussian (whole+cylinder): n_points = {shell.shape[0]}, " f"R_truth = {R_truth}, R_mean = {mean_r:.3f} Å" ) # Gaussian smoothing (σ=1.5) places the iso-contour slightly inside @@ -198,8 +206,8 @@ def test_grid_gaussian_whole_cylinder_recovers_horizontal_ridge() -> None: @pytest.mark.integration @pytest.mark.slow -def test_grid_gaussian_whole_end_to_end_on_lammps_fixture() -> None: - """``grid_gaussian`` whole pipeline on the water/graphene fixture.""" +def test_grid_with_gaussian_whole_end_to_end_on_lammps_fixture() -> None: + """``grid`` + Gaussian whole pipeline on the water/graphene fixture.""" pytest.importorskip("ovito") from wetting_angle_kit.analysis import TrajectoryAnalyzer from wetting_angle_kit.parsers import ( @@ -230,16 +238,22 @@ def _angle(extractor: InterfaceExtractor, fitter: SurfaceFitter) -> float: return float(analyzer.analyze([1]).per_batch_angles[0]) angle_rays_whole = _angle( - InterfaceExtractor.rays_gaussian(n_rays_sphere=400, density_sigma=3.0), + InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.gaussian(density_sigma=3.0), + ), SurfaceFitter.whole(surface_filter_offset=3.0), ) angle_grid_whole = _angle( - InterfaceExtractor.grid_gaussian(grid_params=grid_params, density_sigma=2.0), + InterfaceExtractor( + sampling=SpaceSampling.grid(grid_params=grid_params), + density=DensityEstimator.gaussian(density_sigma=2.0), + ), SurfaceFitter.whole(surface_filter_offset=3.0), ) print( - f"\nrays_gaussian (whole) angle = {angle_rays_whole:.3f}°" - f"\ngrid_gaussian (whole) angle = {angle_grid_whole:.3f}° " + f"\nrays + Gaussian (whole) angle = {angle_rays_whole:.3f}°" + f"\ngrid + Gaussian (whole) angle = {angle_grid_whole:.3f}° " f"|drift| = {abs(angle_grid_whole - angle_rays_whole):.3f}°" ) # Both should land in the physically plausible band. The drift diff --git a/tests/test_analysis/test_rays_binning_extractor.py b/tests/test_analysis/test_rays_with_binning.py similarity index 77% rename from tests/test_analysis/test_rays_binning_extractor.py rename to tests/test_analysis/test_rays_with_binning.py index f1f1acc..92436a0 100644 --- a/tests/test_analysis/test_rays_binning_extractor.py +++ b/tests/test_analysis/test_rays_with_binning.py @@ -1,12 +1,12 @@ -"""Phase 6 quantification: ``rays_binning`` extractor + parity vs ``rays_gaussian``. +"""``rays`` + binning extractor + parity vs ``rays`` + Gaussian. Four flavors: - **HistogramDensityField unit test.** Top-hat evaluator returns the uniform bulk density inside a dense atom box and zero well outside. -- **Fibonacci sphere recovery.** ``rays_binning`` + whole+spherical +- **Fibonacci sphere recovery.** ``rays`` + binning + whole+spherical on a known sphere; recovered R sits near truth. -- **Slicing-mode parity vs rays_gaussian.** Same atom cloud, same +- **Slicing-mode parity vs rays + Gaussian.** Same atom cloud, same ray fan, comparable kernel widths (top-hat diameter chosen to match Gaussian FWHM). Recovered per-slice interface points should agree within a small absolute tolerance. @@ -23,8 +23,9 @@ from wetting_angle_kit.analysis._density import ( HistogramDensityField, ) -from wetting_angle_kit.analysis.extractors import InterfaceExtractor +from wetting_angle_kit.analysis.density_estimator import DensityEstimator from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface import InterfaceExtractor, SpaceSampling def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndarray: @@ -73,8 +74,8 @@ def test_histogram_density_field_uniform_box() -> None: assert np.all(dens_out == 0.0) -def test_rays_binning_whole_spherical_recovers_sphere() -> None: - """rays_binning + whole+spherical on a known sphere → R near truth. +def test_rays_with_binning_whole_spherical_recovers_sphere() -> None: + """rays + binning + whole+spherical on a known sphere → R near truth. Top-hat radius ``bin_width / 2`` defines the per-sample neighbourhood; pick it big enough that each bin captures O(50) @@ -85,8 +86,9 @@ def test_rays_binning_whole_spherical_recovers_sphere() -> None: bin_width = 8.0 # ~270 ų bin volume × 0.45 atoms/ų ≈ 120 atoms / bin atoms = _uniform_sphere_atoms(radius=radius, n_atoms=15000, seed=0) - extractor = InterfaceExtractor.rays_binning( - n_rays_sphere=400, bin_width=bin_width, points_per_angstrom=2.0 + extractor = InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400, points_per_angstrom=2.0), + density=DensityEstimator.binning(bin_width=bin_width), ) geom = DropletGeometry.coerce("spherical") shell = extractor.extract( @@ -99,7 +101,7 @@ def test_rays_binning_whole_spherical_recovers_sphere() -> None: assert shell.shape == (400, 3) r = np.linalg.norm(shell, axis=1) print( - f"\nrays_binning sphere recovery: R_truth = {radius} Å, " + f"\nrays + binning sphere recovery: R_truth = {radius} Å, " f"R_mean = {float(np.mean(r)):.3f} Å, R_std = {float(np.std(r)):.3f} Å" ) # The half-density point sits ≤ d/2 = 4 Å from the geometric edge @@ -108,8 +110,8 @@ def test_rays_binning_whole_spherical_recovers_sphere() -> None: assert float(np.std(r)) < 1.5 -def test_rays_binning_matches_rays_gaussian_slicing_spherical() -> None: - """rays_binning ≈ rays_gaussian within a small tolerance on a slicing fixture. +def test_rays_with_binning_matches_rays_with_gaussian_slicing_spherical() -> None: + """rays + binning ≈ rays + Gaussian within a small tolerance on a slicing fixture. Top-hat diameter is chosen by variance match (``d ≈ σ √12``) so the two kernels produce comparable smoothing. @@ -123,17 +125,21 @@ def test_rays_binning_matches_rays_gaussian_slicing_spherical() -> None: delta_polar = 8.0 geom = DropletGeometry.coerce("spherical") - g = InterfaceExtractor.rays_gaussian( - delta_azimuthal=delta_azimuthal, - delta_polar=delta_polar, - density_sigma=sigma, - points_per_angstrom=2.0, + g = InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=delta_azimuthal, + delta_polar=delta_polar, + points_per_angstrom=2.0, + ), + density=DensityEstimator.gaussian(density_sigma=sigma), ) - b = InterfaceExtractor.rays_binning( - delta_azimuthal=delta_azimuthal, - delta_polar=delta_polar, - bin_width=bin_width, - points_per_angstrom=2.0, + b = InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=delta_azimuthal, + delta_polar=delta_polar, + points_per_angstrom=2.0, + ), + density=DensityEstimator.binning(bin_width=bin_width), ) gauss_slices = g.extract( liquid_coordinates=atoms, @@ -183,10 +189,11 @@ def test_rays_binning_matches_rays_gaussian_slicing_spherical() -> None: @pytest.mark.integration @pytest.mark.slow -def test_rays_binning_end_to_end_angle_close_to_rays_gaussian() -> None: +def test_rays_with_binning_end_to_end_angle_close_to_rays_with_gaussian() -> None: """Both ray extractors → similar angle on the LAMMPS water/graphene fixture.""" pytest.importorskip("ovito") from wetting_angle_kit.analysis import ( + DensityEstimator, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -214,25 +221,25 @@ def _angle(extractor: InterfaceExtractor) -> float: sigma = 3.0 bin_width = sigma * float(np.sqrt(12.0)) # variance-matched top-hat angle_g = _angle( - InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, - delta_polar=8.0, - density_sigma=sigma, - points_per_angstrom=2.0, + InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=20.0, delta_polar=8.0, points_per_angstrom=2.0 + ), + density=DensityEstimator.gaussian(density_sigma=sigma), ) ) angle_b = _angle( - InterfaceExtractor.rays_binning( - delta_azimuthal=20.0, - delta_polar=8.0, - bin_width=bin_width, - points_per_angstrom=2.0, + InterfaceExtractor( + sampling=SpaceSampling.rays( + delta_azimuthal=20.0, delta_polar=8.0, points_per_angstrom=2.0 + ), + density=DensityEstimator.binning(bin_width=bin_width), ) ) drift = abs(angle_g - angle_b) print( - f"\nrays_gaussian (σ={sigma}) angle = {angle_g:.3f}°" - f"\nrays_binning (d={bin_width:.3f}) angle = {angle_b:.3f}°" + f"\nrays + Gaussian (σ={sigma}) angle = {angle_g:.3f}°" + f"\nrays + binning (d={bin_width:.3f}) angle = {angle_b:.3f}°" f"\n|drift| = {drift:.3f}°" ) # Both fall in the same physically-plausible band, drift is a few °. diff --git a/tests/test_analysis/test_rays_gaussian_extractor.py b/tests/test_analysis/test_rays_with_gaussian.py similarity index 90% rename from tests/test_analysis/test_rays_gaussian_extractor.py rename to tests/test_analysis/test_rays_with_gaussian.py index b640c74..5c0f693 100644 --- a/tests/test_analysis/test_rays_gaussian_extractor.py +++ b/tests/test_analysis/test_rays_with_gaussian.py @@ -1,4 +1,4 @@ -"""Quantification tests for the ``rays_gaussian`` extractor. +"""Quantification tests for the ``rays`` + Gaussian extractor. - **Fibonacci correctness** for the whole+spherical case: build a synthetic uniform-volume sphere of atoms and verify the recovered @@ -9,8 +9,9 @@ import numpy as np import pytest -from wetting_angle_kit.analysis.extractors import InterfaceExtractor +from wetting_angle_kit.analysis.density_estimator import DensityEstimator from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface import InterfaceExtractor, SpaceSampling def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndarray: @@ -42,9 +43,9 @@ def test_whole_spherical_recovers_known_sphere_radius() -> None: atoms = _uniform_sphere_atoms(radius=radius, n_atoms=15000, seed=0) n_rays = 400 - extractor = InterfaceExtractor.rays_gaussian( - n_rays_sphere=n_rays, - density_sigma=sigma, + extractor = InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=n_rays), + density=DensityEstimator.gaussian(density_sigma=sigma), ) geom = DropletGeometry.coerce("spherical") extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) @@ -108,9 +109,9 @@ def test_whole_cylinder_recovers_horizontal_ridge() -> None: atoms = np.column_stack([xz[:, 0], y, xz[:, 1] + R_truth]) # Shift so atoms sit above z = 0 to mimic the sessile-droplet frame. - extractor = InterfaceExtractor.rays_gaussian( - delta_cylinder=3.0, - delta_polar=8.0, + extractor = InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=3.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ) geom = DropletGeometry.coerce("cylinder_y") extractor.validate_compatibility(surface_kind="whole", droplet_geometry=geom) diff --git a/tests/test_analysis/test_slicing_edge_cases.py b/tests/test_analysis/test_slicing_edge_cases.py index 671e9a3..48a26c5 100644 --- a/tests/test_analysis/test_slicing_edge_cases.py +++ b/tests/test_analysis/test_slicing_edge_cases.py @@ -1,19 +1,18 @@ -"""Edge-case tests for the trajectory analyzer pipeline. - -Phase 11 migration: the legacy ``SlicingFrameFitter``-internal tests -(``find_intersection``, ``calculate_y_axis_list``, etc.) no longer have -direct analogues in the new architecture (the per-slice helpers live -inside ``_SlicingFitter`` / ``_RaysGaussianExtractor`` and are exercised -by the Phase 2–4 tests). The only edge case worth keeping in this file -is the parser-extension validation, which the new ``TrajectoryAnalyzer`` -inherits from the shared ``_BatchedTrajectoryAnalyzer`` base. +"""Edge-case tests for the trajectory-analyzer pipeline. + +The per-slice geometry helpers are exercised by the main fitter and +extractor test suites; what's kept here is the parser-extension +validation that :class:`TrajectoryAnalyzer` inherits from the +shared :class:`_BatchedTrajectoryAnalyzer` base. """ import numpy as np import pytest from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, ) @@ -23,10 +22,9 @@ def test_unsupported_extension_raises_at_construction(tmp_path) -> None: """Unknown trajectory extension must fail fast at construction. The shared ``_BatchedTrajectoryAnalyzer`` calls - ``detect_parser_type(parser.filepath)`` in ``__init__`` for the same - reason the legacy code did: the actual parser is rebuilt inside - worker processes, where a parser-type error would be silently - swallowed. + ``detect_parser_type(parser.filepath)`` in ``__init__`` because the + actual parser is rebuilt inside worker processes, where a + parser-type error would otherwise be silently swallowed. """ fake = tmp_path / "trajectory.bogus" fake.write_text("not a real trajectory\n") @@ -39,8 +37,9 @@ class _FakeParser: parser=_FakeParser(), atom_indices=np.array([]), droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, delta_polar=8.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(), ) diff --git a/tests/test_analysis/test_slicing_method.py b/tests/test_analysis/test_slicing_method.py index 4955973..9731bda 100644 --- a/tests/test_analysis/test_slicing_method.py +++ b/tests/test_analysis/test_slicing_method.py @@ -1,6 +1,6 @@ """Slicing-method integration test on the LAMMPS water/graphene fixture. -End-to-end ``TrajectoryAnalyzer`` with the slicing fitter + rays_gaussian +End-to-end ``TrajectoryAnalyzer`` with the slicing fitter + rays + Gaussian extractor + min_plus_offset wall detector on the spherical-droplet fixture. Anchored against the well-characterised ~95° contact angle. """ @@ -15,8 +15,11 @@ # on macOS CI). pytest.importorskip("ovito") -from wetting_angle_kit.analysis import ( # noqa: E402 +from wetting_angle_kit.analysis import ( + DensityEstimator, + # noqa: E402 InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -49,13 +52,14 @@ def oxygen_indices(filename: pathlib.Path) -> np.ndarray: def test_trajectory_analyzer_slicing_with_real_data( filename: pathlib.Path, oxygen_indices: np.ndarray ) -> None: - """End-to-end ``TrajectoryAnalyzer`` (rays_gaussian + slicing fitter).""" + """End-to-end ``TrajectoryAnalyzer`` (rays + Gaussian + slicing fitter).""" analyzer = TrajectoryAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, delta_polar=8.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), diff --git a/tests/test_analysis/test_trajectory_analyzer_integration.py b/tests/test_analysis/test_trajectory_analyzer_integration.py index 78e0440..150885f 100644 --- a/tests/test_analysis/test_trajectory_analyzer_integration.py +++ b/tests/test_analysis/test_trajectory_analyzer_integration.py @@ -1,11 +1,8 @@ -"""End-to-end integration coverage for the new methodology combinations. +"""End-to-end integration coverage for :class:`TrajectoryAnalyzer`. -The Phase 2–10 quantification tests exercise each new component -individually (extractor, fitter, wall detector, analyzer scaffolding). -This file fills in the matrix of *new* end-to-end combinations that -have no legacy analogue: +Covers the configurations not exercised by the per-component tests: -- ``TrajectoryAnalyzer`` + whole-fit + ``rays_gaussian`` on real data; +- ``TrajectoryAnalyzer`` + whole-fit + ``rays`` + Gaussian on real data; - slicing pipeline on a cylinder LAMMPS fixture; - bootstrap σ_θ flowing through the analyzer into ``WholeBatchResult``; - multi-frame pooled batches via ``TemporalAggregator(batch_size=N)``; @@ -20,8 +17,11 @@ pytest.importorskip("ovito") -from wetting_angle_kit.analysis import ( # noqa: E402 +from wetting_angle_kit.analysis import ( + DensityEstimator, + # noqa: E402 InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -81,7 +81,7 @@ def spherical_carbon_ids() -> np.ndarray: @pytest.mark.integration @pytest.mark.slow -def test_trajectory_analyzer_whole_fit_rays_gaussian_on_spherical( +def test_trajectory_analyzer_whole_fit_rays_with_gaussian_on_spherical( spherical_oxygen_ids: np.ndarray, ) -> None: """End-to-end ``TrajectoryAnalyzer`` with ``SurfaceFitter.whole()``.""" @@ -89,8 +89,9 @@ def test_trajectory_analyzer_whole_fit_rays_gaussian_on_spherical( parser=LammpsDumpParser(_SPHERICAL_FIXTURE), atom_indices=spherical_oxygen_ids, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - n_rays_sphere=400, density_sigma=3.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -136,8 +137,9 @@ def test_trajectory_analyzer_whole_fit_with_explicit_wall( parser=LammpsDumpParser(_SPHERICAL_FIXTURE), atom_indices=spherical_oxygen_ids, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - n_rays_sphere=400, density_sigma=3.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), wall_detector=WallDetector.explicit(z_wall=4.9), @@ -160,8 +162,9 @@ def test_whole_fitter_bootstrap_through_analyzer( parser=LammpsDumpParser(_SPHERICAL_FIXTURE), atom_indices=spherical_oxygen_ids, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - n_rays_sphere=400, density_sigma=3.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole( surface_filter_offset=3.0, bootstrap_samples=100 @@ -189,8 +192,9 @@ def test_whole_fit_with_from_atoms_wall_detector( parser=LammpsDumpParser(_SPHERICAL_FIXTURE), atom_indices=spherical_oxygen_ids, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - n_rays_sphere=400, density_sigma=3.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.whole(surface_filter_offset=3.0), wall_detector=WallDetector.from_atoms( @@ -223,8 +227,9 @@ def test_slicing_pipeline_on_cylinder_lammps_fixture( parser=LammpsDumpParser(_CYLINDER_FIXTURE), atom_indices=cylinder_oxygen_ids, droplet_geometry="cylinder_y", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_cylinder=4.0, delta_polar=8.0, density_sigma=3.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_cylinder=4.0, delta_polar=8.0), + density=DensityEstimator.gaussian(density_sigma=3.0), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -254,8 +259,9 @@ def test_trajectory_analyzer_pooled_batches( parser=LammpsDumpParser(_SPHERICAL_FIXTURE), atom_indices=spherical_oxygen_ids, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, delta_polar=8.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), @@ -282,8 +288,9 @@ def test_trajectory_analyzer_fully_pooled( parser=LammpsDumpParser(_SPHERICAL_FIXTURE), atom_indices=spherical_oxygen_ids, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, delta_polar=8.0 + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=WallDetector.min_plus_offset(offset=0.0), diff --git a/tests/test_analysis/test_wall_detector_from_atoms_e2e.py b/tests/test_analysis/test_wall_detector_from_atoms_e2e.py index 5737a7f..058be60 100644 --- a/tests/test_analysis/test_wall_detector_from_atoms_e2e.py +++ b/tests/test_analysis/test_wall_detector_from_atoms_e2e.py @@ -1,4 +1,4 @@ -"""Phase 5: end-to-end ``WallDetector.from_atoms`` through ``TrajectoryAnalyzer``. +"""end-to-end ``WallDetector.from_atoms`` through ``TrajectoryAnalyzer``. Wires the ``from_atoms`` detector through the full pipeline: @@ -27,8 +27,11 @@ pytest.importorskip("ovito") -from wetting_angle_kit.analysis import ( # noqa: E402 +from wetting_angle_kit.analysis import ( + DensityEstimator, + # noqa: E402 InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -83,9 +86,9 @@ def _make_analyzer( parser=LammpsDumpParser(fixture_path), atom_indices=oxygen_indices, droplet_geometry="spherical", - interface_extractor=InterfaceExtractor.rays_gaussian( - delta_azimuthal=20.0, - delta_polar=8.0, + interface_extractor=InterfaceExtractor( + sampling=SpaceSampling.rays(delta_azimuthal=20.0, delta_polar=8.0), + density=DensityEstimator.gaussian(), ), surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), wall_detector=wall_detector, diff --git a/tests/test_analysis/test_whole_fitter.py b/tests/test_analysis/test_whole_fitter.py index 76e5498..2e6128d 100644 --- a/tests/test_analysis/test_whole_fitter.py +++ b/tests/test_analysis/test_whole_fitter.py @@ -1,4 +1,4 @@ -"""Phase 4 quantification: ``SurfaceFitter.whole()`` correctness + bootstrap. +"""``SurfaceFitter.whole()`` correctness + bootstrap. Four flavors: @@ -6,7 +6,7 @@ shell points; verify the recovered angle matches truth to numerical precision and the RMS residual sits near zero. - **Exact-cylinder recovery.** Same for a straight cylinder along ``y``. -- **End-to-end with the rays_gaussian extractor.** Synthetic atom sphere +- **End-to-end with the rays + Gaussian extractor.** Synthetic atom sphere → extractor → fitter; angle should track truth within the density-smoothing budget. - **Bootstrap σ scaling.** On a noisy shell, the bootstrap σ_θ should @@ -15,12 +15,13 @@ import numpy as np -from wetting_angle_kit.analysis.extractors import InterfaceExtractor -from wetting_angle_kit.analysis.extractors._sampling import ( - _fibonacci_sphere_directions, -) +from wetting_angle_kit.analysis.density_estimator import DensityEstimator from wetting_angle_kit.analysis.fitters import SurfaceFitter from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.interface import InterfaceExtractor, SpaceSampling +from wetting_angle_kit.analysis.interface._rays import ( + _fibonacci_sphere_directions, +) def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndarray: @@ -117,7 +118,10 @@ def test_whole_fitter_end_to_end_atom_sphere() -> None: truth_angle = float(np.degrees(np.arccos(z_wall / R_truth))) atoms = _uniform_sphere_atoms(radius=R_truth, n_atoms=15000, seed=0) - extractor = InterfaceExtractor.rays_gaussian(n_rays_sphere=400, density_sigma=3.0) + extractor = InterfaceExtractor( + sampling=SpaceSampling.rays(n_rays_sphere=400), + density=DensityEstimator.gaussian(density_sigma=3.0), + ) geom = DropletGeometry.coerce("spherical") shell = extractor.extract( liquid_coordinates=atoms, diff --git a/tests/test_geometry_projection.py b/tests/test_geometry_projection.py index 9fcd4d0..76da404 100644 --- a/tests/test_geometry_projection.py +++ b/tests/test_geometry_projection.py @@ -1,8 +1,9 @@ """Unit tests for the shared (r, z) projection used by :class:`BaseParser.get_profile_coordinates`. -These exercise all three droplet geometries (including ``cylinder_x`` -which was not previously covered by any test). +Exercises all three droplet geometries (``spherical``, +``cylinder_x``, ``cylinder_y``) under both the PBC-aware and +no-PBC code paths. """ import numpy as np @@ -85,22 +86,22 @@ def _localized_cluster(box_length: float, n: int = 30) -> np.ndarray: return np.hstack([xy, z]) -def test_project_with_box_size_matches_legacy_for_mid_box_cluster(): +def test_project_with_box_size_matches_no_pbc_for_mid_box_cluster(): """When the droplet sits comfortably away from the boundary, the - PBC-aware path returns the same radii as the legacy arithmetic-mean - path: minimum-image folding is a no-op and the circular mean coincides - with the arithmetic mean.""" + PBC-aware path returns the same radii as the no-PBC (arithmetic- + mean) path: minimum-image folding is a no-op and the circular + mean coincides with the arithmetic mean.""" box = (20.0, 20.0) pts = _localized_cluster(box[0]) - r_legacy, z_legacy = project_to_profile(pts, "spherical") + r_no_pbc, z_no_pbc = project_to_profile(pts, "spherical") r_pbc, z_pbc = project_to_profile(pts, "spherical", box_size=box) - np.testing.assert_allclose(r_pbc, r_legacy, atol=1e-4) - np.testing.assert_allclose(z_pbc, z_legacy) + np.testing.assert_allclose(r_pbc, r_no_pbc, atol=1e-4) + np.testing.assert_allclose(z_pbc, z_no_pbc) def test_project_with_box_size_handles_droplet_straddling_boundary(): """Atoms wrapped across the x=0 boundary must collapse onto sensible - radii under the PBC-aware path, whereas the legacy arithmetic mean + radii under the PBC-aware path, whereas the no-PBC arithmetic mean sees a spurious cluster centered in the empty middle of the box.""" # Two tight rings of four atoms straddling the x=0 / x=L boundary on a # 10 Å box. Physically one ring of radius 0.5 centered on x=0. @@ -117,18 +118,18 @@ def test_project_with_box_size_handles_droplet_straddling_boundary(): # All atoms are at true radius 0.5 from the (wrapped) center. np.testing.assert_allclose(np.sort(r_pbc), np.full(4, 0.5), atol=1e-9) # Sanity check that this would have failed without the box-aware path: - # the legacy mean lands near x=2.5, putting two atoms at r >= 7. - r_legacy, _ = project_to_profile(pts, "spherical") - assert float(np.max(r_legacy)) > 5.0 + # the arithmetic mean lands near x=2.5, putting two atoms at r >= 7. + r_no_pbc, _ = project_to_profile(pts, "spherical") + assert float(np.max(r_no_pbc)) > 5.0 def test_project_cylinder_with_box_size_does_not_recenter_axial_axis(): """The axial axis of a cylinder (x for cylinder_x, y for cylinder_y) must not be folded; only the cross-section axis is recentered, so the - radial values match the legacy path on a mid-box cluster.""" + radial values match the no-PBC path on a mid-box cluster.""" box = (20.0, 20.0) pts = _localized_cluster(box[0]) for geom in ("cylinder_x", "cylinder_y"): - r_legacy, _ = project_to_profile(pts, geom) + r_no_pbc, _ = project_to_profile(pts, geom) r_pbc, _ = project_to_profile(pts, geom, box_size=box) - np.testing.assert_allclose(r_pbc, r_legacy, atol=1e-4) + np.testing.assert_allclose(r_pbc, r_no_pbc, atol=1e-4) diff --git a/tests/test_visualization/test_angle_evolution_helpers.py b/tests/test_visualization/test_angle_evolution_helpers.py index e83ff78..ac8eb4d 100644 --- a/tests/test_visualization/test_angle_evolution_helpers.py +++ b/tests/test_visualization/test_angle_evolution_helpers.py @@ -168,7 +168,7 @@ def test_batch_surface_area_whole_unknown_popt_returns_nan() -> None: def test_batch_surface_area_coupled_fit_2d_uses_model_params() -> None: - """Both 2D and 3D coupled-binning batches share the dispatch arm.""" + """Both 2D and 3D coupled-fit batches share the dispatch arm.""" batch = CoupledFit2DBatchResult( frames=[0], angle=90.0, diff --git a/tests/test_visualization/test_angle_evolution_plotter.py b/tests/test_visualization/test_angle_evolution_plotter.py index 8233868..74bdc2f 100644 --- a/tests/test_visualization/test_angle_evolution_plotter.py +++ b/tests/test_visualization/test_angle_evolution_plotter.py @@ -92,7 +92,7 @@ def test_angle_evolution_plotter_stat_mean_matches_batch_angle() -> None: def test_angle_evolution_plotter_coupled_results_no_band() -> None: - """Coupled-binning batches have no angle_std → no within-batch band.""" + """Coupled-fit batches have no angle_std → no within-batch band.""" plotter = AngleEvolutionPlotter(_coupled_2d_results()) fig = plotter.plot(per_frame_std=True, running_mean=False) # One main line, no band. diff --git a/tests/test_visualization/test_density_contour_plotter.py b/tests/test_visualization/test_density_contour_plotter.py index 2998c67..51d4bc9 100644 --- a/tests/test_visualization/test_density_contour_plotter.py +++ b/tests/test_visualization/test_density_contour_plotter.py @@ -115,7 +115,7 @@ def test_density_contour_plotter_2d_empty_results_raises() -> None: DensityContourPlotter(results).plot() -def test_density_contour_plotter_legacy_visuals() -> None: +def test_density_contour_plotter_visual_defaults() -> None: """Cap is dashed black, wall is dotted black, colorbar shows ρ.""" fig = DensityContourPlotter(_make_2d_batch()).plot() contour, cap, wall = fig.data @@ -123,9 +123,8 @@ def test_density_contour_plotter_legacy_visuals() -> None: assert wall.line.dash == "dot" assert cap.line.color == "black" assert wall.line.color == "black" - # Colorbar title preserves the legacy ρ glyph. assert contour.colorbar.title.text == "ρ" - # Equal x/y aspect ratio is preserved. + # Equal x/y aspect ratio. assert fig.layout.yaxis.scaleanchor == "x" assert fig.layout.yaxis.scaleratio == 1 From 00b4f2ca22400452f4991f755becc21d7c974d73 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 09:01:06 +0200 Subject: [PATCH 36/53] Fine-tuning, avoiding code duplication, removing unused code. --- docs/examples/coupled_fit_ca.py | 3 +- pyproject.toml | 4 +- src/wetting_angle_kit/analysis/_base.py | 19 +- src/wetting_angle_kit/analysis/_density.py | 23 +- src/wetting_angle_kit/analysis/_grid_utils.py | 20 ++ .../analysis/coupled_fit/_base.py | 134 +++++++++++ .../analysis/coupled_fit/_models.py | 227 +++++++----------- .../analysis/coupled_fit/analyzer_2d.py | 114 +++------ .../analysis/coupled_fit/analyzer_3d.py | 107 ++------- .../analysis/interface/_grid.py | 23 +- .../analysis/interface/_rays.py | 2 +- src/wetting_angle_kit/analysis/results.py | 101 +++----- src/wetting_angle_kit/io_utils.py | 83 +------ tests/test_geometry_projection.py | 135 ----------- tests/test_io_utils.py | 24 -- 15 files changed, 382 insertions(+), 637 deletions(-) create mode 100644 src/wetting_angle_kit/analysis/_grid_utils.py create mode 100644 src/wetting_angle_kit/analysis/coupled_fit/_base.py delete mode 100644 tests/test_geometry_projection.py diff --git a/docs/examples/coupled_fit_ca.py b/docs/examples/coupled_fit_ca.py index fa08e03..9a1405d 100644 --- a/docs/examples/coupled_fit_ca.py +++ b/docs/examples/coupled_fit_ca.py @@ -66,7 +66,8 @@ ) # --- Step 6: Run analysis on a frame range --- -results = analyzer.analyze([1]) +# 20 frames at batch_size=10 gives two pooled batches. +results = analyzer.analyze(range(0, 20)) print("Mean contact angle (°):", results.mean_angle) print("Std across batches (°):", results.std_angle) diff --git a/pyproject.toml b/pyproject.toml index 46ac190..da3de17 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,8 +52,8 @@ viz = [ ase = ["ase>=3.23.0"] ovito = ["ovito~=3.11.3"] # scikit-image is only needed for whole-kind extraction with a grid -# extractor (InterfaceExtractor.grid_gaussian / grid_binning paired -# with SurfaceFitter.whole), which uses marching cubes to recover a +# extractor (InterfaceExtractor with SpaceSampling.grid paired with +# SurfaceFitter.whole), which uses marching cubes to recover a # 3D shell from the binned density. Slicing-only workflows do not # need this extra. grid3d = ["scikit-image>=0.22"] diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index 85d3e12..a440d2d 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -55,7 +55,9 @@ class BaseTrajectoryAnalyzer(ABC): """ @abstractmethod - def analyze(self, frame_range: list[int] | None = None) -> Any: + def analyze( + self, frame_range: list[int] | None = None, n_jobs: int | None = 1 + ) -> Any: """Run the analysis and return a method-specific results object.""" pass @@ -92,6 +94,7 @@ def gather_batch_coords( atom_indices: np.ndarray, droplet_geometry: DropletGeometry, precentered: bool, + center_on_com: bool = False, progress_callback: Callable[[int], None] | None = None, ) -> tuple[np.ndarray, np.ndarray]: """Pool liquid-atom coordinates across one batch. @@ -116,6 +119,12 @@ def gather_batch_coords( precentered : bool If True, skip the circular-mean PBC recentering and use the plain arithmetic-mean centre. + center_on_com : bool, default False + If True, shift each frame's atoms onto its own droplet centre + in the lateral ``(x, y)`` plane (``z`` is left in the lab + frame). Used by the coupled-fit analyzers, which bin a + droplet-centred density; the ray/grid extractors leave it + False and read the per-frame centre from ``avg_center``. progress_callback : callable, optional Called once with ``1`` after each frame is parsed. Used by the inline ``analyze`` path to drive a per-frame tqdm meter @@ -126,10 +135,12 @@ def gather_batch_coords( Returns ------- pooled_coords : ndarray, shape (sum_N, 3) - Concatenated liquid coordinates in the internal frame. + Concatenated liquid coordinates in the internal frame + (droplet-centred in ``(x, y)`` when ``center_on_com=True``). avg_center : ndarray, shape (3,) Mean of the per-frame liquid centres; used as the ray-fan - origin by extractors. + origin by extractors. Near-zero in ``(x, y)`` when + ``center_on_com=True``. """ liquid_chunks: list[np.ndarray] = [] centres: list[np.ndarray] = [] @@ -150,6 +161,8 @@ def gather_batch_coords( # fancy index. positions = droplet_geometry.to_internal_coords(positions) mean_pos = droplet_geometry.to_internal_coords(mean_pos) + if center_on_com: + positions = positions - np.array([mean_pos[0], mean_pos[1], 0.0]) liquid_chunks.append(positions) centres.append(mean_pos) if progress_callback is not None: diff --git a/src/wetting_angle_kit/analysis/_density.py b/src/wetting_angle_kit/analysis/_density.py index 0a62596..2af4e10 100644 --- a/src/wetting_angle_kit/analysis/_density.py +++ b/src/wetting_angle_kit/analysis/_density.py @@ -1,8 +1,6 @@ """Shared density-on-rays kernel and batched tanh interface fit. -Used by both the slicing method (re-imported by -:class:`wetting_angle_kit.analysis.slicing.surface_definition.SurfaceDefinition`) -and the rays/grid extractors that fit a hyperbolic-tangent profile +Used by the rays/grid extractors that fit a hyperbolic-tangent profile to the density along a ray or sample it on a grid of cell centres. :class:`GaussianDensityField` wraps a ``cKDTree`` over the atom cloud @@ -222,10 +220,15 @@ def fit_tanh_profiles_batched( 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 the initial - guess. The recovered ``zd`` is clipped to ``[0, distances[-1]]`` - to keep ill-fit rays from escaping the sampling envelope. + damping converges in 3–6 iterations. The batched solve aborts the + iteration if any ray's normal equations become singular (e.g. a + near-constant density profile that never crosses the interface), + leaving every ray at its current parameters; a non-finite update + instead resets every ray to the closed-form initial guess. This + early stop also regularises the noisier histogram density, whose + fully converged per-ray optima can chase shot noise. The recovered + ``zd`` is clipped to ``[0, distances[-1]]`` to keep ill-fit rays + from escaping the sampling envelope. Parameters ---------- @@ -291,6 +294,12 @@ def fit_tanh_profiles_batched( # axis to keep each ray's RHS a 3-vector. step = np.linalg.solve(normal, rhs[..., None])[..., 0] except np.linalg.LinAlgError: + # A degenerate ray (e.g. near-constant density) makes its + # 3x3 system singular and aborts the whole batched solve. + # Stop iterating and keep the current per-ray parameters; + # for the dominant well-conditioned rays these have already + # converged, and the early stop keeps noise-driven rays near + # their robust closed-form seed. break params += step if not np.isfinite(params).all(): diff --git a/src/wetting_angle_kit/analysis/_grid_utils.py b/src/wetting_angle_kit/analysis/_grid_utils.py new file mode 100644 index 0000000..d0be58c --- /dev/null +++ b/src/wetting_angle_kit/analysis/_grid_utils.py @@ -0,0 +1,20 @@ +"""Small shared grid helpers used across the analysis subpackages. + +Kept dependency-free (numpy only) so both the grid interface extractor +and the coupled-fit analyzers can import it without a cross-subsystem +dependency. +""" + +import numpy as np + + +def edges_from_bin_width(lo: float, hi: float, bin_width: float) -> np.ndarray: + """Bin edges spanning ``[lo, hi]`` with cells of approximately ``bin_width``. + + The number of cells is rounded to the nearest integer; the range + bounds are honoured exactly, so the effective cell width is + ``(hi - lo) / n_cells`` which may differ slightly from + ``bin_width``. Always returns at least one cell. + """ + n = max(int(round((float(hi) - float(lo)) / float(bin_width))), 1) + return np.linspace(float(lo), float(hi), n + 1) diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_base.py b/src/wetting_angle_kit/analysis/coupled_fit/_base.py new file mode 100644 index 0000000..09b86f4 --- /dev/null +++ b/src/wetting_angle_kit/analysis/coupled_fit/_base.py @@ -0,0 +1,134 @@ +"""Shared scaffolding for the coupled-fit joint analyzers. + +:class:`_CoupledFitAnalyzer` factors out everything the 2D and 3D +coupled-fit analyzers share — the constructor, the progress-bar label, +the results packaging, and the model fit / parameter extraction — +leaving each concrete analyzer to supply only its dimensionality-specific +per-batch density binning and tanh-model wiring (the worker triple). +""" + +import logging +from abc import abstractmethod +from typing import Any, ClassVar + +import numpy as np + +from wetting_angle_kit.analysis._base import _BatchedTrajectoryAnalyzer +from wetting_angle_kit.analysis.coupled_fit._models import _HyperbolicTangentModel +from wetting_angle_kit.analysis.density_estimator import DensityEstimator +from wetting_angle_kit.analysis.geometry import DropletGeometry +from wetting_angle_kit.analysis.temporal import TemporalAggregator + +logger = logging.getLogger(__name__) + + +def fit_model_params( + model: _HyperbolicTangentModel, + x_data: tuple[np.ndarray, ...], + density_flat: np.ndarray, +) -> tuple[float, dict[str, float]]: + """Fit ``model`` and return ``(contact_angle, model_params dict)``. + + Shared by the 2D and 3D workers: the only dimensionality-specific + inputs are the already-flattened ``x_data`` / ``density_flat`` the + caller assembles from its grid. The parameter names come from the + model itself (:attr:`_HyperbolicTangentModel.param_names`), so the + same helper serves both the 7- and 9-parameter layouts. + """ + model.fit(x_data, density_flat) + angle = float(model.compute_contact_angle()) + params = model.params + if params is None: + raise RuntimeError( + f"{type(model).__name__} did not set model parameters; " + "cannot build a coupled-fit batch result." + ) + model_params = { + name: float(value) + for name, value in zip(model.param_names, params, strict=False) + } + return angle, model_params + + +class _CoupledFitAnalyzer(_BatchedTrajectoryAnalyzer): + """Shared base for the two coupled-fit analyzers. + + Concrete subclasses (:class:`CoupledFit2DAnalyzer`, + :class:`CoupledFit3DAnalyzer`) provide: + + - ``_RESULTS_CLS`` — the results dataclass to wrap the batches in; + - ``_default_binning_params(parser)`` — the atom-derived default + grid used when ``binning_params`` is ``None``; + - the worker triple (``_init_args`` / ``_init_worker`` / + ``_process_batch_worker``), which differs by grid dimensionality; + - optionally ``_check_geometry`` (the 3D fit rejects cylinders) and + ``_post_init`` (the 2D fit reads the cylinder box length). + """ + + #: Results dataclass produced by :meth:`_build_results`. + _RESULTS_CLS: ClassVar[type] + + def __init__( + self, + parser: Any, + atom_indices: np.ndarray | None = None, + droplet_geometry: DropletGeometry | str = "spherical", + *, + binning_params: dict[str, Any] | None = None, + density_estimator: DensityEstimator | None = None, + initial_params: list[float] | None = None, + temporal_aggregator: TemporalAggregator | None = None, + precentered: bool = False, + ) -> None: + super().__init__( + parser=parser, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + temporal_aggregator=temporal_aggregator + or TemporalAggregator(batch_size=-1), + precentered=precentered, + ) + # Reject unsupported geometries before the (warning-emitting) + # default-grid derivation runs. + self._check_geometry() + if binning_params is None: + binning_params = self._default_binning_params(parser) + self.binning_params = binning_params + self.density_estimator = density_estimator or DensityEstimator.binning() + self.initial_params = initial_params + self._post_init(parser) + + # ------------------------------------------------------------------ + # Subclass hooks. + # ------------------------------------------------------------------ + + def _check_geometry(self) -> None: + """Reject unsupported droplet geometries. Default: accept all.""" + + @abstractmethod + def _default_binning_params(self, parser: Any) -> dict[str, Any]: + """Atom-derived default grid spec when ``binning_params`` is None.""" + + def _post_init(self, parser: Any) -> None: + """Construction hook run after the common fields are set.""" + + # ------------------------------------------------------------------ + # Shared _BatchedTrajectoryAnalyzer extension points. + # ------------------------------------------------------------------ + + def _tqdm_desc(self) -> str: + return ( + f"{type(self).__name__} " + f"({self.droplet_geometry.name} / {self.density_estimator.kind})" + ) + + def _build_results(self, batches: list[Any]) -> Any: + return self._RESULTS_CLS( + batches=batches, + method_metadata={ + "droplet_geometry": self.droplet_geometry.name, + "binning_params": self.binning_params, + "initial_params": self.initial_params, + "batch_size": self.temporal_aggregator.batch_size, + }, + ) diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_models.py b/src/wetting_angle_kit/analysis/coupled_fit/_models.py index 1ff74c0..fe09100 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/_models.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/_models.py @@ -1,13 +1,15 @@ """Hyperbolic-tangent models + grid helpers for the coupled-fit analyzers. Both the 2D (seven-parameter) and 3D (nine-parameter) joint density -models are kept in this module so the shared bounds / warning / cap-angle -formula sit side by side. Public access goes through -:class:`CoupledFit2DAnalyzer` and :class:`CoupledFit3DAnalyzer`. +models live here and share a common :class:`_HyperbolicTangentModel` +base for the bounded NLLS fit, the at-bound warning, and the cap-angle +formula. Public access goes through :class:`CoupledFit2DAnalyzer` and +:class:`CoupledFit3DAnalyzer`. """ import warnings -from typing import Any +from collections.abc import Callable +from typing import Any, ClassVar import numpy as np from scipy.optimize import curve_fit @@ -29,33 +31,27 @@ # ---------------------------------------------------------------------- -# 2D model. +# Shared base. # ---------------------------------------------------------------------- -class _HyperbolicTangentModel2D: - """Coupled 2D-binning joint contact-angle model. - - Density field modelled as a product of two sigmoidal (tanh) terms, - one radial and one vertical: - - :: +class _HyperbolicTangentModel: + """Shared machinery for the 2D / 3D coupled-fit tanh density models. - rho(xi, zi) = g(r) * h(zi - zi_0), - g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], - h(z) = 0.5 * [1 + tanh(2 z / t2)], - r = sqrt(xi^2 + (zi - zi_c)^2). - - Seven free parameters fitted by bounded NLLS. Private (the public - entry point is :class:`CoupledFit2DAnalyzer`); the 3D - counterpart :class:`_HyperbolicTangentModel3D` lives in the same - module. + Subclasses supply the parameter metadata (``param_names``, + ``fit_label``, ``DEFAULT_INITIAL_PARAMS``, ``_PARAM_LOWER``, + ``_PARAM_UPPER``) and the static ``_fitting_function``; the bounded + NLLS fit, the at-bound warning, and the cap-angle formula are shared. + The cap angle reads ``R_eq`` / ``zi_c`` / ``zi_0`` by name, so the + same formula serves both the 7- and 9-parameter layouts. """ - DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 20.0, 4.0, 1.0, 1.0) - - _PARAM_LOWER = np.array([0.0, 0.0, 1e-6, -np.inf, -np.inf, 1e-6, 1e-6]) - _PARAM_UPPER = np.array([np.inf] * 7) + param_names: ClassVar[tuple[str, ...]] + fit_label: ClassVar[str] + DEFAULT_INITIAL_PARAMS: ClassVar[tuple[float, ...]] + _PARAM_LOWER: ClassVar[np.ndarray] + _PARAM_UPPER: ClassVar[np.ndarray] + _fitting_function: ClassVar[Callable[..., np.ndarray]] def __init__(self, initial_params: list[float] | None = None) -> None: if initial_params is None: @@ -63,28 +59,11 @@ def __init__(self, initial_params: list[float] | None = None) -> None: self.params: list[float] | np.ndarray | None = initial_params self.covariance: np.ndarray | None = None - @staticmethod - def _fitting_function( - x: tuple[np.ndarray, np.ndarray], - rho1: float, - rho2: float, - R_eq: float, - zi_c: float, - zi_0: float, - t1: float, - t2: float, - ) -> np.ndarray: - xi, zi = x[0], x[1] - r = np.sqrt(xi**2 + (zi - zi_c) ** 2) - g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) - h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) - return g_r * h_z - def fit( self, - x_data: tuple[np.ndarray, np.ndarray], + x_data: tuple[np.ndarray, ...], density_data: np.ndarray, - ) -> "_HyperbolicTangentModel2D": + ) -> "_HyperbolicTangentModel": self.params, self.covariance = curve_fit( self._fitting_function, x_data, @@ -102,7 +81,7 @@ def _warn_if_at_bounds(self) -> None: tol = 1e-6 at_bound = [] for name, value, lo, hi in zip( - _PARAM_NAMES, + self.param_names, self.params, self._PARAM_LOWER, self._PARAM_UPPER, @@ -114,23 +93,30 @@ def _warn_if_at_bounds(self) -> None: at_bound.append(f"{name}={value:.3g} at upper bound {hi}") if at_bound: warnings.warn( - "Hyperbolic tangent fit converged with parameter(s) at the " + f"{self.fit_label} converged with parameter(s) at the " "physical bound, suggesting a poor fit: " + "; ".join(at_bound), RuntimeWarning, stacklevel=3, ) def compute_contact_angle(self) -> float: + """Return the contact angle (degrees) implied by the fitted parameters. + + The sphere of radius ``R_eq`` centred at vertical position + ``zi_c`` intersects the wall plane ``z = zi_0`` in a circle + whose tangent makes the contact angle with the wall. + """ if self.params is None: raise ValueError("Model must be fitted before computing contact angle.") - R_eq = float(self.params[2]) - zi_c = float(self.params[3]) - zi_0 = float(self.params[4]) + names = self.param_names + R_eq = float(self.params[names.index("R_eq")]) + zi_c = float(self.params[names.index("zi_c")]) + zi_0 = float(self.params[names.index("zi_0")]) discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 if discriminant < 0: warnings.warn( - "Fitted wall is outside the fitted droplet sphere " - f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|={abs(zi_0 - zi_c):.3f}); " + f"{self.fit_label}: fitted wall is outside the fitted droplet " + f"sphere (R_eq={R_eq:.3f}, |zi_0 - zi_c|={abs(zi_0 - zi_c):.3f}); " "contact angle is undefined.", RuntimeWarning, stacklevel=2, @@ -140,12 +126,62 @@ def compute_contact_angle(self) -> float: return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) +# ---------------------------------------------------------------------- +# 2D model. +# ---------------------------------------------------------------------- + + +class _HyperbolicTangentModel2D(_HyperbolicTangentModel): + """Coupled 2D-binning joint contact-angle model. + + Density field modelled as a product of two sigmoidal (tanh) terms, + one radial and one vertical: + + :: + + rho(xi, zi) = g(r) * h(zi - zi_0), + g(r) = 0.5 * [(rho1 + rho2) - (rho1 - rho2) * tanh(2 (r - R_eq) / t1)], + h(z) = 0.5 * [1 + tanh(2 z / t2)], + r = sqrt(xi^2 + (zi - zi_c)^2). + + Seven free parameters fitted by bounded NLLS. Private (the public + entry point is :class:`CoupledFit2DAnalyzer`); the 3D + counterpart :class:`_HyperbolicTangentModel3D` lives in the same + module. + """ + + param_names: ClassVar[tuple[str, ...]] = _PARAM_NAMES + fit_label: ClassVar[str] = "Hyperbolic tangent fit" + + DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 20.0, 4.0, 1.0, 1.0) + + _PARAM_LOWER = np.array([0.0, 0.0, 1e-6, -np.inf, -np.inf, 1e-6, 1e-6]) + _PARAM_UPPER = np.array([np.inf] * 7) + + @staticmethod + def _fitting_function( + x: tuple[np.ndarray, np.ndarray], + rho1: float, + rho2: float, + R_eq: float, + zi_c: float, + zi_0: float, + t1: float, + t2: float, + ) -> np.ndarray: + xi, zi = x[0], x[1] + r = np.sqrt(xi**2 + (zi - zi_c) ** 2) + g_r = 0.5 * ((rho1 + rho2) - (rho1 - rho2) * np.tanh(2 * (r - R_eq) / t1)) + h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) + return g_r * h_z + + # ---------------------------------------------------------------------- # 3D model. # ---------------------------------------------------------------------- -class _HyperbolicTangentModel3D: +class _HyperbolicTangentModel3D(_HyperbolicTangentModel): """3D extension of the binning method's hyperbolic-tangent model. Density factorises into a radial sigmoid centred at ``(xi_c, yi_c, @@ -163,6 +199,9 @@ class _HyperbolicTangentModel3D: freedom over the 2D fit. """ + param_names: ClassVar[tuple[str, ...]] = _PARAM_NAMES_3D + fit_label: ClassVar[str] = "3D hyperbolic tangent fit" + #: Initial guess tuned for room-temperature water; the two #: horizontal centres default to ``0`` because the analyzer #: pre-centers the atoms on the droplet COM before binning. @@ -174,12 +213,6 @@ class _HyperbolicTangentModel3D: ) _PARAM_UPPER = np.array([np.inf] * 9) - def __init__(self, initial_params: list[float] | None = None) -> None: - if initial_params is None: - initial_params = list(self.DEFAULT_INITIAL_PARAMS) - self.params: list[float] | np.ndarray | None = initial_params - self.covariance: np.ndarray | None = None - @staticmethod def _fitting_function( x: tuple[np.ndarray, np.ndarray, np.ndarray], @@ -199,72 +232,6 @@ def _fitting_function( h_z = 0.5 * (1.0 + np.tanh(2 * (zi - zi_0) / t2)) return g_r * h_z - def fit( - self, - x_data: tuple[np.ndarray, np.ndarray, np.ndarray], - density_data: np.ndarray, - ) -> "_HyperbolicTangentModel3D": - self.params, self.covariance = curve_fit( - self._fitting_function, - x_data, - density_data, - p0=self.params, - bounds=(self._PARAM_LOWER, self._PARAM_UPPER), - maxfev=1_000_000, - ) - self._warn_if_at_bounds() - return self - - def _warn_if_at_bounds(self) -> None: - if self.params is None: - return - tol = 1e-6 - at_bound = [] - for name, value, lo, hi in zip( - _PARAM_NAMES_3D, - self.params, - self._PARAM_LOWER, - self._PARAM_UPPER, - strict=False, - ): - if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): - at_bound.append(f"{name}={value:.3g} at lower bound {lo}") - elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): - at_bound.append(f"{name}={value:.3g} at upper bound {hi}") - if at_bound: - warnings.warn( - "3D hyperbolic tangent fit converged with parameter(s) at " - "the physical bound, suggesting a poor fit: " + "; ".join(at_bound), - RuntimeWarning, - stacklevel=3, - ) - - def compute_contact_angle(self) -> float: - """Return the contact angle (degrees) implied by the fitted parameters. - - Same geometric formula as the 2D model: the sphere of radius - ``R_eq`` centred at ``(xi_c, yi_c, zi_c)`` intersects the wall - plane ``z = zi_0`` in a circle whose tangent makes the contact - angle with the wall. - """ - if self.params is None: - raise ValueError("Model must be fitted before computing contact angle.") - R_eq = float(self.params[2]) - zi_c = float(self.params[5]) - zi_0 = float(self.params[6]) - discriminant = R_eq**2 - (zi_0 - zi_c) ** 2 - if discriminant < 0: - warnings.warn( - "3D fit wall is outside the fitted droplet sphere " - f"(R_eq={R_eq:.3f}, |zi_0 - zi_c|=" - f"{abs(zi_0 - zi_c):.3f}); contact angle is undefined.", - RuntimeWarning, - stacklevel=2, - ) - return float("nan") - xi_cross = np.sqrt(discriminant) - return float((np.pi / 2 - np.arctan((zi_0 - zi_c) / xi_cross)) * 180 / np.pi) - # ---------------------------------------------------------------------- # Heuristic binning grids. @@ -283,18 +250,6 @@ def compute_contact_angle(self) -> float: _DEFAULT_BIN_WIDTH_3D = 1.0 -def edges_from_bin_width(lo: float, hi: float, bin_width: float) -> np.ndarray: - """Bin edges spanning ``[lo, hi]`` with cells of approximately ``bin_width``. - - The number of cells is rounded to the nearest integer; the range - bounds are honoured exactly, so the effective cell width is - ``(hi - lo) / n_cells`` which may differ slightly from - ``bin_width``. Always returns at least one cell. - """ - n = max(int(round((float(hi) - float(lo)) / float(bin_width))), 1) - return np.linspace(float(lo), float(hi), n + 1) - - def _default_binning_params(parser: Any) -> dict[str, Any]: """Atom-derived default 2D binning grid. diff --git a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py index eac1887..054e264 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py @@ -28,14 +28,19 @@ import numpy as np from wetting_angle_kit.analysis._base import ( - _BatchedTrajectoryAnalyzer, build_parser, + gather_batch_coords, +) +from wetting_angle_kit.analysis._grid_utils import edges_from_bin_width +from wetting_angle_kit.analysis.coupled_fit._base import ( + _CoupledFitAnalyzer, + fit_model_params, +) +from wetting_angle_kit.analysis.coupled_fit._models import ( + _default_binning_params as _default_binning_params_2d, ) from wetting_angle_kit.analysis.coupled_fit._models import ( - _PARAM_NAMES, - _default_binning_params, _HyperbolicTangentModel2D, - edges_from_bin_width, ) from wetting_angle_kit.analysis.density_estimator import ( DensityEstimator, @@ -45,13 +50,11 @@ CoupledFit2DBatchResult, CoupledFit2DResults, ) -from wetting_angle_kit.analysis.temporal import TemporalAggregator -from wetting_angle_kit.io_utils import recenter_droplet_pbc logger = logging.getLogger(__name__) -class CoupledFit2DAnalyzer(_BatchedTrajectoryAnalyzer): +class CoupledFit2DAnalyzer(_CoupledFitAnalyzer): """Joint contact-angle fit on a 2D binned density grid. Parameters @@ -107,31 +110,13 @@ class CoupledFit2DAnalyzer(_BatchedTrajectoryAnalyzer): #: subclass writes to its own slot. _WORKER_STATE: ClassVar[dict[str, Any]] = {} - def __init__( - self, - parser: Any, - atom_indices: np.ndarray | None = None, - droplet_geometry: DropletGeometry | str = "spherical", - *, - binning_params: dict[str, Any] | None = None, - density_estimator: DensityEstimator | None = None, - initial_params: list[float] | None = None, - temporal_aggregator: TemporalAggregator | None = None, - precentered: bool = False, - ) -> None: - super().__init__( - parser=parser, - atom_indices=atom_indices, - droplet_geometry=droplet_geometry, - temporal_aggregator=temporal_aggregator - or TemporalAggregator(batch_size=-1), - precentered=precentered, - ) - if binning_params is None: - binning_params = _default_binning_params(parser) - self.binning_params = binning_params - self.density_estimator = density_estimator or DensityEstimator.binning() - self.initial_params = initial_params + #: Results dataclass produced by the shared ``_build_results``. + _RESULTS_CLS: ClassVar[type] = CoupledFit2DResults + + def _default_binning_params(self, parser: Any) -> dict[str, Any]: + return _default_binning_params_2d(parser) + + def _post_init(self, parser: Any) -> None: # Cylinder dV normalisation needs the box length along the # cylinder axis; read it once at construction. self.box_dimension: float | None @@ -147,12 +132,6 @@ def __init__( # _BatchedTrajectoryAnalyzer extension points. # ------------------------------------------------------------------ - def _tqdm_desc(self) -> str: - return ( - f"CoupledFit2DAnalyzer " - f"({self.droplet_geometry.name} / {self.density_estimator.kind})" - ) - def _init_args(self) -> tuple: return ( self.parser.filepath, @@ -206,35 +185,20 @@ def _process_batch_worker( # :meth:`_BatchedTrajectoryAnalyzer._run_inline`. progress_callback = state.get("progress_callback") try: - # Per-frame PBC recentering + droplet-centring in (x, y). + # Per-frame PBC recentering + droplet-centring in (x, y); # ``z`` stays in the lab frame so wall position retains # physical meaning. The pooled 3D positions are then # handed to the density estimator strategy, which picks # its own projection (radial for spherical, |x| for # cylinder) and density rule (histogram vs Gaussian KDE). - coord_chunks: list[np.ndarray] = [] - for frame_idx in frame_indices: - positions = parser.parse(frame_index=frame_idx, indices=atom_indices) - if precentered: - com = np.mean(positions, axis=0) - else: - box_xy = ( - parser.box_size_x(frame_index=frame_idx), - parser.box_size_y(frame_index=frame_idx), - ) - positions, com = recenter_droplet_pbc( - positions, droplet_geometry.name, box_size=box_xy - ) - positions = droplet_geometry.to_internal_coords(positions) - com = droplet_geometry.to_internal_coords(com) - positions_centered = positions - np.array([com[0], com[1], 0.0]) - coord_chunks.append(positions_centered) - if progress_callback is not None: - progress_callback(1) - atoms_pooled = ( - np.concatenate(coord_chunks, axis=0) - if coord_chunks - else np.empty((0, 3)) + atoms_pooled, _ = gather_batch_coords( + parser=parser, + frame_indices=frame_indices, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + precentered=precentered, + center_on_com=True, + progress_callback=progress_callback, ) n_frames = len(frame_indices) @@ -268,18 +232,7 @@ def _process_batch_worker( msh_zi = msh_zi_grid.reshape(n_flat, order="F") msh_xi = msh_xi_grid.reshape(n_flat, order="F") msh_rho = rho_cc.reshape(n_flat, order="F") - model.fit((msh_xi, msh_zi), msh_rho) - angle = float(model.compute_contact_angle()) - params = model.params - if params is None: - raise RuntimeError( - "_HyperbolicTangentModel2D did not set model parameters; " - "cannot build CoupledFit2DBatchResult." - ) - model_params = { - name: float(value) - for name, value in zip(_PARAM_NAMES, params, strict=False) - } + angle, model_params = fit_model_params(model, (msh_xi, msh_zi), msh_rho) return CoupledFit2DBatchResult( frames=list(frame_indices), angle=angle, @@ -291,16 +244,3 @@ def _process_batch_worker( except Exception as e: logger.error(f"Error processing batch {frame_indices}: {e}", exc_info=True) return None - - def _build_results( - self, batches: list[CoupledFit2DBatchResult] - ) -> CoupledFit2DResults: - return CoupledFit2DResults( - batches=batches, - method_metadata={ - "droplet_geometry": self.droplet_geometry.name, - "binning_params": self.binning_params, - "initial_params": self.initial_params, - "batch_size": self.temporal_aggregator.batch_size, - }, - ) diff --git a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py index be7144a..a6fccef 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py @@ -26,14 +26,17 @@ import numpy as np from wetting_angle_kit.analysis._base import ( - _BatchedTrajectoryAnalyzer, build_parser, + gather_batch_coords, +) +from wetting_angle_kit.analysis._grid_utils import edges_from_bin_width +from wetting_angle_kit.analysis.coupled_fit._base import ( + _CoupledFitAnalyzer, + fit_model_params, ) from wetting_angle_kit.analysis.coupled_fit._models import ( - _PARAM_NAMES_3D, _default_binning_params_3d, _HyperbolicTangentModel3D, - edges_from_bin_width, ) from wetting_angle_kit.analysis.density_estimator import ( DensityEstimator, @@ -43,13 +46,11 @@ CoupledFit3DBatchResult, CoupledFit3DResults, ) -from wetting_angle_kit.analysis.temporal import TemporalAggregator -from wetting_angle_kit.io_utils import recenter_droplet_pbc logger = logging.getLogger(__name__) -class CoupledFit3DAnalyzer(_BatchedTrajectoryAnalyzer): +class CoupledFit3DAnalyzer(_CoupledFitAnalyzer): """Joint contact-angle fit on a 3D binned density grid. Parameters @@ -91,26 +92,10 @@ class CoupledFit3DAnalyzer(_BatchedTrajectoryAnalyzer): #: subclass writes to its own slot. _WORKER_STATE: ClassVar[dict[str, Any]] = {} - def __init__( - self, - parser: Any, - atom_indices: np.ndarray | None = None, - droplet_geometry: DropletGeometry | str = "spherical", - *, - binning_params: dict[str, Any] | None = None, - density_estimator: DensityEstimator | None = None, - initial_params: list[float] | None = None, - temporal_aggregator: TemporalAggregator | None = None, - precentered: bool = False, - ) -> None: - super().__init__( - parser=parser, - atom_indices=atom_indices, - droplet_geometry=droplet_geometry, - temporal_aggregator=temporal_aggregator - or TemporalAggregator(batch_size=-1), - precentered=precentered, - ) + #: Results dataclass produced by the shared ``_build_results``. + _RESULTS_CLS: ClassVar[type] = CoupledFit3DResults + + def _check_geometry(self) -> None: if not self.droplet_geometry.is_spherical: raise ValueError( "CoupledFit3DAnalyzer only supports spherical droplets; " @@ -119,19 +104,14 @@ def __init__( "the 3D fit collapses onto the 2D one by translational " "symmetry along the cylinder axis." ) - if binning_params is None: - binning_params = _default_binning_params_3d(parser) - self.binning_params = binning_params - self.density_estimator = density_estimator or DensityEstimator.binning() - self.initial_params = initial_params + + def _default_binning_params(self, parser: Any) -> dict[str, Any]: + return _default_binning_params_3d(parser) # ------------------------------------------------------------------ # _BatchedTrajectoryAnalyzer extension points. # ------------------------------------------------------------------ - def _tqdm_desc(self) -> str: - return f"CoupledFit3DAnalyzer (spherical / {self.density_estimator.kind})" - def _init_args(self) -> tuple: return ( self.parser.filepath, @@ -184,27 +164,14 @@ def _process_batch_worker( # Per-frame PBC recentering, then drop each frame's atoms # in the droplet-centred ``(x, y)`` frame (z stays in the # lab frame so the wall position retains physical meaning). - coord_chunks: list[np.ndarray] = [] - for frame_idx in frame_indices: - positions = parser.parse(frame_index=frame_idx, indices=atom_indices) - if precentered: - com = np.mean(positions, axis=0) - else: - box_xy = ( - parser.box_size_x(frame_index=frame_idx), - parser.box_size_y(frame_index=frame_idx), - ) - positions, com = recenter_droplet_pbc( - positions, droplet_geometry.name, box_size=box_xy - ) - positions_centered = positions - np.array([com[0], com[1], 0.0]) - coord_chunks.append(positions_centered) - if progress_callback is not None: - progress_callback(1) - coords = ( - np.concatenate(coord_chunks, axis=0) - if coord_chunks - else np.empty((0, 3)) + coords, _ = gather_batch_coords( + parser=parser, + frame_indices=frame_indices, + atom_indices=atom_indices, + droplet_geometry=droplet_geometry, + precentered=precentered, + center_on_com=True, + progress_callback=progress_callback, ) n_frames = len(frame_indices) @@ -247,21 +214,12 @@ def _process_batch_worker( rho_flat = rho.ravel() model = _HyperbolicTangentModel3D(initial_params=initial_params) - model.fit((xi_flat, yi_flat, zi_flat), rho_flat) - angle = model.compute_contact_angle() - params = model.params - if params is None: - raise RuntimeError( - "_HyperbolicTangentModel3D did not set parameters; " - "cannot build CoupledFit3DBatchResult." - ) - model_params = { - name: float(value) - for name, value in zip(_PARAM_NAMES_3D, params, strict=False) - } + angle, model_params = fit_model_params( + model, (xi_flat, yi_flat, zi_flat), rho_flat + ) return CoupledFit3DBatchResult( frames=list(frame_indices), - angle=float(angle), + angle=angle, model_params=model_params, xi_grid=xi_cc.copy(), yi_grid=yi_cc.copy(), @@ -271,16 +229,3 @@ def _process_batch_worker( except Exception as e: logger.error(f"Error processing batch {frame_indices}: {e}", exc_info=True) return None - - def _build_results( - self, batches: list[CoupledFit3DBatchResult] - ) -> CoupledFit3DResults: - return CoupledFit3DResults( - batches=batches, - method_metadata={ - "droplet_geometry": self.droplet_geometry.name, - "binning_params": self.binning_params, - "initial_params": self.initial_params, - "batch_size": self.temporal_aggregator.batch_size, - }, - ) diff --git a/src/wetting_angle_kit/analysis/interface/_grid.py b/src/wetting_angle_kit/analysis/interface/_grid.py index 27ea392..75ab5be 100644 --- a/src/wetting_angle_kit/analysis/interface/_grid.py +++ b/src/wetting_angle_kit/analysis/interface/_grid.py @@ -20,6 +20,7 @@ import numpy as np +from wetting_angle_kit.analysis._grid_utils import edges_from_bin_width from wetting_angle_kit.analysis.density_estimator import ( DensityEstimator, _GaussianDensityEstimator, @@ -196,25 +197,13 @@ def _validate_per_slice_params( # --------------------------------------------------------------------------- -def _edges_from_bin_width(lo: float, hi: float, bin_width: float) -> np.ndarray: - """Bin edges spanning ``[lo, hi]`` with cells of approximately ``bin_width``. - - The number of cells is rounded to the nearest integer; the range - bounds are honoured exactly, so the effective cell width is - ``(hi - lo) / n_cells`` which may differ slightly from - ``bin_width``. Always returns at least one cell. - """ - n = max(int(round((float(hi) - float(lo)) / float(bin_width))), 1) - return np.linspace(float(lo), float(hi), n + 1) - - def _slice_grid_edges( grid_params: dict[str, Any], ) -> tuple[np.ndarray, np.ndarray]: - s_edges = _edges_from_bin_width( + s_edges = edges_from_bin_width( grid_params["xi_0"], grid_params["xi_f"], grid_params["bin_width_x"] ) - z_edges = _edges_from_bin_width( + z_edges = edges_from_bin_width( grid_params["zi_0"], grid_params["zi_f"], grid_params["bin_width_z"] ) return s_edges, z_edges @@ -233,13 +222,13 @@ def _slice_grid_centres( def _whole_grid_edges( grid_params: dict[str, Any], ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - x_edges = _edges_from_bin_width( + x_edges = edges_from_bin_width( grid_params["xi_0"], grid_params["xi_f"], grid_params["bin_width_x"] ) - y_edges = _edges_from_bin_width( + y_edges = edges_from_bin_width( grid_params["yi_0"], grid_params["yi_f"], grid_params["bin_width_y"] ) - z_edges = _edges_from_bin_width( + z_edges = edges_from_bin_width( grid_params["zi_0"], grid_params["zi_f"], grid_params["bin_width_z"] ) return x_edges, y_edges, z_edges diff --git a/src/wetting_angle_kit/analysis/interface/_rays.py b/src/wetting_angle_kit/analysis/interface/_rays.py index ab6380e..1ed5a22 100644 --- a/src/wetting_angle_kit/analysis/interface/_rays.py +++ b/src/wetting_angle_kit/analysis/interface/_rays.py @@ -155,7 +155,7 @@ def _extract_rays( if droplet_geometry.is_spherical: assert delta_azimuthal is not None n_slices = int(180 / delta_azimuthal) - azimuthals = np.linspace(0.0, 180.0, n_slices) + azimuthals = np.linspace(0.0, 180.0, n_slices, endpoint=False) return [ _ray_slice_in_plane( field, center_geom, float(g), distances, delta_polar diff --git a/src/wetting_angle_kit/analysis/results.py b/src/wetting_angle_kit/analysis/results.py index 2f41f9f..1a22d70 100644 --- a/src/wetting_angle_kit/analysis/results.py +++ b/src/wetting_angle_kit/analysis/results.py @@ -177,28 +177,17 @@ class CoupledFit3DBatchResult: density: np.ndarray -@dataclass -class TrajectoryResults: - """In-memory results of a :class:`TrajectoryAnalyzer.analyze` run. - - Holds one :class:`BatchResult` per batch produced by the - :class:`TemporalAggregator`. Within a single Results object all - batches share the same subclass (:class:`SlicingBatchResult` or - :class:`WholeBatchResult`), determined by the analyzer's - :class:`SurfaceFitter` kind. - - Attributes - ---------- - batches : list[BatchResult] - Per-batch results, in the order produced by the aggregator. - method_metadata : dict - Free-form descriptor of the analyzer configuration (kind, - droplet geometry, batch size, …) for downstream plotting / - serialization. +class _AngleResultsMixin: + """Shared per-batch angle aggregation for the results containers. + + Mixed into the three results dataclasses, each of which exposes a + ``batches`` list whose elements carry an ``angle`` attribute + (degrees). Holds only behaviour — the ``batches`` / + ``method_metadata`` fields stay on the concrete dataclasses so each + keeps its precise per-batch element type. """ - batches: list[BatchResult] - method_metadata: dict[str, Any] = field(default_factory=dict) + batches: list[Any] def __len__(self) -> int: return len(self.batches) @@ -224,7 +213,31 @@ def std_angle(self) -> float: @dataclass -class CoupledFit2DResults: +class TrajectoryResults(_AngleResultsMixin): + """In-memory results of a :class:`TrajectoryAnalyzer.analyze` run. + + Holds one :class:`BatchResult` per batch produced by the + :class:`TemporalAggregator`. Within a single Results object all + batches share the same subclass (:class:`SlicingBatchResult` or + :class:`WholeBatchResult`), determined by the analyzer's + :class:`SurfaceFitter` kind. + + Attributes + ---------- + batches : list[BatchResult] + Per-batch results, in the order produced by the aggregator. + method_metadata : dict + Free-form descriptor of the analyzer configuration (kind, + droplet geometry, batch size, …) for downstream plotting / + serialization. + """ + + batches: list[BatchResult] + method_metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class CoupledFit2DResults(_AngleResultsMixin): """In-memory results of a :class:`CoupledFit2DAnalyzer.analyze` run. Attributes @@ -239,31 +252,9 @@ class CoupledFit2DResults: batches: list[CoupledFit2DBatchResult] method_metadata: dict[str, Any] = field(default_factory=dict) - def __len__(self) -> int: - return len(self.batches) - - @property - def per_batch_angles(self) -> np.ndarray: - """Per-batch contact angle (degrees), in batch order.""" - return np.array([b.angle for b in self.batches]) - - @property - def mean_angle(self) -> float: - """Mean contact angle across batches (degrees).""" - if not self.batches: - return float("nan") - return float(np.mean(self.per_batch_angles)) - - @property - def std_angle(self) -> float: - """Standard deviation of the per-batch contact angle (degrees).""" - if not self.batches: - return float("nan") - return float(np.std(self.per_batch_angles)) - @dataclass -class CoupledFit3DResults: +class CoupledFit3DResults(_AngleResultsMixin): """In-memory results of a :class:`CoupledFit3DAnalyzer.analyze` run. Attributes @@ -277,25 +268,3 @@ class CoupledFit3DResults: batches: list[CoupledFit3DBatchResult] method_metadata: dict[str, Any] = field(default_factory=dict) - - def __len__(self) -> int: - return len(self.batches) - - @property - def per_batch_angles(self) -> np.ndarray: - """Per-batch contact angle (degrees), in batch order.""" - return np.array([b.angle for b in self.batches]) - - @property - def mean_angle(self) -> float: - """Mean contact angle across batches (degrees).""" - if not self.batches: - return float("nan") - return float(np.mean(self.per_batch_angles)) - - @property - def std_angle(self) -> float: - """Standard deviation of the per-batch contact angle (degrees).""" - if not self.batches: - return float("nan") - return float(np.std(self.per_batch_angles)) diff --git a/src/wetting_angle_kit/io_utils.py b/src/wetting_angle_kit/io_utils.py index ccc4f5d..8de1ceb 100644 --- a/src/wetting_angle_kit/io_utils.py +++ b/src/wetting_angle_kit/io_utils.py @@ -3,20 +3,6 @@ import numpy as np -#: Droplet geometry strings accepted across analyzers and parsers. -VALID_DROPLET_GEOMETRIES = ("spherical", "cylinder_x", "cylinder_y") - - -def validate_droplet_geometry(droplet_geometry: str) -> None: - """Raise ``ValueError`` if ``droplet_geometry`` is not one of the - supported values: ``"spherical"``, ``"cylinder_x"``, ``"cylinder_y"``. - """ - if droplet_geometry not in VALID_DROPLET_GEOMETRIES: - raise ValueError( - f"Unknown droplet_geometry {droplet_geometry!r}. " - f"Expected one of {VALID_DROPLET_GEOMETRIES}." - ) - def ovito_cell_vectors(data: Any) -> np.ndarray: """Return the 3x3 lattice matrix (lattice vectors as columns) from an @@ -108,8 +94,12 @@ def _confined_lateral_axes(droplet_geometry: str) -> tuple[int, ...]: return (0, 1) if droplet_geometry == "cylinder_y": return (0,) - # cylinder_x - return (1,) + if droplet_geometry == "cylinder_x": + return (1,) + raise ValueError( + f"Unknown droplet_geometry {droplet_geometry!r}; expected one of " + "'spherical', 'cylinder_x', 'cylinder_y'." + ) def recenter_droplet_pbc( @@ -159,7 +149,6 @@ def recenter_droplet_pbc( enough that the droplet does not overlap with its periodic image; this function does not validate box sizing. """ - validate_droplet_geometry(droplet_geometry) if positions.size == 0: return positions.copy(), np.full(3, np.nan) @@ -173,63 +162,3 @@ def recenter_droplet_pbc( folded[:, axis] = cm + d com[axis] = cm return folded, com - - -def project_to_profile( - positions: np.ndarray, - droplet_geometry: str, - box_size: tuple[float, float] | None = None, -) -> tuple[np.ndarray, np.ndarray]: - """Project 3D atomic positions onto the (r, z) plane used by analyzers. - - The lateral coordinates are recentered on their per-frame center of mass - before projection; the vertical (z) coordinate is left in lab frame. - - When ``box_size`` is given, the center of mass along each confined lateral - axis is computed with the Bai & Breen circular-mean construction and the - atoms are folded into the minimum-image frame around it. This handles - trajectories where the droplet straddles a periodic boundary, in which - case a plain arithmetic mean is meaningless. The axial direction of a - cylindrical droplet (along which atoms fill the box) is never recentered. - - Parameters - ---------- - positions : ndarray, shape (N, 3) - Cartesian atomic positions for a single frame. - droplet_geometry : str - One of ``"spherical"``, ``"cylinder_x"``, ``"cylinder_y"``. - box_size : (Lx, Ly), optional - Lateral box lengths. If omitted, the arithmetic mean is used and no - PBC handling is applied — only correct when the trajectory - already recenters the droplet at every frame. - - Returns - ------- - r_values : ndarray, shape (N,) - Radial coordinate: |x_centered| for cylinder_y, |y_centered| for - cylinder_x, sqrt(x_centered**2 + y_centered**2) for spherical. - z_values : ndarray, shape (N,) - Vertical coordinate (lab-frame z, not centered). - """ - validate_droplet_geometry(droplet_geometry) - if positions.size == 0: - return np.empty(0), np.empty(0) - - if box_size is None: - # No PBC info: arithmetic-mean centering on the confined axes only. - x_centered = positions.copy() - for axis in _confined_lateral_axes(droplet_geometry): - x_centered[:, axis] = positions[:, axis] - np.mean(positions[:, axis]) - else: - folded, com = recenter_droplet_pbc(positions, droplet_geometry, box_size) - x_centered = folded - com - - # z stays in lab frame; analyzers need absolute heights to locate the wall. - z_values = positions[:, 2] - if droplet_geometry == "cylinder_y": - r_values = np.abs(x_centered[:, 0]) - elif droplet_geometry == "cylinder_x": - r_values = np.abs(x_centered[:, 1]) - else: # droplet_geometry == "spherical" - r_values = np.sqrt(x_centered[:, 0] ** 2 + x_centered[:, 1] ** 2) - return r_values, z_values diff --git a/tests/test_geometry_projection.py b/tests/test_geometry_projection.py deleted file mode 100644 index 76da404..0000000 --- a/tests/test_geometry_projection.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Unit tests for the shared (r, z) projection used by -:class:`BaseParser.get_profile_coordinates`. - -Exercises all three droplet geometries (``spherical``, -``cylinder_x``, ``cylinder_y``) under both the PBC-aware and -no-PBC code paths. -""" - -import numpy as np -import pytest - -from wetting_angle_kit.io_utils import project_to_profile - - -def _grid_points(n=20): - """Return a centered grid of 3D points: cube from -5..5, z from 0..10.""" - rng = np.random.default_rng(0) - xy = rng.uniform(-5.0, 5.0, size=(n, 2)) - z = rng.uniform(0.0, 10.0, size=(n, 1)) - return np.hstack([xy, z]) - - -def test_project_cylinder_y_uses_centered_x_as_radial(): - pts = _grid_points() - r, z = project_to_profile(pts, "cylinder_y") - # Radial coord must be |x_centered|. - x_cm = pts.mean(axis=0) - expected_r = np.abs(pts[:, 0] - x_cm[0]) - np.testing.assert_allclose(r, expected_r) - # z stays in lab frame (no centering). - np.testing.assert_allclose(z, pts[:, 2]) - assert (r >= 0).all() - - -def test_project_cylinder_x_uses_centered_y_as_radial(): - pts = _grid_points() - r, z = project_to_profile(pts, "cylinder_x") - y_cm = pts.mean(axis=0)[1] - expected_r = np.abs(pts[:, 1] - y_cm) - np.testing.assert_allclose(r, expected_r) - np.testing.assert_allclose(z, pts[:, 2]) - assert (r >= 0).all() - - -def test_project_spherical_uses_centered_radial_distance(): - pts = _grid_points() - r, z = project_to_profile(pts, "spherical") - cm = pts.mean(axis=0) - expected_r = np.sqrt((pts[:, 0] - cm[0]) ** 2 + (pts[:, 1] - cm[1]) ** 2) - np.testing.assert_allclose(r, expected_r) - np.testing.assert_allclose(z, pts[:, 2]) - assert (r >= 0).all() - - -def test_project_cylinder_x_and_cylinder_y_are_axis_swaps(): - """Swapping x and y should map cylinder_y onto cylinder_x.""" - pts = _grid_points() - r_y, _ = project_to_profile(pts, "cylinder_y") - swapped = pts[:, [1, 0, 2]] - r_x, _ = project_to_profile(swapped, "cylinder_x") - np.testing.assert_allclose(r_y, r_x) - - -def test_project_empty_input_returns_empty_arrays(): - empty = np.empty((0, 3)) - r, z = project_to_profile(empty, "spherical") - assert r.shape == (0,) - assert z.shape == (0,) - - -def test_project_rejects_unknown_geometry(): - pts = _grid_points(3) - with pytest.raises(ValueError, match="Unknown droplet_geometry"): - project_to_profile(pts, "blob") - - -def _localized_cluster(box_length: float, n: int = 30) -> np.ndarray: - """Return n points clustered tightly enough around the box center that - the circular mean and the arithmetic mean agree to numerical precision - (the difference scales like (spread/L)^2, so a 1% spread is plenty).""" - rng = np.random.default_rng(0) - center = box_length / 2.0 - spread = box_length / 100.0 - xy = rng.uniform(center - spread, center + spread, size=(n, 2)) - z = rng.uniform(0.0, box_length / 2.0, size=(n, 1)) - return np.hstack([xy, z]) - - -def test_project_with_box_size_matches_no_pbc_for_mid_box_cluster(): - """When the droplet sits comfortably away from the boundary, the - PBC-aware path returns the same radii as the no-PBC (arithmetic- - mean) path: minimum-image folding is a no-op and the circular - mean coincides with the arithmetic mean.""" - box = (20.0, 20.0) - pts = _localized_cluster(box[0]) - r_no_pbc, z_no_pbc = project_to_profile(pts, "spherical") - r_pbc, z_pbc = project_to_profile(pts, "spherical", box_size=box) - np.testing.assert_allclose(r_pbc, r_no_pbc, atol=1e-4) - np.testing.assert_allclose(z_pbc, z_no_pbc) - - -def test_project_with_box_size_handles_droplet_straddling_boundary(): - """Atoms wrapped across the x=0 boundary must collapse onto sensible - radii under the PBC-aware path, whereas the no-PBC arithmetic mean - sees a spurious cluster centered in the empty middle of the box.""" - # Two tight rings of four atoms straddling the x=0 / x=L boundary on a - # 10 Å box. Physically one ring of radius 0.5 centered on x=0. - box = (10.0, 10.0) - pts = np.array( - [ - [0.5, 5.0, 0.0], - [9.5, 5.0, 0.0], - [0.0, 5.5, 0.0], - [0.0, 4.5, 0.0], - ] - ) - r_pbc, _ = project_to_profile(pts, "spherical", box_size=box) - # All atoms are at true radius 0.5 from the (wrapped) center. - np.testing.assert_allclose(np.sort(r_pbc), np.full(4, 0.5), atol=1e-9) - # Sanity check that this would have failed without the box-aware path: - # the arithmetic mean lands near x=2.5, putting two atoms at r >= 7. - r_no_pbc, _ = project_to_profile(pts, "spherical") - assert float(np.max(r_no_pbc)) > 5.0 - - -def test_project_cylinder_with_box_size_does_not_recenter_axial_axis(): - """The axial axis of a cylinder (x for cylinder_x, y for cylinder_y) - must not be folded; only the cross-section axis is recentered, so the - radial values match the no-PBC path on a mid-box cluster.""" - box = (20.0, 20.0) - pts = _localized_cluster(box[0]) - for geom in ("cylinder_x", "cylinder_y"): - r_no_pbc, _ = project_to_profile(pts, geom) - r_pbc, _ = project_to_profile(pts, geom, box_size=box) - np.testing.assert_allclose(r_pbc, r_no_pbc, atol=1e-4) diff --git a/tests/test_io_utils.py b/tests/test_io_utils.py index 5c7e7ba..45c1fb5 100644 --- a/tests/test_io_utils.py +++ b/tests/test_io_utils.py @@ -6,11 +6,9 @@ import pytest from wetting_angle_kit.io_utils import ( - VALID_DROPLET_GEOMETRIES, assert_orthogonal_cell, detect_parser_type, recenter_droplet_pbc, - validate_droplet_geometry, ) # --- detect_parser_type --- @@ -37,28 +35,6 @@ def test_detect_parser_type_rejects_unknown(filename): detect_parser_type(filename) -# --- validate_droplet_geometry --- - - -@pytest.mark.parametrize("geom", VALID_DROPLET_GEOMETRIES) -def test_validate_droplet_geometry_accepts_valid(geom): - # Should not raise. - validate_droplet_geometry(geom) - - -@pytest.mark.parametrize("bad", ["spheric", "cylinder", "Cylinder_y", "", "sphere"]) -def test_validate_droplet_geometry_rejects_invalid(bad): - with pytest.raises(ValueError, match="Unknown droplet_geometry"): - validate_droplet_geometry(bad) - - -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. - assert isinstance(VALID_DROPLET_GEOMETRIES, tuple) - assert set(VALID_DROPLET_GEOMETRIES) == {"spherical", "cylinder_x", "cylinder_y"} - - # --- Round-trip with detect + temp file --- From f5d36681f1e8ce1fd30113f785dfb4c37f0424b2 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 09:48:45 +0200 Subject: [PATCH 37/53] Honest reporting for the slicing+binning tanh fit. --- src/wetting_angle_kit/analysis/_base.py | 32 +++- src/wetting_angle_kit/analysis/_density.py | 166 +++++++++++------- .../analysis/interface/_rays.py | 21 ++- tests/test_analysis/test_cylinder_coverage.py | 17 +- tests/test_analysis/test_rays_with_binning.py | 28 +++ 5 files changed, 187 insertions(+), 77 deletions(-) diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index a440d2d..2296fb6 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -275,13 +275,22 @@ def analyze( ------- Results Subclass-specific results dataclass produced by - :meth:`_build_results`. + :meth:`_build_results`. Its ``method_metadata`` always + carries ``n_requested_batches`` and ``n_failed_batches`` so + a partially-failing run is visible: aggregate statistics + (e.g. ``mean_angle``) are computed only over the batches + that produced a result, and any dropped batches also raise a + :class:`UserWarning`. If *every* batch fails, a + :class:`RuntimeError` is raised instead. """ if frame_range is None: frame_range = list(range(self.parser.frame_count())) batches = list(self.temporal_aggregator.iter_batches(frame_range)) if not batches: - return self._build_results(batches=[]) + results = self._build_results(batches=[]) + results.method_metadata["n_requested_batches"] = 0 + results.method_metadata["n_failed_batches"] = 0 + return results total_frames = sum(len(b) for b in batches) logger.info( @@ -319,10 +328,27 @@ def analyze( "parser, geometry, or fit errors." ) + # Surface partial failures rather than silently averaging over a + # smaller set of batches: warn, and record the counts on the + # results so a caller can see that some batches were dropped. + n_failed = len(batches) - len(batch_results) + if n_failed: + warnings.warn( + f"{n_failed} of {len(batches)} batches produced no result and " + "were omitted (see the worker logs above for the per-batch " + "errors); aggregate statistics are computed over the " + f"remaining {len(batch_results)} batch(es).", + UserWarning, + stacklevel=2, + ) + # ``imap_unordered`` returns completion-ordered; restore batch # order using the first frame index in each batch. batch_results.sort(key=lambda b: min(b.frames) if b.frames else 0) - return self._build_results(batches=batch_results) + results = self._build_results(batches=batch_results) + results.method_metadata["n_requested_batches"] = len(batches) + results.method_metadata["n_failed_batches"] = n_failed + return results def _run_inline( self, diff --git a/src/wetting_angle_kit/analysis/_density.py b/src/wetting_angle_kit/analysis/_density.py index 2af4e10..fd02f35 100644 --- a/src/wetting_angle_kit/analysis/_density.py +++ b/src/wetting_angle_kit/analysis/_density.py @@ -205,30 +205,37 @@ def fit_tanh_profiles_batched( distances: np.ndarray, densities: np.ndarray, *, - max_iter: int = 25, + max_iter: int = 50, tol: float = 1e-9, + rank_rtol: float = 1e-6, ) -> np.ndarray: """Fit ``rho(s) = d * tanh(zd - s) + h`` to every ray of a slice at once. All rays of a slice share the same sampling grid, so the Jacobian 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 - :func:`numpy.linalg.solve` once per iteration — much faster than - per-ray :func:`scipy.optimize.curve_fit`. + are independent 3x3 systems. A batched Levenberg–Marquardt solver + advances every ray in lock-step on numpy tensors — much faster than + per-ray :func:`scipy.optimize.curve_fit`, while remaining a proper + damped nonlinear least squares: each ray keeps its own damping + ``λ``, every accepted step strictly decreases that ray's residual + sum of squares, and the damped normal matrix is positive-definite + so the batched solve never raises. 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. The batched solve aborts the - iteration if any ray's normal equations become singular (e.g. a - near-constant density profile that never crosses the interface), - leaving every ray at its current parameters; a non-finite update - instead resets every ray to the closed-form initial guess. This - early stop also regularises the noisier histogram density, whose - fully converged per-ray optima can chase shot noise. The recovered - ``zd`` is clipped to ``[0, distances[-1]]`` to keep ill-fit rays - from escaping the sampling envelope. + half-amplitude``, ``zd ~ midpoint crossing``) seeds each ray in the + basin of the global minimum. Rays converge independently to their + own least-squares optimum; no global early stop and no implicit + regularisation is applied, so the recovered interface is the honest + fit — including on noisy histogram density, where it may differ + from a smoother estimator. + + A ray whose density profile carries no resolvable interface (a flat + profile that never crosses from liquid to vapour) leaves ``zd`` + undetermined: its normal matrix is rank-deficient. Such rays are + reported as ``nan`` rather than a fabricated position, so the + caller can drop them from the interface point set instead of + seeding a spurious point. Resolved ``zd`` values are clipped to + ``[0, distances[-1]]`` to keep them within the sampling envelope. Parameters ---------- @@ -238,22 +245,29 @@ def fit_tanh_profiles_batched( clip bound on the recovered interface position. densities : ndarray, shape (R, M) Density values per ray. - max_iter : int, default 25 - Hard cap on Gauss–Newton iterations. + max_iter : int, default 50 + Hard cap on Levenberg–Marquardt iterations. tol : float, default 1e-9 - Convergence threshold on the max absolute parameter step - across all rays. + Convergence threshold on the max absolute parameter step of a + ray's accepted update. + rank_rtol : float, default 1e-6 + Relative tolerance for the rank-deficiency test on each ray's + final normal matrix: a ray is treated as having no resolvable + interface (and returns ``nan``) when + ``|det(JᵀJ)| <= rank_rtol * prod(diag(JᵀJ))``. Returns ------- ndarray, shape (R,) Fitted ``zd`` (interface position) per ray, clipped to - ``[0, distances[-1]]``. + ``[0, distances[-1]]``; ``nan`` for rays with no resolvable + interface. """ z = np.ascontiguousarray(distances, dtype=np.float64) y = np.ascontiguousarray(densities, dtype=np.float64) n_rays, n_samples = y.shape max_dist = float(z[-1]) + idx = np.arange(3) rho_max = y.max(axis=1) rho_min = y.min(axis=1) @@ -262,50 +276,74 @@ def fit_tanh_profiles_batched( zd0 = z[np.argmin(np.abs(y - h0[:, None]), axis=1)] zd0 = np.clip(zd0, 0.0, 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) + def residuals_and_u(p: np.ndarray) -> tuple[np.ndarray, np.ndarray]: + u = np.tanh(p[:, 0:1] - z[None, :]) + return y - (p[:, 1:2] * u + p[:, 2:3]), u + + def normal_and_grad( + u: np.ndarray, resid: np.ndarray, d_col: np.ndarray + ) -> tuple[np.ndarray, np.ndarray]: + # J columns are d/dzd, d/dd, d/dh; J_h = 1 is folded into the + # sums / counts, so only J_zd and J_d are materialised. + j_zd = d_col * (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: - # A degenerate ray (e.g. near-constant density) makes its - # 3x3 system singular and aborts the whole batched solve. - # Stop iterating and keep the current per-ray parameters; - # for the dominant well-conditioned rays these have already - # converged, and the early stop keeps noise-driven rays near - # their robust closed-form seed. - break - params += step - if not np.isfinite(params).all(): - params = params_init.copy() - break - if np.max(np.abs(step)) < tol: + a = np.empty((n_rays, 3, 3)) + a[:, 0, 0] = np.einsum("rm,rm->r", j_zd, j_zd) + a[:, 0, 1] = a[:, 1, 0] = np.einsum("rm,rm->r", j_zd, j_d) + a[:, 0, 2] = a[:, 2, 0] = j_zd.sum(axis=1) + a[:, 1, 1] = np.einsum("rm,rm->r", j_d, j_d) + a[:, 1, 2] = a[:, 2, 1] = j_d.sum(axis=1) + a[:, 2, 2] = n_samples + g = np.empty((n_rays, 3)) + g[:, 0] = np.einsum("rm,rm->r", j_zd, resid) + g[:, 1] = np.einsum("rm,rm->r", j_d, resid) + g[:, 2] = resid.sum(axis=1) + return a, g + + resid, u = residuals_and_u(params) + cost = np.einsum("rm,rm->r", resid, resid) + lam: np.ndarray = np.full(n_rays, 1e-3) + + for _ in range(max_iter): + normal, grad = normal_and_grad(u, resid, params[:, 1:2]) + # Levenberg damping scaled by each ray's own matrix magnitude so + # the augmented system is positive-definite (always solvable), + # regardless of how ill-conditioned the undamped normal matrix is. + scale = np.maximum(normal[:, idx, idx].max(axis=1), 1e-30) + aug = normal.copy() + aug[:, idx, idx] += lam[:, None] * scale[:, None] + step = np.linalg.solve(aug, grad[..., None])[..., 0] + trial = params + step + trial_resid, trial_u = residuals_and_u(trial) + trial_cost = np.einsum("rm,rm->r", trial_resid, trial_resid) + # Accept a ray's step only if it strictly lowers that ray's SSR + # (the LM gain test); accepted rays loosen damping toward Gauss– + # Newton, rejected rays tighten it toward gradient descent. + improved = np.isfinite(trial_cost) & (trial_cost < cost) + imp = improved[:, None] + params = np.where(imp, trial, params) + u = np.where(imp, trial_u, u) + resid = np.where(imp, trial_resid, resid) + cost = np.where(improved, trial_cost, cost) + lam = np.where( + improved, + np.maximum(lam / 3.0, 1e-30), + np.minimum(lam * 3.0, 1e30), + ) + # A ray is done when its accepted step is negligible or its + # damping has saturated (no further progress possible). + step_size = np.max(np.abs(np.where(imp, step, 0.0)), axis=1) + if np.all((step_size < tol) | (lam >= 1e30)): break - return np.clip(params[:, 0], 0.0, max_dist) + # Rank-deficiency gate: a ray with a flat profile leaves zd + # unconstrained (rank-deficient normal matrix); report nan so the + # caller drops it rather than treating the seed as an interface. + normal, _ = normal_and_grad(u, resid, params[:, 1:2]) + det = np.linalg.det(normal) + diag_prod = normal[:, 0, 0] * normal[:, 1, 1] * normal[:, 2, 2] + with np.errstate(invalid="ignore"): + resolved = np.isfinite(det) & (np.abs(det) > rank_rtol * np.abs(diag_prod)) + zd_out = np.where(resolved, params[:, 0], np.nan) + return np.clip(zd_out, 0.0, max_dist) diff --git a/src/wetting_angle_kit/analysis/interface/_rays.py b/src/wetting_angle_kit/analysis/interface/_rays.py index 1ed5a22..405d813 100644 --- a/src/wetting_angle_kit/analysis/interface/_rays.py +++ b/src/wetting_angle_kit/analysis/interface/_rays.py @@ -104,8 +104,11 @@ def _ray_slice_in_plane( density_flat = field.evaluate(positions_rm.reshape(-1, 3)) densities = density_flat.reshape(len(polar), len(distances)) interface_re = fit_tanh_profiles_batched(distances, densities) - x_proj = cos_polar * interface_re + center[0] - z_proj = sin_polar * interface_re + center[2] + # Rays with no resolvable interface return NaN; drop them rather + # than seeding a spurious point. + resolved = np.isfinite(interface_re) + x_proj = cos_polar[resolved] * interface_re[resolved] + center[0] + z_proj = sin_polar[resolved] * interface_re[resolved] + center[2] return np.column_stack([x_proj, z_proj]) @@ -185,7 +188,11 @@ def _extract_rays( density_flat = field.evaluate(positions_rm.reshape(-1, 3)) densities = density_flat.reshape(len(directions), len(distances)) interface_re = fit_tanh_profiles_batched(distances, densities) - return center_geom[None, :] + interface_re[:, None] * directions + # Drop rays with no resolvable interface (NaN). + resolved = np.isfinite(interface_re) + return ( + center_geom[None, :] + interface_re[resolved, None] * directions[resolved] + ) # whole + cylinder_*: pool a per-y ray fan into a 3D shell. assert delta_cylinder is not None @@ -205,11 +212,13 @@ def _extract_rays( density_flat = field.evaluate(positions_rm.reshape(-1, 3)) densities = density_flat.reshape(len(polar), len(distances)) interface_re = fit_tanh_profiles_batched(distances, densities) + # Drop rays with no resolvable interface (NaN). + resolved = np.isfinite(interface_re) points = np.column_stack( [ - cos_polar * interface_re + slice_center[0], - np.full(len(polar), float(y)), - sin_polar * interface_re + slice_center[2], + cos_polar[resolved] * interface_re[resolved] + slice_center[0], + np.full(int(resolved.sum()), float(y)), + sin_polar[resolved] * interface_re[resolved] + slice_center[2], ] ) shells.append(points) diff --git a/tests/test_analysis/test_cylinder_coverage.py b/tests/test_analysis/test_cylinder_coverage.py index 6147946..df83d74 100644 --- a/tests/test_analysis/test_cylinder_coverage.py +++ b/tests/test_analysis/test_cylinder_coverage.py @@ -74,6 +74,7 @@ def _make_analyzer( oxygen_indices: np.ndarray, *, wall_detector: WallDetector | None = None, + temporal_aggregator: TemporalAggregator | None = None, ) -> TrajectoryAnalyzer: return TrajectoryAnalyzer( parser=LammpsDumpParser(_CYL_FIXTURE), @@ -82,7 +83,7 @@ def _make_analyzer( interface_extractor=extractor, surface_fitter=fitter, wall_detector=wall_detector or WallDetector.explicit(z_wall=_WALL_Z), - temporal_aggregator=TemporalAggregator(batch_size=1), + temporal_aggregator=temporal_aggregator or TemporalAggregator(batch_size=1), ) @@ -91,16 +92,24 @@ def _make_analyzer( @pytest.mark.integration def test_rays_with_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> None: - """``rays`` + binning + slicing on the cylinder fixture.""" + """``rays`` + binning + slicing on the cylinder fixture. + + Histogram density is shot-noisy per frame, so a single frame leaves + too few rays with a resolvable interface (the per-ray tanh fit + honestly returns NaN for flat profiles). Pool the trajectory — as + the binning estimator is meant to be used — for an adequately + sampled density, with a slightly wider kernel (``bin_width=5``). + """ analyzer = _make_analyzer( InterfaceExtractor( sampling=SpaceSampling.rays(delta_cylinder=5.0, delta_polar=8.0), - density=DensityEstimator.binning(bin_width=3.0), + density=DensityEstimator.binning(bin_width=5.0), ), SurfaceFitter.slicing(surface_filter_offset=2.0), oxygen_indices, + temporal_aggregator=TemporalAggregator(batch_size=-1), ) - batch = analyzer.analyze([1]).batches[0] + batch = analyzer.analyze().batches[0] assert 80.0 < batch.angle < 115.0 diff --git a/tests/test_analysis/test_rays_with_binning.py b/tests/test_analysis/test_rays_with_binning.py index 92436a0..d8b1e3b 100644 --- a/tests/test_analysis/test_rays_with_binning.py +++ b/tests/test_analysis/test_rays_with_binning.py @@ -22,6 +22,7 @@ from wetting_angle_kit.analysis._density import ( HistogramDensityField, + fit_tanh_profiles_batched, ) from wetting_angle_kit.analysis.density_estimator import DensityEstimator from wetting_angle_kit.analysis.geometry import DropletGeometry @@ -37,6 +38,33 @@ def _uniform_sphere_atoms(radius: float, n_atoms: int, seed: int = 0) -> np.ndar return np.concatenate(pts, axis=0)[:n_atoms] +def test_fit_tanh_profiles_batched_resolves_clean_rays_and_nans_flat() -> None: + """The batched LM fit recovers the interface of resolvable rays and + reports ``nan`` for a flat ray that carries no interface.""" + z = np.linspace(0.0, 30.0, 60) + # ``rho = d * tanh(zd - s) + h``: liquid (high) near s=0, vapour + # (low) past the interface. Two clean rays at zd=15 and zd=10. + clean_15 = 0.03 * np.tanh(-(z - 15.0)) + 0.03 + clean_10 = 0.04 * np.tanh(-(z - 10.0)) + 0.04 + flat = np.full_like(z, 0.05) + out = fit_tanh_profiles_batched(z, np.stack([clean_15, flat, clean_10])) + assert out[0] == pytest.approx(15.0, abs=0.2) + assert np.isnan(out[1]) # flat profile -> no resolvable interface + assert out[2] == pytest.approx(10.0, abs=0.2) + + +def test_fit_tanh_profiles_batched_recovers_noisy_ray() -> None: + """A resolvable interface is still recovered under moderate noise, + and the result stays clipped within the sampling envelope.""" + rng = np.random.default_rng(0) + z = np.linspace(0.0, 40.0, 120) + rho = 0.033 * np.tanh(-(z - 22.0)) + 0.033 + noisy = rho + rng.normal(scale=2e-3, size=z.shape) + out = fit_tanh_profiles_batched(z, noisy[None, :]) + assert out[0] == pytest.approx(22.0, abs=1.0) + assert 0.0 <= out[0] <= float(z[-1]) + + def test_histogram_density_field_uniform_box() -> None: """Bulk density check on a dense uniform-volume box of atoms. From 1598a192cb541f6350fd366bb1fd2cc02bcd3461 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 11:58:17 +0200 Subject: [PATCH 38/53] Other honest auditing. --- .../analysis/fitters/_slicing.py | 67 ++++++++++--------- .../analysis/fitters/base.py | 4 ++ src/wetting_angle_kit/analysis/results.py | 37 +++++++--- .../visualization/angle_evolution_plotter.py | 18 +++-- .../test_analysis/test_fitter_error_paths.py | 16 +++-- .../test_angle_evolution_helpers.py | 6 +- .../test_angle_evolution_plotter.py | 2 + 7 files changed, 99 insertions(+), 51 deletions(-) diff --git a/src/wetting_angle_kit/analysis/fitters/_slicing.py b/src/wetting_angle_kit/analysis/fitters/_slicing.py index 2c29f8e..66658de 100644 --- a/src/wetting_angle_kit/analysis/fitters/_slicing.py +++ b/src/wetting_angle_kit/analysis/fitters/_slicing.py @@ -44,53 +44,60 @@ def fit( ) z_filter = z_wall + self.surface_filter_offset + # Per-slice arrays stay full length and index-aligned: a slice + # that yields no valid angle is recorded as NaN rather than + # dropped, so attrition is visible and the slice index is kept. per_slice_angles: list[float] = [] slice_surfaces: list[np.ndarray] = [] slice_popts: list[np.ndarray] = [] slice_rms_residuals: list[float] = [] + nan_popt: np.ndarray = np.full(4, np.nan) for surf in interface_data: - if surf.size == 0: - continue - kept = surf[surf[:, 1] > z_filter] + slice_surfaces.append(surf) + angle, rms, popt = float("nan"), float("nan"), nan_popt # Need at least 3 non-collinear points to fit a circle. - if len(kept) < 3: - continue - try: - xc, zc, radius = _kasa_circle_fit_2d(kept[:, 0], kept[:, 1]) - except (np.linalg.LinAlgError, ValueError): - continue - # Contact angle from circle / wall-line intersection: - # ``cos θ = (z_wall - z_center) / R``. Drop slices where - # the fitted circle doesn't intersect the wall. - delta_z = z_wall - zc - if abs(delta_z) >= radius: - continue - angle = float(np.degrees(np.arccos(delta_z / radius))) - # Per-slice RMS of the circle-fit residuals (Å). The - # batch-level rms_residual reported in - # :class:`SlicingBatchResult` is the mean across slices. - radii = np.hypot(kept[:, 0] - xc, kept[:, 1] - zc) - rms = float(np.sqrt(np.mean((radii - radius) ** 2))) - + kept = surf[surf[:, 1] > z_filter] if surf.size else surf + if len(kept) >= 3: + try: + xc, zc, radius = _kasa_circle_fit_2d(kept[:, 0], kept[:, 1]) + # Contact angle from circle / wall-line intersection: + # ``cos θ = (z_wall - z_center) / R``. A circle that + # doesn't reach the wall (``|Δz| ≥ R``) yields no angle. + delta_z = z_wall - zc + if abs(delta_z) < radius: + angle = float(np.degrees(np.arccos(delta_z / radius))) + # Per-slice RMS of the circle-fit residuals (Å). + radii = np.hypot(kept[:, 0] - xc, kept[:, 1] - zc) + rms = float(np.sqrt(np.mean((radii - radius) ** 2))) + popt = np.array([xc, zc, radius, z_wall]) + except (np.linalg.LinAlgError, ValueError): + pass per_slice_angles.append(angle) - slice_surfaces.append(surf) - slice_popts.append(np.array([xc, zc, radius, z_wall])) slice_rms_residuals.append(rms) + slice_popts.append(popt) - if not per_slice_angles: + angles_arr = np.asarray(per_slice_angles, dtype=float) + n_slices_total = len(angles_arr) + n_slices_used = int(np.isfinite(angles_arr).sum()) + if n_slices_used == 0: raise RuntimeError( "slicing fit: no slice produced a valid contact angle " - "after filtering and circle fitting." + f"after filtering and circle fitting ({n_slices_total} " + "slice(s) attempted)." ) - angles_arr = np.asarray(per_slice_angles, dtype=float) + # nanmean / nanstd: the batch angle and its spread are over the + # slices that produced a value; the NaN entries above keep the + # dropped slices visible. return SlicingFitOutput( - angle=float(np.mean(angles_arr)), + angle=float(np.nanmean(angles_arr)), z_wall=z_wall, - rms_residual=float(np.mean(slice_rms_residuals)), - angle_std=float(np.std(angles_arr)), + rms_residual=float(np.nanmean(slice_rms_residuals)), + angle_std=float(np.nanstd(angles_arr)), per_slice_angles=angles_arr, slice_surfaces=slice_surfaces, slice_popts=np.asarray(slice_popts, dtype=float), + n_slices_total=n_slices_total, + n_slices_used=n_slices_used, ) diff --git a/src/wetting_angle_kit/analysis/fitters/base.py b/src/wetting_angle_kit/analysis/fitters/base.py index 3dc9d63..3316e78 100644 --- a/src/wetting_angle_kit/analysis/fitters/base.py +++ b/src/wetting_angle_kit/analysis/fitters/base.py @@ -57,6 +57,8 @@ class SlicingFitOutput(FitOutput): per_slice_angles: np.ndarray slice_surfaces: list[np.ndarray] slice_popts: np.ndarray + n_slices_total: int + n_slices_used: int def to_batch_result(self, frames: list[int]) -> SlicingBatchResult: return SlicingBatchResult( @@ -68,6 +70,8 @@ def to_batch_result(self, frames: list[int]) -> SlicingBatchResult: per_slice_angles=self.per_slice_angles, slice_surfaces=self.slice_surfaces, slice_popts=self.slice_popts, + n_slices_total=self.n_slices_total, + n_slices_used=self.n_slices_used, ) diff --git a/src/wetting_angle_kit/analysis/results.py b/src/wetting_angle_kit/analysis/results.py index 1a22d70..2788024 100644 --- a/src/wetting_angle_kit/analysis/results.py +++ b/src/wetting_angle_kit/analysis/results.py @@ -51,10 +51,10 @@ class BatchResult: angle_std : float, optional Within-batch standard deviation of the contact angle (degrees), describing the spread of the per-batch ``angle``. - For slicing fits, the std of ``per_slice_angles`` (always - populated). For whole fits, the bootstrap std when the fitter - was constructed with ``bootstrap_samples > 0``, otherwise - ``None``. + For slicing fits, the ``nanstd`` of ``per_slice_angles`` + (always populated). For whole fits, the bootstrap std when the + fitter was constructed with ``bootstrap_samples > 0``, + otherwise ``None``. """ frames: list[int] @@ -68,23 +68,40 @@ class BatchResult: class SlicingBatchResult(BatchResult): """Per-batch result from a slicing-kind surface fitter. + All per-slice arrays are full length (one entry per attempted + slice) and index-aligned: a slice that produced no valid contact + angle (empty, too few points, degenerate circle fit, or a circle + that does not reach the wall) is marked ``nan`` rather than dropped, + so attrition is visible and the slice index is preserved. + Attributes ---------- per_slice_angles : ndarray - ``(n_slices,)`` array of per-slice contact angles (degrees). - :attr:`BatchResult.angle` is the mean of this array; - :attr:`BatchResult.angle_std` is its standard deviation. + ``(n_slices_total,)`` array of per-slice contact angles + (degrees), with ``nan`` for slices that produced no angle. + :attr:`BatchResult.angle` is ``nanmean`` of this array and + :attr:`BatchResult.angle_std` its ``nanstd``. slice_surfaces : list[ndarray] One ``(M_i, 2)`` array per slice of interface points in the - slice ``(x, z)`` plane. + slice ``(x, z)`` plane (kept for every slice, including those + that produced no angle). slice_popts : ndarray - ``(n_slices, 4)`` array of fitted circle parameters per slice; - columns ``[xc, zc, R, z_wall]``. + ``(n_slices_total, 4)`` array of fitted circle parameters per + slice; columns ``[xc, zc, R, z_wall]``. Rows for slices with no + valid fit are ``nan``. + n_slices_total : int + Number of slices the extractor produced for this batch. + n_slices_used : int + Number of those slices that produced a valid contact angle + (the count of non-``nan`` entries in ``per_slice_angles``). + ``n_slices_used < n_slices_total`` signals per-slice attrition. """ per_slice_angles: np.ndarray slice_surfaces: list[np.ndarray] slice_popts: np.ndarray + n_slices_total: int + n_slices_used: int @dataclass(frozen=True, eq=False, kw_only=True) diff --git a/src/wetting_angle_kit/visualization/angle_evolution_plotter.py b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py index dbb55e1..3ee6a2c 100644 --- a/src/wetting_angle_kit/visualization/angle_evolution_plotter.py +++ b/src/wetting_angle_kit/visualization/angle_evolution_plotter.py @@ -60,9 +60,12 @@ def _circular_segment_area(R: float, z_center: float, z_cut: float) -> float: def _batch_surface_area(batch: Any) -> float: """Per-batch surface-area dispatch over the four result types.""" if isinstance(batch, SlicingBatchResult): - if not batch.slice_surfaces: + # Average the polygon area over slices that carry interface + # points; empty slices (no resolvable interface) are excluded. + areas = [_shoelace_area(s) for s in batch.slice_surfaces if s.size] + if not areas: return 0.0 - return float(np.mean([_shoelace_area(s) for s in batch.slice_surfaces])) + return float(np.mean(areas)) if isinstance(batch, WholeBatchResult): popt = np.asarray(batch.popt) if popt.size == 5: # spherical: [xc, yc, zc, R, z_wall] @@ -90,10 +93,15 @@ def _batch_central_angle(batch: Any, stat: Literal["mean", "median"]) -> float: available scalar is :attr:`BatchResult.angle`, which is returned directly. """ - if isinstance(batch, SlicingBatchResult) and batch.per_slice_angles.size: + if ( + isinstance(batch, SlicingBatchResult) + and np.isfinite(batch.per_slice_angles).any() + ): + # per_slice_angles carries NaN for slices with no valid angle; + # reduce over the finite entries only. if stat == "median": - return float(np.median(batch.per_slice_angles)) - return float(np.mean(batch.per_slice_angles)) + return float(np.nanmedian(batch.per_slice_angles)) + return float(np.nanmean(batch.per_slice_angles)) return float(batch.angle) diff --git a/tests/test_analysis/test_fitter_error_paths.py b/tests/test_analysis/test_fitter_error_paths.py index 7b33af1..628943a 100644 --- a/tests/test_analysis/test_fitter_error_paths.py +++ b/tests/test_analysis/test_fitter_error_paths.py @@ -45,9 +45,13 @@ def test_slicing_skips_empty_and_thin_slices(spherical: DropletGeometry) -> None valid_slice, ] out = fitter.fit(surfaces, z_wall=0.0, droplet_geometry=spherical) - # Only the valid slice contributed. - assert len(out.per_slice_angles) == 1 - assert len(out.slice_surfaces) == 1 + # All three slices are recorded index-aligned (full length); only + # the valid slice contributes an angle, the empty and thin ones NaN. + assert out.n_slices_total == 3 + assert out.n_slices_used == 1 + assert out.per_slice_angles.shape == (3,) + assert int(np.isfinite(out.per_slice_angles).sum()) == 1 + assert len(out.slice_surfaces) == 3 def test_slicing_skips_circle_outside_wall(spherical: DropletGeometry) -> None: @@ -62,8 +66,10 @@ def test_slicing_skips_circle_outside_wall(spherical: DropletGeometry) -> None: R = 20.0 valid_slice = np.column_stack([R * np.cos(theta), R * np.sin(theta)]) out = fitter.fit([high_slice, valid_slice], z_wall=0.0, droplet_geometry=spherical) - # Only the valid slice produces an angle. - assert len(out.per_slice_angles) == 1 + # Both slices are recorded; only the valid one produces an angle. + assert out.n_slices_total == 2 + assert out.n_slices_used == 1 + assert int(np.isfinite(out.per_slice_angles).sum()) == 1 def test_slicing_raises_when_no_valid_slice(spherical: DropletGeometry) -> None: diff --git a/tests/test_visualization/test_angle_evolution_helpers.py b/tests/test_visualization/test_angle_evolution_helpers.py index ac8eb4d..9648221 100644 --- a/tests/test_visualization/test_angle_evolution_helpers.py +++ b/tests/test_visualization/test_angle_evolution_helpers.py @@ -95,12 +95,14 @@ def test_batch_surface_area_slicing_uses_shoelace() -> None: z_wall=0.0, rms_residual=0.0, angle_std=0.0, - per_slice_angles=np.array([90.0]), + per_slice_angles=np.array([90.0, 90.0]), slice_surfaces=[ np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]), # area 1 np.array([[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]]), # area 2 ], slice_popts=np.zeros((2, 4)), + n_slices_total=2, + n_slices_used=2, ) assert _batch_surface_area(batch) == pytest.approx(1.5) @@ -115,6 +117,8 @@ def test_batch_surface_area_slicing_empty_surfaces_returns_zero() -> None: per_slice_angles=np.array([]), slice_surfaces=[], slice_popts=np.zeros((0, 4)), + n_slices_total=0, + n_slices_used=0, ) assert _batch_surface_area(batch) == 0.0 diff --git a/tests/test_visualization/test_angle_evolution_plotter.py b/tests/test_visualization/test_angle_evolution_plotter.py index 74bdc2f..1dab07f 100644 --- a/tests/test_visualization/test_angle_evolution_plotter.py +++ b/tests/test_visualization/test_angle_evolution_plotter.py @@ -26,6 +26,8 @@ def _slicing_results() -> TrajectoryResults: per_slice_angles=np.array([94.0, 95.0, 96.0]) + i, slice_surfaces=[np.array([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]])], slice_popts=np.zeros((1, 4)), + n_slices_total=3, + n_slices_used=3, ) for i in range(3) ] From ad82cb439031d56b1faa422ca81bd305017d2b22 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 12:44:02 +0200 Subject: [PATCH 39/53] Small doc and code fixes --- CONTRIBUTING.md | 2 +- README.md | 5 +- docs/source/API/index.rst | 4 - docs/source/examples/index.rst | 8 +- .../introduction/theoretical_foundations.rst | 32 +++-- docs/source/tutorials/coupled_fit_2d_tuto.rst | 2 +- docs/source/tutorials/coupled_fit_3d_tuto.rst | 6 +- docs/source/tutorials/grid_method_tuto.rst | 8 +- docs/source/tutorials/parser_tutorial.rst | 6 +- docs/source/tutorials/slicing_method_tuto.rst | 4 +- .../visualization_evolution_density.rst | 4 +- .../visualization_slicing_droplet.rst | 14 +-- docs/source/tutorials/whole_fit_tuto.rst | 6 +- .../analysis/coupled_fit/_models.py | 12 +- tests/README | 87 -------------- tests/README.md | 109 ++++++++++++++++++ 16 files changed, 171 insertions(+), 138 deletions(-) delete mode 100644 tests/README create mode 100644 tests/README.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a3964f..e6a6f34 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -87,7 +87,7 @@ parsers' handling of orthogonal cells and periodic boundary conditions. ## Adding a new contact-angle method Subclass `BaseTrajectoryAnalyzer` -([src/wetting_angle_kit/analysis/analyzer.py](src/wetting_angle_kit/analysis/analyzer.py)) +([src/wetting_angle_kit/analysis/_base.py](src/wetting_angle_kit/analysis/_base.py)) and add an integration test in `tests/test_analysis/` that exercises the method on one of the fixture trajectories. diff --git a/README.md b/README.md index 1a1cfb4..57aac60 100644 --- a/README.md +++ b/README.md @@ -104,9 +104,8 @@ from wetting_angle_kit.parsers import XYZParser, XYZWaterFinder trajectory_file = "trajectory.xyz" -# Identify water oxygen atoms by neighbour count. ``particle_type_wall`` -# lists the symbols of the substrate atoms so they are excluded. -finder = XYZWaterFinder(trajectory_file, particle_type_wall=["C"]) +# Identify water oxygen atoms by neighbour count. +finder = XYZWaterFinder(trajectory_file) oxygen_ids = finder.get_water_oxygen_indices(frame_index=0) parser = XYZParser(trajectory_file) diff --git a/docs/source/API/index.rst b/docs/source/API/index.rst index 0b14768..c59287d 100644 --- a/docs/source/API/index.rst +++ b/docs/source/API/index.rst @@ -17,10 +17,6 @@ Analysis Top-level analyzers ^^^^^^^^^^^^^^^^^^^ -.. automodule:: wetting_angle_kit.analysis.analyzer - :members: - :show-inheritance: - .. automodule:: wetting_angle_kit.analysis.trajectory :members: :show-inheritance: diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 0a4ac96..02ec2a5 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -28,7 +28,7 @@ ray-fan extractor and the slicing fitter. ---- Whole-Fit Contact Angle with Bootstrap ---------------------------------------- +-------------------------------------- Whole-shape sphere fit with the wall position taken from the actual substrate atoms and a bootstrap uncertainty. @@ -40,7 +40,7 @@ substrate atoms and a bootstrap uncertainty. ---- Coupled-Fit Contact Angle --------------------------- +------------------------- Joint hyperbolic-tangent density-model fit via :class:`CoupledFit2DAnalyzer` — one angle per pooled batch. The @@ -54,7 +54,7 @@ KDE). ---- Visualising a Per-Frame Droplet Snapshot ------------------------------------------ +---------------------------------------- Pull a single slice's interface contour off a slicing-pipeline result and render it with :class:`DropletSlicePlotter`. @@ -66,7 +66,7 @@ and render it with :class:`DropletSlicePlotter`. ---- Angle Evolution + Density Contour Plots ----------------------------------------- +--------------------------------------- The two trajectory-level plotters (:class:`AngleEvolutionPlotter` and :class:`DensityContourPlotter`) diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 938bdd5..47dc041 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -48,20 +48,30 @@ that the recovered :math:`\theta` is meaningful. There is no sharp surface in an MD frame: the density drops from :math:`\rho_{\rm liq}` to :math:`\rho_{\rm vap}` smoothly over a few Å, broadened by thermal motion. The package treats the -liquid–vapor interface as the locus of half-bulk density, recovered -via a one-dimensional density profile fit: +liquid–vapor interface as the locus of half-bulk density. For the +ray extractors this is recovered by fitting a one-dimensional +hyperbolic-tangent profile to the density sampled along each ray: .. math:: - \rho(\zeta) \;=\; - \tfrac{1}{2} \bigl[(\rho_1 + \rho_2) - \;-\; (\rho_1 - \rho_2)\,\tanh\!\bigl(2 (\zeta - \zeta_d) / t\bigr)\bigr], - -where :math:`\zeta` is the running coordinate along a ray (or a row of -a density grid), :math:`\rho_1` and :math:`\rho_2` are the bulk -liquid and vapor densities, :math:`\zeta_d` is the interface -location, and :math:`t` is the interface thickness (~1 Å for water at -room temperature). The interface is :math:`\zeta = \zeta_d`. + \rho(\zeta) \;=\; h \;+\; d\,\tanh(\zeta_d - \zeta), + +where :math:`\zeta` is the running coordinate along the ray and the +three fitted parameters are the interface location :math:`\zeta_d`, +the midpoint density :math:`h = (\rho_{\rm liq} + \rho_{\rm vap})/2`, +and the half-amplitude :math:`d = (\rho_{\rm liq} - \rho_{\rm vap})/2`. +The interface is :math:`\zeta = \zeta_d`, where :math:`\rho = h`. + +The transition **width** is *fixed* — the tanh argument has unit +slope, giving a transition scale of order 1 Å — rather than being a +fitted parameter. Because the profile is antisymmetric about its +midpoint, the recovered half-density crossing :math:`\zeta_d` is +largely insensitive to the exact width, so fixing the slope instead +of fitting a thickness does not bias the interface location; only the +amplitude/width interpretation would change, and the downstream +geometry never uses it. (The joint coupled fit of §7 *does* treat the +interface thicknesses :math:`t_1, t_2` as free parameters, because +there the full density field — not just the crossing — is modelled.) This tanh profile is theoretically motivated by mean-field theory of liquid–vapor interfaces (van der Waals / Cahn–Hilliard square-gradient diff --git a/docs/source/tutorials/coupled_fit_2d_tuto.rst b/docs/source/tutorials/coupled_fit_2d_tuto.rst index 295b2f2..dd8b95f 100644 --- a/docs/source/tutorials/coupled_fit_2d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_2d_tuto.rst @@ -1,5 +1,5 @@ Tutorial: Contact Angle Analysis (Coupled Fit, 2D) -=================================================== +================================================== This tutorial covers :class:`CoupledFit2DAnalyzer`, the joint-fit alternative to the composable diff --git a/docs/source/tutorials/coupled_fit_3d_tuto.rst b/docs/source/tutorials/coupled_fit_3d_tuto.rst index 549c9c2..a99981c 100644 --- a/docs/source/tutorials/coupled_fit_3d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_3d_tuto.rst @@ -1,5 +1,5 @@ Tutorial: 3D Coupled-Fit Analyzer -================================== +================================= :class:`CoupledFit3DAnalyzer` is the 3D extension of :class:`CoupledFit2DAnalyzer`. Instead of projecting atoms to a @@ -54,7 +54,7 @@ wasting work. ---- 2. Worked example ------------------- +----------------- .. code-block:: python @@ -156,7 +156,7 @@ unambiguous. ---- 5. Cross-check: 2D vs 3D -------------------------- +------------------------ On an axisymmetric droplet, the 2D and 3D analyzers should recover the same angle within a few degrees. It's a useful sanity check: diff --git a/docs/source/tutorials/grid_method_tuto.rst b/docs/source/tutorials/grid_method_tuto.rst index 31a9cbd..79ce54c 100644 --- a/docs/source/tutorials/grid_method_tuto.rst +++ b/docs/source/tutorials/grid_method_tuto.rst @@ -1,5 +1,5 @@ Tutorial: Grid-Based Interface Extraction -========================================== +========================================= This tutorial covers the **grid-based interface extractors** — :meth:`InterfaceExtractor.grid` (Gaussian) and @@ -43,7 +43,7 @@ the ``grid3d`` extra:: ---- 2. Worked example: ``grid`` (Gaussian) + slicing fit ---------------------------------------------------- +---------------------------------------------------- A spherical droplet, with per-azimuthal-slice 2D density grids in the ``(s, z)`` plane — same density estimator as ``rays`` (Gaussian), just @@ -101,7 +101,7 @@ sampled on a fixed grid rather than along rays: ---- 3. Histogram alternative: ``grid`` (binning) ------------------------------------------- +-------------------------------------------- Same per-slice iteration, but the density estimator is a top-hat histogram of atoms within the slab ``|perp| ≤ bin_width_x / 2`` of @@ -142,7 +142,7 @@ either coarser cells or fewer slices, not a finer grid. ---- 4. 3D iso-surface for the whole-fit ------------------------------------- +----------------------------------- The grid extractors also work in whole-fit mode for spherical droplets — the 2D density grid is replaced by a 3D one, and the diff --git a/docs/source/tutorials/parser_tutorial.rst b/docs/source/tutorials/parser_tutorial.rst index 209852b..4a06b33 100644 --- a/docs/source/tutorials/parser_tutorial.rst +++ b/docs/source/tutorials/parser_tutorial.rst @@ -1,5 +1,5 @@ Tutorial: Using the Parser Module -=================================== +================================= This tutorial shows how to load different trajectory formats using the ``wetting_angle_kit.parsers`` submodule. @@ -27,7 +27,7 @@ The ``.parse()`` method always returns a NumPy array of shape ``(N, 3)`` contain ---- 2. Example: LAMMPS Dump File ------------------------------ +---------------------------- .. code-block:: python @@ -62,7 +62,7 @@ The ``.parse()`` method always returns a NumPy array of shape ``(N, 3)`` contain ---- 3. Example: ASE Trajectory File --------------------------------- +------------------------------- .. code-block:: python diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index 1526fdc..f3ff04b 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -1,5 +1,5 @@ Tutorial: Contact Angle Analysis (Slicing Pipeline) -==================================================== +=================================================== This tutorial walks through the **slicing pipeline** built from the strategy components of :class:`TrajectoryAnalyzer`: a ray-fan @@ -211,7 +211,7 @@ in the repository is a cylindrical-droplet trajectory you can use as a worked example. 6.2 ``rays`` (binning) alternative -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The same ray-fan geometry is available with a 1D histogram density estimator instead of the Gaussian KDE. Use it when you want a diff --git a/docs/source/tutorials/visualization_evolution_density.rst b/docs/source/tutorials/visualization_evolution_density.rst index 877defa..64ae542 100644 --- a/docs/source/tutorials/visualization_evolution_density.rst +++ b/docs/source/tutorials/visualization_evolution_density.rst @@ -1,5 +1,5 @@ Visualisation Tutorial — Angle Evolution and Density Contour -============================================================= +============================================================ Two trajectory-level plotters cover the most common visual outputs: @@ -15,7 +15,7 @@ Two trajectory-level plotters cover the most common visual outputs: ---- 1. Angle evolution plot ------------------------- +----------------------- The plotter takes a results object directly and exposes a ``.plot()`` method that returns a Plotly figure. The two key toggles diff --git a/docs/source/tutorials/visualization_slicing_droplet.rst b/docs/source/tutorials/visualization_slicing_droplet.rst index 6e8b2c7..4c12364 100644 --- a/docs/source/tutorials/visualization_slicing_droplet.rst +++ b/docs/source/tutorials/visualization_slicing_droplet.rst @@ -1,5 +1,5 @@ Visualisation Tutorial — Per-Frame Droplet Snapshot -==================================================== +=================================================== This tutorial uses :class:`DropletSlicePlotter` to draw a single-frame snapshot of a droplet, overlaying the recovered interface contour, @@ -28,7 +28,7 @@ The workflow: ---- 2. Import Required Modules ---------------------------- +-------------------------- .. code-block:: python @@ -51,7 +51,7 @@ The workflow: ---- 3. Define the Input Trajectory -------------------------------- +------------------------------ .. code-block:: python @@ -61,7 +61,7 @@ The workflow: ---- 4. Identify Water Molecules ----------------------------- +--------------------------- .. code-block:: python @@ -72,7 +72,7 @@ The workflow: ---- 5. Read Atom and Wall Positions --------------------------------- +------------------------------- .. code-block:: python @@ -87,7 +87,7 @@ The workflow: ---- 6. Run the Slicing Pipeline ----------------------------- +--------------------------- .. code-block:: python @@ -109,7 +109,7 @@ The workflow: ---- 7. Visualise the Droplet -------------------------- +------------------------ The plotter takes a single slice's data; pick a slice index and pull the corresponding entries off the batch: diff --git a/docs/source/tutorials/whole_fit_tuto.rst b/docs/source/tutorials/whole_fit_tuto.rst index 4d70879..63d4556 100644 --- a/docs/source/tutorials/whole_fit_tuto.rst +++ b/docs/source/tutorials/whole_fit_tuto.rst @@ -1,5 +1,5 @@ Tutorial: Whole-Shape Fit with Bootstrap Uncertainty -===================================================== +==================================================== This tutorial covers the **whole-fit pipeline**: an algebraic sphere or cylinder fit to the entire interface shell at once, with @@ -32,7 +32,7 @@ deviation of the angles is reported as ---- 2. Wall detector pairing -------------------------- +------------------------ The full-sphere Fibonacci ray fan (:meth:`InterfaceExtractor.rays` (Gaussian) with ``n_rays_sphere=...``) @@ -226,7 +226,7 @@ case — the explicit detector ignores any wall-atom data. Useful both for whole-fit and slicing pipelines. 7.3 ``rays`` (binning) alternative -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Same Fibonacci-sphere geometry, but the density along each ray is estimated with a 1D top-hat histogram instead of a Gaussian KDE: diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_models.py b/src/wetting_angle_kit/analysis/coupled_fit/_models.py index fe09100..8345c1f 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/_models.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/_models.py @@ -52,6 +52,7 @@ class _HyperbolicTangentModel: _PARAM_LOWER: ClassVar[np.ndarray] _PARAM_UPPER: ClassVar[np.ndarray] _fitting_function: ClassVar[Callable[..., np.ndarray]] + _PHYSICAL_FLOOR_PARAMS: ClassVar[frozenset[str]] = frozenset({"rho1", "rho2"}) def __init__(self, initial_params: list[float] | None = None) -> None: if initial_params is None: @@ -87,7 +88,12 @@ def _warn_if_at_bounds(self) -> None: self._PARAM_UPPER, strict=False, ): - if np.isfinite(lo) and abs(value - lo) < tol * max(1.0, abs(lo)): + at_lower = ( + np.isfinite(lo) + and abs(value - lo) < tol * max(1.0, abs(lo)) + and name not in self._PHYSICAL_FLOOR_PARAMS + ) + if at_lower: at_bound.append(f"{name}={value:.3g} at lower bound {lo}") elif np.isfinite(hi) and abs(value - hi) < tol * max(1.0, abs(hi)): at_bound.append(f"{name}={value:.3g} at upper bound {hi}") @@ -153,7 +159,7 @@ class _HyperbolicTangentModel2D(_HyperbolicTangentModel): param_names: ClassVar[tuple[str, ...]] = _PARAM_NAMES fit_label: ClassVar[str] = "Hyperbolic tangent fit" - DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 20.0, 4.0, 1.0, 1.0) + DEFAULT_INITIAL_PARAMS = (3e-2, 1e-3, 40.0, 20.0, 4.0, 1.0, 1.0) _PARAM_LOWER = np.array([0.0, 0.0, 1e-6, -np.inf, -np.inf, 1e-6, 1e-6]) _PARAM_UPPER = np.array([np.inf] * 7) @@ -205,7 +211,7 @@ class _HyperbolicTangentModel3D(_HyperbolicTangentModel): #: Initial guess tuned for room-temperature water; the two #: horizontal centres default to ``0`` because the analyzer #: pre-centers the atoms on the droplet COM before binning. - DEFAULT_INITIAL_PARAMS = (1e-3, 3e-2, 40.0, 0.0, 0.0, 20.0, 4.0, 1.0, 1.0) + DEFAULT_INITIAL_PARAMS = (3e-2, 1e-3, 40.0, 0.0, 0.0, 20.0, 4.0, 1.0, 1.0) # Bounds vector order matches DEFAULT_INITIAL_PARAMS. _PARAM_LOWER = np.array( diff --git a/tests/README b/tests/README deleted file mode 100644 index 2cefdd9..0000000 --- a/tests/README +++ /dev/null @@ -1,87 +0,0 @@ -Test suite for wetting-angle-kit -================================ - -Layout ------- - -:: - - tests/ - ├── conftest.py Shared helpers and trajectory path constants - ├── test_io_utils.py Unit tests for wetting_angle_kit.io_utils - ├── test_geometry_projection.py Unit tests for the (r, z) projection used - │ by BaseParser.get_profile_coordinates - │ (covers spherical / cylinder_x / cylinder_y) - ├── test_edge_cases.py Validation errors, deprecation paths, - │ NaN guards, factory rejections - ├── 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_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) - -Running the tests ------------------ - -The full suite (including the slow OVITO/ASE integration tests):: - - pytest - -Fast unit-only run (skips the per-trajectory analyzers):: - - pytest -m "not slow" - -Only the integration tests against real trajectories:: - - pytest -m integration - -With coverage:: - - pytest --cov=wetting_angle_kit --cov-report=term-missing - -Markers -------- - -``slow`` - Tests that take more than ~1 second (typically the sliced analyzer - integration tests that fit per-frame circles). - -``integration`` - Tests that read a fixture trajectory from ``tests/trajectories/`` - and exercise an analyzer end-to-end. They require the OVITO and/or - ASE optional dependencies. - -``unit`` - Default; the marker itself is optional. Pure-Python tests against - helpers, projections, factories, etc. - -Fixture trajectories --------------------- - -The two LAMMPS fixtures (``traj_spherical_drop_4k.lammpstrj`` and -``traj_10_3_330w_nve_4k_reajust.lammpstrj``) are small water-on-substrate -runs; the sliced and binning analyzers both produce contact angles around -90–110° on these, matching graphene-like literature values. Tests assert -this band as a regression check. - -Adding new tests ----------------- - -* Put unit tests for a single module next to existing unit-test files. -* Add ``@pytest.mark.integration`` (and ``@pytest.mark.slow`` if the test - takes more than ~1 s) when your test reads a real trajectory. -* Use ``tmp_path`` (pytest built-in) for output directories so each test - is hermetic. -* Reference fixture paths via ``conftest.trajectory_path("foo.lammpstrj")`` - or with ``os.path.join(os.path.dirname(__file__), "../trajectories/...")``. diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..e035e93 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,109 @@ +# Test suite for wetting-angle-kit + +## Layout + +``` +tests/ +├── conftest.py Shared constants + the trajectory_path() helper +├── test_io_utils.py Unit tests for wetting_angle_kit.io_utils +├── test_parser/ Per-format parsers, water finders, and factory +│ ├── test_parser_dump.py LAMMPS dump parser (OVITO backend) +│ ├── test_parser_xyz.py Extended-XYZ parser +│ ├── test_parser_ase.py ASE-backed parser +│ ├── test_water_finders.py Water-oxygen identification across formats +│ └── test_parser_factory.py get_water_finder() dispatch by extension +├── test_analysis/ Strategy units + end-to-end analyzer runs +│ ├── test_geometry.py DropletGeometry (spherical / cylinder_x / cylinder_y) +│ ├── test_temporal.py TemporalAggregator batching +│ ├── test_default_grid_params.py Auto-derived grid_params / binning_params defaults +│ ├── test_density_estimator.py DensityEstimator: binning vs gaussian +│ ├── test_fitter_error_paths.py SurfaceFitter error/validation paths +│ ├── test_slicing_fitter.py SurfaceFitter.slicing() on synthetic shapes +│ ├── test_whole_fitter.py SurfaceFitter.whole() + bootstrap std +│ ├── test_rays_with_gaussian.py rays extractor, Gaussian density +│ ├── test_rays_with_binning.py rays extractor, binning density (+ parity vs Gaussian) +│ ├── test_grid_slicing.py grid extractor, slicing mode (marching squares) +│ ├── test_grid_whole.py grid extractor, whole mode (marching cubes) +│ ├── test_slicing_method.py TrajectoryAnalyzer slicing end-to-end (LAMMPS fixture) +│ ├── test_slicing_edge_cases.py Pipeline validation / NaN guards / degenerate input +│ ├── test_trajectory_analyzer_integration.py TrajectoryAnalyzer across strategy combos +│ ├── test_cylinder_coverage.py Cylinder droplet × every extractor combination +│ ├── test_coupled_fit_2d.py CoupledFit2DAnalyzer end-to-end +│ ├── test_coupled_fit_3d.py CoupledFit3DAnalyzer end-to-end +│ ├── test_wall_detector_from_atoms_e2e.py WallDetector.from_atoms through the pipeline +│ └── test_parallel_path.py multiprocessing.Pool batch path +├── test_visualization/ Plotter smoke tests + helper unit tests +│ ├── test_angle_evolution_helpers.py +│ ├── test_angle_evolution_plotter.py +│ ├── test_density_contour_plotter.py +│ └── test_droplet_slice_plot.py +└── trajectories/ Fixture trajectories used by the integration tests +``` + +## Running the tests + +```bash +pytest # full suite +pytest -m "not slow" # skip the slow integration tests +pytest -m integration # only end-to-end runs on fixtures +pytest --cov=wetting_angle_kit --cov-report=term-missing # with coverage +``` + +The default options live in `pyproject.toml` (`[tool.pytest.ini_options]`): +verbose output, the ten slowest durations, and `--strict-markers` / +`--strict-config`. + +## Markers + +`integration` +: Reads a fixture trajectory from `tests/trajectories/` and exercises an + analyzer end-to-end. Most of these also need an optional backend + (see below). + +`slow` +: Takes more than ~1 s — typically the per-frame slicing runs that fit a + circle in every slice. Deselect with `-m "not slow"`. + +`unit` +: The default class of pure-Python tests (helpers, geometry, fitters, + factories). The marker itself is optional and rarely applied. + +## Optional dependencies + +Several `test_parser` and `test_analysis` modules call +`pytest.importorskip("ovito" / "ase" / "skimage")` at import time, so the +suite runs cleanly without the optional backends — those modules are +skipped rather than failing: + +- **OVITO** — LAMMPS dump parsing (`ovito` extra). +- **ASE** — `.traj` / ASE-readable trajectories (`ase` extra). +- **scikit-image** — whole-mode grid extraction via marching cubes + (`grid3d` extra). + +Install everything for a full local run with `pip install -e .[dev,all]` +(plus the conda OVITO package; see `CONTRIBUTING.md`). + +## Fixture trajectories + +| File | Format | Contents | +| --- | --- | --- | +| `traj_spherical_drop_4k.lammpstrj` | LAMMPS dump | Spherical water droplet (~4000 molecules) on a wall | +| `traj_10_3_330w_nve_4k_reajust.lammpstrj` | LAMMPS dump | Smaller water-on-wall NVE run | +| `slice_10_mace_mlips_cylindrical_2_5.traj` | ASE `.traj` | Cylindrical droplet from a MACE MLIP run | +| `slice_10_mace_mlips_cylindrical_2_5.xyz` | extended XYZ | Same cylinder data, used for the XYZ parser | + +The integration tests recover contact angles in a physically reasonable +band (~90–110° on the water/graphene-like fixtures) and assert on that +band as a regression check. If you change a numerical default or add a +method, expect to revisit those tolerances. + +## Adding tests + +- Put a unit test next to the existing unit tests for the same module. +- Mark trajectory-backed tests with `@pytest.mark.integration` (and + `@pytest.mark.slow` if they take more than ~1 s), and guard any optional + backend with `pytest.importorskip(...)`. +- Resolve fixture paths with `conftest.trajectory_path("foo.lammpstrj")` + or `os.path.join(os.path.dirname(__file__), "../trajectories/...")`. +- Use the `tmp_path` built-in for any output directories so each test is + hermetic. From f3778cb032cb763318596d3e32649089a346fc62 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 13:41:32 +0200 Subject: [PATCH 40/53] Switch from Kasa to Taubin fit of circle/sphere. --- README.md | 2 +- .../introduction/theoretical_foundations.rst | 61 ++++---- docs/source/tutorials/slicing_method_tuto.rst | 4 +- .../visualization_slicing_droplet.rst | 4 +- docs/source/tutorials/whole_fit_tuto.rst | 2 +- .../analysis/fitters/_kasa.py | 107 ------------- .../analysis/fitters/_slicing.py | 4 +- .../analysis/fitters/_taubin.py | 146 ++++++++++++++++++ .../analysis/fitters/_whole.py | 2 +- .../analysis/fitters/base.py | 2 +- tests/test_analysis/test_slicing_fitter.py | 2 +- 11 files changed, 191 insertions(+), 145 deletions(-) delete mode 100644 src/wetting_angle_kit/analysis/fitters/_kasa.py create mode 100644 src/wetting_angle_kit/analysis/fitters/_taubin.py diff --git a/README.md b/README.md index 57aac60..8681f5f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Any sampling × any density is a valid extractor. ### Surface fitting: what geometric shape do we fit to those points? - **Slicing fit** — independently fits an algebraic circle in each slice's `(x, z)` plane, then averages the per-slice contact angles. Good when the droplet might be slightly non-spherical: the per-slice scatter naturally reports a `±σ` band. -- **Whole fit** — fits a single sphere (spherical droplet) or cylinder (cylindrical droplet) to the entire 3D interface shell. Uses the algebraic Kasa method, plus optional bootstrap resampling to put an uncertainty on the recovered angle. +- **Whole fit** — fits a single sphere (spherical droplet) or cylinder (cylindrical droplet) to the entire 3D interface shell. Uses the algebraic Taubin method, plus optional bootstrap resampling to put an uncertainty on the recovered angle. - **Coupled fit** (joint approach) — a 7-parameter (2D) or 9-parameter (3D) hyperbolic-tangent density model that solves "where is the interface", "where is the wall plane", and "what's the cap geometry" in one nonlinear least-squares fit on a density field. The per-cell density is computed by a pluggable `DensityEstimator` strategy: a top-hat histogram (`DensityEstimator.binning()`, default) or a 3D Gaussian KDE evaluated at the cell centres (`DensityEstimator.gaussian(density_sigma=…)`); the KDE variant trades a small constant cost for a smooth, Poisson-noise-free density. Statistically efficient when you pool many frames per batch. ### Wall detection: where is the wall plane? diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 47dc041..2ec83ee 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -197,41 +197,48 @@ Two volume-normalisation notes: horizontal cell width), which keeps the bin's cross-section in the ``(s, perpendicular)`` directions square. -5. Fitting the cap: algebraic Kasa fits ---------------------------------------- +5. Fitting the cap: algebraic Taubin fits +----------------------------------------- Given a clean point set on the interface, the surface fitter recovers the spherical-cap parameters :math:`(z_c, R)` (and -:math:`(x_c, y_c)` in 3D) via an **algebraic Kasa fit**. +:math:`(x_c, y_c)` in 3D) via an **algebraic Taubin fit**. -For a 3D sphere fit to points :math:`(x_i, y_i, z_i)`, the implicit -equation :math:`(x - x_c)^2 + (y - y_c)^2 + (z - z_c)^2 = R^2` -expands and linearises to +A circle/sphere is the zero set of +:math:`g(\mathbf{r}) = A\,\|\mathbf{r}\|^2 + \mathbf{b}\cdot\mathbf{r} + c` +(a circle/sphere whenever :math:`A \neq 0`, with centre +:math:`\mathbf{r}_c = -\mathbf{b}/(2A)` and radius +:math:`R = \sqrt{\|\mathbf{b}\|^2/(4A^2) - c/A}`). The Taubin fit +recovers the coefficients by minimising the algebraic residual +normalised by its gradient, .. math:: - 2 x_c\,x \;+\; 2 y_c\,y \;+\; 2 z_c\,z \;+\; c - \;=\; x^2 + y^2 + z^2, - \qquad c \,=\, R^2 - x_c^2 - y_c^2 - z_c^2. - -The four-parameter linear system -:math:`A \cdot (x_c, y_c, z_c, c)^\top = b` is solved by -:func:`numpy.linalg.lstsq`; :math:`R` is recovered from :math:`c`. -The 2D version is identical with the :math:`y` column dropped. - -This is the **algebraic** fit (minimises the residual on the -implicit equation), not the **geometric** fit (minimises the -distance to the fitted shape). Algebraic Kasa has a closed-form -linear solution, no iteration, no initial guess — which makes it -the right default. Its known bias against small radii is harmless -when the recovered shell already sits very close to the true -interface. - -The slicing fitter (:meth:`SurfaceFitter.slicing`) runs one Kasa + \min_{A,\,\mathbf{b},\,c} \; + \frac{\sum_i g(\mathbf{r}_i)^2} + {\sum_i \|\nabla g(\mathbf{r}_i)\|^2}. + +The solution is closed-form: after centring the data it is the +smallest right singular vector of a small design matrix (one SVD, no +iteration and no initial guess). The 2D circle fit is the same +construction with the :math:`y` column dropped. + +The gradient normalisation is what makes this estimator +**near-unbiased on partial arcs**, which is the regime that matters +here: a droplet cap is only ever a partial arc — the liquid-vapor +surface, never the full circle — and on a short, noisy arc the +recovered radius feeds directly into +:math:`\cos\theta = (z_w - z_c)/R`. On synthetic arcs of known +radius the Taubin radius and angle match a full geometric +(orthogonal-distance) fit to well under :math:`0.1^\circ`, at no +extra variance; the geometric fit itself is avoided only because it +needs an iterative solve and an initial guess. + +The slicing fitter (:meth:`SurfaceFitter.slicing`) runs one Taubin **circle** fit per slice in the slice's ``(x, z)`` plane, then averages the per-slice angles. The whole fitter -(:meth:`SurfaceFitter.whole`) runs one Kasa **sphere** fit -(spherical droplet) or one Kasa **circle** fit (cylindrical +(:meth:`SurfaceFitter.whole`) runs one Taubin **sphere** fit +(spherical droplet) or one Taubin **circle** fit (cylindrical droplet, exploiting translational symmetry along :math:`y`) on the entire shell. @@ -437,7 +444,7 @@ The geometry choice cascades through every component: invariance along :math:`y`). The cylindrical case is mechanically identical to the spherical -one — same Kasa fit, same cap geometry, same :math:`\cos \theta +one — same Taubin fit, same cap geometry, same :math:`\cos \theta = (z_w - z_c)/R` — but applied per-axis-step rather than azimuthally. The slicing tutorial includes a worked example; the whole-fit tutorial covers the cylinder case under diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index f3ff04b..0a00736 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -24,7 +24,7 @@ The pipeline does three things per batch: 2. **Wall detection.** The wall plane z-coordinate is taken as the minimum z over all interface points, plus a user-supplied offset (``min_plus_offset(offset=0)`` for the bare baseline). -3. **Surface fit.** An algebraic Kasa circle is fit to each slice's +3. **Surface fit.** An algebraic Taubin circle is fit to each slice's interface points after filtering out points within ``surface_filter_offset`` of the wall. The contact angle on each slice is the angle of intersection of that circle with the wall @@ -203,7 +203,7 @@ fitter that either NaNs out or returns a non-physical angle: temporal_aggregator=TemporalAggregator(batch_size=1), ) -The mechanics are identical to the spherical case — same Kasa +The mechanics are identical to the spherical case — same Taubin circle fit per slice, same cap-angle formula — but slices step along the cylinder axis rather than rotating azimuthally. The fixture ``tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj`` diff --git a/docs/source/tutorials/visualization_slicing_droplet.rst b/docs/source/tutorials/visualization_slicing_droplet.rst index 4c12364..8330285 100644 --- a/docs/source/tutorials/visualization_slicing_droplet.rst +++ b/docs/source/tutorials/visualization_slicing_droplet.rst @@ -3,7 +3,7 @@ Visualisation Tutorial — Per-Frame Droplet Snapshot This tutorial uses :class:`DropletSlicePlotter` to draw a single-frame snapshot of a droplet, overlaying the recovered interface contour, -the fitted Kasa circle, the tangent at the contact point, and the +the fitted Taubin circle, the tangent at the contact point, and the wall atom positions. The plotter takes raw arrays (atom positions, surface points, fit parameters), so it works on the output of the slicing pipeline once you pull the corresponding fields @@ -133,6 +133,6 @@ pull the corresponding entries off the batch: fig.write_html("droplet_plot.html") The figure overlays four layers: the raw water-oxygen positions, the -recovered interface contour for the chosen slice, the fitted Kasa +recovered interface contour for the chosen slice, the fitted Taubin circle, and the wall atoms. ``DropletSlicePlotter`` accepts ``center=True`` (default) to centre the plot on the droplet COM. diff --git a/docs/source/tutorials/whole_fit_tuto.rst b/docs/source/tutorials/whole_fit_tuto.rst index 63d4556..acd65a6 100644 --- a/docs/source/tutorials/whole_fit_tuto.rst +++ b/docs/source/tutorials/whole_fit_tuto.rst @@ -18,7 +18,7 @@ places: 1. The :class:`InterfaceExtractor` returns a single ``(N, 3)`` shell array — every ray's interface point pooled into one cloud rather than divided into per-slice 2D sub-clouds. -2. The :class:`SurfaceFitter.whole()` runs **one** algebraic Kasa fit +2. The :class:`SurfaceFitter.whole()` runs **one** algebraic Taubin fit on that shell — sphere fit for spherical droplets, cylinder fit (algebraic circle in ``(x, z)`` with translational invariance along ``y``) for cylindrical droplets. The contact angle follows diff --git a/src/wetting_angle_kit/analysis/fitters/_kasa.py b/src/wetting_angle_kit/analysis/fitters/_kasa.py deleted file mode 100644 index dee597f..0000000 --- a/src/wetting_angle_kit/analysis/fitters/_kasa.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Algebraic (Kasa) circle/sphere fit helpers + cap-angle utilities. - -Shared by both :class:`_SlicingFitter` (2D circle on per-slice points) -and :class:`_WholeFitter` (2D circle for cylinder droplets, 3D sphere -for spherical droplets). -""" - -import numpy as np - - -def _kasa_circle_fit_2d(x: np.ndarray, z: np.ndarray) -> tuple[float, float, float]: - """Algebraic (Kasa) least-squares circle fit in 2D. - - 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 with :func:`numpy.linalg.lstsq`. - - Parameters - ---------- - x, z : ndarray - 2D point coordinates. - - Returns - ------- - (xc, zc, R) : tuple of float - Fitted circle centre and radius. - - Raises - ------ - np.linalg.LinAlgError - If the points are collinear (rank-deficient system). - ValueError - If the algebraic solution gives a non-positive ``R^2``. - """ - x = np.asarray(x, dtype=float) - z = np.asarray(z, dtype=float) - a_matrix = np.column_stack((2.0 * x, 2.0 * z, np.ones_like(x))) - rhs = x * x + z * z - 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 points are likely degenerate." - ) - return xc, zc, float(np.sqrt(r_sq)) - - -def _kasa_sphere_fit_3d( - x: np.ndarray, y: np.ndarray, z: np.ndarray -) -> tuple[float, float, float, float]: - """Algebraic (Kasa) least-squares sphere fit in 3D. - - Linearises ``(x - xc)^2 + (y - yc)^2 + (z - zc)^2 = R^2`` into - ``2 xc x + 2 yc y + 2 zc z + c = x^2 + y^2 + z^2`` with - ``c = R^2 - xc^2 - yc^2 - zc^2`` and solves with - :func:`numpy.linalg.lstsq`. - - Returns ``(xc, yc, zc, R)``. - - Raises - ------ - np.linalg.LinAlgError - If the input points are co-planar (rank-deficient system). - ValueError - If the algebraic solution gives a non-positive ``R^2``. - """ - x = np.asarray(x, dtype=float) - y = np.asarray(y, dtype=float) - z = np.asarray(z, dtype=float) - a_matrix = np.column_stack((2.0 * x, 2.0 * y, 2.0 * z, np.ones_like(x))) - rhs = x * x + y * y + z * z - sol, _, _, _ = np.linalg.lstsq(a_matrix, rhs, rcond=None) - xc, yc, zc, c = (float(sol[0]), float(sol[1]), float(sol[2]), float(sol[3])) - r_sq = c + xc * xc + yc * yc + zc * zc - if r_sq <= 0.0: - raise ValueError( - f"Algebraic sphere fit produced non-positive R^2 ({r_sq:.3g}); " - "the points are likely degenerate." - ) - return xc, yc, zc, float(np.sqrt(r_sq)) - - -def _whole_fit_one( - points: np.ndarray, *, spherical: bool -) -> tuple[np.ndarray, float, float]: - """Fit a sphere (spherical=True) or cylinder (spherical=False) to ``points``. - - Returns ``(popt_no_wall, R, zc)`` where ``popt_no_wall`` is - ``[xc, yc, zc, R]`` for spherical or ``[xc, zc, R]`` for cylinder. - The cylinder fit drops the ``y`` column and fits a 2D circle in - ``(x, z)``. - """ - if spherical: - xc, yc, zc, R = _kasa_sphere_fit_3d(points[:, 0], points[:, 1], points[:, 2]) - return np.array([xc, yc, zc, R]), R, zc - xc, zc, R = _kasa_circle_fit_2d(points[:, 0], points[:, 2]) - return np.array([xc, zc, R]), R, zc - - -def _angle_from_cap(z_wall: float, zc: float, R: float) -> float | None: - """Contact angle from ``cos θ = (z_wall - zc) / R`` or None if no intersection.""" - delta_z = z_wall - zc - if abs(delta_z) >= R: - return None - return float(np.degrees(np.arccos(delta_z / R))) diff --git a/src/wetting_angle_kit/analysis/fitters/_slicing.py b/src/wetting_angle_kit/analysis/fitters/_slicing.py index 66658de..18c3f9c 100644 --- a/src/wetting_angle_kit/analysis/fitters/_slicing.py +++ b/src/wetting_angle_kit/analysis/fitters/_slicing.py @@ -5,7 +5,7 @@ import numpy as np -from wetting_angle_kit.analysis.fitters._kasa import _kasa_circle_fit_2d +from wetting_angle_kit.analysis.fitters._taubin import _taubin_circle_fit_2d from wetting_angle_kit.analysis.fitters.base import ( SlicingFitOutput, SurfaceFitter, @@ -60,7 +60,7 @@ def fit( kept = surf[surf[:, 1] > z_filter] if surf.size else surf if len(kept) >= 3: try: - xc, zc, radius = _kasa_circle_fit_2d(kept[:, 0], kept[:, 1]) + xc, zc, radius = _taubin_circle_fit_2d(kept[:, 0], kept[:, 1]) # Contact angle from circle / wall-line intersection: # ``cos θ = (z_wall - z_center) / R``. A circle that # doesn't reach the wall (``|Δz| ≥ R``) yields no angle. diff --git a/src/wetting_angle_kit/analysis/fitters/_taubin.py b/src/wetting_angle_kit/analysis/fitters/_taubin.py new file mode 100644 index 0000000..482276c --- /dev/null +++ b/src/wetting_angle_kit/analysis/fitters/_taubin.py @@ -0,0 +1,146 @@ +"""Algebraic (Taubin) circle/sphere fit helpers + cap-angle utilities. + +Shared by both :class:`_SlicingFitter` (2D circle on per-slice points) +and :class:`_WholeFitter` (2D circle for cylinder droplets, 3D sphere +for spherical droplets). + +A droplet cap is only ever a *partial* arc — the liquid-vapor surface, +never the full circle/sphere — and on a short, noisy arc the recovered +radius feeds straight through ``cos θ = (z_wall - z_c) / R`` into the +contact angle. The Taubin fit normalises the algebraic residual by its +gradient, which keeps the recovered radius near-unbiased on partial arcs +(matching a full geometric orthogonal-distance fit) while staying +closed-form: no initial guess, no iteration. ``benchmarks/bench_circle_fit.py`` +quantifies the radius/angle accuracy and per-fit timing. +""" + +import numpy as np + + +def _taubin_fit(coords: np.ndarray) -> tuple[np.ndarray, float]: + """Taubin algebraic fit of a circle (2D) or sphere (3D) to ``coords``. + + Uses the SVD form of Taubin's method (Chernov): the data are centred, + the squared-radius column is mean-subtracted and scaled, and the + smallest right singular vector of the resulting design matrix gives + the algebraic coefficients. This is numerically stable, dimension + general, and guarantees a positive ``R^2``. + + Parameters + ---------- + coords : ndarray, shape (N, D) + Point coordinates, with ``D == 2`` (circle) or ``D == 3`` + (sphere). + + Returns + ------- + (center, R) : tuple of (ndarray, float) + Fitted centre (length ``D``) and radius. + + Raises + ------ + np.linalg.LinAlgError + If the SVD fails to converge. + ValueError + If there are too few points, all points coincide, or the points + are collinear/coplanar (degenerate, near-zero curvature). + """ + coords = np.asarray(coords, dtype=float) + n, dim = coords.shape + if n < dim + 1: + raise ValueError( + f"Taubin fit needs at least {dim + 1} points for a {dim}D fit; got {n}." + ) + centroid = coords.mean(axis=0) + centered = coords - centroid + sq = np.einsum("ij,ij->i", centered, centered) + sq_mean = float(sq.mean()) + if sq_mean <= 0.0: + raise ValueError("Taubin fit: all points coincide.") + # Mean-subtracted, scaled squared-radius column + the centred + # coordinates; the smallest right singular vector minimises the + # Taubin (gradient-normalised) algebraic distance. + z0 = (sq - sq_mean) / (2.0 * np.sqrt(sq_mean)) + design = np.column_stack([z0, centered]) + _, _, vt = np.linalg.svd(design, full_matrices=False) + v = vt[-1] + a0 = v[0] / (2.0 * np.sqrt(sq_mean)) + if abs(a0) < 1e-12: + raise ValueError( + "Taubin fit: near-zero curvature; the points are likely " + "collinear (2D) or coplanar (3D)." + ) + center_centered = -v[1:] / (2.0 * a0) + # R^2 = |center|^2 + mean(|r|^2) in the centred frame; always > 0. + r_sq = float(center_centered @ center_centered) + sq_mean + center = center_centered + centroid + return center, float(np.sqrt(r_sq)) + + +def _taubin_circle_fit_2d(x: np.ndarray, z: np.ndarray) -> tuple[float, float, float]: + """Taubin algebraic least-squares circle fit in 2D. + + Parameters + ---------- + x, z : ndarray + 2D point coordinates. + + Returns + ------- + (xc, zc, R) : tuple of float + Fitted circle centre and radius. + + Raises + ------ + np.linalg.LinAlgError + If the SVD fails to converge. + ValueError + If the points are degenerate (too few, coincident, or collinear). + """ + center, r = _taubin_fit(np.column_stack((np.asarray(x), np.asarray(z)))) + return float(center[0]), float(center[1]), r + + +def _taubin_sphere_fit_3d( + x: np.ndarray, y: np.ndarray, z: np.ndarray +) -> tuple[float, float, float, float]: + """Taubin algebraic least-squares sphere fit in 3D. + + Returns ``(xc, yc, zc, R)``. + + Raises + ------ + np.linalg.LinAlgError + If the SVD fails to converge. + ValueError + If the points are degenerate (too few, coincident, or coplanar). + """ + center, r = _taubin_fit( + np.column_stack((np.asarray(x), np.asarray(y), np.asarray(z))) + ) + return float(center[0]), float(center[1]), float(center[2]), r + + +def _whole_fit_one( + points: np.ndarray, *, spherical: bool +) -> tuple[np.ndarray, float, float]: + """Fit a sphere (spherical=True) or cylinder (spherical=False) to ``points``. + + Returns ``(popt_no_wall, R, zc)`` where ``popt_no_wall`` is + ``[xc, yc, zc, R]`` for spherical or ``[xc, zc, R]`` for cylinder. + The cylinder fit drops the ``y`` column and fits a 2D circle in + ``(x, z)``. + """ + if spherical: + xc, yc, zc, R = _taubin_sphere_fit_3d(points[:, 0], points[:, 1], points[:, 2]) + return np.array([xc, yc, zc, R]), R, zc + xc, zc, R = _taubin_circle_fit_2d(points[:, 0], points[:, 2]) + return np.array([xc, zc, R]), R, zc + + +def _angle_from_cap(z_wall: float, zc: float, R: float) -> float | None: + """Contact angle from ``cos θ = (z_wall - zc) / R`` or None if no intersection.""" + delta_z = z_wall - zc + if abs(delta_z) >= R: + return None + return float(np.degrees(np.arccos(delta_z / R))) diff --git a/src/wetting_angle_kit/analysis/fitters/_whole.py b/src/wetting_angle_kit/analysis/fitters/_whole.py index c6774e4..7d29c0b 100644 --- a/src/wetting_angle_kit/analysis/fitters/_whole.py +++ b/src/wetting_angle_kit/analysis/fitters/_whole.py @@ -5,7 +5,7 @@ import numpy as np -from wetting_angle_kit.analysis.fitters._kasa import ( +from wetting_angle_kit.analysis.fitters._taubin import ( _angle_from_cap, _whole_fit_one, ) diff --git a/src/wetting_angle_kit/analysis/fitters/base.py b/src/wetting_angle_kit/analysis/fitters/base.py index 3316e78..6caa3a8 100644 --- a/src/wetting_angle_kit/analysis/fitters/base.py +++ b/src/wetting_angle_kit/analysis/fitters/base.py @@ -167,7 +167,7 @@ def slicing( Each slice's 2D interface points are filtered to ``z > z_wall + surface_filter_offset`` (to exclude - wall-adjacent density distortions), an algebraic Kasa circle + wall-adjacent density distortions), an algebraic Taubin circle is fit to the kept points, and the contact angle is the angle of intersection between that circle and the line ``z = z_wall``. The batch angle is the mean over slices; diff --git a/tests/test_analysis/test_slicing_fitter.py b/tests/test_analysis/test_slicing_fitter.py index 7e7e028..12a018b 100644 --- a/tests/test_analysis/test_slicing_fitter.py +++ b/tests/test_analysis/test_slicing_fitter.py @@ -54,7 +54,7 @@ def test_slicing_fitter_recovers_known_circle_angle() -> None: f"recovered = {out.angle:.4f}°, rms_residual = {out.rms_residual:.3e} Å" ) assert abs(out.angle - truth_angle) < 1e-6 - # Algebraic Kasa on exact-circle points fits to ~floating-point + # Algebraic Taubin on exact-circle points fits to ~floating-point # precision; the per-slice RMS residual should sit near 0. assert out.rms_residual < 1e-9 # One slice → angle_std = 0. From 74c9f5abef3f4817efc607f4828d9917b88e8b2c Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 13:52:01 +0200 Subject: [PATCH 41/53] Fine tuning. --- docs/examples/coupled_fit_ca.py | 2 +- docs/examples/parsing_trajectory_files.py | 2 +- docs/examples/slicing_ca.py | 2 +- docs/examples/visualisation_evolution_density.py | 2 +- docs/examples/visualisation_slicing_traj.py | 2 +- docs/examples/whole_fit_ca.py | 2 +- pyproject.toml | 10 ++-------- src/wetting_angle_kit/parsers/lammps_dump.py | 2 +- tests/test_analysis/test_coupled_fit_2d.py | 2 +- tests/test_analysis/test_coupled_fit_3d.py | 2 +- tests/test_analysis/test_cylinder_coverage.py | 2 +- tests/test_analysis/test_default_grid_params.py | 2 +- tests/test_analysis/test_density_estimator.py | 4 ++-- tests/test_analysis/test_grid_slicing.py | 2 +- tests/test_analysis/test_grid_whole.py | 2 +- tests/test_analysis/test_parallel_path.py | 4 ++-- tests/test_analysis/test_rays_with_binning.py | 2 +- tests/test_analysis/test_slicing_method.py | 2 +- .../test_trajectory_analyzer_integration.py | 4 ++-- .../test_analysis/test_wall_detector_from_atoms_e2e.py | 2 +- 20 files changed, 24 insertions(+), 30 deletions(-) diff --git a/docs/examples/coupled_fit_ca.py b/docs/examples/coupled_fit_ca.py index 9a1405d..06f8e45 100644 --- a/docs/examples/coupled_fit_ca.py +++ b/docs/examples/coupled_fit_ca.py @@ -32,7 +32,7 @@ oxygen_type=1, hydrogen_type=2, ) -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) +oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print("Number of water molecules:", len(oxygen_indices)) # --- Step 3: Define the grid --- diff --git a/docs/examples/parsing_trajectory_files.py b/docs/examples/parsing_trajectory_files.py index fc3bb9e..4b44441 100644 --- a/docs/examples/parsing_trajectory_files.py +++ b/docs/examples/parsing_trajectory_files.py @@ -25,7 +25,7 @@ ) # --- Identify water oxygen indices for the first frame --- -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) +oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print(f"Number of water molecules: {len(oxygen_indices)}") # --- Initialize parser --- diff --git a/docs/examples/slicing_ca.py b/docs/examples/slicing_ca.py index c689f54..d60e54e 100644 --- a/docs/examples/slicing_ca.py +++ b/docs/examples/slicing_ca.py @@ -25,7 +25,7 @@ oxygen_type=1, hydrogen_type=2, ) -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) +oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print("Number of water molecules:", len(oxygen_indices)) # --- Step 3: Build the trajectory analyzer --- diff --git a/docs/examples/visualisation_evolution_density.py b/docs/examples/visualisation_evolution_density.py index 1f09762..2fee4c5 100644 --- a/docs/examples/visualisation_evolution_density.py +++ b/docs/examples/visualisation_evolution_density.py @@ -30,7 +30,7 @@ # Water-oxygen atoms. wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) +oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) # --- 1. Slicing pipeline → angle evolution figure --- slicing = TrajectoryAnalyzer( diff --git a/docs/examples/visualisation_slicing_traj.py b/docs/examples/visualisation_slicing_traj.py index 1f305fe..ee263d3 100644 --- a/docs/examples/visualisation_slicing_traj.py +++ b/docs/examples/visualisation_slicing_traj.py @@ -27,7 +27,7 @@ # --- 2. Identify water-oxygen atoms --- wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) +oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print("Number of water molecules detected:", len(oxygen_indices)) # --- 3. Read atom and wall positions for the frame --- diff --git a/docs/examples/whole_fit_ca.py b/docs/examples/whole_fit_ca.py index 0c22c76..2ed0e1c 100644 --- a/docs/examples/whole_fit_ca.py +++ b/docs/examples/whole_fit_ca.py @@ -24,7 +24,7 @@ # --- Step 2: Identify water-oxygen and wall-atom indices --- wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) -oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) +oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) # Wall parser: ``liquid_particle_types`` lists the liquid types to EXCLUDE. wall_parser = LammpsDumpWallParser(filename, liquid_particle_types=[1, 2]) diff --git a/pyproject.toml b/pyproject.toml index da3de17..5110db9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ readme = "README.md" requires-python = ">=3.10" dynamic = ["version"] classifiers = [ - "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10/3.11/3.12", "Operating System :: OS Independent", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Physics", @@ -29,7 +29,6 @@ dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", "black>=23.0.0", - "isort>=5.0.0", "mypy>=1.0.0", "ruff>=0.15.0", "pre-commit", @@ -39,7 +38,6 @@ doc = [ "sphinx-rtd-theme>=1.0.0", "sphinxemoji", "sphinx-copybutton", - "sphinx-argparse", "sphinx-code-tabs", "sphinx-issues", "nbsphinx>=0.8.0", @@ -73,7 +71,7 @@ all = [ "Documentation" = "https://matgenix.github.io/wetting-angle-kit" [build-system] -requires = ["setuptools==76.0.0", "versioningit ~= 1.0", "wheel"] +requires = ["setuptools>=76.0.0", "versioningit ~= 1.0", "wheel"] build-backend = "setuptools.build_meta" [tool.setuptools.packages.find] @@ -95,10 +93,6 @@ line-length = 88 target-version = ["py310"] include = '\.pyi?$' -[tool.isort] -profile = "black" -multi_line_output = 3 - [tool.mypy] python_version = "3.10" strict = true diff --git a/src/wetting_angle_kit/parsers/lammps_dump.py b/src/wetting_angle_kit/parsers/lammps_dump.py index 8efd3ca..a1caeac 100644 --- a/src/wetting_angle_kit/parsers/lammps_dump.py +++ b/src/wetting_angle_kit/parsers/lammps_dump.py @@ -322,7 +322,7 @@ def _setup_pipeline(self) -> Any: ) return pipeline - def get_water_oxygen_ids(self, frame_index: int) -> np.ndarray: + def get_water_oxygen_indices(self, frame_index: int) -> np.ndarray: """Return LAMMPS particle IDs of oxygen atoms bonded to exactly two hydrogens. Parameters diff --git a/tests/test_analysis/test_coupled_fit_2d.py b/tests/test_analysis/test_coupled_fit_2d.py index ad927a6..38e2693 100644 --- a/tests/test_analysis/test_coupled_fit_2d.py +++ b/tests/test_analysis/test_coupled_fit_2d.py @@ -33,7 +33,7 @@ def filename() -> pathlib.Path: def oxygen_indices(filename: pathlib.Path) -> np.ndarray: return LammpsDumpWaterFinder( filename, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.fixture diff --git a/tests/test_analysis/test_coupled_fit_3d.py b/tests/test_analysis/test_coupled_fit_3d.py index 42ca092..8d5e0e9 100644 --- a/tests/test_analysis/test_coupled_fit_3d.py +++ b/tests/test_analysis/test_coupled_fit_3d.py @@ -128,7 +128,7 @@ def test_coupled_fit_3d_close_to_2d_on_lammps_fixture() -> None: ) finder = LammpsDumpWaterFinder(_FIXTURE, oxygen_type=1, hydrogen_type=2) - oxygen_indices = finder.get_water_oxygen_ids(0) + oxygen_indices = finder.get_water_oxygen_indices(0) # 2D analyzer — radial (xi, zi). binning_params_2d = { diff --git a/tests/test_analysis/test_cylinder_coverage.py b/tests/test_analysis/test_cylinder_coverage.py index df83d74..0ba151f 100644 --- a/tests/test_analysis/test_cylinder_coverage.py +++ b/tests/test_analysis/test_cylinder_coverage.py @@ -57,7 +57,7 @@ def oxygen_indices() -> np.ndarray: return LammpsDumpWaterFinder( _CYL_FIXTURE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) #: Known wall position on the LAMMPS cylinder fixture (z of the diff --git a/tests/test_analysis/test_default_grid_params.py b/tests/test_analysis/test_default_grid_params.py index aeb13f5..06eebf8 100644 --- a/tests/test_analysis/test_default_grid_params.py +++ b/tests/test_analysis/test_default_grid_params.py @@ -49,7 +49,7 @@ def oxygen_indices() -> np.ndarray: return LammpsDumpWaterFinder( _FIXTURE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.mark.integration diff --git a/tests/test_analysis/test_density_estimator.py b/tests/test_analysis/test_density_estimator.py index 42cad2f..f8de942 100644 --- a/tests/test_analysis/test_density_estimator.py +++ b/tests/test_analysis/test_density_estimator.py @@ -44,14 +44,14 @@ def oxygen_indices_sphere() -> np.ndarray: return LammpsDumpWaterFinder( _SPHERE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.fixture def oxygen_indices_cyl() -> np.ndarray: return LammpsDumpWaterFinder( _CYL, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.mark.integration diff --git a/tests/test_analysis/test_grid_slicing.py b/tests/test_analysis/test_grid_slicing.py index 98adbfc..9e7b63d 100644 --- a/tests/test_analysis/test_grid_slicing.py +++ b/tests/test_analysis/test_grid_slicing.py @@ -253,7 +253,7 @@ def test_grid_extractors_end_to_end_close_to_rays_with_gaussian() -> None: / "traj_spherical_drop_4k.lammpstrj" ) finder = LammpsDumpWaterFinder(fixture, oxygen_type=1, hydrogen_type=2) - oxygen_indices = finder.get_water_oxygen_ids(0) + oxygen_indices = finder.get_water_oxygen_indices(0) grid_params_gauss = { "xi_0": -40.0, diff --git a/tests/test_analysis/test_grid_whole.py b/tests/test_analysis/test_grid_whole.py index 21e2e58..66ab9ed 100644 --- a/tests/test_analysis/test_grid_whole.py +++ b/tests/test_analysis/test_grid_whole.py @@ -222,7 +222,7 @@ def test_grid_with_gaussian_whole_end_to_end_on_lammps_fixture() -> None: / "traj_spherical_drop_4k.lammpstrj" ) finder = LammpsDumpWaterFinder(fixture, oxygen_type=1, hydrogen_type=2) - oxygen_indices = finder.get_water_oxygen_ids(0) + oxygen_indices = finder.get_water_oxygen_indices(0) grid_params = _whole_grid_params(half_xy=40.0, z_lo=0.0, z_hi=45.0, nbins=21) diff --git a/tests/test_analysis/test_parallel_path.py b/tests/test_analysis/test_parallel_path.py index ebe03e2..69dd68c 100644 --- a/tests/test_analysis/test_parallel_path.py +++ b/tests/test_analysis/test_parallel_path.py @@ -35,7 +35,7 @@ def test_run_parallel_path_executes_with_n_jobs_2() -> None: """ oxygen_indices = LammpsDumpWaterFinder( _FIXTURE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, @@ -68,7 +68,7 @@ def test_n_jobs_gt_1_with_batch_size_minus_1_warns_and_runs_inline() -> None: """ oxygen_indices = LammpsDumpWaterFinder( _FIXTURE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, diff --git a/tests/test_analysis/test_rays_with_binning.py b/tests/test_analysis/test_rays_with_binning.py index d8b1e3b..b1d5646 100644 --- a/tests/test_analysis/test_rays_with_binning.py +++ b/tests/test_analysis/test_rays_with_binning.py @@ -232,7 +232,7 @@ def test_rays_with_binning_end_to_end_angle_close_to_rays_with_gaussian() -> Non ) finder = LammpsDumpWaterFinder(_FIXTURE, oxygen_type=1, hydrogen_type=2) - oxygen_indices = finder.get_water_oxygen_ids(0) + oxygen_indices = finder.get_water_oxygen_indices(0) def _angle(extractor: InterfaceExtractor) -> float: analyzer = TrajectoryAnalyzer( diff --git a/tests/test_analysis/test_slicing_method.py b/tests/test_analysis/test_slicing_method.py index 9731bda..8a5f5e6 100644 --- a/tests/test_analysis/test_slicing_method.py +++ b/tests/test_analysis/test_slicing_method.py @@ -44,7 +44,7 @@ def filename() -> pathlib.Path: def oxygen_indices(filename: pathlib.Path) -> np.ndarray: return LammpsDumpWaterFinder( filename, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.mark.integration diff --git a/tests/test_analysis/test_trajectory_analyzer_integration.py b/tests/test_analysis/test_trajectory_analyzer_integration.py index 150885f..33ce1e7 100644 --- a/tests/test_analysis/test_trajectory_analyzer_integration.py +++ b/tests/test_analysis/test_trajectory_analyzer_integration.py @@ -54,14 +54,14 @@ def spherical_oxygen_ids() -> np.ndarray: return LammpsDumpWaterFinder( _SPHERICAL_FIXTURE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.fixture def cylinder_oxygen_ids() -> np.ndarray: return LammpsDumpWaterFinder( _CYLINDER_FIXTURE, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.fixture diff --git a/tests/test_analysis/test_wall_detector_from_atoms_e2e.py b/tests/test_analysis/test_wall_detector_from_atoms_e2e.py index 058be60..fc05c2e 100644 --- a/tests/test_analysis/test_wall_detector_from_atoms_e2e.py +++ b/tests/test_analysis/test_wall_detector_from_atoms_e2e.py @@ -59,7 +59,7 @@ def oxygen_indices(fixture_path: pathlib.Path) -> np.ndarray: """LAMMPS particle IDs of the water-oxygen atoms in frame 0.""" return LammpsDumpWaterFinder( fixture_path, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(0) + ).get_water_oxygen_indices(0) @pytest.fixture From 9ec8623ef2abbde51f599c6976ffac0387eb414f Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 13:54:47 +0200 Subject: [PATCH 42/53] Bug fix in docs. --- docs/source/conf.py | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 103f105..4643680 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -31,7 +31,6 @@ "sphinx.ext.napoleon", "sphinxemoji.sphinxemoji", "sphinx_copybutton", - "sphinxarg.ext", "sphinx_code_tabs", "sphinx_issues", "sphinx.ext.mathjax", # Kept from original From b35f395b80f1f8334426d3d9ac3c4c857209ccaf Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 14:49:40 +0200 Subject: [PATCH 43/53] Fine tuning. --- .gitignore | 1 + pyproject.toml | 10 +++------- src/wetting_angle_kit/analysis/fitters/_taubin.py | 3 +-- src/wetting_angle_kit/analysis/interface/base.py | 6 +----- .../test_trajectory_analyzer_integration.py | 3 +-- 5 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.gitignore b/.gitignore index 8392b0c..5e40279 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ coverage.xml .pytest_cache/ cover/ prof/ +.ruff_cache/ # Translations *.mo diff --git a/pyproject.toml b/pyproject.toml index 5110db9..8972da3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,9 @@ readme = "README.md" requires-python = ">=3.10" dynamic = ["version"] classifiers = [ - "Programming Language :: Python :: 3.10/3.11/3.12", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", "Operating System :: OS Independent", "Intended Audience :: Science/Research", "Topic :: Scientific/Engineering :: Physics", @@ -28,7 +30,6 @@ dependencies = [ dev = [ "pytest>=7.0.0", "pytest-cov>=4.0.0", - "black>=23.0.0", "mypy>=1.0.0", "ruff>=0.15.0", "pre-commit", @@ -88,11 +89,6 @@ default-tag = "0.1.2" [tool.versioningit.write] file = "src/wetting_angle_kit/_version.py" -[tool.black] -line-length = 88 -target-version = ["py310"] -include = '\.pyi?$' - [tool.mypy] python_version = "3.10" strict = true diff --git a/src/wetting_angle_kit/analysis/fitters/_taubin.py b/src/wetting_angle_kit/analysis/fitters/_taubin.py index 482276c..e43faad 100644 --- a/src/wetting_angle_kit/analysis/fitters/_taubin.py +++ b/src/wetting_angle_kit/analysis/fitters/_taubin.py @@ -10,8 +10,7 @@ contact angle. The Taubin fit normalises the algebraic residual by its gradient, which keeps the recovered radius near-unbiased on partial arcs (matching a full geometric orthogonal-distance fit) while staying -closed-form: no initial guess, no iteration. ``benchmarks/bench_circle_fit.py`` -quantifies the radius/angle accuracy and per-fit timing. +closed-form: no initial guess, no iteration. """ import numpy as np diff --git a/src/wetting_angle_kit/analysis/interface/base.py b/src/wetting_angle_kit/analysis/interface/base.py index 02e0c37..67d4f1e 100644 --- a/src/wetting_angle_kit/analysis/interface/base.py +++ b/src/wetting_angle_kit/analysis/interface/base.py @@ -22,17 +22,13 @@ from abc import ABC, abstractmethod from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, ClassVar, Literal, TypeAlias +from typing import Any, ClassVar, Literal, TypeAlias import numpy as np from wetting_angle_kit.analysis.density_estimator import DensityEstimator from wetting_angle_kit.analysis.geometry import DropletGeometry -if TYPE_CHECKING: - pass - - # --------------------------------------------------------------------------- # Type aliases. # --------------------------------------------------------------------------- diff --git a/tests/test_analysis/test_trajectory_analyzer_integration.py b/tests/test_analysis/test_trajectory_analyzer_integration.py index 33ce1e7..d840198 100644 --- a/tests/test_analysis/test_trajectory_analyzer_integration.py +++ b/tests/test_analysis/test_trajectory_analyzer_integration.py @@ -17,9 +17,8 @@ pytest.importorskip("ovito") -from wetting_angle_kit.analysis import ( +from wetting_angle_kit.analysis import ( # noqa: E402 DensityEstimator, - # noqa: E402 InterfaceExtractor, SpaceSampling, SurfaceFitter, From af78239111e8da7adfd941ffe6e6750a9ab1c466 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Mon, 15 Jun 2026 15:38:07 +0200 Subject: [PATCH 44/53] Update of doc. --- .../introduction/theoretical_foundations.rst | 235 +++++++++--------- docs/source/tutorials/grid_method_tuto.rst | 25 +- docs/source/tutorials/slicing_method_tuto.rst | 6 +- docs/source/tutorials/whole_fit_tuto.rst | 4 +- .../analysis/interface/_grid.py | 8 +- 5 files changed, 132 insertions(+), 146 deletions(-) diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 2ec83ee..2d09313 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -48,44 +48,21 @@ that the recovered :math:`\theta` is meaningful. There is no sharp surface in an MD frame: the density drops from :math:`\rho_{\rm liq}` to :math:`\rho_{\rm vap}` smoothly over a few Å, broadened by thermal motion. The package treats the -liquid–vapor interface as the locus of half-bulk density. For the -ray extractors this is recovered by fitting a one-dimensional -hyperbolic-tangent profile to the density sampled along each ray: +liquid–vapor interface as the locus of half-bulk density. +This interface is then used to fit a circle/sphere and recover the contact angle. +The extraction of the interface is based on two choices: -.. math:: +* The density field may be computed via a Gaussian KDE or a 3D top-hat binning. +* The density may be sampled along rays from the droplet COM + or on a fixed grid in space. + +2.1. Estimating local density +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - \rho(\zeta) \;=\; h \;+\; d\,\tanh(\zeta_d - \zeta), - -where :math:`\zeta` is the running coordinate along the ray and the -three fitted parameters are the interface location :math:`\zeta_d`, -the midpoint density :math:`h = (\rho_{\rm liq} + \rho_{\rm vap})/2`, -and the half-amplitude :math:`d = (\rho_{\rm liq} - \rho_{\rm vap})/2`. -The interface is :math:`\zeta = \zeta_d`, where :math:`\rho = h`. - -The transition **width** is *fixed* — the tanh argument has unit -slope, giving a transition scale of order 1 Å — rather than being a -fitted parameter. Because the profile is antisymmetric about its -midpoint, the recovered half-density crossing :math:`\zeta_d` is -largely insensitive to the exact width, so fixing the slope instead -of fitting a thickness does not bias the interface location; only the -amplitude/width interpretation would change, and the downstream -geometry never uses it. (The joint coupled fit of §7 *does* treat the -interface thicknesses :math:`t_1, t_2` as free parameters, because -there the full density field — not just the crossing — is modelled.) - -This tanh profile is theoretically motivated by mean-field theory of -liquid–vapor interfaces (van der Waals / Cahn–Hilliard square-gradient -free energy) and is an excellent empirical fit to MD density -profiles in the same regime. - -3. Estimating local density ---------------------------- - -To fit the tanh profile to a sampled density, we first need a local -density estimate at each sample point. Two estimators are available, -swappable via :class:`InterfaceExtractor`: - -**Gaussian KDE** (``rays`` (Gaussian) / ``grid`` (Gaussian)) +We first need a local density estimate at each sample point. +Two estimators are available, swappable via :class:`DensityEstimator`: + +**Gaussian KDE** Each atom contributes a normalised 3D Gaussian of width :math:`\sigma`: @@ -99,7 +76,7 @@ swappable via :class:`InterfaceExtractor`: which makes it the default choice. For efficiency, a per-atom cut-off at :math:`5\sigma` is applied via a cKDTree. -**3D top-hat** (``rays`` (binning) / ``grid`` (binning)) +**3D top-hat** Atoms within :math:`{\rm bin\_width}/2` of the sample contribute uniformly: @@ -116,88 +93,98 @@ Both estimators implement the same :class:`DensityFieldProtocol`, so the analysis pipeline can plug either one into the same ray-fan or grid extraction. -4. Sampling the interface: rays vs grid ---------------------------------------- +2.2. Sampling the density field +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Two strategies turn the density estimator into a clean point set on the interface: -4.1 Ray fans -^^^^^^^^^^^^ - -The :meth:`InterfaceExtractor.rays` (Gaussian) / -:meth:`InterfaceExtractor.rays` (binning) factories emit a fan of rays from the droplet -COM, sample the density along each ray, and recover the interface -position as the half-density point of a 1D tanh fit on that ray. - -Three ray-fan geometries are used depending on the -``(surface_kind, droplet_geometry)`` pair: - -* **slicing + spherical**: a 2D ray fan in each azimuthal plane - through the droplet (planes spaced by ``delta_azimuthal``); - within each plane, rays at polar angles spaced by ``delta_polar``. -* **slicing + cylinder**: a 2D ray fan in each ``y``-step plane - (planes spaced by ``delta_cylinder``); same polar fan within each - plane. -* **whole + spherical**: a full-sphere Fibonacci ray fan from the - COM. Equal-area in :math:`(\cos\theta, \phi)` with the golden - angle in :math:`\phi`; total ray count is ``n_rays_sphere``. - Full-sphere coverage is important: downward rays from the COM - hit the wall plane and produce shell points at :math:`z \approx - z_w`, which is what makes - :meth:`WallDetector.min_plus_offset` work for the whole-fit. -* **whole + cylinder**: a per-:math:`y` ray fan in the ``(x, z)`` - plane (planes spaced by ``delta_cylinder``); the resulting shell - is the union of these per-:math:`y` rings. - -Why Fibonacci on the sphere? Naive uniform :math:`(\theta, \phi)` -gridding clusters rays near the poles, oversampling there and -undersampling the equator. The Fibonacci spiral (uniform -:math:`\cos\theta`, golden angle :math:`\phi`) gives near-perfect -equal-area coverage with no clustering anywhere. - -4.2 Grid + iso-contour -^^^^^^^^^^^^^^^^^^^^^^ - -The :meth:`InterfaceExtractor.grid` (Gaussian) / -:meth:`InterfaceExtractor.grid` (binning) factories build a fixed-cell grid in space and -compute a density value at each cell, then recover the interface as -the iso-density contour at the half-bulk level via -:func:`skimage.measure.find_contours` in 2D (marching squares) or -:func:`skimage.measure.marching_cubes` in 3D. - -The two estimators differ only in how the per-cell density is -computed: - -* ``grid`` (Gaussian) evaluates the same Gaussian KDE described in - §3 at each cell centre — exactly the estimator - :meth:`InterfaceExtractor.rays` (Gaussian) uses, just sampled on a grid rather than - along rays. No additional smoothing step. -* ``grid`` (binning) is a histogram: for slicing mode, atoms within - ``±bin_width_x/2`` of the slice plane contribute to the 2D - histogram in ``(s, z)`` (the slab cut); for whole mode, atoms are - binned directly into 3D ``(x, y, z)`` cells. Density is - ``counts / cell_volume``. - -In slicing mode both grid extractors iterate **per slice** — -azimuthal angles ``γ ∈ [0°, 180°)`` for spherical droplets, axial -steps along ``y`` for cylinder droplets — exactly like the rays -variants. Each slice yields an ``(s, z)`` density field and one -iso-contour; the downstream :class:`SurfaceFitter.slicing` averages -the per-slice angles and reports the inter-slice scatter, which is -how the slicing method exposes droplet asymmetry. - -Two volume-normalisation notes: - -* ``grid`` (Gaussian) returns 3D density per ų directly from the KDE - evaluation; no extra volume normalisation needed. -* ``grid`` (binning)'s slab-cut histogram divides by - ``ds × dz × bin_width_x`` so the recovered field is also in - atoms/ų. The slab thickness equals ``bin_width_x`` (the in-plane - horizontal cell width), which keeps the bin's cross-section in the - ``(s, perpendicular)`` directions square. - -5. Fitting the cap: algebraic Taubin fits +**Ray fans** + The :meth:`SpaceSampling.rays` factory emits a fan of rays from the droplet + COM, samples the density along each ray, and recovers the interface + position as the half-density point of a 1D tanh fit on that ray. + + In such samplings, the interface is recovered by fitting a one-dimensional + hyperbolic-tangent profile to the density sampled along each ray: + + .. math:: + + \rho(\zeta) \;=\; h \;+\; d\,\tanh(\zeta_d - \zeta), + + where :math:`\zeta` is the running coordinate along the ray and the + three fitted parameters are the interface location :math:`\zeta_d`, + the midpoint density :math:`h = (\rho_{\rm liq} + \rho_{\rm vap})/2`, + and the half-amplitude :math:`d = (\rho_{\rm liq} - \rho_{\rm vap})/2`. + The interface is :math:`\zeta = \zeta_d`, where :math:`\rho = h`. + + The transition **width** is *fixed* — the tanh argument has unit + slope, giving a transition scale of order 1 Å — rather than being a + fitted parameter. Because the profile is antisymmetric about its + midpoint, the recovered half-density crossing :math:`\zeta_d` is + largely insensitive to the exact width, so fixing the slope instead + of fitting a thickness does not bias the interface location; only the + amplitude/width interpretation would change, and the downstream + geometry never uses it. (The joint coupled fit of §7 *does* treat the + interface thicknesses :math:`t_1, t_2` as free parameters, because + there the full density field — not just the crossing — is modelled.) + + This tanh profile is theoretically motivated by mean-field theory of + liquid–vapor interfaces (van der Waals / Cahn–Hilliard square-gradient + free energy) and is an excellent empirical fit to MD density + profiles in the same regime. + + Four ray-fan geometries are used depending on the + ``(surface_kind, droplet_geometry)`` pair: + + * **slicing + spherical**: a 2D ray fan in each azimuthal plane + through the droplet (planes spaced by ``delta_azimuthal``); + within each plane, rays at polar angles spaced by ``delta_polar``. + * **slicing + cylinder**: a 2D ray fan in each ``y``-step plane + (planes spaced by ``delta_cylinder``); same polar fan within each + plane. + * **whole + spherical**: a full-sphere Fibonacci ray fan from the + COM. Equal-area in :math:`(\cos\theta, \phi)` with the golden + angle in :math:`\phi`; total ray count is ``n_rays_sphere``. + Full-sphere coverage is important: downward rays from the COM + hit the wall plane and produce shell points at :math:`z \approx + z_w`, which is what makes + :meth:`WallDetector.min_plus_offset` work for the whole-fit. + * **whole + cylinder**: a per-:math:`y` ray fan in the ``(x, z)`` + plane (planes spaced by ``delta_cylinder``); the resulting shell + is the union of these per-:math:`y` rings. + + Why Fibonacci on the sphere? Naive uniform :math:`(\theta, \phi)` + gridding clusters rays near the poles, oversampling there and + undersampling the equator. The Fibonacci spiral (uniform + :math:`\cos\theta`, golden angle :math:`\phi`) gives near-perfect + equal-area coverage with no clustering anywhere. + +**Grid + iso-contour** + The :meth:`SpaceSampling.grid` factory builds a fixed-cell grid in space and + computes a density value at each cell, then recovers the interface as + the iso-density contour at the half-bulk level via + :func:`skimage.measure.find_contours` in 2D (marching squares) or + :func:`skimage.measure.marching_cubes` in 3D. + + In slicing mode, the grid sampling iterates **per slice** — + azimuthal angles ``γ ∈ [0°, 180°)`` for spherical droplets, axial + steps along ``y`` for cylinder droplets — exactly like the rays + variant. Each slice yields an ``(s, z)`` density field and one + iso-contour; the downstream :class:`SurfaceFitter.slicing` averages + the per-slice angles and reports the inter-slice scatter, which is + how the slicing method exposes droplet asymmetry. + + Two volume-normalisation notes: + + * ``grid`` + ``gaussian`` returns 3D density per ų directly from the KDE + evaluation; no extra volume normalisation needed. + * ``grid`` + ``binning``'s slab-cut histogram divides by + ``ds × dz × bin_width_x`` so the recovered field is also in + atoms/ų. The slab thickness equals ``bin_width_x`` (the in-plane + horizontal cell width), which keeps the bin's cross-section in the + ``(s, perpendicular)`` directions square. + +3. Fitting the cap: algebraic Taubin fits ----------------------------------------- Given a clean point set on the interface, the surface fitter @@ -242,7 +229,7 @@ averages the per-slice angles. The whole fitter droplet, exploiting translational symmetry along :math:`y`) on the entire shell. -6. Locating the wall plane +4. Locating the wall plane -------------------------- The contact angle is read from the cap–wall intersection, so the @@ -275,7 +262,7 @@ to roughly a 3° shift in the recovered angle. So either pick the wall detector that matches your trust budget, or report the angle for two choices to make the dependence visible. -7. Joint coupled fit +5. Joint coupled fit -------------------- The :class:`CoupledFit2DAnalyzer` and @@ -283,7 +270,7 @@ The :class:`CoupledFit2DAnalyzer` and extractor/wall/fitter decomposition and fit a multi-parameter density model directly. -7.1 The 2D model (7 parameters) +5.1 The 2D model (7 parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ After projecting atoms to ``(xi, zi)`` via the droplet symmetry, the @@ -318,7 +305,7 @@ The contact angle follows directly: \cos \theta \;=\; \frac{z_0 - z_c}{R_{eq}}. -7.2 The 3D model (9 parameters) +5.2 The 3D model (9 parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The 3D extension bins the full ``(xi, yi, zi)`` density and fits @@ -334,7 +321,7 @@ horizontal centre. Nine free parameters; same cap geometry for rejected at construction because translational symmetry along the cylinder axis already collapses the 3D problem onto the 2D one. -7.3 Why a joint fit? +5.3 Why a joint fit? ^^^^^^^^^^^^^^^^^^^^ The coupled fit shares information across the cap and the wall: @@ -346,7 +333,7 @@ many frames per batch; less informative per batch (single angle) and slower per batch (a 7-parameter NLLS rather than four linear solves). -8. Periodic boundaries and droplet recentering +6. Periodic boundaries and droplet recentering ---------------------------------------------- MD simulations are run with periodic boundary conditions; a droplet @@ -388,7 +375,7 @@ below the box length. If that condition is violated, the radial density profile is physically meaningless regardless of the centering strategy. -9. Frame batching +7. Frame batching ----------------- The :class:`TemporalAggregator` groups trajectory frames into @@ -419,8 +406,8 @@ dominates. For a 4k-atom droplet on a typical room-temperature trajectory, ``batch_size`` between 1 and 10 covers the useful range. -10. Geometric symmetry classes ------------------------------- +8. Geometric symmetry classes +----------------------------- Three geometries are supported via :class:`DropletGeometry`: diff --git a/docs/source/tutorials/grid_method_tuto.rst b/docs/source/tutorials/grid_method_tuto.rst index 79ce54c..2a4e0db 100644 --- a/docs/source/tutorials/grid_method_tuto.rst +++ b/docs/source/tutorials/grid_method_tuto.rst @@ -1,17 +1,16 @@ -Tutorial: Grid-Based Interface Extraction -========================================= +Tutorial: Grid-Based Space Sampling for Interface Extraction +============================================================ -This tutorial covers the **grid-based interface extractors** — -:meth:`InterfaceExtractor.grid` (Gaussian) and -:meth:`InterfaceExtractor.grid` (binning). They are an alternative to +This tutorial covers the **grid-based space sampling for interface extraction** — +:meth:`SpaceSampling.grid`. It is an alternative to the ray-fan extractors used in the :doc:`slicing_method_tuto` and :doc:`whole_fit_tuto`: instead of locating the interface as the -half-density point of a 1D tanh fit along each ray, they evaluate a +half-density point of a 1D tanh fit along each ray, it evaluates a density at each cell of a fixed grid and recover the interface as the iso-density contour at the half-bulk level. -In slicing mode both grid extractors iterate per slice — per +In slicing mode the grid sampling iterates per slice — per azimuthal angle for spherical droplets, per axial step for cylinder droplets — so the downstream :class:`SurfaceFitter.slicing` sees one contour per slice and can expose per-slice asymmetry, exactly like @@ -34,7 +33,7 @@ about how the noise/cost trade-off lands on your system: intuition; the per-cell density gets smoother as more frames are pooled. -The grid extractors require ``scikit-image`` for the iso-contour +The grid sampling requires ``scikit-image`` for the iso-contour tracing (marching squares in 2D, marching cubes in 3D). Install via the ``grid3d`` extra:: @@ -42,8 +41,8 @@ the ``grid3d`` extra:: ---- -2. Worked example: ``grid`` (Gaussian) + slicing fit ----------------------------------------------------- +2. Worked example: ``grid`` sampling with ``gaussian`` density + slicing fit +---------------------------------------------------------------------------- A spherical droplet, with per-azimuthal-slice 2D density grids in the ``(s, z)`` plane — same density estimator as ``rays`` (Gaussian), just @@ -100,8 +99,8 @@ sampled on a fixed grid rather than along rays: ---- -3. Histogram alternative: ``grid`` (binning) --------------------------------------------- +3. Histogram alternative: ``grid`` sampling with ``binning`` density +-------------------------------------------------------------------- Same per-slice iteration, but the density estimator is a top-hat histogram of atoms within the slab ``|perp| ≤ bin_width_x / 2`` of @@ -144,7 +143,7 @@ either coarser cells or fewer slices, not a finer grid. 4. 3D iso-surface for the whole-fit ----------------------------------- -The grid extractors also work in whole-fit mode for spherical +The grid sampling also works in whole-fit mode for spherical droplets — the 2D density grid is replaced by a 3D one, and the half-bulk iso-surface is traced via marching cubes. Whole mode takes no ``delta_azimuthal`` / ``delta_cylinder``: diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index 0a00736..dfb608c 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -280,7 +280,7 @@ For physical context on the trade-off see 6.4 Grid alternative ^^^^^^^^^^^^^^^^^^^^ -The grid extractors (:meth:`InterfaceExtractor.grid` (Gaussian) and -:meth:`InterfaceExtractor.grid` (binning)) pair with the slicing fitter exactly the same -way and are covered in :doc:`grid_method_tuto`. Use them when +The grid extractor (:meth:`SpaceSampling.grid`) +pairs with the slicing fitter exactly the same +way and is covered in :doc:`grid_method_tuto`. Use it when ray-fan sampling is too sparse to resolve the interface. diff --git a/docs/source/tutorials/whole_fit_tuto.rst b/docs/source/tutorials/whole_fit_tuto.rst index acd65a6..8aa5cfe 100644 --- a/docs/source/tutorials/whole_fit_tuto.rst +++ b/docs/source/tutorials/whole_fit_tuto.rst @@ -35,7 +35,7 @@ deviation of the angles is reported as ------------------------ The full-sphere Fibonacci ray fan -(:meth:`InterfaceExtractor.rays` (Gaussian) with ``n_rays_sphere=...``) +(:meth:`SpaceSampling.rays` with ``n_rays_sphere=...``) emits rays from the droplet COM in all directions, including downward. Those downward rays hit the wall plane and contribute interface points right at the wall, so the lowest shell point lands @@ -169,7 +169,7 @@ want to report: reliable to two significant figures; 1000 will tighten that but costs ~10× more. - **Cylinder droplets** still work — pair the whole fitter with - :meth:`InterfaceExtractor.rays` (Gaussian) configured with + :meth:`SpaceSampling.rays` configured with ``delta_cylinder`` and ``delta_polar`` instead of ``n_rays_sphere``. The fitter automatically does a 2D circle fit per the cylinder axis convention. diff --git a/src/wetting_angle_kit/analysis/interface/_grid.py b/src/wetting_angle_kit/analysis/interface/_grid.py index 75ab5be..87c5a5f 100644 --- a/src/wetting_angle_kit/analysis/interface/_grid.py +++ b/src/wetting_angle_kit/analysis/interface/_grid.py @@ -1,8 +1,8 @@ -"""Grid-based extractor implementations. +"""Grid-based extractor implementation. -Both extractors evaluate a density field at fixed-cell grid points and -trace the half-bulk iso-contour (slicing mode) or iso-surface (whole -mode). For slicing mode, both iterate per-slice — azimuthal angles for +This sampling evaluates a density field at fixed-cell grid points and +traces the half-bulk iso-contour (slicing mode) or iso-surface (whole +mode). For slicing mode, it iterates per-slice — azimuthal angles for spherical droplets, axial steps for cylindrical droplets — so the downstream :class:`SurfaceFitter.slicing` sees one ``(s, z)`` contour per slice and can report per-slice scatter. From 1386e89ccd9b66b4358bfdd0a7afed613202f677 Mon Sep 17 00:00:00 2001 From: gbrunin Date: Tue, 16 Jun 2026 10:23:45 +0200 Subject: [PATCH 45/53] Some renaming, update of doc and docstring. --- docs/examples/coupled_fit_ca.py | 16 ++-- .../visualisation_evolution_density.py | 6 +- docs/source/examples/index.rst | 2 +- docs/source/introduction/introduction.rst | 8 +- .../introduction/theoretical_foundations.rst | 40 ++++++--- docs/source/tutorials/coupled_fit_2d_tuto.rst | 40 ++++----- docs/source/tutorials/coupled_fit_3d_tuto.rst | 34 ++++---- docs/source/tutorials/grid_method_tuto.rst | 30 ++++--- docs/source/tutorials/index.rst | 2 +- docs/source/tutorials/parser_tutorial.rst | 2 +- docs/source/tutorials/slicing_method_tuto.rst | 6 +- .../visualization_evolution_density.rst | 12 +-- .../visualization_slicing_droplet.rst | 4 +- docs/source/tutorials/whole_fit_tuto.rst | 4 +- src/wetting_angle_kit/analysis/__init__.py | 4 +- src/wetting_angle_kit/analysis/_base.py | 6 +- src/wetting_angle_kit/analysis/_density.py | 15 ++-- src/wetting_angle_kit/analysis/_grid_utils.py | 10 +-- .../analysis/coupled_fit/__init__.py | 4 +- .../analysis/coupled_fit/_base.py | 20 ++--- .../analysis/coupled_fit/_models.py | 46 +++++----- .../analysis/coupled_fit/analyzer_2d.py | 58 ++++++------- .../analysis/coupled_fit/analyzer_3d.py | 60 ++++++------- .../analysis/density_estimator.py | 16 ++-- .../analysis/interface/_grid.py | 86 +++++++++---------- .../analysis/interface/base.py | 6 +- src/wetting_angle_kit/analysis/results.py | 26 +++--- src/wetting_angle_kit/analysis/trajectory.py | 2 +- tests/test_analysis/test_coupled_fit_2d.py | 16 ++-- tests/test_analysis/test_coupled_fit_3d.py | 26 +++--- tests/test_analysis/test_cylinder_coverage.py | 20 ++--- .../test_analysis/test_default_grid_params.py | 10 +-- tests/test_analysis/test_density_estimator.py | 34 ++++---- tests/test_analysis/test_grid_slicing.py | 20 ++--- tests/test_analysis/test_grid_whole.py | 12 +-- tests/test_analysis/test_parallel_path.py | 12 +-- 36 files changed, 368 insertions(+), 347 deletions(-) diff --git a/docs/examples/coupled_fit_ca.py b/docs/examples/coupled_fit_ca.py index 06f8e45..626cde8 100644 --- a/docs/examples/coupled_fit_ca.py +++ b/docs/examples/coupled_fit_ca.py @@ -1,8 +1,8 @@ """Coupled-fit contact-angle example. -Runs the joint hyperbolic-tangent fit on a 2D density grid via +Runs the coupled hyperbolic-tangent fit on a 2D density grid via :class:`CoupledFit2DAnalyzer`. The analyzer solves interface extraction, -wall detection, and surface fit jointly — one robust angle per pooled +wall detection, and surface fitting together — one robust angle per pooled batch. Two density estimators are shown: @@ -36,17 +36,17 @@ print("Number of water molecules:", len(oxygen_indices)) # --- Step 3: Define the grid --- -binning_params = { +grid_params = { "xi_0": 0.0, "xi_f": 70.0, - "bin_width_x": 2.0, + "dx": 2.0, "zi_0": 0.0, "zi_f": 70.0, - "bin_width_z": 2.0, + "dz": 2.0, } # --- Step 4: Pick a density estimator --- -# Top-hat histogram on the binning grid (default): +# Top-hat histogram on the sampling grid (default): estimator = DensityEstimator.binning() # Swap in the Gaussian KDE for smoother per-cell density. ``density_sigma`` # is the Gaussian kernel width; 3 Å is a sensible default for @@ -58,9 +58,9 @@ parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, density_estimator=estimator, - # Pool 10 frames per batch — the joint fit benefits from + # Pool 10 frames per batch — the coupled fit benefits from # statistics; ``batch_size=-1`` pools the entire trajectory. temporal_aggregator=TemporalAggregator(batch_size=10), ) diff --git a/docs/examples/visualisation_evolution_density.py b/docs/examples/visualisation_evolution_density.py index 2fee4c5..19aa538 100644 --- a/docs/examples/visualisation_evolution_density.py +++ b/docs/examples/visualisation_evolution_density.py @@ -62,13 +62,13 @@ parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params={ + grid_params={ "xi_0": 0.0, "xi_f": 70.0, - "bin_width_x": 2.0, + "dx": 2.0, "zi_0": 0.0, "zi_f": 70.0, - "bin_width_z": 2.0, + "dz": 2.0, }, # density_estimator=DensityEstimator.gaussian(density_sigma=2.5), temporal_aggregator=TemporalAggregator(batch_size=10), diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index 02ec2a5..dc4fda6 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -42,7 +42,7 @@ substrate atoms and a bootstrap uncertainty. Coupled-Fit Contact Angle ------------------------- -Joint hyperbolic-tangent density-model fit via +Coupled hyperbolic-tangent density-model fit via :class:`CoupledFit2DAnalyzer` — one angle per pooled batch. The example shows both density estimators (histogram default vs Gaussian KDE). diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index f1a66d5..97768e6 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -94,18 +94,18 @@ an extractor, a wall detector, a surface fitter, and a temporal aggregator, and the analyzer runs them per batch. Examples of useful combinations: -* ray-fan extractor + slicing fit + ``min_plus_offset`` wall + +* ray-fan sampling + slicing fit + ``min_plus_offset`` wall + per-frame batches — a per-frame angle trace with a per-slice ``±σ`` band; -* ray-fan extractor + whole-fit + ``explicit`` wall + 10-frame pooled +* ray-fan sampling + whole-fit + ``explicit`` wall + 10-frame pooled batches — a whole-shape sphere fit with the wall position imported from the simulation setup; -* grid extractor + slicing fit + ``from_atoms`` wall + per-frame +* grid sampling + slicing fit + ``from_atoms`` wall + per-frame batches — interface from a 2D density iso-contour, wall from the actual substrate atoms. :class:`CoupledFit2DAnalyzer` and :class:`CoupledFit3DAnalyzer` -are the **joint-fit alternative**. They skip the +are the **coupled-fit alternative**. They skip the extractor/wall/fitter decomposition and fit a seven-parameter (2D) or nine-parameter (3D) hyperbolic-tangent density model directly to the binned density. One robust angle per batch; ideal when you have many diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 2d09313..3e4d870 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -124,7 +124,7 @@ the interface: largely insensitive to the exact width, so fixing the slope instead of fitting a thickness does not bias the interface location; only the amplitude/width interpretation would change, and the downstream - geometry never uses it. (The joint coupled fit of §7 *does* treat the + geometry never uses it. (The coupled fit of §5 *does* treat the interface thicknesses :math:`t_1, t_2` as free parameters, because there the full density field — not just the crossing — is modelled.) @@ -179,8 +179,8 @@ the interface: * ``grid`` + ``gaussian`` returns 3D density per ų directly from the KDE evaluation; no extra volume normalisation needed. * ``grid`` + ``binning``'s slab-cut histogram divides by - ``ds × dz × bin_width_x`` so the recovered field is also in - atoms/ų. The slab thickness equals ``bin_width_x`` (the in-plane + ``ds × dz × dx`` so the recovered field is also in + atoms/ų. The slab thickness equals ``dx`` (the in-plane horizontal cell width), which keeps the bin's cross-section in the ``(s, perpendicular)`` directions square. @@ -262,19 +262,30 @@ to roughly a 3° shift in the recovered angle. So either pick the wall detector that matches your trust budget, or report the angle for two choices to make the dependence visible. -5. Joint coupled fit --------------------- +5. Coupled fit +-------------- The :class:`CoupledFit2DAnalyzer` and :class:`CoupledFit3DAnalyzer` skip the extractor/wall/fitter decomposition and fit a multi-parameter -density model directly. +density model directly to a density field on a fixed grid. + +The per-cell density is computed by the same pluggable +:class:`DensityEstimator` strategy used elsewhere in the package: +either a top-hat histogram (:meth:`DensityEstimator.binning`, the +default) or a 3D Gaussian KDE evaluated at the cell centres +(:meth:`DensityEstimator.gaussian`). The binning variant is fast +and exact but intrinsically noisy at low per-cell atom counts; the +Gaussian variant smooths out Poisson noise at the cost of a small +constant overhead per batch. The choice of estimator does not +affect the model or the fit procedure — only the density values +fed into the NLLS solver. 5.1 The 2D model (7 parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -After projecting atoms to ``(xi, zi)`` via the droplet symmetry, the -analyzer histograms the density and fits +After projecting atoms to ``(xi, zi)`` via the droplet symmetry, +the analyzer computes a per-cell density and fits .. math:: @@ -296,7 +307,7 @@ The radial sigmoid :math:`g(r)` describes the spherical-cap interface; the vertical sigmoid :math:`h(z - z_0)` cuts off the density below the wall plane :math:`z_0`. The seven free parameters :math:`(\rho_1, \rho_2, R_{eq}, z_c, z_0, t_1, t_2)` are -fit jointly by a bounded nonlinear least-squares +fit simultaneously by a bounded nonlinear least-squares (:func:`scipy.optimize.curve_fit`). The contact angle follows directly: @@ -308,7 +319,8 @@ The contact angle follows directly: 5.2 The 3D model (9 parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -The 3D extension bins the full ``(xi, yi, zi)`` density and fits +The 3D extension computes a density on a full ``(xi, yi, zi)`` +Cartesian grid and fits .. math:: @@ -321,8 +333,8 @@ horizontal centre. Nine free parameters; same cap geometry for rejected at construction because translational symmetry along the cylinder axis already collapses the 3D problem onto the 2D one. -5.3 Why a joint fit? -^^^^^^^^^^^^^^^^^^^^ +5.3 Why a coupled fit? +^^^^^^^^^^^^^^^^^^^^^^ The coupled fit shares information across the cap and the wall: the radial sigmoid is constrained by the apex curvature and the @@ -330,8 +342,8 @@ contact line simultaneously, and the vertical sigmoid pins the wall plane against the cap's lower extent. Statistically more efficient than the decoupled pipeline when you can afford to pool many frames per batch; less informative per batch (single angle) -and slower per batch (a 7-parameter NLLS rather than four linear -solves). +and slower per batch (a 7-parameter NLLS rather than one +closed-form Taubin solve per slice). 6. Periodic boundaries and droplet recentering ---------------------------------------------- diff --git a/docs/source/tutorials/coupled_fit_2d_tuto.rst b/docs/source/tutorials/coupled_fit_2d_tuto.rst index dd8b95f..e9bb466 100644 --- a/docs/source/tutorials/coupled_fit_2d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_2d_tuto.rst @@ -2,7 +2,7 @@ Tutorial: Contact Angle Analysis (Coupled Fit, 2D) ================================================== This tutorial covers :class:`CoupledFit2DAnalyzer`, the -joint-fit alternative to the composable +coupled-fit alternative to the composable :class:`TrajectoryAnalyzer` pipeline. The analyzer solves interface extraction, wall detection, and surface fit together by fitting a seven-parameter hyperbolic-tangent density model directly to a 2D @@ -33,7 +33,7 @@ The pipeline does three things per batch: Apply geometry-aware volume normalisation (``dV = 2π xi dxi dzi`` for spherical, ``dV = box_y · dxi dzi`` for cylinder). -2. **Joint NLLS fit.** Fit a seven-parameter hyperbolic-tangent +2. **NLLS fit.** Fit a seven-parameter hyperbolic-tangent density model .. math:: @@ -79,17 +79,17 @@ Example trajectory:: # --- Step 2: Identify water-oxygen atoms --- wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) - oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) + oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print("Number of water molecules:", len(oxygen_indices)) - # --- Step 3: Define the 2D binning grid --- - binning_params = { + # --- Step 3: Define the 2D sampling grid --- + grid_params = { "xi_0": 0.0, "xi_f": 100.0, - "bin_width_x": 2.0, + "dx": 2.0, "zi_0": 0.0, "zi_f": 100.0, - "bin_width_z": 4.0, + "dz": 4.0, } # --- Step 4: Build the analyzer --- @@ -97,7 +97,7 @@ Example trajectory:: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - binning_params=binning_params, + grid_params=grid_params, # 10-frame pooled batches temporal_aggregator=TemporalAggregator(batch_size=10), ) @@ -144,17 +144,17 @@ Example printed output:: ------- - **Grid bounds and cell width**: pick ``xi_f`` and ``zi_f`` so the - droplet sits well inside the grid; pick ``bin_width_x`` and - ``bin_width_z`` so each cell receives many atoms when pooling. As + droplet sits well inside the grid; pick ``dx`` and + ``dz`` so each cell receives many atoms when pooling. As a rule of thumb, aim for at least 20 atoms per occupied cell after pooling across the batch. The range bounds are honoured exactly; the effective cell width is rounded so an integer number of cells fits, and may differ from the requested value by a few percent. -- **No ``binning_params``?** Leaving it ``None`` uses an atom-derived - default: lateral half-box for ``xi``/``zi``, ``bin_width = 0.5 Å`` +- **No ``grid_params``?** Leaving it ``None`` uses an atom-derived + default: lateral half-box for ``xi``/``zi``, ``dx`` / ``dz`` = 0.5 Å (half the model's default interface thickness ``t1``). A warning is emitted to flag that the user didn't tune the grid. -- **Batch size**: the joint fit benefits from statistics, so pool as +- **Batch size**: the coupled fit benefits from statistics, so pool as many frames as your time-resolution needs allow. ``batch_size=-1`` (the default) pools everything into one batch and returns a single angle. @@ -194,13 +194,13 @@ along the cylinder axis): parser=LammpsDumpParser(cylinder_fixture), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - binning_params={ + grid_params={ "xi_0": 0.0, "xi_f": 100.0, - "bin_width_x": 2.0, + "dx": 2.0, "zi_0": 0.0, "zi_f": 100.0, - "bin_width_z": 4.0, + "dz": 4.0, }, ) @@ -231,7 +231,7 @@ disappears at the cost of a small constant per-fit overhead: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, density_estimator=DensityEstimator.gaussian(density_sigma=2.5), # batch_size=1 now becomes viable — the KDE density is smooth # enough that per-frame fits don't fall into the degenerate @@ -262,7 +262,7 @@ If your simulation uses different units or a different liquid, pass parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, initial_params=[1e-3, 0.02, 25.0, 8.0, 5.0, 1.0, 1.0], ) @@ -283,11 +283,11 @@ Drop the ``temporal_aggregator`` argument (or set parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, ).analyze(range(0, 200)) print(results.batches[0].angle) # single representative angle -This is the natural mode for the coupled fit — the joint NLLS +This is the natural mode for the coupled fit — the NLLS benefits from as much statistics as you can throw at it. Use ``batch_size=N`` only if you actually want time resolution (e.g. to see contact-angle relaxation during a wetting event). diff --git a/docs/source/tutorials/coupled_fit_3d_tuto.rst b/docs/source/tutorials/coupled_fit_3d_tuto.rst index a99981c..198b391 100644 --- a/docs/source/tutorials/coupled_fit_3d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_3d_tuto.rst @@ -14,7 +14,7 @@ hyperbolic-tangent density model directly: with two extra horizontal-centre parameters :math:`\xi_c, \eta_c` over the 2D model. See -:doc:`../introduction/theoretical_foundations` section 7 for the full +:doc:`../introduction/theoretical_foundations` section 5 for the full model. ---- @@ -22,7 +22,7 @@ model. 1. When to pick the 3D variant? ------------------------------- -The 2D analyzer assumes axisymmetry: the joint fit collapses the +The 2D analyzer assumes axisymmetry: the coupled fit collapses the droplet onto a 2D ``(xi, zi)`` profile via the radial coordinate :math:`\xi = \sqrt{x^2 + y^2}`. That assumption is excellent for clean spherical droplets but breaks if the droplet is asymmetric — @@ -65,27 +65,27 @@ wasting work. filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" oxygen_indices = LammpsDumpWaterFinder( filename, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(frame_index=0) + ).get_water_oxygen_indices(frame_index=0) # 3D grid spec. xi/yi are in the droplet-centred frame; zi is in the # lab frame so the wall position retains physical meaning. - binning_params = { + grid_params = { "xi_0": -40.0, "xi_f": 40.0, - "bin_width_x": 3.2, + "dx": 3.2, "yi_0": -40.0, "yi_f": 40.0, - "bin_width_y": 3.2, + "dy": 3.2, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 4.0, + "dz": 4.0, } analyzer = CoupledFit3DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, # 3D density grids need more frames per batch than 2D ones to # reach the same per-cell noise; default is fully pooled. temporal_aggregator=TemporalAggregator(batch_size=-1), @@ -169,24 +169,24 @@ the same angle within a few degrees. It's a useful sanity check: ) # Same trajectory, same frames; pick comparable grids. - binning_2d = { + grid_2d = { "xi_0": 0.0, "xi_f": 40.0, - "bin_width_x": 1.0, + "dx": 1.0, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.0, + "dz": 1.0, } - binning_3d = { + grid_3d = { "xi_0": -40.0, "xi_f": 40.0, - "bin_width_x": 3.2, + "dx": 3.2, "yi_0": -40.0, "yi_f": 40.0, - "bin_width_y": 3.2, + "dy": 3.2, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 4.0, + "dz": 4.0, } a2d = ( @@ -194,7 +194,7 @@ the same angle within a few degrees. It's a useful sanity check: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_2d, + grid_params=grid_2d, ) .analyze([1]) .batches[0] @@ -204,7 +204,7 @@ the same angle within a few degrees. It's a useful sanity check: parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_3d, + grid_params=grid_3d, ) .analyze([1]) .batches[0] diff --git a/docs/source/tutorials/grid_method_tuto.rst b/docs/source/tutorials/grid_method_tuto.rst index 2a4e0db..92c768a 100644 --- a/docs/source/tutorials/grid_method_tuto.rst +++ b/docs/source/tutorials/grid_method_tuto.rst @@ -51,7 +51,9 @@ sampled on a fixed grid rather than along rays: .. code-block:: python from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -62,7 +64,7 @@ sampled on a fixed grid rather than along rays: filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" oxygen_indices = LammpsDumpWaterFinder( filename, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(frame_index=0) + ).get_water_oxygen_indices(frame_index=0) # 2D grid for each slice plane: ``s`` (in-plane radial) spans # ``[xi_0, xi_f]`` symmetrically around the slice centre to cover @@ -70,10 +72,10 @@ sampled on a fixed grid rather than along rays: grid_params = { "xi_0": -40.0, "xi_f": 40.0, - "bin_width_x": 3.0, # 3 Å cells in s + "dx": 3.0, # 3 Å cells in s "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.6, # 1.6 Å cells in z + "dz": 1.6, # 1.6 Å cells in z } analyzer = TrajectoryAnalyzer( @@ -103,7 +105,7 @@ sampled on a fixed grid rather than along rays: -------------------------------------------------------------------- Same per-slice iteration, but the density estimator is a top-hat -histogram of atoms within the slab ``|perp| ≤ bin_width_x / 2`` of +histogram of atoms within the slab ``|perp| ≤ dx / 2`` of the slice plane. Numerically cheaper than the KDE; intrinsically noisier because only atoms in the slab contribute (not all atoms along the slice direction the way they do for ``rays`` (binning)). @@ -120,10 +122,10 @@ Use coarser cells (thicker slab) than for ``grid`` (Gaussian): grid_params = { "xi_0": -40.0, "xi_f": 40.0, - "bin_width_x": 8.0, # thick slab + "dx": 8.0, # thick slab "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 3.0, + "dz": 3.0, } extractor = InterfaceExtractor( sampling=SpaceSampling.grid( @@ -134,7 +136,7 @@ Use coarser cells (thicker slab) than for ``grid`` (Gaussian): ) The slab thickness perpendicular to each slice plane is -``bin_width_x``, so refining the in-plane grid also thins the slab. +``dx``, so refining the in-plane grid also thins the slab. For systems with limited atom statistics per slab, the answer is either coarser cells or fewer slices, not a finer grid. @@ -153,13 +155,13 @@ takes no ``delta_azimuthal`` / ``delta_cylinder``: grid_params_3d = { "xi_0": -30.0, "xi_f": 30.0, - "bin_width_x": 2.5, + "dx": 2.5, "yi_0": -30.0, "yi_f": 30.0, - "bin_width_y": 2.5, + "dy": 2.5, "zi_0": 0.0, "zi_f": 35.0, - "bin_width_z": 2.0, + "dz": 2.0, } analyzer = TrajectoryAnalyzer( @@ -208,8 +210,8 @@ Three notes on the 3D case: droplet fits comfortably inside the grid (signed ``xi_0`` for the slicing case so the slice spans the full diameter). The iso-contour tracer can't extrapolate. -- **Cell sizes**: ``bin_width_x`` controls in-plane horizontal - resolution; ``bin_width_z`` controls vertical. The range bounds +- **Cell sizes**: ``dx`` controls in-plane horizontal + resolution; ``dz`` controls vertical. The range bounds are honoured exactly and the cell width is rounded to fit, so the effective cell size may differ slightly from the value you pass. - **Comparison plot**: run the same trajectory through both @@ -219,5 +221,5 @@ Three notes on the 3D case: is misconfigured (most often the grid bounds are too tight or ``surface_filter_offset`` is too small). - **grid + binning slab thickness**: the slab perpendicular to each - slice equals ``bin_width_x``. If you see a noisy iso-contour, - thicken it (larger ``bin_width_x``) before reaching for ``grid`` (Gaussian). + slice equals ``dx``. If you see a noisy iso-contour, + thicken it (larger ``dx``) before reaching for ``grid`` (Gaussian). diff --git a/docs/source/tutorials/index.rst b/docs/source/tutorials/index.rst index 3074ea7..58ada13 100644 --- a/docs/source/tutorials/index.rst +++ b/docs/source/tutorials/index.rst @@ -22,7 +22,7 @@ math. .. toctree:: :maxdepth: 1 - :caption: Joint-fit analyzers: + :caption: Coupled-fit analyzers: coupled_fit_2d_tuto coupled_fit_3d_tuto diff --git a/docs/source/tutorials/parser_tutorial.rst b/docs/source/tutorials/parser_tutorial.rst index 4a06b33..e593b90 100644 --- a/docs/source/tutorials/parser_tutorial.rst +++ b/docs/source/tutorials/parser_tutorial.rst @@ -44,7 +44,7 @@ The ``.parse()`` method always returns a NumPy array of shape ``(N, 3)`` contain wat_find = LammpsDumpWaterFinder(filename, 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) + oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print("Number of water molecules:", len(oxygen_indices)) # --- Step 4: Initialize the parser --- diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index dfb608c..2ee6433 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -56,7 +56,9 @@ Example trajectory:: .. code-block:: python from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -73,7 +75,7 @@ Example trajectory:: oxygen_type=1, hydrogen_type=2, ) - oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) + oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print("Number of water molecules:", len(oxygen_indices)) # --- Step 3: Build the trajectory analyzer --- @@ -275,7 +277,7 @@ per fit, less per-angle noise but no within-batch time resolution. ``batch_size=1`` is the correct choice. For physical context on the trade-off see -:doc:`../introduction/theoretical_foundations` section 9. +:doc:`../introduction/theoretical_foundations` section 7. 6.4 Grid alternative ^^^^^^^^^^^^^^^^^^^^ diff --git a/docs/source/tutorials/visualization_evolution_density.rst b/docs/source/tutorials/visualization_evolution_density.rst index 64ae542..27aacb7 100644 --- a/docs/source/tutorials/visualization_evolution_density.rst +++ b/docs/source/tutorials/visualization_evolution_density.rst @@ -26,7 +26,9 @@ mean with its own cumulative ``±σ`` band). .. code-block:: python from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -38,7 +40,7 @@ mean with its own cumulative ``±σ`` band). filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" oxygen_indices = LammpsDumpWaterFinder( filename, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(frame_index=0) + ).get_water_oxygen_indices(frame_index=0) analyzer = TrajectoryAnalyzer( parser=LammpsDumpParser(filename), @@ -103,19 +105,19 @@ density field without touching anything else here: filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" oxygen_indices = LammpsDumpWaterFinder( filename, oxygen_type=1, hydrogen_type=2 - ).get_water_oxygen_ids(frame_index=0) + ).get_water_oxygen_indices(frame_index=0) coupled_fit = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params={ + grid_params={ "xi_0": 0.0, "xi_f": 70.0, - "bin_width_x": 2.0, + "dx": 2.0, "zi_0": 0.0, "zi_f": 70.0, - "bin_width_z": 2.0, + "dz": 2.0, }, temporal_aggregator=TemporalAggregator(batch_size=10), ) diff --git a/docs/source/tutorials/visualization_slicing_droplet.rst b/docs/source/tutorials/visualization_slicing_droplet.rst index 8330285..4012a78 100644 --- a/docs/source/tutorials/visualization_slicing_droplet.rst +++ b/docs/source/tutorials/visualization_slicing_droplet.rst @@ -35,7 +35,9 @@ The workflow: import numpy as np from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -66,7 +68,7 @@ The workflow: .. code-block:: python wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) - oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) + oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) print("Number of water molecules detected:", len(oxygen_indices)) ---- diff --git a/docs/source/tutorials/whole_fit_tuto.rst b/docs/source/tutorials/whole_fit_tuto.rst index 8aa5cfe..aea605a 100644 --- a/docs/source/tutorials/whole_fit_tuto.rst +++ b/docs/source/tutorials/whole_fit_tuto.rst @@ -65,7 +65,9 @@ the wall position. .. code-block:: python from wetting_angle_kit.analysis import ( + DensityEstimator, InterfaceExtractor, + SpaceSampling, SurfaceFitter, TrajectoryAnalyzer, WallDetector, @@ -80,7 +82,7 @@ the wall position. # --- Step 1: Identify water-oxygen and wall-atom indices --- wat_find = LammpsDumpWaterFinder(filename, oxygen_type=1, hydrogen_type=2) - oxygen_indices = wat_find.get_water_oxygen_ids(frame_index=0) + oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) # Wall parser: ``liquid_particle_types`` lists what to EXCLUDE # (i.e. the liquid), leaving the wall atoms. diff --git a/src/wetting_angle_kit/analysis/__init__.py b/src/wetting_angle_kit/analysis/__init__.py index de3419f..d58c48c 100644 --- a/src/wetting_angle_kit/analysis/__init__.py +++ b/src/wetting_angle_kit/analysis/__init__.py @@ -6,8 +6,8 @@ Top-level analyzers (call ``analyze()`` to run a study):: TrajectoryAnalyzer # decomposed pipeline: extractor → wall → fitter - CoupledFit2DAnalyzer # joint fit on a 2D density grid - CoupledFit3DAnalyzer # joint fit on a 3D density grid (spherical only) + CoupledFit2DAnalyzer # coupled fit on a 2D density grid + CoupledFit3DAnalyzer # coupled fit on a 3D density grid (spherical only) Strategy components (compose into :class:`TrajectoryAnalyzer`):: diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index 2296fb6..eb40e8d 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -123,8 +123,8 @@ def gather_batch_coords( If True, shift each frame's atoms onto its own droplet centre in the lateral ``(x, y)`` plane (``z`` is left in the lab frame). Used by the coupled-fit analyzers, which bin a - droplet-centred density; the ray/grid extractors leave it - False and read the per-frame centre from ``avg_center``. + droplet-centred density; SpaceSampling.rays / SpaceSampling.grid + leave it False and read the per-frame centre from ``avg_center``. progress_callback : callable, optional Called once with ``1`` after each frame is parsed. Used by the inline ``analyze`` path to drive a per-frame tqdm meter @@ -139,7 +139,7 @@ def gather_batch_coords( (droplet-centred in ``(x, y)`` when ``center_on_com=True``). avg_center : ndarray, shape (3,) Mean of the per-frame liquid centres; used as the ray-fan - origin by extractors. Near-zero in ``(x, y)`` when + origin by SpaceSampling.rays. Near-zero in ``(x, y)`` when ``center_on_com=True``. """ liquid_chunks: list[np.ndarray] = [] diff --git a/src/wetting_angle_kit/analysis/_density.py b/src/wetting_angle_kit/analysis/_density.py index fd02f35..4b23353 100644 --- a/src/wetting_angle_kit/analysis/_density.py +++ b/src/wetting_angle_kit/analysis/_density.py @@ -1,7 +1,8 @@ """Shared density-on-rays kernel and batched tanh interface fit. -Used by the rays/grid extractors that fit a hyperbolic-tangent profile -to the density along a ray or sample it on a grid of cell centres. +Used by :meth:`SpaceSampling.rays` and :meth:`SpaceSampling.grid` to +fit a hyperbolic-tangent profile to the density along a ray or sample +it on a grid of cell centres. :class:`GaussianDensityField` wraps a ``cKDTree`` over the atom cloud plus the kernel-width parameters. :class:`HistogramDensityField` is @@ -10,7 +11,7 @@ in :mod:`wetting_angle_kit.analysis.interface` and the :class:`DensityEstimator` strategy can take either one. :func:`fit_tanh_profiles_batched` solves the per-ray tanh fit for an -entire slice in one batched Gauss–Newton call. +entire slice in one batched Levenberg–Marquardt call. """ from typing import Protocol @@ -20,7 +21,7 @@ class DensityFieldProtocol(Protocol): - """Density field used by the ray-fan extractors. + """Density field used by :meth:`SpaceSampling.rays`. Any object exposing :meth:`evaluate` mapping ``(M, 3)`` sample positions to an ``(M,)`` density array satisfies the protocol; @@ -116,9 +117,9 @@ def evaluate(self, positions: np.ndarray) -> np.ndarray: class HistogramDensityField: """Top-hat (histogram-style) density evaluator over a fixed atom cloud. - The natural counterpart of :class:`GaussianDensityField` for the - ``rays`` extractor with a binning density. Conceptually a 1D histogram of atoms - projected onto each ray, implemented as a 3D top-hat kernel: + The natural counterpart of :class:`GaussianDensityField` for + :meth:`SpaceSampling.rays` with a binning density. Conceptually a + 1D histogram of atoms projected onto each ray, implemented as a 3D top-hat kernel: each sample position counts atoms within a sphere of radius ``bin_width / 2`` and divides by the sphere's volume. This shares the ``cKDTree.sparse_distance_matrix`` machinery used by the diff --git a/src/wetting_angle_kit/analysis/_grid_utils.py b/src/wetting_angle_kit/analysis/_grid_utils.py index d0be58c..483865c 100644 --- a/src/wetting_angle_kit/analysis/_grid_utils.py +++ b/src/wetting_angle_kit/analysis/_grid_utils.py @@ -1,6 +1,6 @@ """Small shared grid helpers used across the analysis subpackages. -Kept dependency-free (numpy only) so both the grid interface extractor +Kept dependency-free (numpy only) so both the grid space sampling and the coupled-fit analyzers can import it without a cross-subsystem dependency. """ @@ -8,13 +8,13 @@ import numpy as np -def edges_from_bin_width(lo: float, hi: float, bin_width: float) -> np.ndarray: - """Bin edges spanning ``[lo, hi]`` with cells of approximately ``bin_width``. +def edges_from_cell_width(lo: float, hi: float, cell_width: float) -> np.ndarray: + """Bin edges spanning ``[lo, hi]`` with cells of approximately ``cell_width``. The number of cells is rounded to the nearest integer; the range bounds are honoured exactly, so the effective cell width is ``(hi - lo) / n_cells`` which may differ slightly from - ``bin_width``. Always returns at least one cell. + ``cell_width``. Always returns at least one cell. """ - n = max(int(round((float(hi) - float(lo)) / float(bin_width))), 1) + n = max(int(round((float(hi) - float(lo)) / float(cell_width))), 1) return np.linspace(float(lo), float(hi), n + 1) diff --git a/src/wetting_angle_kit/analysis/coupled_fit/__init__.py b/src/wetting_angle_kit/analysis/coupled_fit/__init__.py index b981428..2f83d80 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/__init__.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/__init__.py @@ -1,7 +1,7 @@ -"""Coupled-fit joint contact-angle analyzers. +"""Coupled-fit contact-angle analyzers. Two top-level analyzers that solve interface extraction, wall -detection, and surface fit jointly via a hyperbolic-tangent density +detection, and surface fitting together via a hyperbolic-tangent density model: - :class:`CoupledFit2DAnalyzer` — seven-parameter fit on a 2D diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_base.py b/src/wetting_angle_kit/analysis/coupled_fit/_base.py index 09b86f4..917116e 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/_base.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/_base.py @@ -1,4 +1,4 @@ -"""Shared scaffolding for the coupled-fit joint analyzers. +"""Shared scaffolding for the coupled-fit analyzers. :class:`_CoupledFitAnalyzer` factors out everything the 2D and 3D coupled-fit analyzers share — the constructor, the progress-bar label, @@ -57,8 +57,8 @@ class _CoupledFitAnalyzer(_BatchedTrajectoryAnalyzer): :class:`CoupledFit3DAnalyzer`) provide: - ``_RESULTS_CLS`` — the results dataclass to wrap the batches in; - - ``_default_binning_params(parser)`` — the atom-derived default - grid used when ``binning_params`` is ``None``; + - ``_default_grid_params(parser)`` — the atom-derived default + grid used when ``grid_params`` is ``None``; - the worker triple (``_init_args`` / ``_init_worker`` / ``_process_batch_worker``), which differs by grid dimensionality; - optionally ``_check_geometry`` (the 3D fit rejects cylinders) and @@ -74,7 +74,7 @@ def __init__( atom_indices: np.ndarray | None = None, droplet_geometry: DropletGeometry | str = "spherical", *, - binning_params: dict[str, Any] | None = None, + grid_params: dict[str, Any] | None = None, density_estimator: DensityEstimator | None = None, initial_params: list[float] | None = None, temporal_aggregator: TemporalAggregator | None = None, @@ -91,9 +91,9 @@ def __init__( # Reject unsupported geometries before the (warning-emitting) # default-grid derivation runs. self._check_geometry() - if binning_params is None: - binning_params = self._default_binning_params(parser) - self.binning_params = binning_params + if grid_params is None: + grid_params = self._default_grid_params(parser) + self.grid_params = grid_params self.density_estimator = density_estimator or DensityEstimator.binning() self.initial_params = initial_params self._post_init(parser) @@ -106,8 +106,8 @@ def _check_geometry(self) -> None: """Reject unsupported droplet geometries. Default: accept all.""" @abstractmethod - def _default_binning_params(self, parser: Any) -> dict[str, Any]: - """Atom-derived default grid spec when ``binning_params`` is None.""" + def _default_grid_params(self, parser: Any) -> dict[str, Any]: + """Atom-derived default grid spec when ``grid_params`` is None.""" def _post_init(self, parser: Any) -> None: """Construction hook run after the common fields are set.""" @@ -127,7 +127,7 @@ def _build_results(self, batches: list[Any]) -> Any: batches=batches, method_metadata={ "droplet_geometry": self.droplet_geometry.name, - "binning_params": self.binning_params, + "grid_params": self.grid_params, "initial_params": self.initial_params, "batch_size": self.temporal_aggregator.batch_size, }, diff --git a/src/wetting_angle_kit/analysis/coupled_fit/_models.py b/src/wetting_angle_kit/analysis/coupled_fit/_models.py index 8345c1f..01c3ea6 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/_models.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/_models.py @@ -1,6 +1,6 @@ """Hyperbolic-tangent models + grid helpers for the coupled-fit analyzers. -Both the 2D (seven-parameter) and 3D (nine-parameter) joint density +Both the 2D (seven-parameter) and 3D (nine-parameter) hyperbolic-tangent density models live here and share a common :class:`_HyperbolicTangentModel` base for the bounded NLLS fit, the at-bound warning, and the cap-angle formula. Public access goes through :class:`CoupledFit2DAnalyzer` and @@ -138,7 +138,7 @@ def compute_contact_angle(self) -> float: class _HyperbolicTangentModel2D(_HyperbolicTangentModel): - """Coupled 2D-binning joint contact-angle model. + """Coupled 2D-binning contact-angle model. Density field modelled as a product of two sigmoidal (tanh) terms, one radial and one vertical: @@ -240,24 +240,24 @@ def _fitting_function( # ---------------------------------------------------------------------- -# Heuristic binning grids. +# Heuristic default grids. # ---------------------------------------------------------------------- #: Default cell width for the 2D coupled fit (Å). Matches ``t1 / 2`` from #: :class:`_HyperbolicTangentModel2D.DEFAULT_INITIAL_PARAMS` so the #: per-bin density resolves the tanh interface profile. -_DEFAULT_BIN_WIDTH_2D = 0.5 +_DEFAULT_CELL_WIDTH_2D = 0.5 #: Default cell width for the 3D coupled fit (Å). Coarser than the 2D #: default to keep the total cell count tractable for the 9-parameter #: NLLS fit (3D grids at 0.5 Å cells would give ~1.7M cells for a #: typical box). -_DEFAULT_BIN_WIDTH_3D = 1.0 +_DEFAULT_CELL_WIDTH_3D = 1.0 -def _default_binning_params(parser: Any) -> dict[str, Any]: - """Atom-derived default 2D binning grid. +def _default_grid_params(parser: Any) -> dict[str, Any]: + """Atom-derived default 2D sampling grid. Range: in-plane radial (``xi``) and vertical (``zi``) both span ``[0, max(box_x, box_y) / 2]``. The radial half-box is the largest @@ -266,7 +266,7 @@ def _default_binning_params(parser: Any) -> dict[str, Any]: with its periodic image). Vertical half-box is the same value as a safe upper bound on a typical sessile droplet's apex height. - Cell width: ``_DEFAULT_BIN_WIDTH_2D = 0.5 Å`` — half the model's + Cell width: ``_DEFAULT_CELL_WIDTH_2D = 0.5 Å`` — half the model's default interface thickness ``t1 = 1 Å``, so the tanh profile is resolved by two cells. """ @@ -278,11 +278,11 @@ def _default_binning_params(parser: Any) -> dict[str, Any]: / 2.0 ) warnings.warn( - "binning_params was not supplied; using a default " - f"(xi/zi in [0, {half_lateral:.1f}], bin_width = {_DEFAULT_BIN_WIDTH_2D} Å) " + "grid_params was not supplied; using a default " + f"(xi/zi in [0, {half_lateral:.1f}], cell width = {_DEFAULT_CELL_WIDTH_2D} Å) " "derived from half the largest in-plane box dimension. " "For accurate density fields on a specific system, supply " - "binning_params matching the droplet size and the desired " + "grid_params matching the droplet size and the desired " "interface resolution.", UserWarning, stacklevel=3, @@ -290,20 +290,20 @@ def _default_binning_params(parser: Any) -> dict[str, Any]: return { "xi_0": 0.0, "xi_f": half_lateral, - "bin_width_x": _DEFAULT_BIN_WIDTH_2D, + "dx": _DEFAULT_CELL_WIDTH_2D, "zi_0": 0.0, "zi_f": half_lateral, - "bin_width_z": _DEFAULT_BIN_WIDTH_2D, + "dz": _DEFAULT_CELL_WIDTH_2D, } -def _default_binning_params_3d(parser: Any) -> dict[str, Any]: - """Atom-derived default 3D binning grid. +def _default_grid_params_3d(parser: Any) -> dict[str, Any]: + """Atom-derived default 3D sampling grid. - Same lateral half-box rule as :func:`_default_binning_params` but + Same lateral half-box rule as :func:`_default_grid_params` but ``xi`` and ``yi`` are signed (the droplet-centred frame spans both halves of the diameter), and the default cell width is - coarser (``_DEFAULT_BIN_WIDTH_3D = 1 Å``) so the 9-parameter NLLS + coarser (``_DEFAULT_CELL_WIDTH_3D = 1 Å``) so the 9-parameter NLLS fit stays tractable. """ half_lateral = ( @@ -314,11 +314,11 @@ def _default_binning_params_3d(parser: Any) -> dict[str, Any]: / 2.0 ) warnings.warn( - "binning_params was not supplied; using a default " + "grid_params was not supplied; using a default " f"(xi/yi in [-{half_lateral:.1f}, {half_lateral:.1f}], zi in " - f"[0, {half_lateral:.1f}], bin_width = {_DEFAULT_BIN_WIDTH_3D} Å). " + f"[0, {half_lateral:.1f}], cell width = {_DEFAULT_CELL_WIDTH_3D} Å). " "For accurate density fields on a specific system, supply " - "binning_params matching the droplet size and the desired " + "grid_params matching the droplet size and the desired " "interface resolution.", UserWarning, stacklevel=3, @@ -326,11 +326,11 @@ def _default_binning_params_3d(parser: Any) -> dict[str, Any]: return { "xi_0": -half_lateral, "xi_f": half_lateral, - "bin_width_x": _DEFAULT_BIN_WIDTH_3D, + "dx": _DEFAULT_CELL_WIDTH_3D, "yi_0": -half_lateral, "yi_f": half_lateral, - "bin_width_y": _DEFAULT_BIN_WIDTH_3D, + "dy": _DEFAULT_CELL_WIDTH_3D, "zi_0": 0.0, "zi_f": half_lateral, - "bin_width_z": _DEFAULT_BIN_WIDTH_3D, + "dz": _DEFAULT_CELL_WIDTH_3D, } diff --git a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py index 054e264..da2cac7 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_2d.py @@ -1,11 +1,10 @@ -"""Coupled 2D-binning joint contact-angle analyzer. +"""Coupled 2D contact-angle analyzer. :class:`CoupledFit2DAnalyzer` is the modern incarnation of the package's original binning method. Unlike :class:`TrajectoryAnalyzer` it does not separate interface extraction, wall detection, and surface fit — a seven-parameter hyperbolic-tangent model (rho1, rho2, R_eq, -zi_c, zi_0, t1, t2) solves all three jointly on a binned 2D density -grid. +zi_c, zi_0, t1, t2) solves all three simultaneously on a 2D density grid. Use it when: @@ -31,13 +30,13 @@ build_parser, gather_batch_coords, ) -from wetting_angle_kit.analysis._grid_utils import edges_from_bin_width +from wetting_angle_kit.analysis._grid_utils import edges_from_cell_width from wetting_angle_kit.analysis.coupled_fit._base import ( _CoupledFitAnalyzer, fit_model_params, ) from wetting_angle_kit.analysis.coupled_fit._models import ( - _default_binning_params as _default_binning_params_2d, + _default_grid_params as _default_grid_params_2d, ) from wetting_angle_kit.analysis.coupled_fit._models import ( _HyperbolicTangentModel2D, @@ -55,7 +54,7 @@ class CoupledFit2DAnalyzer(_CoupledFitAnalyzer): - """Joint contact-angle fit on a 2D binned density grid. + """Coupled contact-angle fit on a 2D density grid. Parameters ---------- @@ -71,22 +70,23 @@ class CoupledFit2DAnalyzer(_CoupledFitAnalyzer): droplets use the in-plane radial coordinate ``xi = sqrt(x^2 + y^2)``; cylindrical droplets use the coordinate perpendicular to the cylinder axis. - binning_params : dict, optional - 2D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"bin_width_x"``, - ``"zi_0"``, ``"zi_f"``, ``"bin_width_z"``. The range bounds + grid_params : dict, optional + 2D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"dx"``, + ``"zi_0"``, ``"zi_f"``, ``"dz"``. The range bounds are honoured exactly; the effective cell width is rounded to - fit and may differ slightly from the requested ``bin_width_*``. + fit and may differ slightly from the requested ``dx`` / ``dz``. If ``None``, an atom-derived default is used: ``xi/zi`` span - half the largest in-plane box dimension, with ``bin_width = - 0.5 Å`` (half the model's default interface thickness ``t1``). + half the largest in-plane box dimension, with ``dx`` / ``dz`` = + 0.5 Å (half the model's default interface thickness ``t1``). A warning is emitted when the default is used. density_estimator : DensityEstimator, optional How the per-cell density is computed from the pooled atom positions. Built via :meth:`DensityEstimator.binning` (the default, top-hat histogram with geometry-aware ``dV`` normalisation) or :meth:`DensityEstimator.gaussian` - (3D Gaussian KDE evaluated at the cell centres; same kernel - the ``rays`` / ``grid`` with the Gaussian extractors use). + (3D Gaussian KDE evaluated at the cell centres; the same kernel + :meth:`SpaceSampling.rays` / :meth:`SpaceSampling.grid` + with :meth:`DensityEstimator.gaussian` use). Switching to the Gaussian variant smooths out per-cell Poisson noise — useful on per-frame / small-batch analyses where the histogram density is degenerate. @@ -113,8 +113,8 @@ class CoupledFit2DAnalyzer(_CoupledFitAnalyzer): #: Results dataclass produced by the shared ``_build_results``. _RESULTS_CLS: ClassVar[type] = CoupledFit2DResults - def _default_binning_params(self, parser: Any) -> dict[str, Any]: - return _default_binning_params_2d(parser) + def _default_grid_params(self, parser: Any) -> dict[str, Any]: + return _default_grid_params_2d(parser) def _post_init(self, parser: Any) -> None: # Cylinder dV normalisation needs the box length along the @@ -137,7 +137,7 @@ def _init_args(self) -> tuple: self.parser.filepath, self.atom_indices, self.droplet_geometry, - self.binning_params, + self.grid_params, self.density_estimator, self.initial_params, self.precentered, @@ -149,7 +149,7 @@ def _init_worker( filename: str, atom_indices: np.ndarray, droplet_geometry: DropletGeometry, - binning_params: dict[str, Any], + grid_params: dict[str, Any], density_estimator: DensityEstimator, initial_params: list[float] | None, precentered: bool, @@ -161,7 +161,7 @@ def _init_worker( parser=build_parser(filename), atom_indices=atom_indices, droplet_geometry=droplet_geometry, - binning_params=binning_params, + grid_params=grid_params, density_estimator=density_estimator, initial_params=initial_params, precentered=precentered, @@ -176,7 +176,7 @@ def _process_batch_worker( parser = state["parser"] atom_indices: np.ndarray = state["atom_indices"] droplet_geometry: DropletGeometry = state["droplet_geometry"] - binning_params: dict[str, Any] = state["binning_params"] + grid_params: dict[str, Any] = state["grid_params"] density_estimator: DensityEstimator = state["density_estimator"] initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] @@ -202,15 +202,15 @@ def _process_batch_worker( ) n_frames = len(frame_indices) - xi_edges = edges_from_bin_width( - binning_params["xi_0"], - binning_params["xi_f"], - binning_params["bin_width_x"], + xi_edges = edges_from_cell_width( + grid_params["xi_0"], + grid_params["xi_f"], + grid_params["dx"], ) - zi_edges = edges_from_bin_width( - binning_params["zi_0"], - binning_params["zi_f"], - binning_params["bin_width_z"], + zi_edges = edges_from_cell_width( + grid_params["zi_0"], + grid_params["zi_f"], + grid_params["dz"], ) xi_cc = 0.5 * (xi_edges[:-1] + xi_edges[1:]) zi_cc = 0.5 * (zi_edges[:-1] + zi_edges[1:]) @@ -223,7 +223,7 @@ def _process_batch_worker( box_dimension=box_dimension, ) - # Joint tanh fit. ``_HyperbolicTangentModel2D`` expects the + # Coupled tanh fit. ``_HyperbolicTangentModel2D`` expects the # density and grid axes flattened in Fortran order so the # ``(xi, zi)`` pairs line up with their density values. model = _HyperbolicTangentModel2D(initial_params=initial_params) diff --git a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py index a6fccef..e113a34 100644 --- a/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py +++ b/src/wetting_angle_kit/analysis/coupled_fit/analyzer_3d.py @@ -1,7 +1,7 @@ -"""Coupled 3D-binning joint contact-angle analyzer. +"""Coupled 3D contact-angle analyzer. -:class:`CoupledFit3DAnalyzer` is the 3D extension of the joint -binning fit (:class:`CoupledFit2DAnalyzer`). Instead of projecting +:class:`CoupledFit3DAnalyzer` is the 3D extension of the coupled +fit (:class:`CoupledFit2DAnalyzer`). Instead of projecting atoms onto a 2D ``(xi, zi)`` plane and exploiting radial symmetry, it bins the full 3D density ``rho(xi, yi, zi)`` and fits a nine-parameter hyperbolic-tangent model (``rho1, rho2, R_eq, xi_c, yi_c, zi_c, zi_0, @@ -29,13 +29,13 @@ build_parser, gather_batch_coords, ) -from wetting_angle_kit.analysis._grid_utils import edges_from_bin_width +from wetting_angle_kit.analysis._grid_utils import edges_from_cell_width from wetting_angle_kit.analysis.coupled_fit._base import ( _CoupledFitAnalyzer, fit_model_params, ) from wetting_angle_kit.analysis.coupled_fit._models import ( - _default_binning_params_3d, + _default_grid_params_3d, _HyperbolicTangentModel3D, ) from wetting_angle_kit.analysis.density_estimator import ( @@ -51,7 +51,7 @@ class CoupledFit3DAnalyzer(_CoupledFitAnalyzer): - """Joint contact-angle fit on a 3D binned density grid. + """Coupled contact-angle fit on a 3D binned density grid. Parameters ---------- @@ -66,14 +66,14 @@ class CoupledFit3DAnalyzer(_CoupledFitAnalyzer): construction because their translational symmetry already collapses the 3D problem onto the 2D one solved by :class:`CoupledFit2DAnalyzer`. - binning_params : dict, optional - 3D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"bin_width_x"``, - ``"yi_0"``, ``"yi_f"``, ``"bin_width_y"``, ``"zi_0"``, ``"zi_f"``, - ``"bin_width_z"``. The range bounds are honoured exactly; the + grid_params : dict, optional + 3D grid spec with keys ``"xi_0"``, ``"xi_f"``, ``"dx"``, + ``"yi_0"``, ``"yi_f"``, ``"dy"``, ``"zi_0"``, ``"zi_f"``, + ``"dz"``. The range bounds are honoured exactly; the effective cell width is rounded to fit. If ``None``, an atom-derived default is used (lateral half-box for all axes, - ``bin_width = 1 Å`` to keep the 9-parameter NLLS tractable). - ``xi``/``yi`` are in the droplet-centred frame + ``dx`` / ``dy`` / ``dz`` = 1 Å to keep the 9-parameter NLLS + tractable). ``xi``/``yi`` are in the droplet-centred frame (atoms are recentred on the per-frame COM before binning); ``zi`` is in the lab frame so the wall position retains physical meaning. If ``None``, a heuristic default is used. @@ -105,8 +105,8 @@ def _check_geometry(self) -> None: "symmetry along the cylinder axis." ) - def _default_binning_params(self, parser: Any) -> dict[str, Any]: - return _default_binning_params_3d(parser) + def _default_grid_params(self, parser: Any) -> dict[str, Any]: + return _default_grid_params_3d(parser) # ------------------------------------------------------------------ # _BatchedTrajectoryAnalyzer extension points. @@ -117,7 +117,7 @@ def _init_args(self) -> tuple: self.parser.filepath, self.atom_indices, self.droplet_geometry, - self.binning_params, + self.grid_params, self.density_estimator, self.initial_params, self.precentered, @@ -128,7 +128,7 @@ def _init_worker( filename: str, atom_indices: np.ndarray, droplet_geometry: DropletGeometry, - binning_params: dict[str, Any], + grid_params: dict[str, Any], density_estimator: DensityEstimator, initial_params: list[float] | None, precentered: bool, @@ -139,7 +139,7 @@ def _init_worker( parser=build_parser(filename), atom_indices=atom_indices, droplet_geometry=droplet_geometry, - binning_params=binning_params, + grid_params=grid_params, density_estimator=density_estimator, initial_params=initial_params, precentered=precentered, @@ -153,7 +153,7 @@ def _process_batch_worker( parser = state["parser"] atom_indices: np.ndarray = state["atom_indices"] droplet_geometry: DropletGeometry = state["droplet_geometry"] - binning_params: dict[str, Any] = state["binning_params"] + grid_params: dict[str, Any] = state["grid_params"] density_estimator: DensityEstimator = state["density_estimator"] initial_params: list[float] | None = state["initial_params"] precentered: bool = state["precentered"] @@ -175,20 +175,20 @@ def _process_batch_worker( ) n_frames = len(frame_indices) - xi_edges = edges_from_bin_width( - binning_params["xi_0"], - binning_params["xi_f"], - binning_params["bin_width_x"], + xi_edges = edges_from_cell_width( + grid_params["xi_0"], + grid_params["xi_f"], + grid_params["dx"], ) - yi_edges = edges_from_bin_width( - binning_params["yi_0"], - binning_params["yi_f"], - binning_params["bin_width_y"], + yi_edges = edges_from_cell_width( + grid_params["yi_0"], + grid_params["yi_f"], + grid_params["dy"], ) - zi_edges = edges_from_bin_width( - binning_params["zi_0"], - binning_params["zi_f"], - binning_params["bin_width_z"], + zi_edges = edges_from_cell_width( + grid_params["zi_0"], + grid_params["zi_f"], + grid_params["dz"], ) rho = density_estimator.evaluate_3d( atoms_pooled=coords, diff --git a/src/wetting_angle_kit/analysis/density_estimator.py b/src/wetting_angle_kit/analysis/density_estimator.py index 7a0856b..0446c25 100644 --- a/src/wetting_angle_kit/analysis/density_estimator.py +++ b/src/wetting_angle_kit/analysis/density_estimator.py @@ -67,7 +67,7 @@ class DensityEstimator(ABC): kind: ClassVar[str] # ------------------------------------------------------------------ - # Pointwise interface (rays extractor). + # Pointwise interface (SpaceSampling.rays). # ------------------------------------------------------------------ @abstractmethod @@ -75,8 +75,8 @@ def build_field(self, atoms: np.ndarray) -> DensityFieldProtocol: """Pointwise 3D density evaluator on the given atom set. Returns an object exposing ``evaluate(positions)`` for - arbitrary ``(N, 3)`` query points. Used by the rays - extractor to sample density along each ray. + arbitrary ``(N, 3)`` query points. Used by + :meth:`SpaceSampling.rays` to sample density along each ray. The binning estimator requires ``bin_width`` to have been set on the factory call; calling :meth:`build_field` without one @@ -84,7 +84,7 @@ def build_field(self, atoms: np.ndarray) -> DensityFieldProtocol: """ # ------------------------------------------------------------------ - # Grid interface (grid extractor). + # Grid interface (SpaceSampling.grid). # ------------------------------------------------------------------ @abstractmethod @@ -186,8 +186,8 @@ def binning(cls, *, bin_width: float | None = None) -> DensityEstimator: ---------- bin_width : float, optional Side length (Å) of the 3D top-hat kernel used by - :meth:`build_field` for pointwise evaluation (the rays - extractor). Ignored by :meth:`evaluate_on_slice`, + :meth:`build_field` for pointwise evaluation + (:meth:`SpaceSampling.rays`). Ignored by :meth:`evaluate_on_slice`, :meth:`evaluate_on_3d_grid`, :meth:`evaluate_2d`, and :meth:`evaluate_3d` — those consumers derive their cell sizes from the grid spec they're given. Required only @@ -230,7 +230,7 @@ class _BinningDensityEstimator(DensityEstimator): kind: ClassVar[str] = "binning" #: 3D top-hat kernel side length for pointwise evaluation. Required - #: only by :meth:`build_field` (the rays extractor); ``None`` is + #: only by :meth:`build_field` (:meth:`SpaceSampling.rays`); ``None`` is #: fine when this estimator is consumed by grid or coupled-fit. bin_width: float | None @@ -238,7 +238,7 @@ def build_field(self, atoms: np.ndarray) -> DensityFieldProtocol: if self.bin_width is None: raise ValueError( "DensityEstimator.binning() needs bin_width=... for " - "pointwise evaluation (the rays extractor). Either pass " + "pointwise evaluation (SpaceSampling.rays). Either pass " "bin_width when building the estimator, or use it with the " "grid / coupled-fit consumers that derive the cell size " "from their grid spec." diff --git a/src/wetting_angle_kit/analysis/interface/_grid.py b/src/wetting_angle_kit/analysis/interface/_grid.py index 87c5a5f..e852df3 100644 --- a/src/wetting_angle_kit/analysis/interface/_grid.py +++ b/src/wetting_angle_kit/analysis/interface/_grid.py @@ -1,4 +1,4 @@ -"""Grid-based extractor implementation. +"""Grid-based space sampling implementation (:meth:`SpaceSampling.grid`). This sampling evaluates a density field at fixed-cell grid points and traces the half-bulk iso-contour (slicing mode) or iso-surface (whole @@ -20,7 +20,7 @@ import numpy as np -from wetting_angle_kit.analysis._grid_utils import edges_from_bin_width +from wetting_angle_kit.analysis._grid_utils import edges_from_cell_width from wetting_angle_kit.analysis.density_estimator import ( DensityEstimator, _GaussianDensityEstimator, @@ -33,10 +33,8 @@ SurfaceKind, ) -_GRID_KEYS_2D = frozenset( - {"xi_0", "xi_f", "bin_width_x", "zi_0", "zi_f", "bin_width_z"} -) -_GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "bin_width_y"} +_GRID_KEYS_2D = frozenset({"xi_0", "xi_f", "dx", "zi_0", "zi_f", "dz"}) +_GRID_KEYS_3D = _GRID_KEYS_2D | {"yi_0", "yi_f", "dy"} #: Default cell width for the grid + binning combination (Å). The #: histogram estimator has no smoothing scale to anchor to, so a @@ -44,14 +42,14 @@ #: #: 2 Å is the compromise for *pooled-batch* analyses; for #: per-frame slicing-mode use the slab cut leaves too few atoms -#: per cell regardless of ``bin_width``, and the user should either +#: per cell regardless of ``cell_width``, and the user should either #: pool multiple frames per batch or supply a hand-tuned #: ``grid_params`` explicitly. -_DEFAULT_BIN_WIDTH_BINNING = 2.0 +_DEFAULT_CELL_WIDTH_BINNING = 2.0 #: Buffer (Å) added to the atom bounding box when auto-deriving grid #: range bounds. Matches the buffer used by ``_compute_max_dist`` for -#: the ray extractors, keeping the spatial-envelope rule consistent. +#: SpaceSampling.rays, keeping the spatial-envelope rule consistent. _DEFAULT_GRID_BUFFER = 5.0 @@ -61,7 +59,7 @@ def _default_grid_params( droplet_geometry: DropletGeometry, *, surface_kind: SurfaceKind, - bin_width: float, + cell_width: float, buffer: float = _DEFAULT_GRID_BUFFER, ) -> dict[str, Any]: """Atom-derived default ``grid_params``. @@ -70,7 +68,7 @@ def _default_grid_params( frame for spherical, lab-frame for the cylinder axis) plus a fixed buffer. Cell widths are uniform across the three axes and set by the caller — typically ``density_sigma / 2`` for the - Gaussian KDE estimator or :data:`_DEFAULT_BIN_WIDTH_BINNING` for + Gaussian KDE estimator or :data:`_DEFAULT_CELL_WIDTH_BINNING` for the histogram. """ if liquid_coordinates.size == 0: @@ -79,63 +77,63 @@ def _default_grid_params( { "xi_0": -buffer, "xi_f": buffer, - "bin_width_x": bin_width, + "dx": cell_width, "zi_0": 0.0, "zi_f": buffer, - "bin_width_z": bin_width, + "dz": cell_width, } if surface_kind == "slicing" else { "xi_0": -buffer, "xi_f": buffer, - "bin_width_x": bin_width, + "dx": cell_width, "yi_0": -buffer, "yi_f": buffer, - "bin_width_y": bin_width, + "dy": cell_width, "zi_0": 0.0, "zi_f": buffer, - "bin_width_z": bin_width, + "dz": cell_width, } ) # Atom extent. For slicing-spherical, the slice plane's ``s`` axis # is the radial direction in ``(x, y)``, so the natural envelope is - # ``max(hypot(dx, dy))``. For slicing-cylinder, the slice plane's + # ``max(hypot(rel_x, rel_y))``. For slicing-cylinder, the slice plane's # ``s`` axis is purely ``x`` (the in-plane direction perpendicular # to the cylinder axis ``y``), so only the radial x-extent matters # — using ``hypot`` would oversize the grid with the cylinder # length contribution. - dx = liquid_coordinates[:, 0] - float(center_geom[0]) - dy = liquid_coordinates[:, 1] - float(center_geom[1]) + rel_x = liquid_coordinates[:, 0] - float(center_geom[0]) + rel_y = liquid_coordinates[:, 1] - float(center_geom[1]) z_max = float(liquid_coordinates[:, 2].max()) + buffer if surface_kind == "slicing": if droplet_geometry.is_spherical: - in_plane_max = float(np.max(np.hypot(dx, dy))) + buffer + in_plane_max = float(np.max(np.hypot(rel_x, rel_y))) + buffer else: - in_plane_max = float(np.max(np.abs(dx))) + buffer + in_plane_max = float(np.max(np.abs(rel_x))) + buffer return { "xi_0": -in_plane_max, "xi_f": in_plane_max, - "bin_width_x": bin_width, + "dx": cell_width, "zi_0": 0.0, "zi_f": z_max, - "bin_width_z": bin_width, + "dz": cell_width, } # Whole-mode 3D grid. For cylindrical droplets the ``y`` axis is # the cylinder axis and atoms span the full box; the bounding box # (with buffer) captures that. - y_min = float(dy.min()) - buffer - y_max = float(dy.max()) + buffer - x_max = float(np.max(np.abs(dx))) + buffer + y_min = float(rel_y.min()) - buffer + y_max = float(rel_y.max()) + buffer + x_max = float(np.max(np.abs(rel_x))) + buffer return { "xi_0": -x_max, "xi_f": x_max, - "bin_width_x": bin_width, + "dx": cell_width, "yi_0": y_min, "yi_f": y_max, - "bin_width_y": bin_width, + "dy": cell_width, "zi_0": 0.0, "zi_f": z_max, - "bin_width_z": bin_width, + "dz": cell_width, } @@ -183,7 +181,7 @@ def _validate_per_slice_params( delta_cylinder: float | None, droplet_geometry: DropletGeometry, ) -> None: - """For slicing-mode grid extractors: require the right slice-step param.""" + """For slicing-mode grid sampling: require the right slice-step param.""" if droplet_geometry.is_spherical and delta_azimuthal is None: raise ValueError(f"{name} for slicing+spherical requires delta_azimuthal.") if droplet_geometry.is_cylinder and delta_cylinder is None: @@ -200,11 +198,11 @@ def _validate_per_slice_params( def _slice_grid_edges( grid_params: dict[str, Any], ) -> tuple[np.ndarray, np.ndarray]: - s_edges = edges_from_bin_width( - grid_params["xi_0"], grid_params["xi_f"], grid_params["bin_width_x"] + s_edges = edges_from_cell_width( + grid_params["xi_0"], grid_params["xi_f"], grid_params["dx"] ) - z_edges = edges_from_bin_width( - grid_params["zi_0"], grid_params["zi_f"], grid_params["bin_width_z"] + z_edges = edges_from_cell_width( + grid_params["zi_0"], grid_params["zi_f"], grid_params["dz"] ) return s_edges, z_edges @@ -222,14 +220,14 @@ def _slice_grid_centres( def _whole_grid_edges( grid_params: dict[str, Any], ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: - x_edges = edges_from_bin_width( - grid_params["xi_0"], grid_params["xi_f"], grid_params["bin_width_x"] + x_edges = edges_from_cell_width( + grid_params["xi_0"], grid_params["xi_f"], grid_params["dx"] ) - y_edges = edges_from_bin_width( - grid_params["yi_0"], grid_params["yi_f"], grid_params["bin_width_y"] + y_edges = edges_from_cell_width( + grid_params["yi_0"], grid_params["yi_f"], grid_params["dy"] ) - z_edges = edges_from_bin_width( - grid_params["zi_0"], grid_params["zi_f"], grid_params["bin_width_z"] + z_edges = edges_from_cell_width( + grid_params["zi_0"], grid_params["zi_f"], grid_params["dz"] ) return x_edges, y_edges, z_edges @@ -407,13 +405,13 @@ def validate_compatibility( droplet_geometry=droplet_geometry, ) - def _auto_grid_bin_width(self, density: DensityEstimator) -> float: - # Pick the auto-derived bin_width that matches the estimator: + def _auto_grid_cell_width(self, density: DensityEstimator) -> float: + # Pick the auto-derived cell_width that matches the estimator: # Gaussian uses density_sigma / 2 (Nyquist-ish for the KDE); # histograms use a flat 2 Å (no smoothing scale to anchor to). if isinstance(density, _GaussianDensityEstimator): return density.density_sigma / 2.0 - return _DEFAULT_BIN_WIDTH_BINNING + return _DEFAULT_CELL_WIDTH_BINNING def extract( self, @@ -428,7 +426,7 @@ def extract( center_geom, droplet_geometry, surface_kind=surface_kind, - bin_width=self._auto_grid_bin_width(density), + cell_width=self._auto_grid_cell_width(density), ) if surface_kind == "slicing": s_centers, z_centers = _slice_grid_centres(grid_params) diff --git a/src/wetting_angle_kit/analysis/interface/base.py b/src/wetting_angle_kit/analysis/interface/base.py index 67d4f1e..dd2781c 100644 --- a/src/wetting_angle_kit/analysis/interface/base.py +++ b/src/wetting_angle_kit/analysis/interface/base.py @@ -203,11 +203,11 @@ def grid( ---------- grid_params : dict, optional Grid spec. For slicing, six keys: ``"xi_0"``, ``"xi_f"``, - ``"bin_width_x"``, ``"zi_0"``, ``"zi_f"``, - ``"bin_width_z"``. ``xi_0`` should be negative for a + ``"dx"``, ``"zi_0"``, ``"zi_f"``, + ``"dz"``. ``xi_0`` should be negative for a centred slice that spans both halves of the diameter. For whole, add three more: ``"yi_0"``, ``"yi_f"``, - ``"bin_width_y"`` (xi/yi grids are in the droplet-centred + ``"dy"`` (xi/yi grids are in the droplet-centred lateral frame; zi stays in the lab frame). If ``None`` (default), the grid is auto-derived per batch from the atom bounding box plus a 5 Å buffer, with cell width set diff --git a/src/wetting_angle_kit/analysis/results.py b/src/wetting_angle_kit/analysis/results.py index 2788024..2f2c252 100644 --- a/src/wetting_angle_kit/analysis/results.py +++ b/src/wetting_angle_kit/analysis/results.py @@ -8,10 +8,10 @@ - slicing fitters → :class:`SlicingBatchResult` - whole fitters → :class:`WholeBatchResult` -The two joint-fit analyzers — :class:`CoupledFit2DAnalyzer` and +The two coupled-fit analyzers — :class:`CoupledFit2DAnalyzer` and :class:`CoupledFit3DAnalyzer` — each return their own results type (:class:`CoupledFit2DResults`, :class:`CoupledFit3DResults`). -They carry density grids plus joint-fit parameters and are therefore +They carry density grids plus coupled-fit parameters and are therefore not part of the :class:`TrajectoryResults` hierarchy. """ @@ -132,17 +132,17 @@ class CoupledFit2DBatchResult: frames : list[int] Frame indices pooled into this batch. angle : float - Contact angle (degrees) implied by the joint 2D tanh-model fit. + Contact angle (degrees) from the 2D coupled tanh-model fit. model_params : dict[str, float] Fitted parameters of the 2D hyperbolic tangent model; keys are ``"rho1"``, ``"rho2"``, ``"R_eq"``, ``"zi_c"``, ``"zi_0"``, ``"t1"``, ``"t2"``. xi_grid : ndarray - In-plane binning grid centers (Å). + In-plane grid-cell centers (Å). zi_grid : ndarray - Vertical binning grid centers (Å). + Vertical grid-cell centers (Å). density : ndarray - ``(len(xi_grid), len(zi_grid))`` binned density on the grid. + ``(len(xi_grid), len(zi_grid))`` density sampled on the grid. """ frames: list[int] @@ -167,7 +167,7 @@ class CoupledFit3DBatchResult: frames : list[int] Frame indices pooled into this batch. angle : float - Contact angle (degrees) implied by the joint 3D tanh-model fit. + Contact angle (degrees) from the 3D coupled tanh-model fit. model_params : dict[str, float] Fitted parameters of the 3D hyperbolic tangent model; keys are ``"rho1"``, ``"rho2"``, ``"R_eq"``, ``"xi_c"``, ``"yi_c"``, @@ -175,13 +175,13 @@ class CoupledFit3DBatchResult: horizontal centers ``xi_c`` / ``yi_c`` are reported even if the underlying fit fixes them to zero by symmetry. xi_grid : ndarray - x-binning grid centers (Å). + x grid-cell centers (Å). yi_grid : ndarray - y-binning grid centers (Å). + y grid-cell centers (Å). zi_grid : ndarray - Vertical binning grid centers (Å). + Vertical grid-cell centers (Å). density : ndarray - ``(len(xi_grid), len(yi_grid), len(zi_grid))`` binned density + ``(len(xi_grid), len(yi_grid), len(zi_grid))`` density sampled on the 3D grid. """ @@ -262,7 +262,7 @@ class CoupledFit2DResults(_AngleResultsMixin): batches : list[CoupledFit2DBatchResult] Per-batch results, in the order produced by the aggregator. method_metadata : dict - Free-form descriptor (droplet geometry, binning params, + Free-form descriptor (droplet geometry, grid params, initial parameters, batch size). """ @@ -280,7 +280,7 @@ class CoupledFit3DResults(_AngleResultsMixin): Per-batch results, in the order produced by the aggregator. method_metadata : dict Free-form descriptor (droplet geometry — always spherical for - this analyzer, binning params, initial parameters, batch size). + this analyzer, grid params, initial parameters, batch size). """ batches: list[CoupledFit3DBatchResult] diff --git a/src/wetting_angle_kit/analysis/trajectory.py b/src/wetting_angle_kit/analysis/trajectory.py index 6d94455..dbf11c5 100644 --- a/src/wetting_angle_kit/analysis/trajectory.py +++ b/src/wetting_angle_kit/analysis/trajectory.py @@ -14,7 +14,7 @@ documented there. The per-batch wiring lives in :meth:`_process_batch_worker`. -The joint-fit analyzers (:class:`CoupledFit2DAnalyzer`, +The coupled-fit analyzers (:class:`CoupledFit2DAnalyzer`, :class:`CoupledFit3DAnalyzer`) live in their own modules and share only the worker-pool scaffolding, not this strategy pipeline. """ diff --git a/tests/test_analysis/test_coupled_fit_2d.py b/tests/test_analysis/test_coupled_fit_2d.py index 38e2693..c86e958 100644 --- a/tests/test_analysis/test_coupled_fit_2d.py +++ b/tests/test_analysis/test_coupled_fit_2d.py @@ -37,18 +37,18 @@ def oxygen_indices(filename: pathlib.Path) -> np.ndarray: @pytest.fixture -def binning_params() -> dict: +def grid_params() -> dict: # The per-frame tanh NLLS is sensitive to the grid layout on - # this fixture: ``bin_width_*`` values are chosen so the edge + # this fixture: ``dx`` / ``dz`` values are chosen so the edge # construction rounds to 49 × 24 cells, the grid the angle # anchors below were calibrated against. return { "xi_0": 0, "xi_f": 100.0, - "bin_width_x": 100.0 / 49.0, + "dx": 100.0 / 49.0, "zi_0": 0.0, "zi_f": 100.0, - "bin_width_z": 100.0 / 24.0, + "dz": 100.0 / 24.0, } @@ -56,14 +56,14 @@ def binning_params() -> dict: def test_coupled_fit_2d_with_cylinder_fixture( filename: pathlib.Path, oxygen_indices: np.ndarray, - binning_params: dict, + grid_params: dict, ) -> None: """End-to-end ``CoupledFit2DAnalyzer`` on the cylinder droplet.""" analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - binning_params=binning_params, + grid_params=grid_params, ) results = analyzer.analyze([1]) @@ -80,7 +80,7 @@ def test_coupled_fit_2d_with_cylinder_fixture( def test_coupled_fit_2d_per_frame_batches( filename: pathlib.Path, oxygen_indices: np.ndarray, - binning_params: dict, + grid_params: dict, ) -> None: """``batch_size=1``: one fit per frame.""" frames = [1, 2, 3] @@ -88,7 +88,7 @@ def test_coupled_fit_2d_per_frame_batches( parser=LammpsDumpParser(filename), atom_indices=oxygen_indices, droplet_geometry="cylinder_y", - binning_params=binning_params, + grid_params=grid_params, temporal_aggregator=TemporalAggregator(batch_size=1), ) results = analyzer.analyze(frames) diff --git a/tests/test_analysis/test_coupled_fit_3d.py b/tests/test_analysis/test_coupled_fit_3d.py index 8d5e0e9..33228dc 100644 --- a/tests/test_analysis/test_coupled_fit_3d.py +++ b/tests/test_analysis/test_coupled_fit_3d.py @@ -94,16 +94,16 @@ def frame_count(self) -> int: CoupledFit3DAnalyzer( parser=_MockParser(), droplet_geometry="cylinder_y", - binning_params={ + grid_params={ "xi_0": -30, "xi_f": 30, - "bin_width_x": 6.0, + "dx": 6.0, "yi_0": -30, "yi_f": 30, - "bin_width_y": 6.0, + "dy": 6.0, "zi_0": 0, "zi_f": 30, - "bin_width_z": 3.0, + "dz": 3.0, }, ) @@ -131,39 +131,39 @@ def test_coupled_fit_3d_close_to_2d_on_lammps_fixture() -> None: oxygen_indices = finder.get_water_oxygen_indices(0) # 2D analyzer — radial (xi, zi). - binning_params_2d = { + grid_params_2d = { "xi_0": 0, "xi_f": 40, - "bin_width_x": 1.0, + "dx": 1.0, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.0, + "dz": 1.0, } analyzer_2d = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_params_2d, + grid_params=grid_params_2d, ) angle_2d = float(analyzer_2d.analyze([1]).batches[0].angle) # 3D analyzer — full (xi, yi, zi). - binning_params_3d = { + grid_params_3d = { "xi_0": -40, "xi_f": 40, - "bin_width_x": 3.3, + "dx": 3.3, "yi_0": -40, "yi_f": 40, - "bin_width_y": 3.3, + "dy": 3.3, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.6, + "dz": 1.6, } new_3d = CoupledFit3DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params=binning_params_3d, + grid_params=grid_params_3d, ) angle_3d = float(new_3d.analyze([1]).batches[0].angle) diff --git a/tests/test_analysis/test_cylinder_coverage.py b/tests/test_analysis/test_cylinder_coverage.py index 0ba151f..9c62c53 100644 --- a/tests/test_analysis/test_cylinder_coverage.py +++ b/tests/test_analysis/test_cylinder_coverage.py @@ -157,10 +157,10 @@ def test_grid_with_gaussian_slicing_on_cylinder(oxygen_indices: np.ndarray) -> N grid_params = { "xi_0": -70.0, "xi_f": 70.0, - "bin_width_x": 3.0, + "dx": 3.0, "zi_0": 0.0, "zi_f": 80.0, - "bin_width_z": 1.5, + "dz": 1.5, } analyzer = _make_analyzer( InterfaceExtractor( @@ -194,13 +194,13 @@ def test_grid_with_gaussian_whole_on_cylinder(oxygen_indices: np.ndarray) -> Non grid_params = { "xi_0": -70.0, "xi_f": 70.0, - "bin_width_x": 2.5, + "dx": 2.5, "yi_0": -12.0, "yi_f": 12.0, - "bin_width_y": 2.0, + "dy": 2.0, "zi_0": 0.0, "zi_f": 80.0, - "bin_width_z": 2.0, + "dz": 2.0, } analyzer = _make_analyzer( InterfaceExtractor( @@ -231,10 +231,10 @@ def test_grid_with_binning_slicing_on_cylinder(oxygen_indices: np.ndarray) -> No grid_params = { "xi_0": -70.0, "xi_f": 70.0, - "bin_width_x": 8.0, + "dx": 8.0, "zi_0": 0.0, "zi_f": 80.0, - "bin_width_z": 3.0, + "dz": 3.0, } analyzer = _make_analyzer( InterfaceExtractor( @@ -256,13 +256,13 @@ def test_grid_with_binning_whole_on_cylinder(oxygen_indices: np.ndarray) -> None grid_params = { "xi_0": -70.0, "xi_f": 70.0, - "bin_width_x": 3.0, + "dx": 3.0, "yi_0": -12.0, "yi_f": 12.0, - "bin_width_y": 3.0, + "dy": 3.0, "zi_0": 0.0, "zi_f": 80.0, - "bin_width_z": 2.5, + "dz": 2.5, } analyzer = _make_analyzer( InterfaceExtractor( diff --git a/tests/test_analysis/test_default_grid_params.py b/tests/test_analysis/test_default_grid_params.py index 06eebf8..3c73869 100644 --- a/tests/test_analysis/test_default_grid_params.py +++ b/tests/test_analysis/test_default_grid_params.py @@ -1,4 +1,4 @@ -"""Auto-derived ``grid_params`` / ``binning_params`` defaults. +"""Auto-derived ``grid_params`` / ``grid_params`` defaults. When the user constructs a grid extractor or a coupled-fit analyzer without specifying the spatial grid spec, the package picks @@ -54,8 +54,8 @@ def oxygen_indices() -> np.ndarray: @pytest.mark.integration def test_coupled_fit_2d_auto_default(oxygen_indices: np.ndarray) -> None: - """``CoupledFit2DAnalyzer`` with no ``binning_params`` lands at ~95°.""" - with pytest.warns(UserWarning, match="binning_params was not supplied"): + """``CoupledFit2DAnalyzer`` with no ``grid_params`` lands at ~95°.""" + with pytest.warns(UserWarning, match="grid_params was not supplied"): analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, @@ -70,8 +70,8 @@ def test_coupled_fit_2d_auto_default(oxygen_indices: np.ndarray) -> None: @pytest.mark.integration @pytest.mark.slow def test_coupled_fit_3d_auto_default(oxygen_indices: np.ndarray) -> None: - """``CoupledFit3DAnalyzer`` with no ``binning_params`` lands at ~95°.""" - with pytest.warns(UserWarning, match="binning_params was not supplied"): + """``CoupledFit3DAnalyzer`` with no ``grid_params`` lands at ~95°.""" + with pytest.warns(UserWarning, match="grid_params was not supplied"): analyzer = CoupledFit3DAnalyzer( parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, diff --git a/tests/test_analysis/test_density_estimator.py b/tests/test_analysis/test_density_estimator.py index f8de942..194b1a2 100644 --- a/tests/test_analysis/test_density_estimator.py +++ b/tests/test_analysis/test_density_estimator.py @@ -66,19 +66,19 @@ def test_coupled_fit_2d_gaussian_estimator_recovers_known_angle( per-frame batches where the binning variant has occasionally landed in degenerate fits. """ - binning_params = { + grid_params = { "xi_0": 0.0, "xi_f": 40.0, - "bin_width_x": 1.0, + "dx": 1.0, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.0, + "dz": 1.0, } analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_SPHERE), atom_indices=oxygen_indices_sphere, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, density_estimator=DensityEstimator.gaussian(density_sigma=2.5), temporal_aggregator=TemporalAggregator(batch_size=1), ) @@ -97,13 +97,13 @@ def test_coupled_fit_2d_gaussian_estimator_smoother_than_binning( occupied bulk region: the Gaussian density's CoV is strictly lower than the binning density's CoV on the same fixture and grid. """ - binning_params = { + grid_params = { "xi_0": 0.0, "xi_f": 40.0, - "bin_width_x": 1.0, + "dx": 1.0, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.0, + "dz": 1.0, } def _density(estimator: DensityEstimator) -> np.ndarray: @@ -111,7 +111,7 @@ def _density(estimator: DensityEstimator) -> np.ndarray: parser=LammpsDumpParser(_SPHERE), atom_indices=oxygen_indices_sphere, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, density_estimator=estimator, temporal_aggregator=TemporalAggregator(batch_size=1), ) @@ -148,19 +148,19 @@ def test_coupled_fit_2d_gaussian_estimator_works_on_cylinder( shifts the angle ~2-3° higher (the KDE iso-level sits slightly inside the geometric edge) and gives similar per-frame std. """ - binning_params = { + grid_params = { "xi_0": 0.0, "xi_f": 70.0, - "bin_width_x": 1.0, + "dx": 1.0, "zi_0": 0.0, "zi_f": 80.0, - "bin_width_z": 1.0, + "dz": 1.0, } analyzer = CoupledFit2DAnalyzer( parser=LammpsDumpParser(_CYL), atom_indices=oxygen_indices_cyl, droplet_geometry="cylinder_y", - binning_params=binning_params, + grid_params=grid_params, density_estimator=DensityEstimator.gaussian(density_sigma=2.5), temporal_aggregator=TemporalAggregator(batch_size=1), ) @@ -183,22 +183,22 @@ def test_coupled_fit_3d_gaussian_estimator_recovers_known_angle( plumbing or volume normalisation would push the angle well outside it. """ - binning_params = { + grid_params = { "xi_0": -40.0, "xi_f": 40.0, - "bin_width_x": 3.3, + "dx": 3.3, "yi_0": -40.0, "yi_f": 40.0, - "bin_width_y": 3.3, + "dy": 3.3, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.6, + "dz": 1.6, } analyzer = CoupledFit3DAnalyzer( parser=LammpsDumpParser(_SPHERE), atom_indices=oxygen_indices_sphere, droplet_geometry="spherical", - binning_params=binning_params, + grid_params=grid_params, density_estimator=DensityEstimator.gaussian(density_sigma=3.0), temporal_aggregator=TemporalAggregator(batch_size=1), ) diff --git a/tests/test_analysis/test_grid_slicing.py b/tests/test_analysis/test_grid_slicing.py index 9e7b63d..e096c0c 100644 --- a/tests/test_analysis/test_grid_slicing.py +++ b/tests/test_analysis/test_grid_slicing.py @@ -65,10 +65,10 @@ def _default_grid_params(half: float) -> dict[str, object]: return { "xi_0": -half, "xi_f": half, - "bin_width_x": half / 25.0, + "dx": half / 25.0, "zi_0": 0.0, "zi_f": half, - "bin_width_z": half / 25.0, + "dz": half / 25.0, } @@ -131,10 +131,10 @@ def test_grid_with_binning_recovers_known_spherical_cap_with_coarse_bins() -> No grid_params: dict[str, object] = { "xi_0": -35.0, "xi_f": 35.0, - "bin_width_x": 4.0, + "dx": 4.0, "zi_0": 0.0, "zi_f": 35.0, - "bin_width_z": 2.0, + "dz": 2.0, } extractor = InterfaceExtractor( sampling=SpaceSampling.grid( @@ -174,10 +174,10 @@ def test_grid_with_gaussian_smoother_than_grid_with_binning() -> None: grid_params: dict[str, object] = { "xi_0": -35.0, "xi_f": 35.0, - "bin_width_x": 4.0, + "dx": 4.0, "zi_0": 0.0, "zi_f": 35.0, - "bin_width_z": 2.0, + "dz": 2.0, } geom = DropletGeometry.coerce("spherical") @@ -258,10 +258,10 @@ def test_grid_extractors_end_to_end_close_to_rays_with_gaussian() -> None: grid_params_gauss = { "xi_0": -40.0, "xi_f": 40.0, - "bin_width_x": 3.0, + "dx": 3.0, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.6, + "dz": 1.6, } # grid + binning per-slice has fewer atoms per cell than rays + binning # (only atoms in the slab contribute, not all atoms along a ray): @@ -269,10 +269,10 @@ def test_grid_extractors_end_to_end_close_to_rays_with_gaussian() -> None: grid_params_bin = { "xi_0": -40.0, "xi_f": 40.0, - "bin_width_x": 8.0, + "dx": 8.0, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 3.0, + "dz": 3.0, } def _angle(extractor: InterfaceExtractor) -> float: diff --git a/tests/test_analysis/test_grid_whole.py b/tests/test_analysis/test_grid_whole.py index 66ab9ed..79a581d 100644 --- a/tests/test_analysis/test_grid_whole.py +++ b/tests/test_analysis/test_grid_whole.py @@ -60,13 +60,13 @@ def _whole_grid_params(half_xy: float, z_lo: float, z_hi: float, nbins: int) -> return { "xi_0": -half_xy, "xi_f": half_xy, - "bin_width_x": bw_xy, + "dx": bw_xy, "yi_0": -half_xy, "yi_f": half_xy, - "bin_width_y": bw_xy, + "dy": bw_xy, "zi_0": z_lo, "zi_f": z_hi, - "bin_width_z": bw_z, + "dz": bw_z, } @@ -169,13 +169,13 @@ def test_grid_with_gaussian_whole_cylinder_recovers_horizontal_ridge() -> None: grid_params = { "xi_0": -1.5 * R_truth, "xi_f": 1.5 * R_truth, - "bin_width_x": 1.0, + "dx": 1.0, "yi_0": -y_extent / 2, "yi_f": y_extent / 2, - "bin_width_y": 1.5, + "dy": 1.5, "zi_0": 0.0, "zi_f": 2.5 * R_truth, - "bin_width_z": 1.0, + "dz": 1.0, } extractor = InterfaceExtractor( sampling=SpaceSampling.grid(grid_params=grid_params), diff --git a/tests/test_analysis/test_parallel_path.py b/tests/test_analysis/test_parallel_path.py index 69dd68c..c9625b9 100644 --- a/tests/test_analysis/test_parallel_path.py +++ b/tests/test_analysis/test_parallel_path.py @@ -40,13 +40,13 @@ def test_run_parallel_path_executes_with_n_jobs_2() -> None: parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params={ + grid_params={ "xi_0": 0.0, "xi_f": 40.0, - "bin_width_x": 1.4, + "dx": 1.4, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.4, + "dz": 1.4, }, temporal_aggregator=TemporalAggregator(batch_size=1), ) @@ -73,13 +73,13 @@ def test_n_jobs_gt_1_with_batch_size_minus_1_warns_and_runs_inline() -> None: parser=LammpsDumpParser(_FIXTURE), atom_indices=oxygen_indices, droplet_geometry="spherical", - binning_params={ + grid_params={ "xi_0": 0.0, "xi_f": 40.0, - "bin_width_x": 1.4, + "dx": 1.4, "zi_0": 0.0, "zi_f": 40.0, - "bin_width_z": 1.4, + "dz": 1.4, }, temporal_aggregator=TemporalAggregator(batch_size=-1), ) From 859f1cae2cb980a8e6bf8800694b4e5d193b2e63 Mon Sep 17 00:00:00 2001 From: Gabrieltaillandier Date: Wed, 17 Jun 2026 19:55:16 +0300 Subject: [PATCH 46/53] review docs tuto and theory, a few docstring and add visu 3d iso-surface --- docs/source/introduction/installation.rst | 4 +- docs/source/introduction/introduction.rst | 4 +- .../introduction/theoretical_foundations.rst | 144 +++++++++--------- 3 files changed, 79 insertions(+), 73 deletions(-) diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index 06b36cd..2fcc1c6 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -50,8 +50,8 @@ All optional dependencies Install OVITO ^^^^^^^^^^^^^ -OVITO must be installed using the following Conda command: +OVITO must be installed using pip: .. code-block:: bash - conda install --strict-channel-priority -c https://conda.ovito.org -c conda-forge ovito=3.11.3 + pip install ovito==3.11.3 diff --git a/docs/source/introduction/introduction.rst b/docs/source/introduction/introduction.rst index 97768e6..a694db7 100644 --- a/docs/source/introduction/introduction.rst +++ b/docs/source/introduction/introduction.rst @@ -130,8 +130,8 @@ All methods can analyse: ``fix recenter group_id INIT INIT NULL`` - All methods do require that the simulation box be large enough - that the droplet does not interact with its periodic image + All methods require the simulation box to be large enough + so that the droplet does not interact with its periodic image (i.e. its lateral diameter is comfortably below the box length). If that condition is violated, the radial density profile is physically meaningless regardless of the centering strategy. diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 3e4d870..0dadae2 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -1,10 +1,10 @@ Theoretical foundations ======================= -This chapter walks through the physics and numerics behind +This section presents the physics and numerics behind wetting_angle_kit, from the contact-angle definition to the -extraction, wall detection, and fitting strategies that the analyzers -compose. +extraction, wall detection, and fitting strategies of +the analyzers. .. contents:: :local: @@ -42,7 +42,43 @@ The job of the analysis pipeline is to estimate :math:`R`, :math:`z_c`, and :math:`z_w` from atom positions, robustly enough that the recovered :math:`\theta` is meaningful. -2. The liquid–vapor interface in MD trajectories +2. Geometric symmetry classes +----------------------------- + +Three geometries are supported via :class:`DropletGeometry`: + +* ``"spherical"`` — full 3D droplet with no special axis. +* ``"cylinder_y"`` — cylindrical droplet along the :math:`y` axis + (the internal frame's cylinder axis). +* ``"cylinder_x"`` — cylindrical droplet along the :math:`x` axis; + internally swapped to ``cylinder_y`` for the analysis (atom + positions are permuted, then the result is permuted back). + +The geometry choice cascades through every component: + +.. list-table:: + :widths: 50 50 + :align: center + + * - .. image:: ../../images/wetting_angle_kit_cylinder.jpg + :width: 100% + + - .. image:: ../../images/wetting_angle_kit_3d_droplet.jpg + :width: 100% + +* spherical droplets are treated as fully three-dimensional objects; +* cylindrical droplets exploit translational symmetry along the + cylinder axis and can therefore be reduced to a two-dimensional + fitting problem; +* the sampling strategy, wall detection, and fitting procedure are + automatically adapted to the selected geometry. + +The cylindrical and spherical geometries share the same fitting +framework and contact-angle definition. The only difference is that +the cylindrical analysis is performed on cross-sections along the +cylinder axis rather than on azimuthal slices. + +3. The liquid–vapor interface in MD trajectories ------------------------------------------------ There is no sharp surface in an MD frame: the density drops from @@ -53,10 +89,10 @@ This interface is then used to fit a circle/sphere and recover the contact angle The extraction of the interface is based on two choices: * The density field may be computed via a Gaussian KDE or a 3D top-hat binning. -* The density may be sampled along rays from the droplet COM +* The density may be sampled along rays from the droplet Center of Mass (COM) or on a fixed grid in space. -2.1. Estimating local density +3.1. Estimating local density ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ We first need a local density estimate at each sample point. @@ -93,7 +129,7 @@ Both estimators implement the same :class:`DensityFieldProtocol`, so the analysis pipeline can plug either one into the same ray-fan or grid extraction. -2.2. Sampling the density field +3.2. Sampling the density field ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Two strategies turn the density estimator into a clean point set on @@ -101,7 +137,7 @@ the interface: **Ray fans** The :meth:`SpaceSampling.rays` factory emits a fan of rays from the droplet - COM, samples the density along each ray, and recovers the interface + Center of Mass (COM), samples the density along each ray, and recovers the interface position as the half-density point of a 1D tanh fit on that ray. In such samplings, the interface is recovered by fitting a one-dimensional @@ -149,15 +185,17 @@ the interface: hit the wall plane and produce shell points at :math:`z \approx z_w`, which is what makes :meth:`WallDetector.min_plus_offset` work for the whole-fit. - * **whole + cylinder**: a per-:math:`y` ray fan in the ``(x, z)`` - plane (planes spaced by ``delta_cylinder``); the resulting shell - is the union of these per-:math:`y` rings. - - Why Fibonacci on the sphere? Naive uniform :math:`(\theta, \phi)` - gridding clusters rays near the poles, oversampling there and - undersampling the equator. The Fibonacci spiral (uniform - :math:`\cos\theta`, golden angle :math:`\phi`) gives near-perfect - equal-area coverage with no clustering anywhere. + * **whole + cylinder**: at each step along the cylinder axis + (spaced by ``delta_cylinder``), a 2D ray fan is cast in the + plane perpendicular to that axis; the full interface shell + is the union of all per-step rings. + + The choice of Fibonacci on the sphere is motivated by the fact + that naive uniform :math:`(\theta, \phi)` gridding clusters rays + near the poles, oversampling there and undersampling the equator. + The Fibonacci spiral (uniform :math:`\cos\theta`, golden angle + :math:`\phi`) gives near-perfect equal-area coverage without + clusters. **Grid + iso-contour** The :meth:`SpaceSampling.grid` factory builds a fixed-cell grid in space and @@ -184,7 +222,7 @@ the interface: horizontal cell width), which keeps the bin's cross-section in the ``(s, perpendicular)`` directions square. -3. Fitting the cap: algebraic Taubin fits +4. Fitting the cap: algebraic Taubin fits ----------------------------------------- Given a clean point set on the interface, the surface fitter @@ -210,16 +248,15 @@ smallest right singular vector of a small design matrix (one SVD, no iteration and no initial guess). The 2D circle fit is the same construction with the :math:`y` column dropped. -The gradient normalisation is what makes this estimator -**near-unbiased on partial arcs**, which is the regime that matters -here: a droplet cap is only ever a partial arc — the liquid-vapor -surface, never the full circle — and on a short, noisy arc the -recovered radius feeds directly into -:math:`\cos\theta = (z_w - z_c)/R`. On synthetic arcs of known -radius the Taubin radius and angle match a full geometric -(orthogonal-distance) fit to well under :math:`0.1^\circ`, at no -extra variance; the geometric fit itself is avoided only because it -needs an iterative solve and an initial guess. +The gradient normalisation largely removes the bias that algebraic +fits can exhibit on incomplete arcs. This is precisely the situation +encountered for droplets, where only a portion of the underlying +circle is observable. Since the recovered radius feeds directly into +:math:`\cos\theta = (z_w - z_c)/R`, accurate fitting of partial arcs +is essential. On synthetic datasets, Taubin fits agree with full +orthogonal-distance fits to better than :math:`0.1^\circ`, while +remaining a closed-form method that requires neither an initial guess +nor numerical iteration. The slicing fitter (:meth:`SurfaceFitter.slicing`) runs one Taubin **circle** fit per slice in the slice's ``(x, z)`` plane, then @@ -229,7 +266,7 @@ averages the per-slice angles. The whole fitter droplet, exploiting translational symmetry along :math:`y`) on the entire shell. -4. Locating the wall plane +5. Locating the wall plane -------------------------- The contact angle is read from the cap–wall intersection, so the @@ -238,7 +275,7 @@ wall plane :math:`z_w` has to be located explicitly: * :meth:`WallDetector.min_plus_offset` — derive :math:`z_w` from the interface itself, as :math:`z_w = \min(z_{\rm interface}) + \mathrm{offset}`. For slicing extractors the minimum across all - slices' interface points lands on the contact line; for the + slices' interface points is taken on the contact line; for the full-sphere ray fan, downward rays from the COM reach the wall plane, so :math:`\min(z_{\rm shell})` is again physically meaningful. @@ -262,7 +299,7 @@ to roughly a 3° shift in the recovered angle. So either pick the wall detector that matches your trust budget, or report the angle for two choices to make the dependence visible. -5. Coupled fit +6. Coupled fit -------------- The :class:`CoupledFit2DAnalyzer` and @@ -281,7 +318,7 @@ constant overhead per batch. The choice of estimator does not affect the model or the fit procedure — only the density values fed into the NLLS solver. -5.1 The 2D model (7 parameters) +6.1 The 2D model (7 parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ After projecting atoms to ``(xi, zi)`` via the droplet symmetry, @@ -316,7 +353,7 @@ The contact angle follows directly: \cos \theta \;=\; \frac{z_0 - z_c}{R_{eq}}. -5.2 The 3D model (9 parameters) +6.2 The 3D model (9 parameters) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The 3D extension computes a density on a full ``(xi, yi, zi)`` @@ -329,11 +366,11 @@ Cartesian grid and fits with two extra parameters :math:`\xi_c, \eta_c` for the horizontal centre. Nine free parameters; same cap geometry for -:math:`\theta`. Spherical droplets only — cylindrical droplets are +:math:`\theta`. Spherical droplets only (cylindrical droplets are rejected at construction because translational symmetry along the -cylinder axis already collapses the 3D problem onto the 2D one. +cylinder axis already collapses the 3D problem onto the 2D one). -5.3 Why a coupled fit? +6.3 Why a coupled fit? ^^^^^^^^^^^^^^^^^^^^^^ The coupled fit shares information across the cap and the wall: @@ -345,7 +382,7 @@ many frames per batch; less informative per batch (single angle) and slower per batch (a 7-parameter NLLS rather than one closed-form Taubin solve per slice). -6. Periodic boundaries and droplet recentering +7. Periodic boundaries and droplet recentering ---------------------------------------------- MD simulations are run with periodic boundary conditions; a droplet @@ -387,7 +424,7 @@ below the box length. If that condition is violated, the radial density profile is physically meaningless regardless of the centering strategy. -7. Frame batching +8. Frame batching ----------------- The :class:`TemporalAggregator` groups trajectory frames into @@ -417,34 +454,3 @@ inversely with :math:`\sqrt{N}` in regimes where shot noise dominates. For a 4k-atom droplet on a typical room-temperature trajectory, ``batch_size`` between 1 and 10 covers the useful range. - -8. Geometric symmetry classes ------------------------------ - -Three geometries are supported via :class:`DropletGeometry`: - -* ``"spherical"`` — full 3D droplet with no special axis. -* ``"cylinder_y"`` — cylindrical droplet along the :math:`y` axis - (the internal frame's cylinder axis). -* ``"cylinder_x"`` — cylindrical droplet along the :math:`x` axis; - internally swapped to ``cylinder_y`` for the analysis (atom - positions are permuted, then the result is permuted back). - -The geometry choice cascades through every component: - -.. image:: ../../images/wetting_angle_kit_cylinder.jpg - :align: center - -* the interface extractor picks a 2D/3D ray fan or grid axis; -* the wall detector reads :math:`\min(z)` over either the full - interface or per-slice as appropriate; -* the surface fitter applies a sphere fit (spherical) or a 2D - circle fit in :math:`(x, z)` (cylinder, using the translational - invariance along :math:`y`). - -The cylindrical case is mechanically identical to the spherical -one — same Taubin fit, same cap geometry, same :math:`\cos \theta -= (z_w - z_c)/R` — but applied per-axis-step rather than -azimuthally. The slicing tutorial includes a worked example; -the whole-fit tutorial covers the cylinder case under -"Alternative configurations". From b21e6a71d8bcbada6b113e6c4f9656c875bb356d Mon Sep 17 00:00:00 2001 From: Gabrieltaillandier Date: Wed, 17 Jun 2026 20:00:28 +0300 Subject: [PATCH 47/53] review docs tuto and theory, a few docstring and add visu 3d iso-surface v2 --- README.md | 10 +- docs/source/introduction/installation.rst | 6 +- src/wetting_angle_kit/analysis/_base.py | 2 +- .../visualization/density_contour_plotter.py | 197 ++++++++++++++++++ 4 files changed, 204 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 8681f5f..4e149ca 100644 --- a/README.md +++ b/README.md @@ -54,10 +54,8 @@ The documentation is available [here](https://matgenix.github.io/wetting-angle-k ### Prerequisites -Before installing wetting-angle-kit, ensure you have the following prerequisites: - -1. **Python 3.10 or higher**: Make sure you have Python 3.10 or higher installed on your system. -2. **Conda**: Ensure you have Conda installed. If not, you can install it from [here](https://docs.conda.io/en/latest/miniconda.html). +Before installing wetting-angle-kit, ensure you have **Python 3.10 or higher** +installed on your system. Core (only to analyse simple xyz trajectories): @@ -80,10 +78,10 @@ pip install wetting-angle-kit[all] #### Install OVITO -OVITO must be installed first in the conda environment and using the following Conda command: +OVITO must be installed first using pip: ```sh -conda install --strict-channel-priority -c https://conda.ovito.org -c conda-forge ovito=3.11.3 +pip install ovito==3.11.3 ``` ## Quick Start diff --git a/docs/source/introduction/installation.rst b/docs/source/introduction/installation.rst index 2fcc1c6..ad1eeee 100644 --- a/docs/source/introduction/installation.rst +++ b/docs/source/introduction/installation.rst @@ -4,10 +4,8 @@ Installation Prerequisites ------------- -Before installing wetting-angle-kit, ensure you have the following prerequisites: - -1. **Python 3.10 or higher**: Make sure you have Python 3.10 or higher installed on your system. -2. **Conda**: Ensure you have Conda installed. If not, you can install it from `here `_. +Before installing wetting-angle-kit, ensure you have **Python 3.10 or higher** +installed on your system. Optional Dependencies Strategy ------------------------------ diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index eb40e8d..02d2377 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -71,7 +71,7 @@ def analyze( def build_parser(filename: str) -> BaseParser: - """Build a parser by sniffing the file's extension. + """Build a parser by detecting the file's extension. Used by worker processes to rebuild a parser locally from a filepath, since the parent's parser instance is generally not diff --git a/src/wetting_angle_kit/visualization/density_contour_plotter.py b/src/wetting_angle_kit/visualization/density_contour_plotter.py index 259fd7b..ad8ac1b 100644 --- a/src/wetting_angle_kit/visualization/density_contour_plotter.py +++ b/src/wetting_angle_kit/visualization/density_contour_plotter.py @@ -15,6 +15,7 @@ from typing import Any import numpy as np +import plotly.colors as pc import plotly.graph_objects as go from wetting_angle_kit.analysis.results import ( @@ -155,6 +156,202 @@ def plot( fig.write_html(save_path) return fig + # ------------------------------------------------------------------ + # 3D isosurface plot with density-threshold slider. + # ------------------------------------------------------------------ + + def plot_3d_isosurface( + self, + *, + n_levels: int = 10, + title: str | None = None, + save_path: str | None = None, + ) -> go.Figure: + """3D isosurface of the density field with a density-threshold slider. + + Only accepts :class:`CoupledFit3DBatchResult` or + :class:`CoupledFit3DResults` sources (the full 3D density grid + is required). + + Parameters + ---------- + n_levels : int, default 10 + Number of iso-density levels exposed in the slider. + title : str, optional + Figure title. Defaults to + ``"Isosurface ρ ≥ ... — {label}"``. + save_path : str, optional + If provided, write the figure to standalone HTML. + + Returns + ------- + plotly.graph_objects.Figure + 3D isosurface with a wall plane and a density slider. + """ + xi, yi, zi, density, params = self._extract_3d(self.source) + + XI, YI, ZI = np.meshgrid(xi, yi, zi, indexing="ij") + positive = density[density > 0] + rho_min = float(positive.min()) if positive.size > 0 else 0.0 + rho_max = float(density.max()) + iso_levels = np.linspace( + rho_min + 0.05 * (rho_max - rho_min), + 0.95 * rho_max, + n_levels, + ) + + # Sample one color per iso-level from the colorscale. + t_values = [ + (iso_val - rho_min) / (rho_max - rho_min) if rho_max > rho_min else 0.5 + for iso_val in iso_levels + ] + iso_colors = pc.sample_colorscale(self.colorscale, t_values) + + fig = go.Figure() + for i, iso_val in enumerate(iso_levels): + color = iso_colors[i] + # Single-color colorscale so the entire isosurface is uniform. + uniform_cs = [[0, color], [1, color]] + fig.add_trace( + go.Isosurface( + x=XI.ravel(), + y=YI.ravel(), + z=ZI.ravel(), + value=density.ravel(), + isomin=float(iso_val), + isomax=float(rho_max), + surface_count=1, + caps={"x_show": False, "y_show": False, "z_show": False}, + colorscale=uniform_cs, + visible=(i == 0), + opacity=0.6, + showscale=False, + ) + ) + + # Semi-transparent wall plane. + z0 = float(params["zi_0"]) + wall_x = np.array([[xi[0], xi[-1]], [xi[0], xi[-1]]]) + wall_y = np.array([[yi[0], yi[0]], [yi[-1], yi[-1]]]) + wall_z = np.full_like(wall_x, z0) + fig.add_trace( + go.Surface( + x=wall_x, + y=wall_y, + z=wall_z, + colorscale=[ + [0, "rgba(0,0,0,0.15)"], + [1, "rgba(0,0,0,0.15)"], + ], + showscale=False, + name="Wall plane", + ) + ) + + # Reference colorbar: an invisible Scatter3d that carries the + # full-range Jet colorbar so the user sees where the current + # iso-level sits on the density scale. + fig.add_trace( + go.Scatter3d( + x=[None], + y=[None], + z=[None], + mode="markers", + marker={ + "size": 0, + "color": [rho_min, rho_max], + "colorscale": self.colorscale, + "showscale": True, + "colorbar": { + "title": {"text": "ρ", "font": {"size": 16}}, + "tickfont": {"size": 14}, + "len": 0.75, + }, + }, + showlegend=False, + hoverinfo="none", + ) + ) + + # Slider steps — each toggles one isosurface; + # wall + colorbar always on (last 2 traces). + steps = [] + n_iso = len(iso_levels) + for i, iso_val in enumerate(iso_levels): + vis = [False] * n_iso + [True, True] # wall + colorbar + vis[i] = True + steps.append( + { + "method": "update", + "args": [ + {"visible": vis}, + { + "title": title + or (f"Isosurface ρ = {iso_val:.4f} — {self.label}"), + }, + ], + "label": f"{iso_val:.4f}", + } + ) + + default_title = title or (f"Isosurface ρ = {iso_levels[0]:.4f} — {self.label}") + fig.update_layout( + sliders=[ + { + "active": 0, + "currentvalue": {"prefix": "ρ = "}, + "pad": {"t": 50}, + "steps": steps, + } + ], + title=default_title, + template="plotly_white", + scene={ + "xaxis_title": "ξ (Å)", + "yaxis_title": "η (Å)", + "zaxis_title": "z (Å)", + "aspectmode": "data", + }, + ) + if save_path: + fig.write_html(save_path) + return fig + + # ------------------------------------------------------------------ + # Internals — 3D source extraction. + # ------------------------------------------------------------------ + + def _extract_3d( + self, source: Any + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, dict]: + """Return ``(xi, yi, zi, density_3d, model_params)`` from a 3D source.""" + if isinstance(source, CoupledFit3DBatchResult): + return ( + source.xi_grid, + source.yi_grid, + source.zi_grid, + source.density, + source.model_params, + ) + if isinstance(source, CoupledFit3DResults): + if not source.batches: + raise ValueError("CoupledFit3DResults has no batches.") + ref = source.batches[0] + mean_density = np.stack([b.density for b in source.batches], axis=0).mean( + axis=0 + ) + return ( + ref.xi_grid, + ref.yi_grid, + ref.zi_grid, + mean_density, + ref.model_params, + ) + raise TypeError( + f"plot_3d_isosurface requires a CoupledFit3D source, " + f"got {type(source).__name__}." + ) + # ------------------------------------------------------------------ # Internals — source dispatch. # ------------------------------------------------------------------ From 897cc7ee7de6433c8542471aab5ad0e68eea18d3 Mon Sep 17 00:00:00 2001 From: Gabrieltaillandier Date: Thu, 18 Jun 2026 12:00:50 +0300 Subject: [PATCH 48/53] docs: replace 3D droplet add polar angles and update reference in theoretical foundations --- docs/images/wetting_angle_kit_3d_droplet.jpg | Bin 58754 -> 0 bytes docs/images/wetting_angle_kit_3d_droplet.png | Bin 0 -> 35172 bytes docs/images/wetting_angle_kit_cylinder.jpg | Bin 37634 -> 12104 bytes .../introduction/theoretical_foundations.rst | 2 +- 4 files changed, 1 insertion(+), 1 deletion(-) delete mode 100644 docs/images/wetting_angle_kit_3d_droplet.jpg create mode 100644 docs/images/wetting_angle_kit_3d_droplet.png diff --git a/docs/images/wetting_angle_kit_3d_droplet.jpg b/docs/images/wetting_angle_kit_3d_droplet.jpg deleted file mode 100644 index c70a9a3cb0332406028581c28ec278e331597bdb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 58754 zcmd?QcT`hr*Do4GrGrREiqd=U1lWr7CLp~D2pB>OMS=v_BE2fTvnin$fl#GLLXj?A zia-*IfJj0!O1s(TJNrBDICp$w+;QLWo%_dKD|4-tm9=KZeAe?k^EZEUuIn$?8-P3d z+Irdm5)u+X7I6h!uK+av{`PMze=GU76#RYj^+y0bIf>JaMp60RT4u zKyvFpbOZk9LUM!j=B?XgFcr z22*D;9tfk%>vtvOypNjtnarkfe6lY7krb57EUawo`~reP!Xk3=3W`d~Dw>bAv~_g# z^v$1ISXx=z*t$M~Oz?}&1zCVk|=6qnv zW&2&lgAwd2%sMy^B&UcI$6k|uo$1Uq!0dgR2K9?=irZu5^R4^J2Y6oKO)Vjl!l3nK z^RqhqH6WKe3doN;-;jt_ex7QEcNp(uxVq0Lcxmyq_g#g0`o>pv4?|#Hta`+bx6}#nQXrMJtd0CF zDsv(J15aRUDGSkmnnfd8?4V;K;hNIzx5xf(oaj>Fzr)&DOkJ%9# z+^=o^GjFzEokoQ=5zIh;{Ku|;O5caK-GAKQhsomhKRQw;Ga!p-aEq~{)D(ywcI3hV zVQ9C}^-qV%O~2YNNm3th&>;ex0%hO(pE3)^KO z{Cv*Nvfx>|U+PZIAv;Wee8qjRc%}gvG6#ND7gmQxHKB#cbx#Mftq=@6dsTXIxhXPI z22^^_pFEU)Rsdxgyao`28$RF?|4*r3xc5Wi3h0i%;;zJL8FcGytwVsn+T zaKVY@W^nqZQW?i|(2H1ymIW6S{0-Du%p3;hP4zn5~oIVnsz%pf^IhM?d0Q%WE-#KnI_Ea zx%Jy19PKE_+hwoEJ(Qk-+&dj*RmWvu{ChP|klPl}M2TcW&uHCqSeRj#PrdDvLZASb z^k&ggIAkz~5?bSr?NuCK6A%$;^Dc6RSl~vFeXRAIp&r8U*n-Z}Jk&xvzdU)r=K3#D z)HMKSnz~{>yxt~xpm8onPaJW+YXHSAt#fj_?Zd}gQXx?s{$!YsKU9S*9 zXE0?W=mjF#WeTad@h+_?Hph=7gBjpIk1pm6XCka)d=g7FP^n2*vIJqQFU6k!emM6c zQ1y0x9U;J?JJb?!HAZ;7abU~bj8Baz5`1|zmUd_o8rqg}MfDEid#ACfJ`-v+0KLe+ zO(2a?;-v%1Al-k}$*Kd#r8Ml5#0}(9xJhy>Cz^lD?~tQ7V^K3xLTHbK>SW|}5exaz z?>>bVTJ$WT364)31DL-W>$>p=YTjTzlPIl`DWBvUnR zg$p4dFf~H$h9vXX(UO?lGH9`0=fV@IPsb-Fh=xWV-N{>n8IRE&zl=5H!dVRP>MQ80 zv}*td9LEuH#|t}`KCswF2VdaDISeodFG9ZTE$|M2nBmVPPvYw3IUh~#mM0o@7j4go z)+UAqcD(BqB&gw9hW_e{2a=(aje}K(O6A5v)t)jO`y|@TE-XGxN{<=0N<6;h+fBkv zmrsCvOk_zyn${L>wV+(UXxG#c*(5rqO=!Gne&oQjHAvQTrSF!sbx*;j)zl8`^Didv zp2=~`xqe3j_3_z4nKjr8b*$~3mww|%96ON#?9(O|)Kk#`#ylfs%Hj`HR#n-hO^V9- zagASUMDgv~xZr`t*g-ym8vYm)5s7(oV;$uvIJbz&eq9=E96mu83-j`As4II5OO+_D zvt1P_#D8k8`J@*6=(0iMg%c?&mQag(L+qRkCrTY1c$0o{V{gTQw<{HlCJ@~55X-$F z)uq@8J{7@uY`jy>U)EphG}uyb`O==|=C;Cc; zSI1dz6SpHHJhCny$n4R|5wMXREl~;I1PTVXM`=(0{1hHr>S$! zJ&u06H(|N3dFM)L^gtst0O4p49-M*M!i$5@RL#xVDIN2$zlWX!c^W{TM~AGun|N)KES+rd}n- z&_Q&$jfyk&qw5QOd}Cx8-)B=TrtP9{rdo`E|F6U%^uq@OD!css^V(2RQ6>J)58WT~ zRKa$3FX+qjFxwb+-M=DP=gYHf99)x`a=!=(^jZ_Xp4&qi&Gbc5y7CY^0lG< zTnmcud?nOj(4)A~9{EEuFg1o*9CRn7=*8H9O+(_=;)&Nmcrj30d}Bcaw-$S-jGw zkce1FcJeQElawLc&TY3N?4($~MG=d1$?KunkGMt}7qpuQ&}y(DMV2CF=(B%P4Q^Xg zGY(p@5W`FQPUT%yZxMd+U>bd1J9bEYHF-tfOF(1p*_1bbz&rFzosl6qDR*g?*nbWy zn(FpG$XfBYr=p{<^^6ZAhoa}jw}#YR@shFX!m#Hc!OOqYg+14JW*YQ@2{s!Pl0X_< z+p>aca`I_wG?I`$bO(tIR8;4mFZXAAcQ77m5ypiwc|TKEk2sJRoi(2Qj@-@6A)4AE0-PB$>W9B2q*_Yrj&dD(wx2G2|Hfp{1 zCHT!q!=fB$Q#qVAupw~)V- zM$U42s9FE=d7jbCrvXIDxSndDrDEL0LpZ?%wS1T$-&xT<-K|)*XZxdGt*3Ci1VWoz zzBCaLCdSMTW3l?#JPh^~9R|61LxTJ^w0x%XDjcfj~5l?=J=(M2KAyU3J#R8b{5G%+Rm z6xxOCtX}dQYLs%AdRFr??F=4764Td{AN*2wQz|I=F-_{``koH$j)I{i3@ft@Ptt_& zJ)XM;>@03{JQCKh%5#nVVnoD1F#{nLGd$=Z1>++)*Meeo2o5+Z)0sK}{MW*QV7$HL zfJOrQw_~5cVy3+_Tu*D`Yq_O$WQZ1c0{gg5wf%#UPDaY6(=Ox9zA z8vva$Z|Wv+Z`}K;{zCmeD4e9dHBITl08fis(*toeVuPXbV{|ktHbYlzvxW+bwkdG6 zm9XsHz29|#o;WW;esoiAFs-}tM9`ZtNXUSwj%tw>I|QCE(cE2BuiRal1v;H_&2sM? z;!=(gC9x74Kk6>#`nEVO3)Hc1G*~u+R0@C4n!LEkYRP!E*Z~jKHn*PyHYf3W2+Jf0 zHKxc=3YGq;A)w)5@{5R6E*KlHCa`A|*MJ$xREytJPU+k&l^5`9z!jy{HNYKze)Afz z%Q9SZ>Cvbmc16p3HDptHRcUw{koq%8@-jij_tJ8PsIZ50RO-S6T~9U?&av3diz$>? zw8JgNpCs!%Ts#Tgl(E^%Q?Cb!r<6JS*NhI`tF_sR9#v7P&C=1KMD<5_^u6wtAl$wZ z!9CYt4D#i;U`hg+!T4~Mi3b()Ko8u^YFKAbDn&8J1ZFu30ju1u;iD z$T(=*fra=!{I*RvdtdM{|5>xwZ0t9TD==TFHhKUnb79O)lUTy>lG0$Wvxsb8&hzP5 zfbG+;3W{yzAbSCkHoLfit&Ym1kMmE3Y^(Vx9%}e|D>A3skAbvzk4D6mOJ^ZI68M+8 zbqz9gbuuWChisN2C|+KXkF~TVugTtmz?;R9eIyqU90MjI2~m8TP&#FPnRDR`u7w{*jelwLHI%;*bU%i~)b+lQnb< zrDEB4gY+!K*Za@jW|x3%&~m|TaUG+4V0@iK{T2uMm%J5+8PZq&0yqLZ8&OmV-}cK_cwR~#4% z@<$w|$S=PyG>SXSg5d@%$shSOm67J5ZWlUP5H5SnmdPH1S6Tj$n<~QFH>Ov1L5ye? zo$-7x8O)%Y=bkU+#iP&{f!;5_G3q$ohVHGVQWJ`J<=*vsvv$n3+I8lSwa*mP(w}LC zKY?Z=*EsN5>5vj=?c>>?OP+7xzftC`345Fycha@Cg+oJg+8Jo(XqX1*j!_~?)KV}u zjBiSD%ZEqm>|jMF<||g)KU-j&gxB?|`(5H`kS&-3;q|MQH5>76XC4IZXM9RYxz0_0 zengY~WSSsyhwI+++^)iKmO-&ZY0%g?neyOhn`uJP)szT^5^r(zJ3ahBOpxBZI_5*N zk_X~PMR@F6=*SRE2pxl6?X~xQ`*27yNy$pAG8)s}mCzj2kr*izkVU6frmud*E4yJ! zfT6PFMJ+Z)5A^dd!%DkZHnIC!@rUF1}G8Qgsa|Cq%?GbBT`n<^H5`{}mpRjxAG+9`|20 zlveUggDdLyuqjnppfK;}%Yf>#w~|mBO$0F`l@9xtq-61MvnYL`(hj=k|7VH2?kUXT z;5y00U7Yt}iu(P1D)=t*j1I^ReAE5dX7Mz%5Q+K&_al>@MWwg3>JwbttA2=y*{-(Q zyUx3z;jg{^XHB_Tk~Dvg;X0ChRM~8cxGAELGlej1o*o1_VW7^YP^Wu@wfjTL8w5MJ z(@-kum{@Shb8IHiCDh1JO~u4!;4P zDtuAT;MKt zt7X5?$hJ&OVWBH>0RbH}HXB6oRPivQOnWX!VukuVapQ*76BB9(PAh+r}A`B7W7YQX=a zwPDY!MQy0MVeh9A|5)+A$I7RLrbCZf>W`UHU& zt3_HpjmkSKmD>fMm$BCKl55|I5}u6`MtvGPy0~o~iE`_7q@v1pBdF-7u-u9)F`$V? z%_OEjfoo?tlUE8G2W0n|!EH0ambH#Op7Z;Rrdlp9Z^3@zRL}mL?NtMFGw$FbVh$tF zc2Kgxv5iz}m`U0dMW9ps@>=%IcZ8V;$}jLPgS%t-Yr;qLL12%6QspUg@-x*c_DjOUKyFl4B=-trB3O(1QlMEIIp)WvrvIhRQdOx^ zf39{rr*6<$*e%30!y|pBzk8kg0Al?cstKfpZlz$DdKtvJ&(;r8~?O!)~bqONtoG>zcD8?755igF> zAl^meYxHyA8t~>Cz}|>y4e^re3AKaN!#MGEXZv8*MVN4vV_0SXB6FMj`V-t6*7)}h zBI|~2zt>6&ChzC0@R`}il`G(0xk$1g`n5Uss&rpibS3#t4I=e z!gZU_kLw%E%l=bc3Z{{;XVB5Dh)Gr+Z?pQXHIE#IR71@NZGl;@e#XeZVT=4dx&7in zM^_pW+T5c0Sv7^@Mp zteDssICW>maj*9-ekZnI0__{;$nYVIcQEhPN1Eu^CvUiYX5Hxcf?uhZwyT8AF__$Y zlq#GN>Whq6z6Lnq+-oafDwoy9xfLc6z*oUhy{e#>wG~jR!FHOXhn0kx&$16U=$MTE z(tVyei~OAwxw&jospAzNFlPC(v254&DmSzY{3@uZzI;zgt@W(1%5E^oBZ&0!+fJk# z^s7H9b%RA@*W1x@aAIZF4wT%DLnkWBHG3ur9XT+QsbVv!yCbM*B`_|6^kEDuFSPxo zuDH*pAl-S4e$Sea$j!}77c;38pjbc1L6$p<{&9c6xX^Hs!m-dxhY}z40x5@%!F*4r zGfQZUKf9B{O);pf{}JzQ&SbFU&bDwnaYXQUQA(OfmrR#RZCTlED{Vln=nYNL8ExJzAqz-z#ky)!?G?_Gm$nF&(ggh_LFXYn6 zw!)pJB_=EHv?DqruK}kX$SWb-VKPju9NLJqoh-(1)9f!i*;7$(fv@tQNF#NPYX8f4OIPjZQ0q+4n5!G&q zWXjZG>GY~y{8yi@WUwq8T^PG&PqNlT%&Kz`$}%AbKuZTF3aeP9DWMYmh6p67gOoBm=_7+DSU8Zh)TZLo4l&hnLewkvDr!i7oWny--{N}Y2SC6<@cT5DV(3J!e0 zlkCy+A!`jev$xGvM@z<*iO>JR6aIzEUJeZb(LM;e!+5on6G$ND$e}H1os(nmyFZ-*pqT z#(~=|pvw&o3l;z_`H9zwM`dTHY2AEWJaNxKbZDKS+khU`BoeDMmzbKxe_y$Vcmy*fcL8M@oC^??h1HVX=f*F#Df}gx(fs;4s$2%vf&56 zGuRR&``=CaoJdBgGj^$#eU|gr4o9erJrZeI6i(e!$I@|WCiHlmK`PRx{^`nlha)YnV(!!JovVxn&EJjnm5V%g5 zjf2L@zHVKu<8*7w9$Usp{EqYHSM?ww@<0!1zNk>S-8KFbA|`EEjA0e6QSv zGkz;NbE_y31LnLxg>EE+-A62JwuQ->7CSBmQM9hJz5qSwX~UgmS2|R`Zm8e%1wR<^ zD8fQmEmHd35Pl(+kOEifrDzk6*pjG`qdmf2QxdPz;sB;8vyA99S%G4rE)b>fQ#7om zfiJKk=pT1>yaTj_wFCUqoAv4a!@b+bSm7_X_~Gf`MuIYq5vG7&A8Jep=f_Lq7YBTk z)}+}pcjDkPF=<7dgoqN&GdciFVcIMC3Vs$Qh+Kx`HZ)*6Qj9{ay1V9mOYY{}lm$hj=k*pejXm(2Rk6;rD#-Odr;P17Ux+TIcw*>7A={!zT;D8JBq7e+tR9gJ@rqKAg|TLv>^Rh#jD{lMLt>s>=r$%<9+UJ;CQlXEiv;!j?W_;pC{N2 z7@Jf^7@KrUuxZM7GN50{p9k^V=(_RU0h0}`F@6RHp?&EIqJyuzdt1=O%rEjPV}mY5 zb`9+I*wvdnVrSzARRO&NRlKdv%pe zsC9?>TQb%BKJ@PtgRtLKKSSRZ%7r;b#!ItlxtiwB?PJ>)r_{P{kUuJ^2Rr4YdaB-@yKb>w(R&iD3I1g0uq`Uvh+r2DqVpY@8d z{dixhsk>KAiRkWyR^^#Ok>=VQzcrsGpmJerd|8&e^4Te)sS|m48xflU{yXIvw02OP z8>bUkb0v_O2mP()A299k{3f)}M%tar~$HQFRvC*DjvzelB zkrHu-5POzLcWfg^R>ue{`cQJhSS{_Kx=Vh#sSjzw4<;0ki*Qi;+`HJnob6+^4kU-E1$O>UF7vLh$O<}CS=u}u#T#jVN5pt7)4ag z4!WJpeI|wWPB03;&bCRYL4bEGzOPEdg!`AWxO(YgU-W^Zrj%O9a`AlF_>Z_9+Ve+5 z{9`}x4~eqX4Z(_&qSxO5t+0noYYm0y;#-7Z8@zGftB=xcGjihUShK#&JI%kE6TzUH zQq^>Fa2~@CRZ8m=-64X5k}-~?gm;25t*K-Y$RM->%b+RL!wIat5SZxgKg9WHX61>3 z*#w|qd5U^aQ3nVT84mUZ1-96t$OhM=)#(W9xSs>uR2QMNB|&IzMjC1P58|@soijeZ z-iQfDlx@PK!I-v9?_f|+ML%r`IGyG)(f-S-=*p^o^0#R%MtU7;>0xTAp?6x0x=lnM zT&0Umkt4Y)_XiU_vB-((4*=yE9GC!9*3S^aia^xt&wA{&1^hFCw;5u;xdjS4OLe@0 z6qF?lVUJ%Cx*a82Z3EYcG57}VgwrZ{z&P`~XhKxu#r8^so4KhT|C(98cN@Y~&nb85 zggD`q|2^UTBi#QrH~wEop2b-aWc1enSFzb^fO*BM&f)VRma9QF#j9EnQLp3ct$(!N zg1tM}-0NfNCd12~1roA1V(`9kSKPzc&MTIS=Y$aSis*!+Xfe%&QOcqbu|Z>J>coFY z@3n+8La0)VPM#*Wop`@H0pu(Tye%m{JVbz5 zGRTh}Q#W*3ScuH~b>va8X>z_Eb}ntQ77KTX8hBFR)Mg=3@QZ&OQ&BI}G3E|u)7p2R zwK7Y}sxdVR>ePZB_D3W;auB|jK~d(P1HXbF_29S%8aZI1c#qf%Ev6)u$wW*l>E48M zFwG2TaXC3R)^v7jHfTa!S5iPP@W67qJ?pFEpuJ6zP3lM!p{`L5Isb8+Rpeb|0&?NW zS%!xStG$KXSC!1_QF*B^t6V&p`W7`awK@!s>Q!2W=aN=OWugk67d%IRejrSyg81h1 zjpSzMh#eh$=Wl->b%Xr=orgE+-#*-kq`OH!XT^jo#sG_(olu zS_%Bv2^X}hgX1c7eJW~-Y3bqeFbVNL8(~G}kOij_ZrDp>do|>Vp6HH6Rv(I4Y7v zcb!63mGkDJ^>czOTa{*pH#-N}WuonasqxIr1%F>BVdkGn;Vf%NirNX`GEGd~i&W9A z`8N7(uOTIg&4KksT^$8=9l6NIw-0Y}nAaYco&7;*sUavjayL&6cpHbF7fKp-&4=HD zwwq=LUP<6+^H>fXsrx!$;=>6SBqX}iuIlb3=)g#vo!xLHuwA3{^x}BXUNI4Ek7=`t ztEM)hFt1D+lVyLBA@!@lB4HaFOGI3J{Xv*H0MMW=w*irxi1OG1kO3z?VXCu47w41KS^Ox8VR5- zR_=GA$?0$=(!src=YN(a2m$^(q%o<_%OqK4ClaV!LQK zgBG=pz2INJ^CL^aG8z#otX1?Um#*W!(}%WD6GU4v#B1G#kUF5E7zNX4fL0Dz;Xt-z zv2ul)_6x0|Su3|-uDfy`h1+w*a)OZ`(NaNH5+J21 zoD)X}iY@Y^+Zq?{Os1DAj(|y#-e$|9tgFH*dA#TC-|{CY)20oD&JuNgJ0PTR+j8S-LEaQ2hk@;pPtVIXvVCge)#c zuckY$P5vRXIwgvJNU1_}Nb}&u`uIjWhPYZ4LXwtk=Keyhph_m#qM*j2T3O9adD1^4 zfA2(3Xh`Gjv-j49?PO77I95+3AT23PZ2z8 zvm~EYxbUPKqp1z&gZ~K7HjBA>#dafZgi~J_5c+FZ#4jsRhvZ-`-c&yD_f|F$d^&WV%kPmRoytp4(q|M1Tf@JoDl63kq@AwM*jnn*5k z?7J_I1d$)eC)yd2{MFU2yLMl_ok)S$Hqb|q)L;{OddSc*H#@n?7ELBv#pcP8J}WFf-CAU#G2Nm2V{4bcDk~I zbkO<*Axwo|f6SGCU6)M(0P|_pUv%qUWM&C7{U;i5LeNE$8@g)!8)T!?D zzl!pXkES*-8sFXWBK88Otdy<+P~OPRnmVBY&ak>~vk{OezaKj~;UJuVojLDwCE z2V+=bxvkz<5T@#&-~lelneCng6FO8q0gU=g@KiH@Jj>e}wfnPcve+9e7$=y9**#TV zv6iqN7`5oJ?A5(|4^zeS?DrGt-0lcm-r_*sR8lY9g(m@m9RWT-*Vg9z=nmTW6%;|x z0*0W$iE;2|t7xHth5ntzn5SD#pEIR@No4)&Oqj7CsU>6v)PXF^%g>f$ zk6;YT=5r~wEa0$rsDF-qi9r%uI7jv$CJVTav3es3vnyM2fCigK&=P(kRq87f)wu{2 z926(q%pma{YyBP9_`tCXg~B?U5e}RK=%-A)_%;+ndLnt|OIQKnvFMn5%#v98W4wEA za4^~_u82vp%a@XH_~?22Vqm3Z2>1824_ei`GO8bp%ALN9@c!CGmKMN-8 zm%(&p!?Mw_gZn)!AGulY3%aGmW60jsU2$LNW|zO*_YP6Q4afOxK@YGqt>iv%y$0vx z^nA4u78butucA9Ulg&Ap7Gu^tt6E3>f+n^;I)U_zl6+^}?64c6XN&r$WU`8>*~AWe zcc&zMA5F&?)evnN9J%^XtUVT{RW>mei+Ymz;u+L61Qb-A(0CqkYUq4Mj9t1~_H}r_ zx8BWjt7~unnchbG#k>Al7U&)sc@o5e+_(;HT)(n8d--V(0&h;i4nOuN;# zRZt08C}JUEY#OaRs!`0{wv)tGCQTcWz|$H*Fi_H^FLPLaRkvBKP*d zuD0G798dBtzR{_1&mwCUz1l8@_LQJIzngIlK+y+0my zJB0w9a;7&c1@j&eQ2{oT>}EZuJyaiZkufZdIH6P(^|d}z5(8%{eodmd?UE2 zVXpg^Z5xEvhHnRCD9KojmbqRkLPXVA6)3%;3{N(^rf@ZfpFiy_^bHlLby@a}fytvM z__yZ7yv2VEp45Zncd{^x-v}ObQ;Ead^izHFt0r`to!NJ`zARgWrKr;9yY)RuvB=u; zPvNDpRZdZ5%dyS0SJSgZI^%M_dx0jLm*py2GdeHxD=CN-r-%aV*cj_xv0MQH{4}Fj zbKmoC#5Dd-?Dx<9zogN^4Z|mkkFx)+`FFa^AO6lj%-}n5)t(l0U$hXEQqS4ki+>l& z&QEHrN%frxjr5(`=@NCG&PQg|LKnI!smP70y{6Ijef@U_D=eDIM7 zOpY+>nw2cwP>R`8Fi~N7&|RA!EDQlF-P`C)5KgCKfA`tc*0k1&&NA~Alc0VpC1T&S z*4UUy*aEI(0XMsAq%>Km?^p1=aIUmC9mN!la(&-Xz$_CjW=VH1wlQiD4MxhpaPjM? zk@x#jpynB1EH7+T;@B$kHVsi?X8N)V^0VlBk0`#-A6@2$rd-;@s`gkn~5 z_0Eku%lp0Bxo+e0d0WP(p=BtZubZ8P%ita>lljWG!q3XSdtO9M1C7I}!l$`uu7t!Y z7LiVO`4I$h{Nv~KbrCCy;u@%8MhEITsorFazg*~7mqgFds>!L zvH7HwoU^7VrcTWeOQ)U0DD>=@-vLc{BVI`krwqh(_8|tHL0bGKl9N)9C<_%S)63$I z;rxT{FwsP`*gBIPOEDoJr3}Sd;3!g8>vHVFRsAMKzA$06agQz z`a)a-$gFP5iN#`^_*{+ye*T;D_y0YjXpD;Rbz-*fBQ_=(?9TL$^7e&ZbwU?6)t-sh zw(19`m@BSipeCN@>TCbUb$Gw)U!x5QV=Qr8QlGxZ^!{iZDUP$fiM9vb>PoCCi_TB{fBJ$m?lx zVvK{{Y_#o8KB+Z`tu<{dzz+$?M3pHC;a-~NY!%H&05g?J3!b8tk>_V^3ul%KXD{Cl z`+`6>6&4n7Rl0w*N6II#x;*^z-5|RNPYTmhl8@EjO8cz5M>_R7^^Hc&yxWEK+E%>( z*JpiGI?0(8NuTt9eCSRTPZ{~6zYn!Vh-b;QrITD)hyG)uTbdxu_zz9Ugm!9K9~vN> zZ}fx?TZZ3@p#7HRbhnp}z>P{*sO#z?6hFq1CAMl%Jj8ta8hD^&t<$i&YZX8v$g;jP z!SL|15=K@KHg`mRda~~rifcp%M_y48sQw^$;EoORb%?bQR;CWzCLMEt*v32 z4s%>#22VRa%=x-^&dVFDRQpIucyy%c<(6lzq#rngpTGSio2OJD!P9;RBTJol&dmrH zLQC1WbDz63!1ja|BdZgK=Bm@sez@A$dUEF~%>E-d<=NRnfm*?=(VTxQ@3+#<)x(B^ zBmTe>qpxb0lth@u_~-uyF#g-YFrYCF7*q;-Jk#gZ_-BzE?w~S3-YK?7kYJ8~gYFv$ zLWKUh5JcPcDD0rPlyT~PHOqCeMon|Ag^$LpaiYZ^b~ei@!6E*%CxmQRGO3rF zK!Lj=@=qeO@`VPdvm!p6Q*01v>98B$Z{G`nHSy_gPPf{%%igmgzM1dx;{KBk+PdX= zyW(T_LTxsYR&##gg0htbVH2DdkT;U1&w#g=QU_y5WXe#K6aO z2EaZQ-y_(h=-5I~zs(S*PCg5&wHl&LvV8QrE-c1k;OT=hPuY(y&E2D>w#r)pRr8ol z5K_4o?aS0*Ah&%M63mXqt8;kdE$0tg{NA~X6Hut>N-U&47RLZ13-v;$%LX#4s)dXm zUS&^fF2^B>;3gNRF&gF_J2*GxEiHh^c@3SZ33Oi4G zyoVn^yulA{Mv|F{;*U4Pv&#Eu5Pf&S!x8aUyiWVv3>T(;53PtP@Q7-AXbOnWl0OrJ z=xITSY<4BO(H-9)TP|*2=n1I2nzUVyLhQ%1&G(8EYWp=PbNLTdW@vJX=0s)|8MOH)2lULK>y zx#ysQEXkhp9jS4J{Gy>VV^CKza-sELz~bdI~+?3KE^qE*BeeM=5UwP5EwI{Vih#l;@osJRLX9MmBu!FeRKP<@G zED6{E6P20o&ouLfX;yyeudue>Z7UgUEB5eq_2PS(_V8A@@J$}Qd*ZxtScl2sfHvzR z@W_=g%ze6-85+B>9uq{vYrY`QopcSjyFu_SmB?ABk^2yfTaN=81UGo{Y+L7^e4sKS z!E_p!@=u<3IPkjZG`coc4`TvsfxM^?OHm1U^eBX_@h*hRDrYIoL!sKijYBcTg1uVu z%)X&b|9#HW-RbZZrj)d^Y0q-G^fb7!gs|2qTz9Olam3i+Wny$zqLuhhVrbfH{eK#B zj`k`NoooB>dUUunK@)3G=rV;;S9st0e(n&c76fu@ut^j*9=2-fo@qF&3sN-z@7rCf zEQE$s&i=qAW2v28ZmU_goPvdeI7Q88H@Z7!clu@zy-NBJ@6cUMS$oUYyxnbi3N}0) zZ6VAro@-m_(c4%{-TrXY9WE;}e)N4-VrYIWfcgV>Q;xZGUU?~2CuN8fc_6O@+q1f+-J+_F9L zg)Jzbr;q8*j{7UBf0|ZgXk~QP+}}$5lI}=+-#T8y%_(xZcu*d%cp=7zLoAyl^5fI? zz?jepE7bBrEwN&o)9p0V!FYS$Jkc=Gl%+ktyf0x-bw17d$6c%6MO__<#M-;nheexA zP1auCUa86x{;xV#88dLuk*(YqNm4P#yPiSB%9BPS*&gdzehq-cl^2xHwsHE%wY|>04|+?95l(mewq?Je0#67^+`S9DTnHdXuk*fQw1E#s(y> zi))#&`20P|Y^vS=7pM2{LSEA2{IkV5;}*Q@nG+$LJZo~rh7gN4w|a9GGKMC;mI94H znKz$|%w0G@KW)PLJl<(jy|EuOC+xloI$Y5-)$(3gr|a`Lq&w1xyER_B zWa}WQIm>%>HW#knc5C#iaI8vo0Lm}^>^rdY%t^pPA;pBrpyB)_Ssd)LF0PM?|DTFb#q# zTJ##g1vAE5W^PQ8gVJs7*1^BW)3jWD)h>~?Hjjo_D>C6-ooPzNc6X#^KTq~|1@2Tz9raC9yJ<*kPA6%uLf2Mj!|IJpd;(bd)Cd|=@ z3rslZE@v?!>1bnkH3todgRn`6#tS{SnegHKxR}DiR%|vVJv-vpQ9YTgM(|jHjx`$Bg_QkzHu4&-c zk&DWVSh2z>80#&I4i+{?o2Sc z)!Tf~Dx7VBNC9)aAhr7ti{if1a3Kg{x!@Id;hM63Ki}%$NToa@uKx4j4!93zC%NQ= z>|(mi9!RCSaKFq~m(G6zQ^ZA_eIhtxHI~Yusb-R_3RHNH0d}%Db-LXJj6&M;c{hB< zSpbT)XnnHXRpu4YC~{n;y`Rn4w-7DRqRu`hOP!J;%NE=3_Dg||m?T-`G!d`Obpdxb z(g-G81ETGR>S4$zsep+G99~kHQiMF#2>l|{T+EB<+E~;Si}4d5Db!^0D@1xhzJ=6W z?G%%-;w84=eFI1H_O_mC_~5h?`_*VMB9{1a{)^4IZj%tSWTZ+2${DVs5RHk5(m-TK zYrK%3lMnH6rf>@m+vuT0J!)+YB@OV;HuY4Lm_fd+)3P7!sT^V@CL{E^O+nlr?@cY$ zp!#(lx1NVB4qj?tVrnsPJbm|^7Otfk=L0W)aZn~vAXm{6au~hu*!EY;fcVT(c^*vE zX2+Lz077>AS&a_WX{Y;qM~lRuA0`pbUQ0*BG5kmm0y|+u*q`|B96T<3Sr#sW%Lsw@ zi>c`cxoOu%i@fbqsg|%{&Rl(dneQ2eqXt>UtD3%EeMuH>7HZy@VR!_nnIZ&?KxQ2qtJ=x0~^q$9i1cX2L=xDq{u)qzU zmEsnY#9OBlxmy*Y@tVkYOHP^r8{YRe{x9O*Gpeb*UH4|$P(hI1jf#j;r5AxLWeEZT z(mPpzh!Bc22@uGV-cb-xs+7<}D4|G;5CQ@M(k0Xc=`8_?5kh?DdiL38oIT$4jPaiF z?laB@M!tl=ob#W5x$f)w-ORvQx=(Yb=dKi5Hy#CdB8k|XE0Epm9DXGg_Tv|?jITd2B zo)j`v7O5e(WEe=-lb*{obk!VMOH!0}UN#DoliqcSS6t=}I`xyV;!qfsdXMHtln(v4 zaA6cKP4WT35=r6Br6La(XlwXPf>J3Km9t}eY|?xjhK>DxBKt7Oy{Y;625-bzFYDK@ zc8UzI{%m)J9@h`9!dcAkOi3E&Bo%t511Q3G4P-{#m+OyillZ251yaN>7>J_yw#yrJ z&nBQDSv-1e>eg`|eG?+mD)?@0Z*sehx65BREn4#n=MVJ%D{g^ZQ5Y+$B*w?On7(`7Xos^1o2Q;1QK=ovTt>_1>H*7G(n!~A4ty}CdksqEcqvY+Y6 z?zzq@=~RO3y!om(_e4>4Nr9{YA;**(H$4g4s+b=Ce!cJ zP?X!=hixd&tzS(1aQ`sMX*};vplg|ph|IX{#I*=$80VaO_vZAYhxE6yK7m3RVl{K@ zS0)iIKuu?J@v^MewLwZ7wG7kVL9wGXw#$`;f4QPkM;7bAZ59x)Uo-PLW0u@A7x?tR zUzWd!-g}bmcEkh`O~_TY>ONEup1qVvI{IqgcEQMWz~2^rEzfx+&m3||ZRPp!9opwi zg)i^)d~B+mF}g%!))&PATWkw)k| zV8%u0(vU7*3rzMxEq*kniBB#=3s~tcl{7B=^UeakzMQCt*&cO;zOb_>oNQL8ck;^o z?~hDRm12#xSl}1N!q01yZYiSU3Iy82J4Le$GTmrc3b+T%={lD3CTm0R87FjqfS?f+ zaWae)sp0*Js?PSzv8E88(4Gn&KHWEW?$tgC)MuBMLFKI*JkY% z2h);85jGTo#e-CMP9L5EU&B8BkbXD2n-@}((d(K%+L} zr-?tD&>34@VLdy4BL&+l`w}>;0gl1^!5%HvlTwBhoUZ}%1r-nvms`q{{ivS#GBnr+ zQL&&`Gyi~uX4z8dGi!}uuw3Y|sw0G@KdRr~8S~My39&T5rH?q;87J48^fRhV}yfI~uAI$`WP2-@PS;xh*ra zJ8$GA&l2jTY{NZ!e)(nG6(K;4bKB7-%JR=_V!03=I|`(=2vQ?9;h>wDiXf-{)hO9k zv6HWB$v1A+VwDyXDDBB!rZL0K7GYeTWM8uag ztNJnTyH$tH6BAaEaQE7iQkfO<_F}_Gd^pRqM4u~_8YZrk$Zm!L<&1i6y>fnlOUWD! z+!Mlj20M1h)+v^TL4et4q5I2pax%iNTAZOJP z?hA+~dDq!_>=&SQb)(pZ;iBed&X*NAU7o!YzZ+TXKYQGN=iS(dY7mE%MbLeln?edG zv$$GAgUcD7FRV23qb00uBrY0o7^vc_obObcyGYb0|JMD*Y^`*6=Tpf^(V~s+>l>JP z!(Gg@(NV%r(Qfc%gl8;eDyxvTNA;sYe3qc|&IS$!efgZU9kKa^*kbRu_{!!erBtSw z1&?VVUXGi2&`LX%camN$U!w{vX@e96JecLXp`;DDxeFTy5?JY&G)yTjqN``&B&*k{ z*T+apn+NwuVgC1;+nWyxQ$E|x%8j5{?>Eoj59Lgj#%TQkRT1%TZF0$rWxkCT(Oj-#!nrb%37;!3c6i!!lkU72US zvW6uj#$gyD2FpVir8vt98xjT3E0EX%PPh}fxTmVzx+AfR^o!~AJkGVI%d=+J+tbo6 z&T;tXi@u;UiHaT^FSRltB8=W6@JxVgGD8Yj<5Wm%xDBmaj4z$aw5*_|Urb5)9HaDW zBxxZN3MdxN$6I(MnJu2=!#%U*$d|cn>@KA=9_p?%RT3B)G)}bpBQul`yEAfp=h-5M zOego&B8MzobGglj5BxGqce?#Iil`o@gK}#UTT84PYu64Y^0zBh=>}KuG}m>EHzwxX zt)&dI?r_Jg%?!hY0WtgDCizkmvpdC7 z%0ent`CqI*YuY>VeCZy|g|?am8srJ6H(t}>$}Ua!kj%sT-RmEG-EU(!{RET-nX~YlQQmNUh(g`(nwBak24odfWhx~ z50_MEbFN!iyH7^vZs>BZS8G&u>B|HZ$7MVO)Sq1Q;-Ta`w3`>&4NC1mAtdKI9&lS; z|Cc{O{SeT@4i{;d{?LDX2__jBZv3rhR8?%*bsWGyM769YV$HJk;`K`uzv7-VOo`RHenta?%t z9r?!s!5MHv(j`A>(cftZB&@Xi*6OirY`~B~PqzA;y^UT)dmSEzK<_8ji&S+)>CX%+H(jx=cudT#%wRod710HF>%-mNI@f{Pl3A55n%8c&_PS8sYH&+T zfn)uWK($pp?c+%f-Wn8f&~1zK)!e%LYm`9qqBpL$n*|k%xif&m(7s86Rx%|uh0Z}f z&`s~r(xRkBw{yJj)*nQ{+8|@?YDm$uZnp?AbwO?(Am@fN2SKkcFp)k0XS4$TZ~?eC z`EV^UoQpR2kTM-ptuzRXBm1pvp%LX=r962dy*RCIC)AT?)Xv|Bc1{!483It93ruMz>84)=2= zo;OGw^m>$&0l1>)0N-qh+4}Y!7If*aXg$C2;+V2nRNk^&OM;P1pf7(l8`lL@CH)jR zbMJzx<4(iMP=n3AvxYh+oJL-alP$7n>KrwJ3a-R{Ufa+LB z;Uh8kdI>IDO>2N$Q9{zXj^xyraDgId)~or24PV3Zb>4-Wlix=$?M<&eIIMN{A9Z$6 zQoBGL@GxScG#3%wD{Z%wS(CkjTCF0XxVo=!Q!>lSpK;81DF4U@VK9_1)NT&aFgVEH z37-8;d3=E&qBx5aFz9#Ce44P+Ly-pWuolY=sQSn3Uwo$oC^*70%pAE+2dsaXuJ*Zc ztD=Q^tUX6K)YQ|=E0@Y$zgs-Gs1};cs!`Ewwh&cllq<{||6oY(7n9=-2Lnbi zK9Fm8gIG&2>l|oO@(c0PH-3vqqYUNR002xQz5IDy->*WwsLDd$G+9r_%rYmE3Lk7R z|H*@~y^)pq1MTk)4F;tLd-(*Nhplkfk*L`o82Kxa9z7h-AqR{IJ`BxtW>&B7X zRznARNWR;u_-6j5IkSup*@@n_2L|7jAs&f-^fS6GDAgM95d7 zDMDs(Gx8h0H2)sk1@wFOQcc}pJ=uj{On-FB&-`>MZssuTh{~~Qocb=!)kCn@jV2ii z=~K?dSKOaEN=Zg-Ol8+(AiR5cBYqfTV@PA0yjOV>U{CJx^j3J(Zgy3+& z!y!DdmUarlREP~Q-|Fjt`5Z%z`9PBPRl3#4;Wut?P;N*PxSlz)Dk_qS5wEY9gLG*OO=n(TePL(>!92(sNk6+t(1eDxIujF;=(94X2+jrf&*&MARpX z#l=cKI4^DJTRkqFZ8NlJ@Lz#u)Y#Z%l$CBALf$QMV3$%C$;QT@tufe$?nwa#9RcDv z_fx74n2$mTA zi^&^EY$G~OvJgw<2p;&oV%zYeMIv^mi#*;1#tgr!_=Mfu_70bN)=wTMt!6y=aI2Z6 z3fdfV`Ru2*4|oFOg9$`Z3VrI^no$JK+sZ=WA4lGQZY{$3hDPEYg1$E$<6TqeT_SU5T3leWBTyvXF0v#b3R;Fe*lU0 zAqM`1Iw~th3p@w+I7w`WBILWgh~QbSrJx>A#L$f>ks(mB_ji;FF`$-71v#Om5gHn2 zOz?V={Z$cn_@chCgXIs3azmM>XQd+UI^T;rJf7yAHmXzD-^ujdWv^)VafN)AT5^A3@yh3Nj>UUZoxQ zlzO}(h}%eao)7;*b>yYUl!ac;kS<$a(3-pv^QUuy!B+)XcB?p@*M}-uH|g0fM`0{4 zL$)23?S4k_E^wXHX`Q3rqe+eFMc5QmP*WMzbeT-1bKhGN`%-;;FYqbX8+!^`@IoUD zB@V?S(_gIgjiMqaH(X6IT4?c5vB5<5X4mk@egy$>DJ6;EC{CXOsh-E56>Xa~oHoO} zCHk93UM3pY*r7KMdDa%$w4~vZG<=~hsUQl$33xzF$)5{Z!>`iFrfSGm{so}7Z$o=IAoem`9(pyO)__*D8G|8}eN2PGBX)-f2;Ln{pCiW`|mKQ!V> z^mv@>dI67`C{*qCg;u_Co;FYY_i>wl4|<6FU+`7m5B^{Hs()==|Cg}1)+3r6MI6XN zpOhjzQ)9J6r)EvbecdNMOP={m~d%W3Bkj_9<f`0-eySSJe;56dG%Zj}TkBFENrt;E84B6dwo;lS zr0CM+wCM*C0sx-!VFSgh2?>s_ku42{E#)cQbr3a$8s8)liwvx6qV5)3?MSY-6z~W? zb&Ex_rk*&aQrTHXk0OxUJi$(?(A!-j>QTHx=W}5RrJTY0Rf47#$hQ-y!z8;3DGLXQ zwtgjcw_Q{Frp6tk+as=YosLq%f#Y6KA8n1e_ZLH)7VdKq4$z>Z+k=TY@k^~azf)o& zOJS)GL)%Pyrw)$6mQ8Jqc5RkHP-scAxAe@r!q@!i_OacFzaZy2&x*%6)9#VCa0vFR z6o<*=y*TN1rDs&-){NmCp3lmh{T^bWu<#+C`2n5G(R=({Y7%;9LQZ(_2AU(cT&
J>D|gm?MqCCkOIWE|_H} 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 RPQ&?c_5FX2|5}>C{{Xbh0RsR4 diff --git a/docs/images/wetting_angle_kit_3d_droplet.png b/docs/images/wetting_angle_kit_3d_droplet.png new file mode 100644 index 0000000000000000000000000000000000000000..5df0088ef81e389089f6850edaf46dd267b4262e GIT binary patch literal 35172 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%0D})dE;pXM=&w6()cmp0 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 A&j0`b literal 0 HcmV?d00001 diff --git a/docs/images/wetting_angle_kit_cylinder.jpg b/docs/images/wetting_angle_kit_cylinder.jpg index 6ab0583337027862f36e8c42eb199ee0da63fbbc..8ad859a827c8c9911cd158fc572e9ee371f08d52 100644 GIT binary patch literal 12104 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 ZoTT&j3Il_67SbICg!UWIS)Tv@O#p-&Wy1gf diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 0dadae2..1b09bb3 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -63,7 +63,7 @@ The geometry choice cascades through every component: * - .. image:: ../../images/wetting_angle_kit_cylinder.jpg :width: 100% - - .. image:: ../../images/wetting_angle_kit_3d_droplet.jpg + - .. image:: ../../images/wetting_angle_kit_3d_droplet.png :width: 100% * spherical droplets are treated as fully three-dimensional objects; From e77af5f5528f20b2d401afb481792cfbb52b6d9f Mon Sep 17 00:00:00 2001 From: Gabrieltaillandier Date: Thu, 18 Jun 2026 19:09:07 +0300 Subject: [PATCH 49/53] docs: update slicing tutorial, add coupled-fit 3d example, and compute median angle in results. --- docs/examples/coupled_fit_3d_ca.py | 82 ++++++ docs/source/examples/index.rst | 13 + .../introduction/theoretical_foundations.rst | 14 +- docs/source/tutorials/coupled_fit_3d_tuto.rst | 26 +- docs/source/tutorials/slicing_method_tuto.rst | 234 +++++++++++------- docs/source/tutorials/whole_fit_tuto.rst | 6 +- src/wetting_angle_kit/analysis/_base.py | 11 + src/wetting_angle_kit/analysis/results.py | 14 ++ wetting_angle_kit_JOSS/paper.md | 26 +- 9 files changed, 296 insertions(+), 130 deletions(-) create mode 100644 docs/examples/coupled_fit_3d_ca.py diff --git a/docs/examples/coupled_fit_3d_ca.py b/docs/examples/coupled_fit_3d_ca.py new file mode 100644 index 0000000..ebbdc46 --- /dev/null +++ b/docs/examples/coupled_fit_3d_ca.py @@ -0,0 +1,82 @@ +"""Coupled-fit 3D contact-angle example. + +Runs the 3D coupled hyperbolic-tangent fit on a full ``(xi, yi, zi)`` +density grid via :class:`CoupledFit3DAnalyzer`. The nine-parameter +model fits the spherical-cap interface and the wall plane +simultaneously, recovering a single robust angle per pooled batch. + +Only spherical droplets are supported — cylindrical droplets reduce to +the 2D coupled fit by translational symmetry. +""" + +from wetting_angle_kit.analysis import ( + CoupledFit3DAnalyzer, + DensityEstimator, +) +from wetting_angle_kit.analysis.temporal import TemporalAggregator +from wetting_angle_kit.parsers import LammpsDumpParser, LammpsDumpWaterFinder + +# --- Step 1: Define the trajectory file --- +filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj" + +# --- Step 2: Identify water-oxygen atoms --- +wat_find = LammpsDumpWaterFinder( + filename, + oxygen_type=1, + hydrogen_type=2, +) +oxygen_indices = wat_find.get_water_oxygen_indices(frame_index=0) +print("Number of water molecules:", len(oxygen_indices)) + +# --- Step 3: Define the 3D grid --- +# xi/yi are in the droplet-centred frame; zi is in the lab frame so +# the wall position retains physical meaning. +grid_params = { + "xi_0": -30.0, + "xi_f": 30.0, + "dx": 3.2, + "yi_0": -30.0, + "yi_f": 30.0, + "dy": 3.2, + "zi_0": 0.0, + "zi_f": 60.0, + "dz": 4.0, +} + +# --- Step 4: Pick a density estimator --- +# Top-hat histogram on the 3D sampling grid (default): +estimator = DensityEstimator.binning() +# Swap in the Gaussian KDE for smoother per-cell density: +# estimator = DensityEstimator.gaussian(density_sigma=3.0) + +# --- Step 5: Build the analyzer --- +analyzer = CoupledFit3DAnalyzer( + parser=LammpsDumpParser(filename), + atom_indices=oxygen_indices, + droplet_geometry="spherical", + grid_params=grid_params, + density_estimator=estimator, + # Pool all frames into a single batch — the 3D density needs more + # atoms than the 2D one for comparable per-cell noise. + temporal_aggregator=TemporalAggregator(batch_size=-1), +) + +# --- Step 6: Run analysis --- +n_frames = LammpsDumpParser(filename).frame_count() +results = analyzer.analyze(range(0, n_frames)) +print("Mean contact angle (°):", results.mean_angle) + +# Per-batch detail: +batch = results.batches[0] +print( + f"Frames {batch.frames[0]}–{batch.frames[-1]}: " + f"angle = {batch.angle:.2f}°, " + f"R_eq = {batch.model_params['R_eq']:.2f} Å, " + f"z_wall = {batch.model_params['zi_0']:.2f} Å" +) +print( + f"Droplet centre: " + f"xi_c = {batch.model_params['xi_c']:.2f} Å, " + f"yi_c = {batch.model_params['yi_c']:.2f} Å, " + f"zi_c = {batch.model_params['zi_c']:.2f} Å" +) diff --git a/docs/source/examples/index.rst b/docs/source/examples/index.rst index dc4fda6..568ca35 100644 --- a/docs/source/examples/index.rst +++ b/docs/source/examples/index.rst @@ -53,6 +53,19 @@ KDE). ---- +Coupled-Fit 3D Contact Angle +------------------------------ + +Full 3D coupled hyperbolic-tangent fit via +:class:`CoupledFit3DAnalyzer` — nine-parameter model on a +``(xi, yi, zi)`` density grid. Spherical droplets only. + +.. literalinclude:: ../../examples/coupled_fit_3d_ca.py + :language: python + :linenos: + +---- + Visualising a Per-Frame Droplet Snapshot ---------------------------------------- diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 1b09bb3..569d47c 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -48,13 +48,8 @@ that the recovered :math:`\theta` is meaningful. Three geometries are supported via :class:`DropletGeometry`: * ``"spherical"`` — full 3D droplet with no special axis. -* ``"cylinder_y"`` — cylindrical droplet along the :math:`y` axis - (the internal frame's cylinder axis). -* ``"cylinder_x"`` — cylindrical droplet along the :math:`x` axis; - internally swapped to ``cylinder_y`` for the analysis (atom - positions are permuted, then the result is permuted back). - -The geometry choice cascades through every component: +* ``"cylinder_y"`` — cylindrical droplet along the :math:`y` axis. +* ``"cylinder_x"`` — cylindrical droplet along the :math:`x` axis. .. list-table:: :widths: 50 50 @@ -66,6 +61,8 @@ The geometry choice cascades through every component: - .. image:: ../../images/wetting_angle_kit_3d_droplet.png :width: 100% +The geometry choice cascades through every component: + * spherical droplets are treated as fully three-dimensional objects; * cylindrical droplets exploit translational symmetry along the cylinder axis and can therefore be reduced to a two-dimensional @@ -137,7 +134,7 @@ the interface: **Ray fans** The :meth:`SpaceSampling.rays` factory emits a fan of rays from the droplet - Center of Mass (COM), samples the density along each ray, and recovers the interface + COM, samples the density along each ray, and recovers the interface position as the half-density point of a 1D tanh fit on that ray. In such samplings, the interface is recovered by fitting a one-dimensional @@ -266,6 +263,7 @@ averages the per-slice angles. The whole fitter droplet, exploiting translational symmetry along :math:`y`) on the entire shell. + 5. Locating the wall plane -------------------------- diff --git a/docs/source/tutorials/coupled_fit_3d_tuto.rst b/docs/source/tutorials/coupled_fit_3d_tuto.rst index 198b391..7b83d5f 100644 --- a/docs/source/tutorials/coupled_fit_3d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_3d_tuto.rst @@ -14,7 +14,7 @@ hyperbolic-tangent density model directly: with two extra horizontal-centre parameters :math:`\xi_c, \eta_c` over the 2D model. See -:doc:`../introduction/theoretical_foundations` section 5 for the full +:doc:`../introduction/theoretical_foundations` section 6 for the full model. ---- @@ -70,14 +70,14 @@ wasting work. # 3D grid spec. xi/yi are in the droplet-centred frame; zi is in the # lab frame so the wall position retains physical meaning. grid_params = { - "xi_0": -40.0, - "xi_f": 40.0, + "xi_0": -30.0, + "xi_f": 30.0, "dx": 3.2, - "yi_0": -40.0, - "yi_f": 40.0, + "yi_0": -30.0, + "yi_f": 30.0, "dy": 3.2, "zi_0": 0.0, - "zi_f": 40.0, + "zi_f": 60.0, "dz": 4.0, } @@ -171,21 +171,21 @@ the same angle within a few degrees. It's a useful sanity check: # Same trajectory, same frames; pick comparable grids. grid_2d = { "xi_0": 0.0, - "xi_f": 40.0, + "xi_f": 30.0, "dx": 1.0, "zi_0": 0.0, - "zi_f": 40.0, + "zi_f": 60.0, "dz": 1.0, } grid_3d = { - "xi_0": -40.0, - "xi_f": 40.0, + "xi_0": -30.0, + "xi_f": 30.0, "dx": 3.2, - "yi_0": -40.0, - "yi_f": 40.0, + "yi_0": -30.0, + "yi_f": 30.0, "dy": 3.2, "zi_0": 0.0, - "zi_f": 40.0, + "zi_f": 60.0, "dz": 4.0, } diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index 2ee6433..5a6d379 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -8,10 +8,14 @@ interface-derived wall detector. The slicing pipeline is the right choice when you want a per-frame angle trace plus a sense of the spread across slices. +.. contents:: + :local: + :depth: 2 + ---- -1. Overview ------------ +1. How it works +--------------- The pipeline does three things per batch: @@ -31,10 +35,88 @@ The pipeline does three things per batch: line; the batch's reported angle is the mean across slices, and :attr:`SlicingBatchResult.angle_std` is the empirical std. +.. _ray-param-reference: + +1.1 Ray parameter quick-reference +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +When configuring :meth:`SpaceSampling.rays`, the required parameters +depend on which ``(surface_kind, droplet_geometry)`` pair the sampling +is paired with. The table below summarises the mapping: + +.. list-table:: + :header-rows: 1 + :widths: 30 30 40 + + * - surface_kind + - geometry + - required ray params + * - slicing + - spherical + - ``delta_azimuthal`` (+ ``delta_polar``) + * - slicing + - cylinder_x / cylinder_y + - ``delta_cylinder`` (+ ``delta_polar``) + * - whole + - spherical + - ``n_rays_sphere`` + * - whole + - cylinder_x / cylinder_y + - ``delta_cylinder`` (+ ``delta_polar``) + +.. list-table:: + :widths: 50 50 + :align: center + + * - .. image:: ../../images/wetting_angle_kit_cylinder.jpg + :width: 100% + + - .. image:: ../../images/wetting_angle_kit_3d_droplet.png + :width: 100% + +**Parameter glossary:** + +- ``delta_azimuthal`` — azimuthal step (degrees) between slicing planes + for a **spherical** droplet. +- ``delta_cylinder`` — step (Å) along the cylinder axis between slicing + planes for a **cylindrical** droplet (used by both slicing and whole + modes). +- ``delta_polar`` — in-plane ray step (degrees). Shared by every mode + *except* whole + spherical (which uses a Fibonacci ray fan instead of + per-plane polar rays). Default: 8°. +- ``n_rays_sphere`` — total number of Fibonacci-distributed rays over + the full sphere (whole + spherical only). Full-sphere coverage — + including downward rays — is intentional: downward rays from the COM + hit the wall plane and produce interface points at :math:`z \approx + z_w`, which keeps :meth:`WallDetector.min_plus_offset` consistent. + +Passing parameters that don't match the ``(surface_kind, geometry)`` +pair is silently ignored; omitting a required one raises at +construction time via +:meth:`SpaceSampling.validate_compatibility`. + +1.2 Key tuning knobs +^^^^^^^^^^^^^^^^^^^^^ + +- **Slicing step** (``delta_azimuthal`` for spherical droplets, + ``delta_cylinder`` for cylinders): smaller step → more slices, + more detail per batch, more cost. The default 20° gives 9 slices + for a spherical droplet, plenty for a stable mean. +- **In-plane ray step** (``delta_polar``, both geometries): smaller + step → more rays per slice, denser interface contour, more cost. +- **Wall offset** (``WallDetector.min_plus_offset(offset=O)``):\ + raise ``O`` if the interface-derived baseline lands slightly into + the wall layer (visible as inflated angles). +- **Surface filter offset** + (``SurfaceFitter.slicing(surface_filter_offset=...)``): excludes + interface points within this distance of the wall before the + circle fit. Raise it if the wall-adjacent density is distorted by + layering. + ---- -2. Requirements ---------------- +2. Minimal working example +--------------------------- Before running the example, ensure you have installed the package with the ovito extra (for LAMMPS dump files): @@ -48,11 +130,6 @@ Example trajectory:: tests/trajectories/traj_spherical_drop_4k.lammpstrj ----- - -3. Example Code ---------------- - .. code-block:: python from wetting_angle_kit.analysis import ( @@ -106,114 +183,81 @@ Example trajectory:: for batch in results.batches[:3]: print( f"Frame {batch.frames[0]}: " - f"angle = {batch.angle:.2f}°, " + f"angle (mean) = {batch.angle:.2f}°, " + f"angle (median) = {batch.median_angle:.2f}°, " f"per-slice σ = {batch.angle_std:.2f}°, " f"rms residual = {batch.rms_residual:.2f} Å" ) ---- -4. Expected Output ------------------- +3. Understanding the results +----------------------------- On the water/graphene fixture above, single-frame output looks like:: Number of water molecules: 1320 Mean contact angle (°): 95.16 Std across batches (°): 0.0 - Frame 0: angle = 95.16°, per-slice σ = 1.86°, rms residual = 0.45 Å + Frame 0: angle (mean) = 95.16°, angle (median) = 95.02°, per-slice σ = 1.86°, rms residual = 0.45 Å ``std_angle`` is 0 here because only one batch was requested; pass a multi-frame range to see the spread across batches. +3.1 Per-batch fields +^^^^^^^^^^^^^^^^^^^^ + The returned :class:`TrajectoryResults` object holds a list of :class:`SlicingBatchResult` entries (one per batch). Each batch carries: -* ``angle`` — mean contact angle across slices (°). +* ``angle`` — **mean** contact angle across slices (°). This is + ``nanmean(per_slice_angles)``. +* ``median_angle`` — **median** contact angle across slices (°). More + robust than the mean when one or two slices are outliers (e.g. due + to asymmetric density near the periodic boundary). * ``angle_std`` — empirical standard deviation across slices (°). -* ``per_slice_angles`` — array of per-slice angles. +* ``per_slice_angles`` — full array of per-slice angles (``nan`` for + slices that produced no valid fit). * ``slice_surfaces`` / ``slice_popts`` — per-slice interface points and fitted circle parameters (for plotting; see :doc:`visualization_slicing_droplet`). * ``z_wall`` — wall position used by the fitter. * ``rms_residual`` — mean of per-slice circle-fit RMS residuals (Å). +* ``n_slices_total`` / ``n_slices_used`` — total slices vs. how many + produced a valid angle. A gap signals per-slice attrition. ----- +3.2 Mean vs. median +^^^^^^^^^^^^^^^^^^^^ -5. Tips -------- +Both ``angle`` (the mean) and ``median_angle`` are computed from +the same ``per_slice_angles`` array, ignoring ``nan`` entries. +The **median** is the recommended default when reporting a single +number, because a single outlier slice (e.g. a nearly empty +azimuthal plane near a periodic edge) can pull the mean +significantly. When the distribution across slices is symmetric, +mean and median agree. -- **Slicing step** (``delta_azimuthal`` for spherical droplets, - ``delta_cylinder`` for cylinders): smaller step → more slices, - more detail per batch, more cost. The default 20° gives 9 slices - for a spherical droplet, plenty for a stable mean. -- **In-plane ray step** (``delta_polar``, both geometries): smaller - step → more rays per slice, denser interface contour, more cost. -- **Wall offset** (``WallDetector.min_plus_offset(offset=O)``): - raise ``O`` if the interface-derived baseline lands slightly into - the wall layer (visible as inflated angles). -- **Surface filter offset** - (``SurfaceFitter.slicing(surface_filter_offset=...)``): excludes - interface points within this distance of the wall before the - circle fit. Raise it if the wall-adjacent density is distorted by - layering. -- **Cylindrical droplets**: pass ``droplet_geometry="cylinder_y"`` - (or ``"cylinder_x"``) and configure ``delta_cylinder`` instead of - ``delta_azimuthal`` on the extractor. - -For a side-by-side plot of the recovered interface and the fitted -circle, see :doc:`visualization_slicing_droplet`. +The :class:`AngleEvolutionPlotter` supports both via its ``stat`` +parameter (``"median"`` by default). ---- -6. Alternative configurations ------------------------------ +4. Common configurations +------------------------- -6.1 Cylindrical droplets +4.1 Cylindrical droplets ^^^^^^^^^^^^^^^^^^^^^^^^ -For a cylindrical droplet (e.g. water on a periodic stripe), swap -``delta_azimuthal`` for ``delta_cylinder`` (the step along the -cylinder axis) and tell the analyzer which axis the cylinder runs -along. Pick ``"cylinder_y"`` if the periodic ridge spans the box -along the lab-frame ``y`` axis; pick ``"cylinder_x"`` if it spans -along ``x``. The package handles ``cylinder_x`` by applying a -self-inverse ``x↔y`` column swap at the parser/analyzer boundary so -all downstream code can assume the cylinder axis is ``y`` — -analysis logic isn't duplicated between the two cases. Picking the +Use "cylinder_x" when the cylinder axis is x. +Use "cylinder_y" when the cylinder axis is y +Picking the wrong axis is the cylinder analogue of confusing the in-plane -radial direction with the symmetry axis; symptoms are slicing -planes that go across the ridge (almost no atoms per slice) and a -fitter that either NaNs out or returns a non-physical angle: - -.. code-block:: python - - analyzer = TrajectoryAnalyzer( - parser=LammpsDumpParser(filename), - atom_indices=oxygen_indices, - droplet_geometry="cylinder_y", # or "cylinder_x" - interface_extractor=InterfaceExtractor( - sampling=SpaceSampling.rays( - delta_cylinder=5.0, # 5 Å between slicing planes - delta_polar=8.0, - ), - density=DensityEstimator.gaussian(), - ), - surface_fitter=SurfaceFitter.slicing(surface_filter_offset=2.0), - wall_detector=WallDetector.min_plus_offset(offset=0.0), - temporal_aggregator=TemporalAggregator(batch_size=1), - ) +radial direction with the symmetry axis;could lead to NaNs +angles output or non-physical angle: -The mechanics are identical to the spherical case — same Taubin -circle fit per slice, same cap-angle formula — but slices step -along the cylinder axis rather than rotating azimuthally. The -fixture ``tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj`` -in the repository is a cylindrical-droplet trajectory you can use -as a worked example. - -6.2 ``rays`` (binning) alternative -^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +4.2 Binning density estimator +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The same ray-fan geometry is available with a 1D histogram density estimator instead of the Gaussian KDE. Use it when you want a @@ -237,7 +281,7 @@ interface thickness (~1–3 Å for water) keeps the tanh fit well-conditioned. Numerically the bin width plays the same role ``density_sigma`` plays for ``rays`` (Gaussian). -6.3 Pooled batches +4.3 Pooled batches ^^^^^^^^^^^^^^^^^^ Replace ``batch_size=1`` with ``batch_size=N`` to pool @@ -276,13 +320,33 @@ per fit, less per-angle noise but no within-batch time resolution. harmless; for transient regimes (wetting, dewetting, vibration) ``batch_size=1`` is the correct choice. -For physical context on the trade-off see -:doc:`../introduction/theoretical_foundations` section 7. - -6.4 Grid alternative +4.4 Grid alternative ^^^^^^^^^^^^^^^^^^^^ The grid extractor (:meth:`SpaceSampling.grid`) pairs with the slicing fitter exactly the same way and is covered in :doc:`grid_method_tuto`. Use it when ray-fan sampling is too sparse to resolve the interface. + +---- + +5. Further reading +------------------- + +- **Theoretical foundations:** the physics behind each ray-fan layout + and the Taubin circle fits are detailed in + :doc:`../introduction/theoretical_foundations` (§3.2 for sampling, + §4 for the Taubin fit, §5 for wall detection, §8 for frame + batching). +- **Visualization:** for a side-by-side plot of the recovered + interface and the fitted circle, see + :doc:`visualization_slicing_droplet`. +- **Angle evolution:** to plot the per-batch angle trace over time + (mean or median, with running-mean overlay), see + :doc:`visualization_evolution_density`. +- **Whole-fit pipeline:** for a single-fit approach on the full 3D + interface shell (with optional bootstrap uncertainty), see + :doc:`whole_fit_tuto`. +- **Coupled fit:** for the NLLS coupled model that fits interface + + wall simultaneously, see :doc:`coupled_fit_2d_tuto` (2D) and + :doc:`coupled_fit_3d_tuto` (3D). diff --git a/docs/source/tutorials/whole_fit_tuto.rst b/docs/source/tutorials/whole_fit_tuto.rst index aea605a..0632b43 100644 --- a/docs/source/tutorials/whole_fit_tuto.rst +++ b/docs/source/tutorials/whole_fit_tuto.rst @@ -105,11 +105,7 @@ the wall position. surface_filter_offset=3.0, bootstrap_samples=100, # 100 bootstrap resamples → angle_std ), - wall_detector=WallDetector.from_atoms( - wall_atom_indices=carbon_indices, - method="mean_top_layer", - top_layer_tolerance=1.0, - ), + wall_detector=WallDetector.min_plus_offset(), wall_atom_indices=carbon_indices, # routed to the wall detector ) diff --git a/src/wetting_angle_kit/analysis/_base.py b/src/wetting_angle_kit/analysis/_base.py index 02d2377..fdcf2af 100644 --- a/src/wetting_angle_kit/analysis/_base.py +++ b/src/wetting_angle_kit/analysis/_base.py @@ -285,6 +285,17 @@ def analyze( """ if frame_range is None: frame_range = list(range(self.parser.frame_count())) + + # Validate that every requested frame is within the trajectory. + n_available = self.parser.frame_count() + max_requested = max(frame_range) + if max_requested >= n_available: + raise ValueError( + f"frame_range contains frame index {max_requested}, but the " + f"trajectory only has {n_available} frame(s) " + f"(valid indices: 0..{n_available - 1})." + ) + batches = list(self.temporal_aggregator.iter_batches(frame_range)) if not batches: results = self._build_results(batches=[]) diff --git a/src/wetting_angle_kit/analysis/results.py b/src/wetting_angle_kit/analysis/results.py index 2f2c252..bd90b4c 100644 --- a/src/wetting_angle_kit/analysis/results.py +++ b/src/wetting_angle_kit/analysis/results.py @@ -74,6 +74,10 @@ class SlicingBatchResult(BatchResult): that does not reach the wall) is marked ``nan`` rather than dropped, so attrition is visible and the slice index is preserved. + The inherited :attr:`BatchResult.angle` field stores the + **mean** across slices (``nanmean``). Use :attr:`median_angle` + for the median, which is more robust to outlier slices. + Attributes ---------- per_slice_angles : ndarray @@ -103,6 +107,16 @@ class SlicingBatchResult(BatchResult): n_slices_total: int n_slices_used: int + @property + def median_angle(self) -> float: + """Median contact angle across slices (degrees). + + More robust than :attr:`angle` (the mean) when one or two + slices are outliers — e.g. due to asymmetric density near the + periodic boundary. ``nan`` slices are ignored. + """ + return float(np.nanmedian(self.per_slice_angles)) + @dataclass(frozen=True, eq=False, kw_only=True) class WholeBatchResult(BatchResult): diff --git a/wetting_angle_kit_JOSS/paper.md b/wetting_angle_kit_JOSS/paper.md index 7d7c897..97ab0f6 100644 --- a/wetting_angle_kit_JOSS/paper.md +++ b/wetting_angle_kit_JOSS/paper.md @@ -119,25 +119,13 @@ simulation boundaries and avoiding artifacts in interface detection. This consistency facilitates seamless integration with downstream analysis methods, enabling researchers to easily incorporate support for additional file formats or simulation engines. -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, -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. +The analysis module provides several complementary strategies for extracting contact angles from molecular dynamics trajectories (Fig. 2). Depending on the chosen workflow options, frames can either be analysed individually to preserve temporal information or concatenated into larger batches to improve statistical sampling. This choice allows users to balance temporal resolution against statistical robustness. + +Both spherical and cylindrical droplet geometries are supported throughout the analysis workflow [@Scocchi2011]. Spherical droplets provide a direct representation of the three-dimensional cap geometry, whereas cylindrical droplets reduce curvature effects and computational cost through translational symmetry along one direction. The latter geometry is therefore widely used for large systems or when finite-size effects are of primary interest, although it represents an idealized approximation of a fully three-dimensional droplet. + +Two approaches are available to estimate the liquid density field. The first uses a grid-based representation, where the local density is obtained by binning atomic positions into spatial bins. The second relies on Gaussian kernel density estimation (KDE), which provides a smooth continuous representation of the density field. + +Once the interface or density representation has been constructed, contact angles can be determined using different fitting strategies. Geometric fitting can be applied either to the entire droplet, providing an overall estimate of the contact angle, or independently to multiple slices of the droplet, allowing for the detection of asymmetries and transient shape fluctuations. Alternatively, a coupled-fit approach directly fits a hyperbolic-tangent density model to the density field, simultaneously determining the interface geometry and wall position from a single optimization procedure. ![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%} From 46a780263667421d5a712376af95b55e8d7be933 Mon Sep 17 00:00:00 2001 From: gtaillandier Date: Thu, 18 Jun 2026 16:10:11 +0000 Subject: [PATCH 50/53] (auto) Paper PDF Draft --- wetting_angle_kit_JOSS/paper.pdf | Bin 747472 -> 751404 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/wetting_angle_kit_JOSS/paper.pdf b/wetting_angle_kit_JOSS/paper.pdf index 29c91b27f84eae5728f9d3876a9752206531c696..49152c8ce5c774320994959f6c50898ef1676a0c 100644 GIT binary patch delta 26661 zcmY(qV{o8t)HE176Wg|J+qP|cl6xkaiEZ1qZQHh;OuWy#Z|&~4Kl)r%U3Gq5Rek#O znVn&%yk_{-2nuNm%ADd{3WWja%EcUTB6QEx+!FoOM3oTuMf}T(ji$ZD;iP$0E#i4z zWX|yIMXrlxZOptL8#~%Tws16t$>xys>}E%3L4mR7A35Q1`zlXMB!e-&y{A!T??#hc@P~fhT&KwD(h{caKQmFxtPpBi=KWysmB+$uO z*;b3A&8r14mNlq{bIZxg!g?fhuv6>v%d1A^1s2M4YxW91>rV6jssa;k^~LEbCQ-HK zJEm=x7+J5ox|X^d^ufrxiP}547w$6CtWv%&nK^W*@w$v-wC`~y@0173$UkI`2_ngS zf}ki6-a^GJZLR=}1=UsSGSy9GpPb+_o#zm#bNf_jnuPduY`w*%@dv9@>N!KA4mV=Y z0*aH$T>H)gJ=7Zd+$lq=MTziZ`>6a@seEt7#U%xDy_t1$DN9y?+nT+73uWCoHMY8a z-vzuizT*T0g8Yu+lv-?*Om*%@)1%+gfLwtYTdby;c?IYw<0OVII8?5PnvNq}_zmsZk-@_f(9l@_Zh0_8^@HYqv{M zH#0~7W|p8dmDLP)1hh~RYJVRZ7G|J~v>hb@pUMj|=H-qxoof&BW@y52GfpvgZ8$5P zBeA;t7Ym@L0ljmo-N&)I#!FQj9KXcoydJSXpq&QDTF7 zNW;lifufjH)EL)NndMxFe`1)}dY8<<;!pUG+E$W|YH<}w97iBeG9=Gd6tMlVlz5jm zA7XOzvpF~Jd~_DP>todDsm|x~wZT#*1c!0Pr3P3@;bEv)FMq38Kuc<}9q$N{8|!eS zx=C(YEFWgE(IeojTxTs%be4Vy%{FXUyrJl!T*9@RmD*!TW9u96Cx<iHq`S-ELcBJneEICp>^H8YCM-ICqrZ;tML73qN^3sG!r~3$<4}uFn zqX3i(SR=?Sbb{QZ?T4&jbEHIipil5(Of#no{TUGvql+-2TS1SojS)3(v=00WFb9$c zbEmJgbiex&C3GcVGQS96?%XNXGDPJ>?2J7>S36H6X0~2o3UaVv_{ch#Xjd)KW`ur9 z&e)=8+8i{NdMI6nMwB&MYecfWECM>0EC7yBc-&kuEZH7-lg#Qba!2P=DBkT`x9_o} zGQN7^@{-3Kg4wI3AG7bK%kugjoL-sTtW7r__giUOQJo`$Jr&U&7<_whp6jSu)gT>Z zb_5XxHg6tG;_osp4tj%8A;jr_>e$kGO{_lS{+VVvSDVy@ih-X`7dI)J?!e>TG6R?o z&{qA5Px{BLWXt5~O;Nk`m6ejcZSdh_k8ut=i_5`Rjm$E|9wRV%6%h(ciIR~vA$CO? zL;(@sjAH}N*i$9?o2&2A8EBULAaFIC{v8ri8iuJ)1L&iEE#6v5IW6tE85DG3KD2Zo zZnPC$ZVb`k$`Wm(y>Vg*M+&p}vjDoCFWy9G(QtV&EXO61>x>h@=j`XLpb*bg(q$rs zVd2!f%n1qd&S_ZyaeL&llJmTmR0@tP?&-E%iU{;=9`lbIF3Ol{Z*cAgq~TOflKH)BeA)y8jgAPpgVj;dG4Wp$>(S0K+* z1NnYtLbFsznsQ_oqg4K95lkfvB+1j^r$@Rw#ykY}d~yNXI{U-yR}e@2683~vkIwhigQFw@Oi^TFmpVmj<-5z94KLqsJ_936 znnu_OAne{j1hU^4|7GR=VqwKs8YTh_v?(-@zvz8((USgeVBz)N+2Mf8Ixe@;*2cK6 zgy;E6wk+|)iBq{TMLua3i>Riwm~=h}aBld0`)a~N!1hE;IRRb{0cc#-j@gKJd!eRU zjNj3yfg4e*+qfCdbVbnr>I~q-J$bm1WNyRj<20%+Mjqp`Z(vUmWW`^U3I#m;IH|_J=>s z@PTzfLU#5perRo7#L={(zQ8W)ca?w9w2Le<*Kc?n&hK|=;P4v^g&NA11J+D*#0ReG zY%!fNjVY+hB00sBWwce1omVRSk>S$FAv?WeGYT;^6z7bZ7stun%jEm9R;}CAw#>Qz zVaZ-;?j5KN5IS$6w*}=K0cA~zy@8_wXXD^bnbUyB0Gjm2qRHA&x?Z#nk<>X~Ad%se zQ^?JZIX!ov-0Dq?HkmIYfSpZJ>^}X(B-;XFX<3#wasJ_ZY#me7vm2prYA4gThn()e zLl`=OqcXfab~cn0NA?BjUvCE!Jw20#-Y6x{&%-^(@2Yexe_##U%)?-KKLiSkj%w67M|73?){2U!06d?4g0fGhgpaYHdmJqljYD6 z`pIb`n}mErtiQkGkF)v}+voYfQhC%Eg?v^SK>aw|kkOy(#z{Biqr#?#|31mPtS7_H zHbLCp)UKw^|7|gSugKNUucv7E)pCKie)X!UslLi@r@H87`KiMW$yqBq7=t-J>1Nz$ z-Bq860$}>w%N##6FmP_as4_%9d}h^mygobpm_0Yt(@kvzkr4ro4Ag9vISuN$lSXa< zfB1TtF;OMUXhU;`_%36Mz$`)x`h~)xjq6A<$w`*TUn-{0oTSF-%)-y(SSS`-r7z`4 z^y#yCbGp{`!eYH+9A;C>e~k8(Q+~Bh{b>;$c{7mLZ72W1`jp&lrA|W zZWZI2ZKICi8NJE1L>#kd8d5%iGi^G+52knYce3iqZa#xF*QHTF%*DKPAS7o9WyP;A zPAI$}WC>-=8y-AsjzJf~$1V`ZD}I@HadsOtKw@lmpN-kti=EqoB}i8G0y-^?#+jQ= zsQZw#$5}g|K{*Jm7O?$M=|2u>EwG6+OO+BQ7}tds#o4YMZIHft@qw!!(rhDtYB&D5DK~|}2XUevh-0b1vTCG6weB&R#mBJ9r##?0z zr$}l#Yb+wp9|lvgdH6H6z`eDs|9#hcK3Hh*Y9Q8*gh6HXQRXp@B9dM%at?aDE*9Pv zUmEY$LIGzOIU^th1sS#Gd1#-y zIioC9+k|iqvPk1aq@RUs+ATCLelD`Efuxxl#}R}4-RwlB4|>v$sk8Vs!4>oA@t8eC zfo0iu^n@Xov|S*UxiF=TnQFMdJp|ziI_Yb~tdT;#XWa~TH6HCR<6;Vo26W~s1X5Y% zTE|&vG<{SeqMsuNldG@}H$deE70gRFm2LBy!e3Z3URg<)3|a0tyy;LoFO9{voKOXh zNIafaW`zjbo4{=5Gr7OP*Q)(-7|Yta+b|GQ(lcg;faxH>nFGVTu2>E9`5o~~5NsPs z%XK?%tJq}Mjry)6jQtC6=f+Iq;_iw!Tr_%l>DLi=hQt&v)ru4wk54$a;Z?a;uwyjS zTnlEmH29$hZTm;Y)%ptAIz_xe|ETMzBE66)yNHQ9`V0jGz8x`o$d^5+j3&;$+xLAI z5p`udp%ty+1#!5EFQe+;_Y=~*_wZ|(G#d&xjz$zT9?dnHgazQ1`U&eojn zLByf?>o&pZqA*dq>wt=TgL63Aj4uZR5su!PIft35KT4!_vB4eJOzoWz0V#CB_pq3A zy&PH;>>ArMHFOgI$!u(khcF0=bd{4hqp4#2$;H4vbA2XhWC+u(zVM2{47E2Rn$tY> zzW!F~34LqYH+CA5pi<>PL20~F8rPhQ8t4OIYGFTsuPk`1p=hVd#V=5q&fSVQ4dXi%>Qj2*o-6_ zx+j3_Dr~F*X7cDSKv9x#qJTi5dzg@$~C%a)i(INfQlyGdG9fr-ZPiLz7iq z^}(jNZo?Z;`iE(}DHC66a0*mLeb8tb#&F-PgFd{e>?IZRH*BFm94ud0ibxM6Q^}d) z#b;%{k+}d8>7W*_)!fpOI4Zu8~Zxd=8n?G_at6mF?dAK$|Q?7>ZV?xgUFreWjR{ z2&T1qV!r946|qT9UO`}=j{F_Fc&jYD_&yynO8{qnj_w*^Xa4z(oucZj@t-!)IW}+k ziCJ$U-P%OGCFJq@uI=~SbW^E--@L!hRV#0epI@}y-R#XhGwr0N`Y0k3+LX%oWI9xKT!uLm3D)r z{?5KXOp>q`5MfGwuQfX{Xg2#B9PIOVR0k2R^SQ!=L9crvGZmL#K6ZP0-q0|VgFy!1 z^6Rg#2E@2DusC3<<>&;i=(?E~^1g#$T?sbrujsKf* zpla4yNa8YG3kLdcl}tkCk#grZ6oM^mTY0`6Rfj%nFW^VHcyii^Tpaw0 zSKo}z^MWncn!bTKa!mNW7Q1c<{9WW0!keg<#Z)f=#VCQ4+Qe{%UGH)*;$4M3%>1|> zP{`qEDMb)-Mz&kB`cF-UG;KbMvW#p?Dg>=5NFpER%>9k=#e|7m3Fa{w&wajXc7{hD zk6jk?*lc5%{W``+6@{r%-ZG-1*HK^%Kz|X$W>Z?3Gs%{i$~_9ib>nTO>7tQ?V&$nMFa zLrLB#p!zA>2zbpyS~TpJb}}#3^{2T+x5P?X#?uKyb-@HfGqJ#ZWMGgU7gs$724xk* zqueXT#Z=oCOiuN}m@jsRyPNhJpQX1`<7duN;8D@(q-P@rI}B|E&{Vs#tP&gykevVK zRnt%%iKQ{22T9Q!34z5}6LR~O78JbJiVf}flRb8&yy5T*+S)ZS16^L(Tl9VN*%PU9 zUIUaI;svgT!g!;vqMsJ*DsW^#py}bFK!Z!;DF#`M(?D6iu~i^RLq)=w$RW7Amwg$+m|4NWA3lYK)+{0m15G z!-Yx^@VRJWY0a2ev`}&F&}>qj@%s}e{=Sg~NvSkb#38K-?dp@i?5iSx)!*0bPjy8* zmi2*n1b&;shq^DFrV=x^#t8mp!6~yXO+vj{eCqU;w;evDvQH;GhDGkE_k@##?J3m* zlGeZEW`XVEP~Jjd-G*y{HHINf6YxJYvs&VOwJ%As3w226Hc3?vH-F*CRT(=5kod>U z@v=aMX1ddj3;z=n=agCmTqn)&V}z3bSRKXmrMi128`hmLU?=2{JVf?Rvsow2%&A%# zwhPM=B~yV$l$R&f2jRt}yjyDDxY>Z5rh$8{fy)Hneb<>q8p!3gF!asc-qU2Is`-{_ zsJhRbZmeZcq&?WC>CBuGOq{a#`LO8QbVUs<9EgDl*dSrW%Z7RZ*033eFwh1zgM4nc zg}L|U-Th4l^GRBYNotWoQYKOtzYwy0 z4NcQ6ZxK~P$&S9OlrXDHm6E%c`8ef9$3&Z0Y39QYM0H?&^l3<(++2}iE0R!M{X2s* zC#fya8=~RoBJ7L+%-;lr`*b^7990ulJ+ccCvr%K$q6^?Rcy!R(49MR3%?-{+5%k_q zO!yUJiT08cU|Q#kS$w{^H?gGd)f)(e-P|o|@;k%6N4-mRv8!z=T-;yFY>ZVy3+Go* zT0)htY*QW1c0$If>@eG+=H0lLGt@a?{KS_$TZJ`v9wM`F@7~m= z8x^sr&0jRKpOr6%Nko&JTsDCV89-hB5*@*7c*ozlxz5Qi&qy0Bz>nE7KvGxX#R#U+ft7i^FKnbqWW%qf`f=!ki;j_Py$yiN=0?Nwt5qJid9LHn6(9IoZ6L)vL}7AHRR`d>&0W z^%kDf2%Q$8Gnc78WiL!pX4ID(s%KXO+(Epr7hE`^j-C}3&(!OQt_Zuw8E~sNnz-j( z1gUI|P3#};&~ZGeR{lWAG``GAjtmu@IDv)Q{)CpxvNb`(W$twbGHY2Vf1PG`*-lS)&`q z{Bo(Jc7y*uCjOH9hlnnwm8>pqrz{$UYMzJwpWM{^(NB=430m&i=&P`O5g!hdPKLuv zlWvYJq9g)cnCY8sO_oVRjvJ;C}0~dqo)ba2oN>o1mFt1iTVBc z{z4)dge71+bzUZNdcQZxL#?r)o{L|XOL2T z`f9mOY|EYqoE?Uj0qG7`e(>5}7?*?u(SHINeQkVdepmR?`jyK#<&^h$)h*vLIDa5I zqmC$jhL>+UKQtLRigrwoWOT?F>RtH*8cy|Dm56>xFM6`3fnBC+CTsO-`%36FOf&Gw zU6WSagz6E^+Kq^TUQ(~+*5whts_f`@3t3o0jgv1GOvY5Vz>MSsMm%Xe1QLpVUl|m9 zsCn$^3uXiyf{3Wt=uf$}gzBhM#d^N7>%}mZte*=kEL-M>lkVr5CcKQz2c~@gPub@~ z?IAz&UXL!h`rp!i6dav)liKF!&a14ei#PFH;j6poWE0Olm2EMy1_}k!Qc~C}1Fr$l z;I+jjatXXh8;qM|qDJhF=9Q3XpYI#MSFb&|-qG-xQ*QX(laGx($IF>ht<}^-pM4h~ zIrwdV2uzgwsw(US`fV4u7bhD_p|8%XpX<3wqAvnCKVdf+K^!9!fnl9x>6H&99*-VVBd`=hR1-+4 zG^%wv2iMh}zd-v0Y2wpf-`N(lN)&avb=qF$-qL`#@OhBNm`(X_L5y5i^09sEsy6*-UE^jVLCsaBpTI6ej>XQv@_ zn_o0<8fnqAo!{fX>TR-moQS{{X+io0sU;avCcyUwr=@3^H?dAC?S=F^4-(GlE2blc zqzXqQ1f4v8vk{M)^5JrLr3a=Sz=>Rr44iRDeVK}YGF}<8JbFTVw$P43@401i-a_5* zNC20hJXZ!$ze$@qnR;V17H)xOF%^IE%#PWdTm7`f`rUM0aMTlPfLLsSF6vid(cW*) zVUTDB?uBvJo9oX|rE^lJ2_bl5feK88`nS$Z^oU~XdasnUJ<<-8Csjf@Fr9es1<^rb zH<>YxCeK_`r7i5dGT9|s*G}y3-)A^dj5Q7sNFu3QfwQX#@wNzej=!JX66Lxv)pn_W>t;X-@-9V zP2FOqJe;7UVyEC1VGfum2v>;-s83uOrQH^G{ZsdM6ifo4^sHjwZc##19IG2p8|_Pf zbCAny0rxZb)Y0#6c#IaY!J3CMfgLx-BP-fLT~v8$E6s2+5wla(=@M^()7!}NRZ$c? z#9HXm$pWTNgsFN|5_2nW3h^QtN}}&#z?5LJ*SnnA=`3WMsDVc>Xs3+F=uu>hPtCrr zSr`ORWP~KD>3>ezJxF@WJ>M5QvtbInu&XlS2dn$0f<`Yz)v8{&kH4-ucjDLQk7?~? z)e89=MHB=YeKc}1PZ9zd^emYkni{3*FHFs(B=lF+kVJp<%L@v-gGMBQ6hvxD!{0sZ;EZ;W^e0(7a+x^PifOH&3$S^r_MWUaqCTDFC8|0Wpkz5YdDxHq zsam+Qk7xlO9=L6`PlNhrq{dgZrJCgVECC^XK8`vNHNs z0nBVZW&yFHQ7V0q=)^D_Uk~*ee8MF}Gw=~LHxpJ2ecT_-T(GdR|rY&<>VQ!GZ#M0m5-pxEb9Zc&#USLy984dw;lY3?|b$ z7F}5tQeLtI6RVq3x7VDntOv_t$Ogb29Ak-=mWF)RMY^g{LZ-)T(ac8<6equ}zOfs= z!8O?wQu_KK(OPu9pio&LSh-nw{%`dPH0g~wvbQ02ztfyUR?c;UNP=6m@QrR#-Z?Or zK%`B4z{JZL3h7FRt7<$QLr4~MsG~}R5owLN#=YL`^sF$oeH$Q6JZmg< z{MQ(=(t2tj4UH-}#XYHIA*4<4e73R0#yyYJa;Blj{cF`YgKbpe-_ zE8-O&afSaIkuc{4XOMk?4}3lI1H_tr0=9V`QV{)YH*`JiHtd?-2dS1+Y?im{qN@-i z&nOBFjs(u-Z{@G=zDq_RoN9u)(6X~Fk~s&_M;6Y9ZL@T>UW?J)1|6rM(01dUFn(P6 zX*u@Xe0$(;#B;0md0>W($Vc~+ee94ShP8C*w9D{Hd{^jnuhrUGB_#K0A$Y0SmL=WV z<+O)1#t^K&%jO*F5d=o6V5FXTO*GZctmi>Ft3Hc{WgRJG+2$_rl{wD1K8dQCmZ~~3 zC^mJyED2mIfxH*^`+`;8mBo)j7(n@;^dmFtlD{N)~r|d~?IPS%{LLp1&A-=$*~(I4eO83)^}q_zLCByCwvIIxG%(RqlApljmd-nE*8Y%NK}5 zy+F>bo(huoH?oyy1^Xpu<)_YNWPbH3%ZsU15T1c(0U;~kO6QX1@seHo&lcI+-++wH z!{GVa1D59CGO`GbHqF4wezZ*nLAO_JC;=+CHWo=LQS;sk^R#L!Ab#6hP?a@9o?0r$ zo_$2xzueC*b&GZLTDb<)Wst-+-V)P06Q1B_0oW=L2SycliLbeaAF({U7bz&-8?IzP zapp13VPqgsJ?avpy&yxQKW?KwMk!3geL?R?5SWG}i(-V(WZ(aFMI*IWK3RK=~W zH42C8Fv3qN*+4;5n;n6=D;CEbb)qA|afUe(uhn!&C^X`o9x3n__qY?JN!rX|rqXut zG{%j5oPAw7IijhTQVha@T~GW{54`(IW>em^y7dm&=~GY_l`5ynOTb?IEr2wvv60nv z;O7a+$Lx!M5JtJ{qP%8e32lia<~ROR=eF#_b)2uIjYvlH@pWYgq=fn%YZp^r)r)#Zvdw(IT4|Bd^LV37by1w~1VnAea*`jiv-gh&i{`HYDJTq)%1`j{@&`^C5yCK?0rAb<~rFbhDF_Nq~h&B_G@|0+m z2Q&z-z4UPPv|=79dozrOE}~mL)0n?N6X>V90!l6OcvNmK5iI?DWC(KbahL$tH(}T5 z6fRwx0=dBp>!bQ^c{OAv3%nC#RfOM2(c>o_p&*1b*!L&zIR!aJJPy z^G%4!EJ8&Cca+u%Ce{vLZT?2?lSiiBBxYB6iN1?NnO(_hae1IAzT#Ofr#fo`KOfN+ zGfzb9yh}%spX;-Rw|wYFooON^(wrMsukY+DB&Drsd$pVIuqxGL{`=u(YLD(*B&~Vn z9kFajRw`_8smf+O2j-b&Eri-G=GyjjI~A646B=+=d!<*APGyqkbrOFJXR!y>cW9wG zb;qajbn(GsyTEA;V-Z`B?GHA9fX+q5(fv!szjMDd`!CJk*a7?IR+ko3DZjmucYUas zmZ+TuZX63)ZkL_DFpd6JNB!A;Dz+?jRSWeS@09h7$UZ(aiS3`H)M=TA_USmf%hqS<)vjXk(Q$zs`CO_NvMoB*6%4rjS11ENp%&XDhn@(=ht~! zIEyFwJlrbGpTlR(;)oze^i}JwOa0zHhhzeT`z^6vZuZR376IHfFZat1f0qH(;^uyL zbs<90ub`#3vX6ToWzBowGWJV{I(nakI`l?Z1=7T!MP2M_(#vk?@ro4+nYM)xdOVXNETRN0GT{K>60z8H5T)h&t(;{z(=`uZ2vfE)lQ#m@U)9s351Uzg%m zaqV4i;}G9dtetkV3^1T^ZPn_%7NLJPtKF}tIke@t2t?$ntOn;k%7d;z^_UYe=nFZ; zXT^$&+P3l&m_K*e5|-?g^lN7-;tKSv@@|KS=|A%i@6Ipdo#2cnJRYl+fgYRL7D@8w)*O$s6C@HV&lb9*Pfhm6Mx9nt?%ILI!F-dWlEN^@_VMLdI;W&< zTqQ&s=f1!iRHdx3#Y?Ql+xn5~b+|6?1s@_wH_2@eX*PX*`%Q!pg#f64)PHfh=y>Wb zNlC&}t@SK_tuG-eEfrH+c3-~Vbl@~jA~Tf|7!NESi?NnriOvouR&7ux7=EWQ?u&bp z3K$Ib$r}UegsHqXafMmMq4^u0R?f=XiRdLFBu;^_4#)MzSuJ`K7Y%e-3x&)x*|>GC z*ZRpiLfsk?<4I!Wk&98K3+2V9x?IL77w1)84Qiw_l$-I88(%ENLar-u^(2e7rch3T zD>40id#_|0Bl*LXQ)232zyFl^_nI$z*?fz+&r*^6p;N*ChK8!)^7G=6-Y`yV5DDI; zaFTJ!zJD)TFUOu37I#YkC=f<;KlxPik5XXR%3-~3RO?*E~j<4Osv*_A!Lpwl*T z{&A+N1s=u|^&Q5q#Bi!6mXFKvFymfT$4iHOw92~1{ZD#`-pKWJA}9OsIE?nYRDy@1 zkrker!an%ONhs&b8`aPkC*}Ex|7-GLo|>jVFh@T1)BG?e?u*Ur_Wt>CLd>Rt8VwLv zCSi2?=GLFxm^o^RNHi{5_4W09F+F_kbaQnzG978Q$4^%P*;=cO7aTNO zh-SuKj9~JetTo<#KOnhBAoz7OBedlD?zR8Pajn2R(U43V#}?J$qU>`Zq=SP;3LvL- z!>MW25qumN;BZrk?tj_qBU3uWvIEpt@q?%$7x}^@zaflDurqc+4E{*UCb*{%%4N8x zbr;U9SY288R?O=)VOD9nL!e|WnB*hQ!-C_j^DY>oO{F|uq3-z|C49G>zaf070;y6! z+;}V63X>__Tejuzhm0)G2$W@Clh3fxpm?UezgUICUBsJcb>q% zvL8R8=Z6zwfpK3&#&^{2xQ}+8DatYv=0JhDa`~EW1L#@|ltV!^wsaz86cufJC##G+ z6`$OzBy#Qbwzpyvpb@g4jT@2Zr`F=5%Fq`KUU?4&&dH(Y8aQvN{AwKIw_GnB6o-#H z^t7rVR-0DzxhQM&L1Efv4o2S_QUI&9DBE*cEHB|ue3AmqO#zF#Se zphfFx(3cD)Im$@5HqdYWj<5G#p_n5Uvgem#;W7#5{_vw#mVZWPY>i{^YcgTlk+|>e zW~k&2xBP7nKaj61?rNt;!TlH8G6q5>X3QxsTacBJ+Z{n6`H{fd9SB4kgukkirCs9| ztjMN9nprNIXDYh?1LNF5uar2#@>9PU=lMf2&+gh`&Or{U>K~`}Wdn zVwh_-0hLhhdT|)VWo{$ut^HyZZB@8+a&d-<`pFI*7>6mXLVK4jYtep?C3Iou-{ClL z-5L?H0D{*?jjntpNMMr+XJFd1vD+ERh?V#SpVU?sLs!h^t(8zB>@)&ty9^u1JNBhN&x;WB)tN845b2Hca*hzMtHT%wn zr*q@rmqf)wjT&?+f{0$VCC3S`_F?%d!~sVeb&$(^1R?y+CYn;uc5to+Ry9-reyV8mhkEvd zL>D$nm%69)Kecqcfl7VItAgk=2hf2j!p(evLiFFA0$%;-z{(FjAE94&U6(Csit*ua z{Mu#bUyYgK;($mYf|H}2JUqIV(OAtW*I|o`W=A;vbJV#LNt)c^(2x^9Ph6yV8SLMnJ`a;Q1wBlX5)cXcB z*Roq@fd#u_huI;se5J7o;sqXuVjMcRYDd{UuHRPKeE@gMuKhC2sQo5!Xm)iZnIg6} z^(bD;6k0TX(n0syV`@YO9d7Y!z#@Qj zlGfVIFk0n-km*U8lPue0XyL}v9}p{aDW-&b+KQ?pXfq=ImX$)dHWA6^!}6YoQy)Ku zCIu>nOMw)t3?9Dz_+!aS{+C#GDYls1;#do1PVGs1ZkAImMo5aEA#?+UXMv~ObguA; ztyxeje<4IuUcuT;Gxv%NWbMf$xMhVVi^)xk)B`ruj`3#L>5HR6eIN&dZQ5t#>If5< ztg|xT?6Op+DEDnXVkd47m&LDjtp^>TL9`2%>j7IvvcKu%$Kxq}!^}m1*s}Q9hM9b; z3Y5Kn0`v}g9o;lZ1iY^y{dc~iLl4ijYwHMGxmTlA#$)Aa&Jzo1$N zbr!;e-|7abII^mmnWTWL7hTz8p30$uo%|n;Zkq6W1@dy}yZFneE8OO5<~Os>Z!gts*9)n+VXO+uhoz|xfG-Lt*PZf(CX7_kF7j?7p-TvJMrM(8~`gc z43niC@&zw|dN%a%<-K$UB@@TB;p^s3X5uSXY8LV|OM9UUgNL3+@sJJ5313%vHoAAE zD*KMaS7Z{D;=QM{w)i*SJxNpcbf=o{{^v#Cp*6My$7os2A-$#K6RPEMLmRv`^6+V1 zf)dGyJe39>84IFm6Ks^gBwYJ846x2ImDj-cA`yo7?0Tucp#orayljdrb6t8{o;%ER zE$_+Li96^89_UZyx-Qy4HNSB`SFOtI;9y7URvbO?8FYUup_ZDWQwl4A)wJVNnsmSG z3JYt>`m^;mGW#2@zu57iD_co0GeJp!7~N%ChIfzik(deX)?UuGyia7Q0=}h)WJAOL z0G|hc84bTX_$KnMyII}!pXb@Pf6fzB(;O(1IUhpENs+cQd__#1VJ!IcQ&b;DdSMq` zWy$GJ<2|?p@e7?a9Nt$xu+Q#k%rr@{`7>|wN0G{sDv@1IWHMf0xEpdUZC#T;F6l2s zENWmsAiRj7qvoFfrTY!30(!wT%DUG%Wi02bXHgrG_D2CWz*;0+s4vkqO<>vi&N{S01#T_IH!6toKc@ z4IfC6vkwe{r4`#zL@nCrLMOxi@FevG8*L$6q?W5~Sy-YDgy&A#faFN2wCJBt$*CMk zm|AoN6G1WKDT{-Xdx6HA_}fYbwW_73IIhtOR^}qBa zzx3_LZz882zM4Ls&Y-u27bCFdz;+SEA+uav&mx*cb(R+L?Q+?cnkwA5QH@>|a6Mz7 z0h5Q0MU-+J^uTJkAW|8;IAV3Dik}y|JSqWW=oE&CbQfcQ*YoALn+Jb;0#PQ$5=vFx z+&aO}6l}dxL}v*&4ra~P-B*kihr{EubwfTn`x-J+Ja_i& zVO~+5B3vEdW&4aACo?fPzO$cuOz2HxrgJa;VB)2XPFeIl&98FMwXh$Ib-e2{$(@GlPkRB`X6vCzmAy z4-1=2Cee@_V@*qPbb|JOzD(wB=N z`_D!HpW{R8D)Fx0lHf~Qp0QmDtSv*?oYIUq5Mva8kgqa&5p7Dv*0u4j%ZgZ`xXRC; z`SePxpYJn5<0<@~M@l<~=h3u4X@aY)wS{7CVl9dy@bU3{BKaN0;{ADdl>$V}9~a6ai-dT;-Mv&0yxl!KZZ!OV6G4mr>*q)f9ys6k zD&EFR6y{3ELt!N5ZvWUiF+N1%{~QzC!@W1~t1c(O8UvfcGQ?3mavs-2X2jLK52yPX@2Hq;2Hx zNalhxWp>0&LnEBDgq7wIvpl4(;|lj_b9!`gfeUEL_w1I3`&T?%d?#}c01NiX_HmS| ztf3RwK52qf9s#&1bY@d-SP>N7xQ)&3cayMtu{&IAdV-&HD3F)k6$J8c-X)^)4^p&s zm!-kk>Z0v3RA4Y@WA^Kudp_u~E0cBvB@Xdz%rc2w;pjiR=hMIxyA0kM6KM$6f0=}P z5CT+8ZgucouhaIRdXBA?&5hZir3oEi;0xFZ95xI!a8i6^jSFfMq|RQyYLu#i3?7D9 z$_nqtcMo~mN_(&ON^HWU$t7I<^A#C7CKVG{IB>lM^C6jFKKE3G>#(U5Q^qPK1+>t zHiBLfbuO#4UQW_)umJn(S(BXrzgDvp>(1Muy`To-%*#6S(qyjuo`{*BK6x96>3q6a zbL6oXj5ELAl`%nTxuz>P+e4Z5+VGMst?Mb&rT3p?Z`3ID%rKSkyMm#A(M zhD6A62YUTtk(h{rT2{xBX{M+u`~yl;ZG)lkIJV!LEhCNX_c z%7T#e3FN=mvI*;Ii>m9Hchi13ZsWnfUWSFZ5(Zi`{W8^f&=YKZ@z-n|C;*s4UM&__ ztg2lzGvvEU zfp-s6kV*cqWJHR3wsqEh#{%v$G9I;-1q~+Hpz7MAS>UT0Tap;N3eKS+UsHeCqJx*E z`I!q3J=h?W+RHBx^O2&tgtU0L5#FI8x*Xk++DWy-`!aePwoGV2Ho!)PCm*(n!pE4l zfPE4~q2*AS7HaWh=K3Bh?GnnN`MPdNzFCs(#YP1maK>*taz==1_5h*Q*Ny56>hW-| zNZvi%enNQh*|p~KZmFPILL>@P=0?+F_^M*ewgMt6w+Fk$kQelDy5jNEkL{QM~xMOd>G-B<_IvCKZvWm4nxf}PX%tTcx zpf}Ee!w|B(rC{K8BIzS@RTv8~gC*QR?7dA5JfgvA^E!%5kX>*539_MO>B%`9zR1%J zo;+~Cf3O~_XUS=h-SJR4<_iy1ZF>JY9X!^;xux+AOK7CR=R_rX7(xpwUZQ;y->g!W(6|pav&v3cqo`v z{UPpNHXGZ`b(=7=js<&bpsxOCl*1r)b1P*~F>JS9ihP-==g*c$rd_gz$W3^BY@uD> zBs_kw&kAhrux%P0HL*96<8u@4UKNaP?9go^zC$;=3;=gPz)o%%Upw?{D&D~m|7Fb< zXsce`3Rp7%=r{hp6G;~i^dNvi*_b3B^*^g8$}PaopI#zmnRU^rYzd(#4DLoK7%$nkEm+clv)d zcIAOkt^GesXeKHp$r{-ivoA9Cea{-AE5^Rm*e=nuN(os{C~GMDE`(Mz)?^4pB}=k| zko|X%>fZOh_x=4b&Y5$b=lgs<-}O1qoOARXdEUhq;!bg{R(4ChcG!$5_|1}C$V3g) z?#|1^J_B6E>a&-s!iv6b@^+2&L|j7zL44PG-bBnu_lm^fM!$)1=9>!ikIXV#5<9UP zN>`uGirhDJlBfTZWQ11FYNrUlAXZc&44)osa|*T?3liR2Wqdg^Jc{XESY^Vej>k_8 z{YvZn>OyZ(=$#@jEyRzTF6g$T70R}@ylJKRgo^-Cc?m|osb>CxUQcQ6Cd_#~nL*#h zY;sPL{d^~%0N;m)lZC6~uhi^%?Vc}l{{9~m<-K~d9i6Q~)0ySsw6K~>8JTy8TF&3I zc9-mrimarb*Jz*}v&{}=D35;OGIS!8gSEv^td%##juOQ1O{19c>3KD}1*DFS0&Pv2 zAtP1V^!p2%CP33neW7!}oxvc1OYN%FWJUwIUq`z0h9fMUP6~1;CDdWDzrN^gT@mpC z{ezUc*M0{F9n;$LRaQ7v`NekMH@B6%(oVecHN#046?heraH!Yt0ak?ymW8S!)fjiJ zq0{21qm=wIh2_}5hQQ=fLB$`S+#k?#~mXP7>>*qIWQT8(XVX7#tX@cvsSy+!T9r{%K=@G$(4*&C>f> z=VhMkwY_lxRyPIN~P^m{GQ{V;p|zOYzn3frii$T?3D@Hr*8FR9wS;~6e4 z@ruv!{WHjJ>9ROpuEFWiQK72j-loLnBkATf4Lja*P`f4F_EmjSa||DS_-Xl~@culf zhaEgQI+NDvgHqAHK`ga*In%y9rB`}tKRd{AGOXNNLoSiALYKa{8ZFhJmGx8;_5K2< zi&u&MNg~ByC%WlRbP9ld85cKZ`?2la)K!903NPbWTdFR*siQ)~BXj9`aW@Wh^&b;? zUsXh}*;~tebzRMXS?8+lX|@V z9}je0E!`{a)iigdgDNd~{t)$#OIqz=b}z?`_E@{eet7>`bolHAqYmPyf?8T}bbtK} z@&gI!SVMXDMDWNsFEj0elkNy*%sMPpF-{G(yHzW|=6#Q(ZN_5OLO|9_;fy7gl$^JZ z;JNx1Y_US^Ya;IbuhnyF>=MonctLsl&x&-uJX%SHk;abRt%c5huKvu(*Y|+W*L&{6 z>7vJ>3K7Hi?lhGlUxn2Sm%mmzve1DVzxev9gpqF+7%If&(?6l_5yZ&t$-gHynFl&s$fsdS_vurQL6`O_$i-+QtkVgXf+& zFyS}fek2#yC2|F=Pdh8+T1x5z9yuJ>2!@?8Dph(HPBGgZTQ*weMz<4p$KgC9ci;WF z$=%~KGnolgBXj=hJZIzbv6^^NZAspB-u&ZlYb;S`gfez5OzJ7??s+mkp*O=`dM1N0 zOn9x^Z(p=stHLEj(W6RF=bHAe))wD3_KHjx#?*?(zh-*oc-x`ZdRY$Hr=r5uy{|re zxD;Q^mK;q9bu+tucb~Ys&}2oDv+LrK$unN^e60ICAF603aQ940w`qGYJhLR`z6}<6 z?K%8f8wxcUV1neOl$Sc&`aN(#OxA0ux&9g7YP!<(B(me?(_|OHlY~n8ncHV&L>5;QEaiGOc=$wA~rZ=My8fkoH; zbnf=k4#YgD-Bsv$#X>^#huc2qEQh`1cfMR$5|c|hxTiQ=APv3$%agt{FH6<6#3~Ez zoj%DB_cqxuwE7bpgC-#{nwef7`6P?zMYvZULiP?n_F$}nSebIlDw>X;WHuaFQ`9Ut zjl7bQ*(%pD67$-Yn?LP!d$ep;pGbLfM902eHVll?)@2m!}Wej^qU{WMJWdd;>(0g4sMJFApJk*n6IKfgo_W zjYAu|R$w#K)_Jh~&z-Gh5pXGlu-O%X!@z&+U~$_!`0Z1O?IYWbaodeCo1h%QF)*m@ z*61HQxb1GZE#zRE?cp)o!(+Aw!)8v7iL> zrU(LoAn@}$9tlGe{)dDYREPl|9)tkL3t+K$ume&6iN?SXNCYSl2Sfjm!2jPch=XPl zke3HuHb6A^P-q+o1!RLj!H^gM3>pu^BSA7q3=9d!Y(kMjV&HhVjJvIiC)rjSizJXR zXoRga98UTn>6d~qa3HY}BFBqE!LS6Z00xhOp+L9URuP7PfuRt9OcO+vV-ua5Edh&! z!_ki0n3hs^@dCMx5DETmonY{282IvvpE|KF0f#7y|S~FaVH9GzNC=EgG#&@TAvgU1 zBLvQez{6p91X2JE5+Q(2gaVa>CV;X)@5JLa%`Sj|qwq2w?zY}2BohCh{)65!7z&RE z9R(+VA|PRK9O0(}fs2u_jn&7E7~@2MvSbXM{Jo4wzG|RPz~EQ}7LEicO%T3KKtFv5 zMF2GmCZuh}qd-5zV6p!$M&sqLI74kXG65lgL?AXD8wJMtw&RHZ7aaddZXA zLx8S>Lg8WHT@?ao3>dQri~tG)hapj0prjFSJYEJ)z+$A~Ab1%BUIrlpM_}LxMiNtM0 z#Fond_?M6)z#-~}vv1`oBo+rc!!J)o;^3eeHVwMj7+kCVOPnD=N_a5tK{_xrNEL|% z(-Y#CbOCxWPj2)8_BTO-=&-=6Cdhs|1d!1Lk)(hzvyp)}N{5`BfQ+iZMghTYrE-}Q z7M4G)h(m(z2BPxtadQ)}1~b}re1Hz~ul>PT1rxOZ4go3$)WKi-Q^0ZY{{z4e0az>= z)FJL~0>J*bjmq&a1h)Gl;9%t-{Ix%2t9+vVe-JQW0U#j$K?nHl^7~(O_yYn440{9u z{~H9#j|z_8sIdQ=0~$>ANU&u7Rf(Vu5IE%jj{+Jjr{HH>0{)jB@f&6Pe?vflMGuQY z0kusK5w73;(I=!6AY-$G7;0VF|#VChg5Ah;I{;Dfs0xRQWT4CxXXZAQc2Fkybfl<*cJ%KBq&xB&;eQ*09swnFk9M1pQxS+V_)y?g0!TcBP| zLUMqV83=y`cz4+(y(D34KKJ*Lx2Ai)JOY6u{H~4h83^)sMc_9TVMR|sRD{ezj?n>q zjL_YH8x4Z4V3>oL*rW{( zj;$DFfolKcO@=^W`?h$u01x=L2hHYzj?hct;r~b+Ox=GQ7Hs&N%IEl@lD`utY!bI* zz{3$0Hw2(B=zv4SZRQbz(ED85%g4;7~vLvbnjlwVB)6=&NoX%uQ8eJD0evL;m+VdQ@&+$-@bIm8`^^=>_o?|a?FUY4LWTDI_>EWJ)_vou2%MP% zahlKv^cVu5qy;@nkAPP=YC*&3g~9n67EZtrEX4(6^gQiVZM{hXVyd!8I1&p^UVfYe zxOAZxSi$lsqwY-h@)p405Ww*zIzC1O8VuWwhwmv9C#*d8o9{i}R&cvP)wDHfIpfBy zJ69OZGgJ?0_ciVb4Px=QYwcR|Ac`{J*(%GK(r%cN#vWcQ55yA&PPR7Z#_mZtD$c*# zqG=%R(Xgk6b^zb|)F%DMLNhxePL#7dKWjc7RsM*B=oqlxm9@0{*99G z?GVJymXr0fEEm|Pluf1e-WW+lnHlPeW4T?kSe->Fc0~ogY>tW{!3oWK(jJ$yFqvpg z6MAj!n+O+g>fU@RhU`TJGDMfO9HAXD9~Js=N032?86}Ag-G!VMD^&Tm;|*g|fbv5v zH3Mkn%V&4LhQ&BTM^1dcA(i`>(dyhe6JoU`DLcG+{zhE2pZdW&B1qV(y(< zCi5Q11O>4&gnyG{QxG_~R=*my+(hOl+zqy$n+xG_is;Q`{`{h;R@eWyQQ4`2*+P^= z@)XuQ|%CgE`D(7aGjqZnbg;6A!2URjjPpX=e${GfrHO!42JY$h` zGH*Fh-ovI$<%yVR&5kqI*Hi9<+~?7J(zVOfds^wnZ9($)n3$dWw6ku#d3_~7#Dpx6 zY;^j3K}-nM%EmC*{0>z4^KI%4nyy{hnInQ9(wA5`;IMNK#<`9VXPah5E^*vHH40Vy zz$&JTrU*X2LO*ziz4_$P<)?W=OsH9xJ-2t?S%AuV?-E=LL&sDacfK#Qs2sXW{w!6c zN@se8-OS`MM3zNt@`9bBqw#?bG6Nl?lbCtozz8Ne<#x8 zER)uZgOU_JeM=_=m5V^x!)ucgGsV(a`%h`u8&p#&dd>F#x#CDxcgiDK8+%91Os3qPhICYwvGrjVRa@8oiET_;TWPDPrvgsFC`{a$jq{t8dUjDem8SJ#r&DA;hRfY^GNOstNOzjy>km| zYY&~)D334N=d8vF-#yr%o1;N{DEpZ4EPBY!kyy>&`eAL4duHL=vByC>Y(Dm+DBUp= z5|oF=e|i|y^)#-OX!Xs)dyzJ2-_`0+z&rQ#y-a^YxY`wYvoch>Ao zyfazP0{EGHLY0Jt*kzU9>P@PK-F#{{R{GVcC)0|8tKy*Da7eGIA?soi=0eLKh|p%U z?A8X(zgx(eTQJ3Va;8nRO1xPu4$Ss9uN(8bFnxIBNOI8Ksq>3-xVQH90^V<4X-4K| z4WF)5HLOW~%Sq;rI-`m7yd~7yN9xlvo0EFIH-x73ZlTiEDf&B~&3r?2?Iem-8Aqrz z;kg=4>8rjt8Nz#|bv5A9mB@V2(b#Ev)D?mu(SPw+UyRz#wrTnzrDO6ke6^{^KKJ$< zEt$xPekSy%)Tw!0)8wJcR#%x7lKzN{bzW*#W19GCsQbRg=iIB7nE1HEHKAR+HqCYW9EXjXp{jVAfLf%WKnneh3pd2l1Vc{KS&h5i43aKV4XbkLyq}{b zm2>^->o%@*3ryUbb5k`7_YuAe3(ae@fx*Y7&W-fX5=WYMymmq#R=}OB4LGIZe?73v z341t*``zKNs#m#t?!NXfwJmXdMe*UoXyGVGn+6e|a34`=WNJ^@*P+&;8=dyq;di?T z<@6RsR_PuFokhp*+7UGk!atAro=@}24b6RCnEO3&6hB0i}O4IPUy zU(+ua$ol%+UG6M-1Sb+LZa?TSoAUCmUE(cg`*a7dZY6LxO3R+DCr;NB3)Hmx`*I@z zH+8His_~$`NW7W@E^*-Pz+#wmVhd#|(~r+xt=t1!*B$F?<*vwZKeELmF|jp$u5rm8 ztx#cI()azvT~YPO)g*h)|nVTi`82fjLx2@iLRG2;^=;;3$IuuKhjy0zg zJem)msjsId)|vLRTP*kmi%;=oJX+W@beR^rXTe-vf+`w2^BG2U?3PTX4TY9Hic zPIBd@y59dM6*)o*92| zvBw@Z9pidgT^eHL=e2V| z;Lj;_YS76itGa8<%VK+M*S?xkg)Cut4To|XxN<~qA2i6jC)Z+;be-xIVC+JyJK=xP zVn*=GASts)vRk5n;-Xb<<2YG+He*J_Yr%bDU!LEgOuxz}3ze7l+(z@ea61Pza|9M^ zE`Eayl~lx1!i{R*m)E*Uooiy4#V(eY z&(Qbd7$;Bmc=Ii(vAprL>n75}^M29(WmnAmtcPiQGxz5^57BB@ z>d)iEmS=^So6Xre$q(2%th{|#R073R0^ej&KkOcnp~#qo|7lJz@?@kPca+qqOXF!; z<@1Xo%cY;AAN2d!O4aIJ%!$Ut<`|denP-tTvfibysF&2pUgjsvl82=9LdV>|&z+JO zulR!_VaBcNWn{YUzz-}#f-TR-K5E41?5O#sIfTs18zWCJ(>N}y(X}f$`{m|;3eWF2 znw!5yF%%}B*7ff%I z*JkGtvzBt^$H(GG)VzS|oHf6lLs(F}ue&{sS3%5grlA^UI@y(EylQukJVlXMaAvsA z*M3ZE?!7)$r8(!?Vx&gdWs`{Ms0detsR8(`fx$$+TwX=^SWn!{9cjQ%<%Yu&+h$Vz|xZa+^pe`#S3e_3Hpaw$hAt6pf8e|X1)X+{P` z%i7$M!zZs|CO}Pkyz1JHv7~@gdFCCXU6QV}t{QZek7t<2yAe)mc0gj@GWdT9a$Ik8 zt(G3^a&-|HTkC!Qh3;@jz%BXKPFHC|rc+tDt6@l0ZVRFbw?$3<{BpoUrZm1($vh41 zI-65p^7u0RQUe(BDgt%`*fmx0B^p8Hwg?47V38zA&zP zFiOpyv-tM7wy%&*k;EsewW zOg)#HkCrsL6$|&=aMK&U<=%L*Sh^=1tl!a#_RPgXJ=diKjzwR!-`8`MOG>0V>Rlsy z@v)xE<685AIxmsMxF?Th&AEUlr=ZS2`zh#g0R9KG3{d<7$^?*Yp)$bAAJ8yh#un-V z2%Lt>ZT#T?PM?OB11YD$CVX~aW0)Pdr)&?^1@|bdGT9;*@VP z-*JADhr2s>>ifEeZYXv(g_vnEQ0P8#sN`OYlyd~lQ@6W-*jX^k=#}0g=Vaj0wV2t8 z$kaaf+M&(hd%}}rqTb6)s}7YyIZd=DipP{B`8l{iJa;X$3*O(nFQA z_UKk8#!e)Etmf~Y_F=2>=mwWZCbFAGd)n+Gtl||zBnteX|SZ}k^(CQ3mC1H z-fuRq#JGe)$qr_mUs-?-uFSC%;XDubIE<^IOVzsX*u#jMhud)j*iV_+d%tx@w4)j% zqg1O3=0kg|GmaOaQ^m0k5?A&o#{OMRbjp4>b7K`Ix=LkmP63G`DPuqrrO3Otl#4=G z*V+>aOhOv=revv64^Nw&(ffzcXKD?3$W;y#Iu_mOvg`bjC)htAUn-T1g==W+G*Solp2h%gkV zaOPXXtpW~9#euas$1#AmI@nk{5|?fng+p~Gf{|^q=Z>`liy|gTOUmvKsay5KKWuJk z*SXOfycPFCv(u_P+pa|&^jOTfGL6WLBFmW-@i?C&lv&n}jtJ9Bt<0;GbXlZ-Hf^5R zN#fqt6zaMT-lRUGzA5=4uB%U|n4|@%6Nqo4)bP)Yn|k@bCUp5I*$*UP@c24IA_pcg z=is$!g?VIVUEvKqHQS=)RjRdVx5OoGM7Pz%GwA#S_?YdK?#2(N#{{fCG|{gsQ|C-U z(19&d?ogOP}j#M$VJ(q2XvjZW$02% zD!UUp8w)(D+4e+KjvZyo?bE>60`n$kaUjM=B55AYS{1K?0#|-IKZBXyq=WHF?-wSk zGCsCI!Bi;kIOb}iP%C!PnqZJj8x9X3oY2U%o7%~NnG{CG` zhJ7m?ug4crmg^@$eznE&sh4skZi47pezmzr!J#zbUeAiLSkzYl>5tO^mqcqMoWTWF zhCZJX&wV!WX>t?%;Rg8-mgp0PeW$s(iG$S%w&8+}LS$^Z3}RG*Xd-N4g7eT74})8i zNRI>1ldsWrjxID^nL~>Qp7os_48T4;fu(hOpwyfqAy!3>%7ZwJ2>*$c`M4+GxmiB>=sT z$+-ZVH*4|_P3+<6m{ME_c-8NK=ML})aq{eB@^T$6jPn}43-kje2dcxlZ*COC+Xf3Q zm!|n9pgq*bvWX7jHzrkvsr~0Ot8Z|Z*>_d--kir^-ZjPtoK1O3P+nd*B&g`Cu33g- zUGZMh2gK19VXHEg1xFVOe_>Z=C>is|xsejpwvhL(A6P-85yCP8rdBtymsu?t?$YHe z`(z)N9f|mVLsfXt0Z?vnCnI~(hb#CcoO%wutln%`?Lb%3*~WJlWF9+(#gFL|l5A>_ za+~7QZMiYo{2Pujr7d+xems7OM)Rb#P6Yvd{q=NWQIG}t88`%Ld=USSKexc>M9mRp|%wLC&I8^mXPM z{c8 z8j(qvT-)KAgwukDt$D=KBN6+;e61BPg>(l@2uqB_y`yJvxAoS(`L9&#**(EN7(+Lxq#BvDA)-w%JbXApSrSl z_6XLJi?cNbAE)Rg?3PrjkPyh~`_p>qemo0!$5--H_$o#@ zOIw;@DoU?GPZH)be0!tldt;}*+_L&l-xjMY|AwgDV)~fq5HR|tWbgZ!O?A*Ho(2kJ zk%dCq{{7pm5fl(e-PJD!rzu`J7bu!6oqf4}&F!_9y*fOd-}1NZs;|vTWY!LVi)t3) zgqGuB5;u`Xd#UGDUP)$PhrPXfX*J+qc7C(K`54{T6p}nlNqklAD=Ym~0A>-H@mn?L z{DdptF#Q0TEh zX#VkTy2-M+k?Z4QVpX*=;afC&aScwqo8<7EFYe^x66T?c!P%`v85Eqg*-e($)sr1P z<0nY)B0X4MZn3yar{Y!y9X24Qs+4PdV{ug(4X3Mj}yQ1s@L0qeg`Bt{KDn{b0N7czkJS zm)Nwv+9P~B436qU_Y0~AmkC%Yy;4`9M`Ls|Ik{G>SwX6ZQ(P?WO1XZb^&T0fKf8Y| zpv1-JE0mzvq(g9*Y~YZZ-^(MeUq`kaZ%SD$s?}cvt@7~P=5d-U(5;4gk>ycjDikx( z0cBzlhCp7A#EGz5qlo^v$6s`hpO?*X@%g%EjUG{BV1z~LgQh@lPy50RBF{ewrg4QrTR|is+hwZt zv(RGU%Kk#^;A=v}7IHU>mfjSRRf=+kvg2pm6fsE=6aBsqJY3!uwo4l&I@BR&w|nBm z+31(mnS}SS&la)+X#*UJ*o&lE9F$DPx%pvYt+?QL4>a~rqM@A_nVWd?uk@y6pWT=@ zn;}?g-nI`8CctFm+W2KxBh1*lt4yurGJD);=k2{(*uCUHH^$6gge~8parrB?>X%ugP~B|bmzB( znFb?6ip|8`JIc#oLHF3((qN>vTvtqhh*&&>^Whmz{Cma0a3&P1i*te+Wh{FFv06>) zKYAO$?M<p2q4zVyS~CNZV!UTX9Qb$vAC|Fsx`M>XuzZ5Gx=iIxIaXWfSPH`MbyA1 ztxDFz+`C+-;Z?seAcuYZMN~s=GE3`jCIFZ0WCo|Yhf-F>8xt}92_7^7W#U|P)d1c> z)ousBanl?^%)nANbU?;fbDy=}Z!+QusdO$v80O$O1SqR-U&^6h@cAqIIP}!C>_(X* z$AoS&OLX6MK3E7b*I0o>_>&puFcoyi=_6sKr;FK6ff^g`gNe=>PiHLVu@kD`9o2p6 zYbx&2ycg@+k0hGgx1WsKeanod|Ha@#8HeL^KVAM?q@C=9xy<3p&oUF_BUpmAx8P3Z z+Pjz|4D8aH{}Ht7-)vmNUhOa(^$i&h!jOi@nV}6TEyw!NlJ$vej|7Hewco4~zj4F4 zy1FA)5aG8gtIjrHwIz+(;PewSfN^!bi2GnmmP|xXIt!1VFe~zSQJofT?)}}c=6K^Z z%Iz=xPul~l*#i^Z*&w734BuaLNsBZU1BvJ&Tfp9C7n$SA{k$i>%;=1I&L9LDdh<{1 z3(Vw}o>@LnYY)Vr8YP0um4EIu5ec6`F4lK&V`d$$*`2H#@i}Fk4^7!-%N*M1YIqK_ zTk%f{rD7Z(mI-L{*rsLF1^NHS7d_>63L+ z{4A)2?ipy-$6_OzwQV@)6_Y|fyyT(&)RbmMCjXS&g^-N5G*8lt_nBF+EAMSysT5|+ zHPi4QHL)a(?8UF)Nzfn%HvYn-O7;xDDuAu$B}Z3Yf2rc&zxw@*kHL?3J``HP#&4BM zqzhi{u@#)6)W)yFNT*=<_9AFs9iU8yK|^dbe9riseMjNy=(4*Jt?n{2iOX_Y5UP#4 zsmKeNGcD41el}X1Q!d`wX!hu!2+#Utj_8A@{d>Ju8*esCiJOo73U2OtOpNJ~1CV$; z{_+s-JQBRSkn|zwOpkMWipO#Yl2kCqBgYwGRa=u?y}PrY2MlN7AV{=j#vj&H)5K2KdWliUn#UjO$5(8)9Z53IT_|dJ7=fB? zFgik0Z|{|J{+fY1&fzjnY*gqXoTWJa~;+=OoTIUePz@(CyTcidR~#na~PWOhN}Q_Fk;7 za+t=V1Qt0R)xnRgYHKI@-ftSjP7OKIIAlNu`aoCDxffdnY$RYWxyHF78h1}OepucN zo@%Dm<}-;%1Y>;+IOu?O0}e1Z$Elg;qVgA2?r@gKGf$-Ubbjh3&GmZhps#U4+JWp) zUIxUp)f`ifmD7*xRrK1Kn;hIHbJ_>=?aBD`es$a%<(D8_It~NjXm)Qj93)PWm(log zaLw3;C~xDWJS((}=}*l`^G#di#t{aR5@3@1L7CSgq8~pT2ZhZbfO3y$oEvVFAC(7S zialYgV>Psdo$Fy&EE=3HYA?#UWssz%&f$xqOoG^z_*nH>QWU(!^JYCqd{eWZRM|4o zbjBN#?~D=3io~wE<@z}vO;|U0I}p40FN!SG3dvSCoyaj)jI08wUi)?uhzPHAn~k3M zlv;P*U&4_rqnY}ZKwzhH@68t7O?cKY*HO&OwW{I<+T>p*g-y=a;lFB~ff$|3=fpU` z>r))*pOA6NUNd_f{ZH0Y33pw;!#>!}$3-ISpK8MrHZLx}3lI7{IA%MeF&;BHzv|gQtUjxWCAvU^=-klU4m`O= zVao!Ni(ZH2`dGF}6OD4O#L;wxyk6LAVdT{Q#}Q6+hadH+)G0T;<9+o>e2@i$-bJ)= zCnp0D;P!X)bkd=>^aGGvt(9TcprHSzq7(R8*~G}+`x<-9z#vekTmF-w0HV*iIB3Tu z!M0wVtegjP7u0UlMZwUmxKSl2qk;&%ca+$FHb!l1Eu_8qpkA!!h};)?7DzPfU`+5gfCtQ8*|Ev_2*``dsb! zmD@SJgCQD&-ZRG@2NGM8m%n<(g4>2gU2TJ`bb2xL4^aW@;@ou@r_J=9j1*ObQ#8XH zPy~K`Ip#GGw}kNE=?WojDG*CwW6^$T{z6E=VU1dq7DOx+&`Ljz@E533UZ{ZC;33H(1n zwAKkg9@2rZ{O@ilwag9@7oavu?GE-uZeau+m6n!f+)gnI5-Wj74Dy;S8i9}xrP_+ip$b+p+>qWf;@&GtyXVw055c1v%>r>Voxwdg z47Sj23~1k|`_;EqU*-g=LmUn@v%!3umSh+up^VEyBBU;EPL@cI1DK z7?L(D$Er(!&W44MT!?RQ5=zpHrWB!Ag8V_2au^dkezHU5{rjBYfNYA;G@O(Qqs~wX zwFia7T<}$C0H=-uK57(II|?T;ZyH*Ux%>{iav)?_4irQH1dy(Rc`!ltKQfHPLRW{- z8x4pL7BdE?g}N4Bh-d^i5VV=0xDBkv3q%WAGJf5fQjOfu!)3Uw;_DiSslSIDWmN;896u8cHO zST8I|Ycgxz10I+@j#K9gTQ_+Ay*h*-kGgV}4q5?Y=ZcH_2lr><<`JRcXFLIIT80I0jv%xOR zn8vf}i)!KyzZ!7sTfDEc_ZN4M>`(4HhFbUj2~DqVn0t0Cheu*p!^X!~g}w!k7WNDE zEk{!XjQdc-XM-8#Q0^~%y(|n`pY&PBtx8P0dP`jJ+dmIwmKBfQ%B<-3i`hSio2EP} z84M}!fLZZL40bVFR9;a>h*BI{EcRg0-bPJTLugbS;S<~qp7V|H8?w&C3;ArU#c>XN zv_-j*oOJnZYT40YcU{~6FZfCvq>|w)Y~DBh-_V*h$MlHLvKk`~DJ^4TvTm=Y5cQ5A zhf2BLc*VFm_+$U&H#()fvkqeg^gr-;*i0Om?qLKbwy}J-^w< zlqruw4jI?FymQ1A=f~$++?T-nT#Lo@Zevvi<#Ce6Gi~tjAr=(JpOv)wK7wbg4*k~q z6USy3T@u2M-*5~^3+>f|YKM6-SUy+1NP#v%`|-+g=Ta31iq_ zI{qm?lSnCTBI_Z9U~1F>d` zNDumC@4Cu3O+z+8N74XN8*2F@mRF%)8V{EJuVVE0(nv9r=xD}%3F;2EE@!-g`NQz6 zH=&D|Ew2hmaE-Ckc)-Plw2V10<+^*Br+NY$bE~JQ*H-)Vb~EfpGi-n&)bJR&u@_NR zSu{Appr6ys#LM6t4}ZCQ<3*LG@6b!z*y%L8BP6(%>6?;EDOEUE4f2pbf0n+>l>zOq zzG7HR5rqJl5A%FOEk1Z0|!J(W)KWxEGx5ik5~UeO1JpdV*`k|9Q7y)EkAwvNHG=G z_0Fg%$N24VZ>m`GKw7CTD`Mk17pZZ!X7@Z}g>9^SDw$)c{8s?yCp#_ytJ@QLX-yPn=tmiVMGxVFy{1HLNE#z&5h#4HP}6$IJ+YfAmam9z zi;MXZ6JULTn(`+|MwKVC|GhBn_{VVz{DkKNm36h1Jy5Tui57RMh_vkdL5D`rdYW4? zJ6(#}H`LM$ar#o;PsK&e8^Wil(rXv-=x$hB(HjZpnX<$D>spu^IVBH%0*mT1G!L<2 zR&3iB30FID;exzkvaT7s;^*;^h(G^!`tsYUFQV%Mj~L9xbGB=6{qYjhm)0Ikze?xN zKjy`^=&zqgG zrrO3%o{~-o8xHw4 zy&?9=N?S|DofdcqAjxb`C~-zmMF0VY`};YJ#p58VAOsS-sigx5(z&IzY#G4`(t18d z7qFqCGGS$ZCZTjS*JNT4(T#<#8y~g^Khq_yFW{Z4>JE z8RYF3Sr~o8_D|Z6<_d+!H}aZp{El5dzn(Cd^+%xO(8m17A(tOg9R6)x3qQjRr-40l z@1hLz>;+q4W?D-ohuEtr<>%+M*IP}Q3*bC)=sO?nQTc{GqAy!v)t)AFs!~>D%P=y# zrz954?U})#eK0qdNyIH8;#FI*`;*+fUqG}=b?wB`y;j5pibz=(OmreMU@pFG=~zo= zIq8oZ`>rhCC-VzpE-IUoy46z!H>dAAiGa?&Tx%>tMhhpOZdYwWDgViP63t(xX z-AzN#c*OzB-83VS`bdzN*4XIfB+XB?U~`}04i_1;Q)sE|IWKQH3k-HC>vAJ02;>vy z)HzwHdJRA+2i%t9n;|MmJ-Bxj5HJL~{pv*DF<@YtCVmIU560$24(_b1L?>Jg5LBN7 zgSACzY8Cr2$j@k4=3`59fYz|J2&{U(4vC*E7-U{Te!s=1pqgU&s3~pC$41iV7|wdK zD9%o}Qa<_Q<0f>tk&@GNwAqWjo13%gd!#N0FDGC4QG#4ch~Q=0L187@g@J!Ta;1hg zAK(lk%4Ur8o|Fz>JM4V%=CYxSdHe!x8XNuRQEWI_+yx;_*WD{cD(J=?wdZ3g+v)gK9&ykM5Kp^E)|Ix!> zgzd`OQ_N-`-76h6_<0E7PL~I&#ZAKCIIG@{$Gc+3hM~7bXL2w~DLMZaVpF@gzoN#e zMY*%L8tHphd^5!w1`-d`ckZXl)ECl(sZ302;9oHGR&!5CWF~MHW)`OZ>k81B{6pJ{ zu)SMbMu?@QAPou$wj`?nbw4w{jjhiW&d~#%CFd{Tt%zP+aUp>K8Ix0!@+*Ry1c|Ce z*Yy44mhf1P{`>DHN4rdUXkgvS8xSbT`4K)?P#bcfMVs0eH8jKHWT=w*EBH zwMSW4Z02uf>Ym(K;If`KwM?o%XneA-J;%_VT%azX?Ne_LnITQ&eSRqzq(L;Zae33~ zDVvr1Ld4aYR5yGTr@I`VCY_*-l9fAtz8>D79ojp{AcFJYUW}yW?|zxzOwy`kE`5?rkTw*2P7e&80B#prj5m(m~Ux$2<6FtWq2*B)C=% z(nifx`d7S1j`(Uc*Edit8RouLJhTiHo%Qt7j60U~E7)GA|5o7FehW2)|2$-*iF(vU z5x`?HOKBpB;pzGFLBo)lQo~2}VYBfy($d?^MgsIj4Pw8(0MNsCtUXzj5u!YaZ9xVy_0DpE^d$lic3IR>_OtZSCX- zL7CoIYBK~`^5lsxCT{+Aoc3t0a>PIrAH5R1l{+ZVP3Uxf7P)|Rm721rD@;iJ2J+Rx4lF~j$*jx+N9*ST3ebnXJe4ysPL*S;sIznU83OJH_a8E3+> zR};t5**l0Rc}?U>ML_t&%K{J~a^E*v>)L}mrjFvTa%7IF3lH7au`=R})c)nF<5k(H zVNAGBk=9u5)V}HL=|tR>hvvR|^!+f};OOhFAb-{T2l%84j}WxtjCK_?3Hsrx)&a#7 zvMM%V%7xf#XcGUOnLj+ndxA9mBvO|ZCreX2;8Ql|i6>l^WFCzN;SrnIaO)-t=YIo;=n*%SaK!{JK>nQD0>po^f)o7vQ{|F=`Ns=6mDI(gU;45nX> zPhq2kD;eSf%qW(39IQhO8>RTwTLVrSma%9-_7YA*`FUG+*W0x04m`l2^sF0nPWQ4^ zzSSYBa^V?w74M9v2S`e>P-B&ED;wiAVqsMYh^TAZgU}Jcxp6NVVNo1>vSSUO6&Onf zx;Vq)`LlzZL;0jlFiTpdP?B8wrr+l{@~2xx5T;zy3F206Hus#K#7!3jEG>&F2!f@3 zy|Ky`C3%GIpQihj22dES6hF>pvsSs`BAw9SD6XVrk3{yn(9~bZ63p-rH3b>(86Hmf zS?M^pSX@XWz|e(7((s&2ehf>lI?Rs%aX-rxbU-0VSjNn1>|x`G94(g24sx2aRE5&a z&;L+!sb=93#5UwQAtKor3~${)eGzI$%`JeE&vC~DI~~FzZ?XI)nUVZKT6KBP+xkA2 zRXQMwd|rv~Bu-J$nZ#?2rCWJn5fHK%oBzifc{t9uma~}YdNXuFnoiSIq{&DUXj!UD zPQ)8t;9O-YI|Ub-hM6@-9^*_W27Y|hcbMdBa~oUiGhLYrLGhyG*(OZWk2l9V)0%0~ z9D?J5LU__L!(XLIc8ksO4I!9{#!D0!(3wc7c$xk3*hKROrx#JD${~)if{gI z*0Q1ME^@P7$hx6_XIEzHQH}QnFc}XaQ_J6LIWf`1yzf=V>gF=4G?sl&D)F;_wzT-# zdYWM?St>N$o<|u)O|(wrYo$1|PqIh*9)@Uwq9ECKSM`SJ!hUTsB=dzJ-GKYqp=2e_hl4o^U?k(Fh~?r- zTgi#W=OFy2?40R2cDVF| z5!Ml;bgkGry*NY5nkQ@pV225NT%d+Fd#26Ds+?c}>-4ZK#Xss&IUbjr1brA|2=X=) z; zi)lUfN2pc#IZIDMSc&9V2bRNQJ33G#;b`605`1dbP71O8=k}lhV0&N1hNoP(hj_b1 zXd)-_4)30R$kgkDNzNXQ#ooCwws;8BG8K32YGql7s_(Yhu%C59Q5lk+H$)Y~6sloD z2Wne)PC(HXBR@boRMfJ8aA^p8^lP_@*lP}KvV9?e)N-KfO>YA zG?CQNkI7M5)l=c`BkPWsv|dSR2GYjnXxq)d?nc-%2O)BWKuQXE20FN?-BjSJ$_}){ z7{7HCm*dH#v;WxNt2!6KDE^iLUMuvz-}tcR&*ClV=OdJp*p(@8knYWhzc<*V$g4ppQ#>;$B*bH_6o}C-gWmC=~V< zOsh9bYEyr-wf5_w{ohD?OP#Gf%IJ{bxoIB$8(XvIvwGCoe>`Q z+A=VPivz7Y<^H??NfR{2C6Xh`aVZnrotkYWyDRZ{nOCBp>R6Dxy-Sg(4_!?q^B&>+ zURr*MXQP>-xsZ2N%OUOej5aQV;x^y2<%|~63h213xI{E!j)CxV@0e3g7eN>$+hZ84 z6wLbWz@Tb?Rj90jg^Yv~EuKqy>4Q*lShN8QEmV)&B1c9r-U)H6cU^~-i?>b&0qTGd|Lf+f6Td#5|ku)?6;Tg%C3-U%7rYL;U6 zw@R@VW%*-U`%u>PxN{qxw1ninTW_K{`(K(609G90rV<^~Xi`e#NJQGDNt={LQRF=1 z{G<>O?Nm?0slt8j>uF!SIe7j-63)A~gP}zyxaVM-=ix*uU}7`lBZ)T`?+u)(Aa=UB z%u`q28&&D>1+5_GJv}&AR?$&7!&8?INTvYJ(hh>-MeBI2+Y~iY4uIz$CZHaj z>itT|`KIai*Dso5r~js*18C)sXUS}_ zAR2z!SZD+{8*oWQ^EwqC#uORGVQZuS-Pn-z zS${LmHJFW{{ULKV=dFT7f}-(ch)8Ylm|mq`WbgKPn1VNY%EgY^HO@jp;cnutgOm1@!t zDgBd$gNTtxnTSEe!Pdc9#nH&bjEISYg)?lZAM!szwGQ<|BP{=i@oNP@$^K_m>t_l~ z2}o*n6C^?^fjR_KYik-zEXaQo1m8g@!C2YZQ|H=YF@R>?|Ljpb_8*TJpI|FzpFiNi z+~hZf%smntai^$-8q%YL8pdxWkRl7pDx5oQ2Q8aAr3=Us4s;SY(!al6doNSFJ~w;+ z#Z98AeJgPsCo@j$G${{|`+dX;0RLu@P)y^_Aq^BBXBQY+6G(R6OMg%Z!2B&m{EmRX zdwh-U?EM6REEMavAlT|97gnl+EEf=y-OtHpf!kbEO0w7%dYIS%6GJ9M9(TzI1y*!x8`O>#d zDX3U|J`^QRDr8OcGMCt$zruaRc836vmMyKLOln@BNYWNrzeVP1WWoBet{7uK3nr(s zK#*|*X!35Z{eoVPTQ{X0KDM-%mQzylU;Flj@$n2z2*GI1KF7{5z+iSVdrqv1s`r;h z0RBig+t7fw6%!&*@%0}L^`r{~b?)YnR`%*eL~!lnIpZI{hC|R)OEM7+8wvyIVWx0S13KWCyc6Ro7-RqZoqbsU3jX5M7FE7H6(Q zi2LC-R@)j|;KjW?Gi@14;*M3c>|8QKO%I79M#ehF9c+;{hNoh-OT=6+t?HZ~KoK+; zc5r`qF=qL3|M)wW$XwP*MVbIFbW1l6MVav5E2_@CV!COMLu7;H2 z95&p;q1T2%OUFPIB3D&n_=S-;WXU&Q9*!sS*snSz zbq2;-5u8q5hM#XuNABt0=7P!J)JXn<7)m0eX8JGeI^yhkv=I!JHmn#qPI-JBpt`_D z5oHUpcx?Imq@PiwG!+094Qb5#7$-?EI#ZU7`Zqr=~- z(ZeK+%~LkDNj=HN?I(+Br@Xh-&88h27%u$)luppR<>F!IH}%I6sBF#ScR`s&S1GihN)Ra(wI>!f^foKOR3YyqH}E&agZS%Y{!k(g1Zi>$?XiX zz61W?N*el(<;I-*ktoe9=F1{AbZlt_U5PcAZX)=2IL%l#&(l2t39u~3AzZcM1k&Re zHG+zf-VK*oFkYc@&cFWLD$WkTm`42ihS_beUMy>7$O}*?DeUtT_0}%&V`=^k4@rl~ z(e;aj!9(|aoI|z@_w|^L^R3Tvo8NT)hX55Pn27C1MfcMZxpUHM{>wBnZSF&__^*&k zu>A{ZczEMIO}>IVabW3q)cn9GaF7)@2iSDQ&$=HP%`Rfk4eGq2U@Rah{aeYrXx1m4 zf3Q+pNr5fg_jI?Ajr}Nj4_yV$R7S$qjE|MoZ-q6s!BVd27d3`nT==j)(m(b*7}L9g zy0uDjy@}@9FlR%*QEG_xr7&FZ6>V#>CgvOxYim0yWnQF+0u+jGAhzOd(H}RO zGt`e-*NY;qsMCrfLL6j~{;H&9ibvui(Zjr5fWv1TFz-l;Zq)tz`*$W(A9gMT$qXF} zZZspzwGe6lM1!I5q>-DN&9v{xgK;q1_aWKUect_>J%CvDJK1+mHI#u1mE_5EIviZF zKV;DjUQi+8)dfq#;EpyXdJ+#oy1aKo12 zeC}DzTF&)0zge+AkE$K!kpzN?J{<4v;1|HN5L)`qgG^nNXRYj2#B_h z!JPNvuaE=tTi#)~W6_EVRNEikBHUoR__0vT!D3G~!0C0WvAiXm6gah60*#l`YbRkd24x@7?*4?s? zMu_|Tm;8i(#f;XNT!UJDHt-}Mr}+<5%jI^4gPi{FG@sZq-C|7;s@?J&x6r%TRIh_6b~#i+TPJVk;^+$ z?kcuKM#17)8X_9rCx@1%+9zw&&NN9wdDwjZu4ym>ukHm8VktXHPgu!18$)Z_B?*U? z>KyS8xez;tMeU!J-hAPa zfqDT*_(JZfHLtR=p_Q!s)C-QXQ~khOLDDs9#6}o1Kc_*c$o^4LuRX?C09-~K^BbIU zO3P%=;xNSfV*w!3#ks5gd~&8Ffyz;GWF?oCUP+qJVo@ypmhO-FLIf^FO z(FJ&uV*%uJNaK>9ZP7z3nn`h<3HOWR%;M6f`PZb!%~3BjLLd9l*f=&s=*dRbd^rl6riqULHGRMWFh_$xHYof3+G9MM{pEbD0@ zL*hd!$0Q=R1kt~a-W%NxY>bcxn{1vK?jHhbTo~cS)qOy$L7BEYP-3DGRG;eSmeW3M zgSrvT_mqf<_y?5fsfu@=#oRL7eqU*{v3s>!nu>|aweRL z;}^~m&yi`?N5KsPF~!9NlIyGaY?-NlLcAr2-yVL49F-~y$)4z#izv+L6PB}T!YYu? zt0qF!V{(mjJA*>3uQVVJBN51X0qW0^r3B-OZm67k;ePYpAea2OsAjBU;`M|uf1#+g z{W{L~LgrOymY_d49hq?W@ypT0U^>N8`d2qhz~q0$ z#DoWQFBk6bWkxRy>s~Jw+%_W|q4aKVZ;h9wZtAzWfT|8dRlSPQjyU3AybAmBW1*X4 z#vAdIV-6fZdxoHZ7uB@zel|bx%8fhRz4P9qF*3Ex_*%j)$-)uHgeS|!f440HqQe1U zYqLvkSQ*y8Z%PKTd8gU;axs%e6@iCdbMp1*n}DEvtN+xBywtC}Fc);$p+N=`9{ zy9VCYlkCR2)P${iwvZ`=`=pZz0=MVa4LAM;Gf4>m5oOWgVv+w7mZ8>pj#ud@Z<1J} zy4i6<{#fGl?PKvr`AV_Bo=aUAHBKk_zU;wv__HssF_%M8YR#ER*>LKubz6B1f{mZg z3co+AZe`aP^a0CX%;PN#*IJjbGLBH!=Q0g-gELB(4%Zf$-K@ox1y~M5$n+CWM2+Gw z0kb1OT9TrGdK4`&g*`rb)GA?&gxsZQ6m)P79D<8L^rS?HaNHUp3<9)>xxOhwPgsyh z&aZxsfsST7CHcahA%DEO{8E;PdaP{~TP8=kdalB}rgB~pjS+&ebH8s{DV7T)eG@KX^;03Ef3!P zM0P5pFiMw)X7|pIJ72g$-5N$8SBwcN8M(UN?PPCo1n_|0k= zmpn?o6C`UEw5mr?D0axlerhAww)J3sZ>tAS9r9SSx(;?MDN|l>3^zx&=y9$#JM)w_ zS(=H(ifY|4FgYJaFmyH-YWM(RoQ{x@zwq$4!2)|%DtIUhV++Lw{=H<(-}a*tr{Twx zTm9~z`O#boNgEgxrHrZT`_#}Ir5wcls6gp!u|XmeBBQb0*CN@I5+29%aq>8RX!s04 z5({ULGItk2(a&YxwX8z*hI3gt?1B>Thg#I5VVZ9; z@Z?3j2L0*ZO`1r^?=cs88!~*XhZ-nRbIvi}RFeK;R?3f+g{k;sR|0Rkn<{=7nYrui8naLPrg;}&)Mo*?qjL!Dc zp9DIQM!)#aUxb6E<8&IubBEAAuTbo4BD(rdd21(u`HjEj78>#?Pa}*=JSHT^-OskD zl4#iXLdP5Syvq}`c?tE-s~SKF`PdAX%YtyzCqg8i)hWi0{qfbCV`}yfhENf0ss)HX z)Fzx2Y1q?i(Z8Biolk=uOL*{*M6WohP=Bw&-R#V;RnVyM_cQtPv;Jy)`T`0G;VJ(Q z)OwnMMQhc}fVpOcV`NihV&-OL;$~xPRh)&%0{wq8sju@eO#ilEr8D7T0D1GBaXf`5(1bFT%`%K(KPKaxk|BF2j%> zrC!X#BBaW^gW!QPv9SH$9|Wp3r|n5O;CoJ~P9Ul<$dv>H4f`Ul3w&%cH;onOHe`a0 zeal%k+Cm0lB42ndK2rArAAO0Wb3>Ae#~1W}68 z4n-nGx`2i*O+*Dk5u`~Mk>0!XKg#>w&$a$<-F4T=oSDo#IeVVjd&xfLuL!sxFdI9!wP*u!D?%%sQenoXgyU2RTr=JOdOcYU3I-T1f3 zNrF@T@b_;_9TdSxi79$W$_!C0C+-YoEQ6vCSZa;>srp-S{O%50_PXM$J%_>Nf!N4X zF0!w1k!GKXiDxVrE@<4fU{Zo58O3@DP?X!~ZxC}NX?icJN~`P=aP5R>x|bUGED)?( z!ieyAOJzcg>fw_0o{$fwE0(*-H%g+v7--lPS$?P|Tp_D9wV zyCkUH0jrjJ`IMu~Wp`(d(N}EEcn4n1@1>H?^@h+y`}MdNaC%Ke@3Zp$I59PK#f*@X zgs89=v9csS-uoe$n$d0<4+Sb|%LP*Tox8Z09UrzDsQK(EmZhHdua7*sOs=_}e|K>E#-7y;JyXKG zr%{vl-5EV_g`q6<=b!N!vhEcRM+Rb6AKM{Z4&*p5z5O5_Nw*xp0={6>pKrS$c8P? z@TkpHGkO%&;-0Pa$SO_JgVH=`l+Cwk1^N0%ud=cM{_OsDW*W!agf@2YGlozC?Z-+n zorF2#jWo>Wqi4m1NG?a(ohLCOd$L}AjK!}z?YKT%YbXF_(0KoI zBGYx_o2>G^2VTajk>1AjjpzMG7fowl(64BOyoDP(=UFmQeSgst`wmcG95bG)cKT(_ zwvn;d_etNu?GAU9F_Z66MO+YophFw8s3sPx;mfzjWR=+n#3^ zvcO*FU8`Ts?XLT479-t*BWc&{b=zgj&HOpuB*RH)Z*N9aFZ20F*dHN-(bzjBeMLo{ zY;$ky(gX~a;Lur7Wiu;0sEoe)Md48jSLFW5jA80eQGL{(lF@D~N>8T{VM*=Su@x2` zN|Szca|=#3qxHi(ug0?v-}%^wS2Rki`wA9dQ=bM2O!X8XU&n>8^D8d6D(Fd0Vv3a< zD_?ETugM(P3@)J%u^?dsuN{C5qeM?swpq{Qe2l!;@sM9u+~s;lq$JAUn*6VmUr z7mBtmmEF&035piYajYF4O&rpp(ELEqFDf=l1PZ8b0bOKAN*y~b^sU^SJY4TuxsiPq z2pM3#1$5OI_NPce0c7QfbGmaM1VJKyXZ=o^kzp{@pK>xD{!ao4{Wl@~ZxP~8!s0)V zfS~}TevmRNQVK314MFn4kSGZhiWdn%Nzl0jJ1N~d0;RyMjFOTo>3%V zP!um5E-itCLwON0NC{~fI6%=2QU$WUfp~!8UXVP%)dr$xLy(aOUI+pqA%pn+OBo3i z1o1yeb718Qi0&+cT#ST4d7;u|I6|5XlO|W*XA0uM$&tQ^POB~37YKxco?np_pb6QB$VN@>_{q$?#71-#(^vjQ(CK)itH5=b0qnj|Cbr$A>;9jipr zn5;r19Eex~nFAN5KqrCaDUc)u^jIu*8Wc)J0r`(ufO#H7U_I9F-&L3Szl#x1iI943$fFDqi*#+r?*?>l!e+7=~ zlLh`qb!0ie1!4eK1MsyA$Abq1N$#7!U7wKOo%1+B9$z{Q$Of3J4?Y8I8GxZka55!K3h*=rFOf~`feDz6 z9NjVjbJD?t$wH-}K&lB?iLBBw6Yxb5`0=jA``ck+0stOYX5Q4z#>fQskEVb9~k!#?I+HlKp^S*$_?7KDAeM5+vifVAB>MnA;o!iwSKwk1;>yB!i>g9Pn?YO(zT-7vY~N_B`|sQsSBt0Kh<1rSEstg0d4b3CY(UUO#L+JgmKAU8_))c zeT2#rP0C}>JZ0>D7$^4CGuTi}*oINR1V?&yyG8iENkSUyDPkzJC-{td?!yrI%MFYo z4JskRHpy03%1#J%<$|;{SqQ>xNfP9ZBF`icU9{GXYdA}XmY~j84j8>mRqG(xCU-+s z2uwv;2qy1lmHIxy@XShNaLe0^rJUNkY=gJ}%qK%n@=D9Msl}}*Y zRA%7E(DOaaIoKsa@P~KLGpLQOchXvvQc^B(oeLH^XXZ(xDv)$f(h|D5<(Qfzvp6^e zXK~x8&L4PPAc5TEVv+9isz?f0*Tz4Wr}4c`@1Y>SBfB@hP+W2}JRqo(Gk$fHC;s7r z`p^b*Z9qRDAs?qzlaKH@hZ4i%mfh*iO(s*bx>c(0Yv z@QIo{x02js!j#juPd5WjPVo0sC?YhqTHUi-be}WXeHEOYO_;A+49!r@FvB;z8zXfs zB;wTHO4L>?&My3_zJG9{@(N+hVb@>UZnQ#KPfYZH`umw97w+}Pb0_<5aT!}lD%{{U zP=QzBNY_qo1ZhUt%90PlV*CM!b@>;wMw85Z(-`DkB%HJEis;^~;=K#^;>)-p69&Ke z?GA^gRh#pyZJpSW`I`%H&S5(InOv2#xlcpG5^tVKHFzO#w!N;c&-J60TN#}erOpHm zS0b}+U=OW^D+S^7XF(=6$#CwtT9v56Tl`m)!mD``LL%k9pX(f8ttwTC;eGH#=S=61 znrt@MefBLT*3&f68y7D|;Xl;q-ueD~Tk~jdPT)u~=jH4Cf>R~T=`-`;=hX1r4l99A znh9K27L3})SMkt$m9$J0S||6r{taZ37faw%19?LK{dX;fgc6Z zz}3#*IFdg{OVB=WHGg;V^MiBWu^ie7LM*xo1zHPgDNTCOr^Ju}UQGlZi9xR&8Wu(O zr^9+i@-KroXg+QD){>B=Pscd$^KWbin|AgVi91Z}IHQ{T>fNd?ZCT!ztbd&3`q;%U zZnn)TJLr5<)m4D*QB2f%XW7*6kYAb?plaapb0~+ga;eLHSjoM%>ixr8*b0w>+w=(; z*b`lNnoSh-9K{2TP?q{@w9GkhsB3gl_wYks3x(=2%z(*2|9C54#o=Ot-)=2G?Y26x z6LF|`)VV$1KK96@RCCnRm@<9)?S8nbaqY6@iHVT)#6E5&-qm9KQG~w|;h~e-B_GNo zJ4a*D%BA%mb&sosQ2X_TGOj6fH@n6jU-~tO9%}DLrw%Z4{dhgMHw?GG5{bnx=uQXp z-a}jnnNY%zf+&0GzKFRB;(uzJb7V6@I`XKD7&GKQa^E~ySxPp)Dq@ZNo;yJ=S51Nb zG;%LSZhp|0(1QN4@Qa$UgU2r>zi6$iF_f<21_N;X3{;lmy`i}MN+QqpV4(~vp zEeotz0bdfSD&azZWwYwj^_VDHn|Yzp#>%oY1}Wo2h(PBj=k_#g72Yb2^I^^%Pj{90 zJ4#>g&JLhS-_0j!2Oeb7cq^^TUo=kVxkRaSq|~=hbvr3V+tE+%F6UVOObZEv@7>| zz?a$fW$bM(<)E2Ve7f<#pzH7#7Y4VodpCtO_(?@Z;hHKro4gw3`%4W5gQtx0K6zNU z-HIHywT`b8>4wWV$Ipn}sM$|sc9|}%rZ;9$bu-v}ocrr?NpRvd=i8{j+d772^oA&jc!>i1lI z?d37N#9gX(u5%B0%S+|5#dr4ptLCk_`U@AM?y^FCym+;t*HqI(40ZBzl6EC)5mC#< z6?KS1qre(O_Is8zCvjcLvYxHo!X~BSA3UuSi1-<#+KX~**y1GrV}YfLA=3FU8`ZbN zrdV4Y#V3xU#atJosw(BV3$H$_SnU^z2Ak8D6;8Tn-*Al{d*jr z$Uvk3JskKl9zuStkTkIMCGR_vfk1f4D{b;?8m}}tkn{DOoZNq}T4at_;p&doPQSnY z^P-}zLdIT!A>eSNl!}ZLL#!W zfp-1YR8d_Ycg?#lj@oT#X5{GJ-X3B5<+9sSNA2E1t%6-)fMbkKPWMjLoS(bRbxf$% zjA3d=MygV(PIq3ia?S>}5VPsDYqE@1rF~8>i}%!8{&wptzuoLNV(neHZ8I(UXv5F1 zDfHP6tw*w_1D9N>m%Y&J#5XOj^P_Tox>M8NjL=7=X1-1Ayx_T3%dNT#YU~%mvEw*B zXt0|nxEkKiBL?|Ttr~87tKV=AUbEcRj0Qe z-_kbvwzq{7eU$rL7V-1f=vxSmhA4t5oZGVxJechn-s@J)zdsz#<>+BXgeO^S%0pj! z**E1ck2m=hm-7v+v&1&zh>mEk8ogz)`RAuvKIRX4;FiDb;<$)315dQ_@@p+;4rUW@ z0t1ijwGey;Eih=Ry(xO^%IID=2YM4f5;0oP-aLigJUB?b-?=gwA6YbO@$+|WIme&{ zoUq&2WhNtmf@h=W?6u4DXvYHeayb2)T1gfK!d@@ahS%S?E`J~e$cb!Q=3iMUTI5-Qux2-7 zZP$A;GBzH`V=#)<-|254)jR2pE*gxc6x19UPd;3ZW4b5IbD+NS>(XSmN8Fbvw8D|) zq-N3g0uEAtJ-TjOzHZ_}{q$&{x8aU@f&I#t`US(04hvG6YFxvf$Vk0tyFclfMchsi zIy5kSL)T!c-^Amrw*g0aIT7D!OZxCdsL|Mvy_e1Xx(&m-eUcxVhI2=rxsf{E5oi`OT~RG1&QTb=4a-o{uJ z*2b5?J4V7yDMr-iMPfaG5sziVKAzY1X>XSuy$R4s7psYJlE9v*0luntc6ctEb7YAj z(|#+H_^F(fVE(yHHxAPsUnpr*?qfFKc*<(eAbvnsRL*r;4ie+`g1CNM!T4Ecv{)tI<1;jP7m+$;VNlT9p(Z(Mr08ea_YvOKC?qjlRBUpzfh!I6!3dARIMvkbKz?M8e(2onAE#fTJkR)nWE z%^#|<6F0QG;6$2c7y%eYjHiVx-4dKO;}!y&f`1W&S0+SAx`S%(6zwwl<7wY=Uw-2I zMrh8oHOrAvFVx1iN4bn)PGRKMNU><{^pP6I`3Y3Z7=Kgp72JY+eu^1WfA&pD-%RMW z3bbTVYBu(ovaw`ddI6B&Bp*{EmKmAD&Huy~CEnueZuHs4p;z=qJ$Hf9>oSP(riPrb z=vdn8i~+;qY>O=J;!l|3!*|3fQxf%}$foHR5f*sLoJW+gfbHGa{gY9Bdy-ZGqyEY| zimHxfp7_K3(O>dbbIBx_j#Mg3TW5YQa>5lkO>L%8M(~Lv#uGR<`vz_19T*MDYb^!{ z1utu(iB!r&gTl5hm3*(MEwAT3wda=+##5V1Yq{G!3fSeW!ZgBm5HXtj+-BYW$3SC| zy>)Rq`}_;JllnXxb*?9s{e_hMznPJW&vi6@!5c<-tE2Rpf_HO|jG_YdQK5VCTQ??U z)V8TVFMkw|k1bvA7qk=U&#^Hp)}4$B3@C9;w)P8<9drFmLrO|!-^7r^fS~ZSlz4}k z66(lilCo&WOH=f$tw7(|vBG_dPEAIAiQd8dquhhEI)=_xirQ<{cLVsn+~Wmq2W;Bm zN$?5sA*mndpRAp&o7|0zKfke%=Y;!qlug!%vwovG*Te$6WWG~G{pIf5>%wA*LASYj zyDvpf$(h5in=AranMj4EW}6O!&2vMF=%t^n)g9=iK7zrK@=LclKPKa)ZrYn;#e(Vw z-=|2*Z3SUF>Ooiww<_c zdfWd8gfddBn0ux zTAdunDRjUtRoqOd(!y*hTcI3q?Kb4FHn^O^24ikFv9Oia*=^S_b!sR1g5U)kPF*OqgiutGusTeU-5J79!@Q*U(iS(PZJlb19Lf zy~y*e!~vDvc9=!0_&vJ;G?fy!vX%BX$>Q1D47G4VKEJEba6GwLR04!dEGa z-q8btmy)?kY2b38x}wp{LD}yoq(}LOK7?^U>)a12{V3rGfA^KDk(n<{}&t3M}Ys z@%qKoHeVqk$p>>4e0fdfAHtQ}%nMC)SJ&o*hODHtzrXomS(@q~Wb6(tdDRee8^=eK z!h|pgK8-nz$3ciPm|!vWgo~Q7&v6_?F%H#)OPMWeI0noE^19*V%Qm`SIEDAuS1A;p zFum3=x-`);hdV_SNV?j0(JnR|#{~`75}1$KY~ICD5&4p?AugVe)owO~MrsMB#BkuG ziKu~Kdv)uW!e+4#PO4PQqC|7hCL!R0GdB7gVO6rM5`mjEU!yG(83Vp2@dh zaohtb3(7#9Be)c3cLdV|l+Iw$1ZS}LUx6NY;0(3_f}FvIz?w6d2$1cN9w>AHmjKl+ z zKVxx6_uE5E)Ao|po{be@%dKQ;4yx#BCHb2!+5yqBwqYkH5s`bFrzjs)w%qxQa ztIq3P?7JL;O?eN$4}! z_Qfgm*n#dwU@%8l#-L7qDArkP`5enDE7MY~+JwA*=(wqXd8@fylASm{w!q4@EViJI zPNH{HextN#E?LQUv$>pfQ1i~tX>xo$o^EPhS*Plh!uOCzD)A z;Je;sVQY7;`}pWi#GxICJO)n From ebf6c4edf22078214b00c08db5c8cb6d771c68f2 Mon Sep 17 00:00:00 2001 From: Gabrieltaillandier Date: Fri, 19 Jun 2026 17:14:42 +0300 Subject: [PATCH 51/53] refactor: standardize documentation comments and tutorials and codebase --- README.md | 2 +- docs/examples/parsing_trajectory_files.py | 2 +- .../visualisation_evolution_density.py | 2 +- docs/source/tutorials/coupled_fit_2d_tuto.rst | 4 ++-- docs/source/tutorials/coupled_fit_3d_tuto.rst | 2 +- docs/source/tutorials/parser_tutorial.rst | 2 +- docs/source/tutorials/slicing_method_tuto.rst | 2 +- .../visualization_evolution_density.rst | 2 +- .../analysis/density_estimator.py | 2 +- .../analysis/interface/base.py | 4 ++-- wetting_angle_kit_JOSS/paper.md | 4 ++-- .../schema_methods_analysis.pdf | Bin 509610 -> 1054210 bytes 12 files changed, 14 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4e149ca..b97c398 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ slicing = TrajectoryAnalyzer( wall_detector=WallDetector.min_plus_offset(offset=0.0), temporal_aggregator=TemporalAggregator(batch_size=1), # one angle per frame ) -results = slicing.analyze(range(0, 50)) +results = slicing.analyze(range(0, 24)) print(results.mean_angle, results.std_angle) # --- Joint coupled-fit (one robust angle over a pooled batch) --- diff --git a/docs/examples/parsing_trajectory_files.py b/docs/examples/parsing_trajectory_files.py index 4b44441..e644f78 100644 --- a/docs/examples/parsing_trajectory_files.py +++ b/docs/examples/parsing_trajectory_files.py @@ -90,5 +90,5 @@ print("Total atoms loaded:", len(positions)) # --- Extract subset of atoms (first 50) --- -subset = xyz_parser.parse(frame_index=0, indices=list(range(50))) +subset = xyz_parser.parse(frame_index=0, indices=list(range(24))) print("Subset (50 atoms) shape:", subset.shape) diff --git a/docs/examples/visualisation_evolution_density.py b/docs/examples/visualisation_evolution_density.py index 19aa538..70121ca 100644 --- a/docs/examples/visualisation_evolution_density.py +++ b/docs/examples/visualisation_evolution_density.py @@ -45,7 +45,7 @@ wall_detector=WallDetector.min_plus_offset(offset=0.0), temporal_aggregator=TemporalAggregator(batch_size=1), ) -slicing_results = slicing.analyze(range(0, 50)) +slicing_results = slicing.analyze(range(0, 24)) splot = AngleEvolutionPlotter( slicing_results, diff --git a/docs/source/tutorials/coupled_fit_2d_tuto.rst b/docs/source/tutorials/coupled_fit_2d_tuto.rst index e9bb466..c8a3cc0 100644 --- a/docs/source/tutorials/coupled_fit_2d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_2d_tuto.rst @@ -103,7 +103,7 @@ Example trajectory:: ) # --- Step 5: Run analysis for a frame range --- - results = analyzer.analyze(range(0, 100)) + results = analyzer.analyze(range(0, 24)) print("Mean contact angle (°):", results.mean_angle) print("Std across batches (°):", results.std_angle) for batch in results.batches[:3]: @@ -284,7 +284,7 @@ Drop the ``temporal_aggregator`` argument (or set atom_indices=oxygen_indices, droplet_geometry="spherical", grid_params=grid_params, - ).analyze(range(0, 200)) + ).analyze(range(0, 24)) print(results.batches[0].angle) # single representative angle This is the natural mode for the coupled fit — the NLLS diff --git a/docs/source/tutorials/coupled_fit_3d_tuto.rst b/docs/source/tutorials/coupled_fit_3d_tuto.rst index 7b83d5f..17ff576 100644 --- a/docs/source/tutorials/coupled_fit_3d_tuto.rst +++ b/docs/source/tutorials/coupled_fit_3d_tuto.rst @@ -90,7 +90,7 @@ wasting work. # reach the same per-cell noise; default is fully pooled. temporal_aggregator=TemporalAggregator(batch_size=-1), ) - results = analyzer.analyze(range(0, 100)) + results = analyzer.analyze(range(0, 24)) batch = results.batches[0] print(f"Angle: {batch.angle:.2f}°") diff --git a/docs/source/tutorials/parser_tutorial.rst b/docs/source/tutorials/parser_tutorial.rst index e593b90..276444b 100644 --- a/docs/source/tutorials/parser_tutorial.rst +++ b/docs/source/tutorials/parser_tutorial.rst @@ -112,7 +112,7 @@ The ``.parse()`` method always returns a NumPy array of shape ``(N, 3)`` contain # --- 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))) + subset_positions = xyz_parser.parse(frame_index=0, indices=list(range(24))) print("Subset of 50 atoms extracted successfully.") ---- diff --git a/docs/source/tutorials/slicing_method_tuto.rst b/docs/source/tutorials/slicing_method_tuto.rst index 5a6d379..803a96a 100644 --- a/docs/source/tutorials/slicing_method_tuto.rst +++ b/docs/source/tutorials/slicing_method_tuto.rst @@ -175,7 +175,7 @@ Example trajectory:: ) # --- Step 4: Run the analysis on a frame range --- - results = analyzer.analyze(range(0, 50)) + results = analyzer.analyze(range(0, 24)) # --- Step 5: Inspect the results --- print("Mean contact angle (°):", results.mean_angle) diff --git a/docs/source/tutorials/visualization_evolution_density.rst b/docs/source/tutorials/visualization_evolution_density.rst index 27aacb7..7fece07 100644 --- a/docs/source/tutorials/visualization_evolution_density.rst +++ b/docs/source/tutorials/visualization_evolution_density.rst @@ -54,7 +54,7 @@ mean with its own cumulative ``±σ`` band). wall_detector=WallDetector.min_plus_offset(offset=0.0), temporal_aggregator=TemporalAggregator(batch_size=1), ) - results = analyzer.analyze(range(0, 50)) + results = analyzer.analyze(range(0, 24)) plotter = AngleEvolutionPlotter( results, diff --git a/src/wetting_angle_kit/analysis/density_estimator.py b/src/wetting_angle_kit/analysis/density_estimator.py index 0446c25..bff9f07 100644 --- a/src/wetting_angle_kit/analysis/density_estimator.py +++ b/src/wetting_angle_kit/analysis/density_estimator.py @@ -63,7 +63,7 @@ class DensityEstimator(ABC): dispatched by the analyzer / extractor that consumes them. """ - #: Human-readable kind tag (used in tqdm labels). + #: kind tag (used in tqdm labels). kind: ClassVar[str] # ------------------------------------------------------------------ diff --git a/src/wetting_angle_kit/analysis/interface/base.py b/src/wetting_angle_kit/analysis/interface/base.py index dd2781c..bd3517b 100644 --- a/src/wetting_angle_kit/analysis/interface/base.py +++ b/src/wetting_angle_kit/analysis/interface/base.py @@ -66,8 +66,8 @@ class SpaceSampling(ABC): composing :class:`InterfaceExtractor` after pooling atom positions. """ - #: Human-readable kind tag (used in tqdm labels). Set by each - #: concrete subclass. + # kind tag (used in tqdm labels). Set by each + # concrete subclass. kind: ClassVar[SamplingKind] @abstractmethod diff --git a/wetting_angle_kit_JOSS/paper.md b/wetting_angle_kit_JOSS/paper.md index 97ab0f6..fcf3fd5 100644 --- a/wetting_angle_kit_JOSS/paper.md +++ b/wetting_angle_kit_JOSS/paper.md @@ -119,7 +119,7 @@ simulation boundaries and avoiding artifacts in interface detection. This consistency facilitates seamless integration with downstream analysis methods, enabling researchers to easily incorporate support for additional file formats or simulation engines. -The analysis module provides several complementary strategies for extracting contact angles from molecular dynamics trajectories (Fig. 2). Depending on the chosen workflow options, frames can either be analysed individually to preserve temporal information or concatenated into larger batches to improve statistical sampling. This choice allows users to balance temporal resolution against statistical robustness. +The analysis module provides several composable strategies for extracting contact angles from molecular dynamics trajectories (Fig. 2). Depending on the chosen workflow options, frames can either be analysed individually to preserve temporal information or concatenated into larger batches to improve statistical sampling. This choice allows users to balance temporal resolution against statistical robustness. Both spherical and cylindrical droplet geometries are supported throughout the analysis workflow [@Scocchi2011]. Spherical droplets provide a direct representation of the three-dimensional cap geometry, whereas cylindrical droplets reduce curvature effects and computational cost through translational symmetry along one direction. The latter geometry is therefore widely used for large systems or when finite-size effects are of primary interest, although it represents an idealized approximation of a fully three-dimensional droplet. @@ -127,7 +127,7 @@ Two approaches are available to estimate the liquid density field. The first use Once the interface or density representation has been constructed, contact angles can be determined using different fitting strategies. Geometric fitting can be applied either to the entire droplet, providing an overall estimate of the contact angle, or independently to multiple slices of the droplet, allowing for the detection of asymmetries and transient shape fluctuations. Alternatively, a coupled-fit approach directly fits a hyperbolic-tangent density model to the density field, simultaneously determining the interface geometry and wall position from a single optimization procedure. -![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%} +![Schematic representation of the composable strategies in wetting-angle-kit to compute contact angle from a MD trajectory.](schema_methods_analysis.pdf){width=80%} Additionally, wetting-angle-kit supports two geometric models commonly used in the literature for droplets: spherical and cylindrical [@Scocchi2011] (see Figure 3). diff --git a/wetting_angle_kit_JOSS/schema_methods_analysis.pdf b/wetting_angle_kit_JOSS/schema_methods_analysis.pdf index 39b793787cbfa1ef9ecfabe287a472920f864b03..4067de69bdba2f01d2d4056490964e77f113bab9 100644 GIT binary patch literal 1054210 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

tT3NCk}Sr^BS^B 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≻n^J z>(}NJ)}{&T%4eTgff6-{k+j2d>)PqL5Jxb+aVBY|91&*vD5a{B(KI$|Mft=NG4qED zXcda5asdBV8*sb##BVOK$Amsp?iH`bHB1?2Oe1fG=H}^#Vp9+w7QGRzRZX2p+nLUa ztM<|-s{jV`!?8Cjp5GnCyXd8|I#mhebTS`0Uq+V?q(z^6wn=ye#R=MORr{UId#1Bn z+va8tBRYO6l~l2${-=j0TEK;aHpe+b+k2TKm+UY9d|Tk`yZu-H$%Vrow!aJG8R8$p z>cTh0+V^R5p-_t>%uM#+52lWL#u@MbVo{2ABv6XR{NQ&7cWpCkbyrFJRn73RhW)D= zJVOhXzJSJM#$2+7+q=qg^LVk_Fl{#Gpa9_2kaO)F%|pC*Q)RnbB3p-eJgv@iGBug6 z5B*6xuxMT~wjScu$$VPeG?E^gJmND`z43zLS!N0;jY2?cCcJ3Y*b}iRZ0Zh!QNT%= zGPS;S%mRLt((w|}Mq`#K5v=Tk@`2dK!j_7X@XA>pBp4bkN|0hkC2L#!@*!G-aIz)_O>J3qO(@^yH&H%@Pyw)*a8jy2mP_gGIk zP{XHg7J=PnvgZ=ogMnDgaVf#tzXEHAdXN2?VUk*r6p zN!UM)!uwn}iJo)^chiMZ12;G>JB@Y0%o1IL0Iq=#mTv-;fr#lDwJf@(3@;)^WK&Gi zE`_b^@C_MqbNJC5GAkPKKKjE7#E((L^3HC<`z_taDlW$jiGn`|c8}^eLx)PIiX`c4 z$q+qwnRdBuip!0Qm%C62mCq}}o8nN2Q`E#pj0*PTQBISIJSuyt2&X|5eB_LvGmdJA z5>r8x@j)@u0yM*7K1wP*$wjLqju@vD%@(Z37hF_CbFA*R%03Y=*d%%1c0nP`##*RU zOFfv0UO~xUC`&)gZbJfixXiB1Vj|;3o0bcBS#Xqau!&C{@A7-zR90qrK|4yo|{R~<;_OTB&w1LxD76w$!tafwOkKH3)s7&HBiAxML#H= z2Ndh9CAZKkTi@VwvU8a`a+HnZuZ`0=N8Z~W4Xs>mrXO+pN?$O}tvb1LSG_bhljxzn zoYqh4*Fv0QsNFD$}Z@#e=V?I zT(h2Zv}%6qWEgq)K5mJRZerb}egZYrmf)dVTJMcR+-FLh?iHUW!zY4y`TW27za%ed zSNDx;Mb|u9W0B&5igDK)Ws0{(pdh*j`;qvEH?al9VIQG36k8&vnu4-|c!b$?@ru1) z5>N=Z6vT}Lut;~IbEn=yWf{*Z53s(eZ~}hMn!}Dhnxi8`?)p)VnDwJcGJ0uymKu71 z<2zpCn+HVs(5g@>KIyoNSyC$9s0?Bz%SxWpqqDP}u2wB>+tys$h66u%mg1DFjtU#+ zvPPF)&e)pu3Ci=tzTx=n$b2-JftoB_7-eS6tw9hJN1i6z{Oz!e@eoRi1Io}{|2rt& z7KP%4_st+N_>aUEvz7}oZmPgnZ3KMPyT44J#c)NC^iXa#8$m0rA+smHnr!99j<4xe zxg`;uqLYjeHWOBYq=w%9K){F+$42>%7P1!WxBESq{5C6719rMY+P{vq@Vl*@?83d%&~IKCwk5eUl#q?QaFhuy=Il&wlQj}#Bk*IEYYPt{OVqGIlQ>BOx%J9A9uJ@AXlhzl0e ze~cY6`mSg0R%X(#56+vBP|h1@w_7&814jXlVy_94ukZVYrD`8=<0t@Xa<3>x6a0{R z%wSEsM?GR#JB`R+k}A)^zd#|ka&3lG?nPiJRBp{pzcMw5lpj+3nx@c5h)C}1HBDzc=BU;Nt&puvzc;MCXYLX$VQU1u4JP7Oo~n)F zPLi9~PG=<{{BpY8ti{*~>qvBSN(+6I%i9#j6Mr>HFPR^6m>qd|-22y%+#0;xAq=LZ z*kqYKu|kR>m>L+Hd7x?F2B?^l3IR#DB!>s#3L(`-

c@!_H- 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

t~Vra!5@}3EwMK8owCa(FFTS7EsL#513pXHo*=u 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 z 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 W{PDMkdZ%@k_~Dyxe){Xr5B~+TMrq3c literal 509610 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


_ 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

20Colwk>j0!QAh)yme=6KIq%ekxiABs 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 IeD~q Date: Sat, 20 Jun 2026 10:38:25 +0000 Subject: [PATCH 52/53] (auto) Paper PDF Draft --- wetting_angle_kit_JOSS/paper.pdf | Bin 751404 -> 1091873 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/wetting_angle_kit_JOSS/paper.pdf b/wetting_angle_kit_JOSS/paper.pdf index 49152c8ce5c774320994959f6c50898ef1676a0c..13c4d49d256eccc0b3d1d2d026187eb94cc10236 100644 GIT binary patch delta 459513 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

%mWpi;F89oJx7FG5v1bgqigyhvlYH|;EL0>uJg zXyvo%QrF2#hguBy8YO4JIh?QF(_02A##B$|Xem!pb?%;+;RGC&}jDpvDE z&VYhQt=CmL)+V9Bc1Vj(1>`J#9u@OcBGom*tz3YX` z%`TLe3BpiS{p~K{ZhMlO-5kY5RG2}c$_5I)&%J+GB192j3(jY!RC=&LG1wS>5T`Hl zazLnsNq%Bsb+sB@Vv*1vGW5R(2h7aO!FXw|o-fFvA(`zxO9?k1-hi+X;jKjfDKHRz z`-Qv>20a=8sbobTJWV$=S#GBFX$Q#Id4_{JA#dnu7u)!LeMLyhqH zaLbYJ_gRtw9OH!xJ25!`c`m5pb>0Iq$#i}0eB=m$Vg-}A%6U*n` ztu;ntXceVpW%2?_0yL<_`g7mLCAVkS*Vkv70`t1oU2%J;L3xFP2ACO(fB&MW90m5= zoAEA%KGFNonBX=j(n(sxC1LOohrXho?}3aTCnj)GhLvVoLfugt4Md3SxcCd%Bw6Gp z!A9ZSJV_%G&UGF+}#MqNt)$uMa%r;K-S>n!P0 zholadoLPj8N>w*l5#lh@1logxgS5u%gzu|KYZ!AlFhqx;;kkoLgg}^@sC<$K6g0d= zmu11vU-%N6_D9P*Ev>%2u5^;z=heWj<2(Iqzw`XukL0m`b0zMb^kv>#MXo#Yxrk9}8JU%{z4A#*|3KA^j0U5?8hG&%Hn()G&ftl;5`;|88@p2o4-)o!_v|ccb)T% z8w@SKsVSW#BjRz>y?M*oOTYjpk1KI-W@?IK!6b-MwS1Nwi#~6*zmTs=k5-Sjl(>{< zS`B@MB#2I(RVX*##CQ5d!}B6gP>lTEs#x3d(}50#@;pn2x27q|^kYTCx2##_|z z6JvS{WjFRjrH)##rYtQlN8K)OIlsZfEAkyDrJ~BY6p*96e z7WWnHey)WCdI;Z|bn*L=>AkDF>3C207-@iU=hddw+O*2unpg5vttbnkEc=#0n%;MH zbIbdLvRCF{Cg$Sc>uUhRDO8Y>9nNcOGh!j6=jBh^Tv&xxjnvfQ^)AzNfgh?ynNnwa zh#vr8z3Urz!&-AcHm01kO!v4iuK1Ci_~+7Lghhz-k5SB&t5?r8A3+JG$&~ew9d17C z;NSq5!1LWPE=2VNRKRf#ZJlxTw$neS1z-B7%D{DHP^MvKWn~rmHr=8kyL-J#hsC=! zdwd51{BGfLg>=CryI~~WS4vI9mZTC5?6A;KK(a_^JLFn|K$ZGz6VHZw+O})sRMpCB z=+zV8Md)q{>wE`pc(%5J&tn>8H38Qfh!Sr%_oL&`nH&q%Yq7A-enhFJvQni?v(H*f z0vlX+{CaTd{eeR_N{M6dbEj(8!;A|gkAPjZU>heKG5B31r?Re2izdE@jpI!i9BX-& zI%x#ez)x--9+?F5snt-jeP(&O#V$ zCp38rBu5ZsPaG8AX+h*%YKE*SVlY(({7$q#nccf2?Q6z>snyU3?irr3KLWL5o~j%z zu7#_Y0=le)q)1L|OrpAu$UXKAQzyFc@qL`s_J5pLvVt^?TKe~AZ}CT>0Ye$lYi41e z7&RN+==(`x9W-9JoJ(jShe&iPigFlU-rn5-dLog_f)An)Ge)}71I0#(<6fQwAeKo< z0eA(J2a1A zT|GR)x=kzR1lxz1o>~0IPrD3aYPsmK7wO8CAd;T8guk%@O@Ta(1~DUxCwYKVjF9s4 zgZwzz^e?>l5Q^0KL0$Axo1IGLDMPV%f8U1Iy0FgBgdkp_XjiKYYyd$)JV|;UNDyZK`s7-;-o%8 zJezbOWJ}&@QdM5goIP@5EirYaRM>7a?azI_v~G408X8)LXFlq235m6Umm(2G_QsL1 zH!5IZVePv%nXs2m@1>qeg32y@mr;?qzi=5)KY?NsKo-q%CqzRMqEunP2OQRW``mR$ zVu=9&&^am>sB!+23P4rl<0FXcpw1U4Oc~p~jus*Tq(}xb9rR&Th!dy*OG}ZVe4GR3 z$|a2fgR>AUt(ymE2E9pYJ1IXV#{Ju=qUME#h52IekODa>Q4j(@F%8<*j#_}C*Uilh z@T{sJ?!a)5cXYfgDlk!k2hGt#B)~r4RFt~_P%Eh5%l5WKtI-QE@%Hu>ziGcW_^)Lsu`-AUj*8<^ zYdNxN)Z68NjMvr%8^p@mI>HOLum_l;(E!V}lZBcR@g+H=si~O+-d}Qcg@^RQg&|Ff zzNaO~G*u!mz?GE~V=z$^6A1w!5H3g@xP01n(hUXd8q}27SXh7fV!8~|#vxj5;q6GH z%KF;cSz8`Zdbb);y%)~*xdAG`tDP1n8o@MEXUkh(5ggpbr6s@~#BcJjY1lghxky-A?sRm(%`Y9KgdR9jY^vmvLN##{Bt-+Z2v9T>yR{XrsoxJUS)~5B0iN8}Mk!UR zOO=gfvXiqhq2k4AQVV!Seer?VHH;(}THMq-resZs>0SA(;_KvRnBAI8$*K%djr!SMnekP`_URW2`j#mOF(i${=Salp1#ZpW(x@Tjnj*( zt9~w=S-T@iPDHaIORW6iE#RVvMP>E%fYj>eViyYh)Q0r{M5=@ReZX?e9e`snpCPrO z1RPrDU0bu5RMRPd008L+*x-{kN>WmVbf5Q>F;6RdSm>q&R8&-`ZWf-N4S*$k_RPtC z;Y5;;91A5FB{;9F>=`M>92-Z5C_6jYBeCwQjF$LO>00H0q{odYtf;UXf_vo|4@1is z1za++vM#VOaAd^_q+c%}mgqsl9Y;Rk9$U`*n=@{FD*PfP()XmPn9K8z$1*QKc|WO|qfko>XcQ{-na z=H}%E8KFo$V#5=L6TqFYER1m?)UXTAW|z(GGp^YFAt8!|`VngY1SY$Oi)WiQNI$;1 z1Nw%yw>Kaegeb9lj+a5#PJTXu!K)Jdzmq*O8Kggu{_p|H5m1>dgpmfU#|H<&f_QG*C&&j1QKpHHKPk&<`1_wR1!f3fH-n*2IMz@r9e}f zgslGV?d<^(Lj7R#h5VwwS*@O+prDVpH=W>Mzh*29jl4fg*%F2WL|#B%w*pX_l?CF- zuBvtD{^+7_`(X-8_X_yvfR*+SA1LqCVQ(&M^4lJ@7%A3S>?7!1c!$`LHZvI0mB9!JYyMv$+FF$4bBmnOfU zA&=LD!`Ii>6B83#!zq!Gkt*P%_wGaab{7YG}fwb9lg@nTXjYj9Kp;*yJ&p$oSh(T?} z4kTf5R(Z`>{sjmvh&^Kz#7|H~^2H!(12`5H8*8~uc*z%k^zE!lSEoXYydM=6weuWs z$UtoXI+JcN@D3J23|y=A*U>WP(<-7X4Nk%R8$cb!3Um28UkzH@dKcVIqUH{S{qL`D z&Uf-~C}Ta=yFvu3$^Hc}y>knK7bU6ImA>%SliA&hoi@G{miNstWftv7S)ao4q zO`e??03xt|ii@|$vIPIPVU5QF;nk~GA3l5-K?%a+h}pnFdag?cwmArEz+L}#0iDpI z#*~;xNJue(wy+T-1cf48bM{?*TE0MCKoSU#F(UJ2+AOY_acqfqA{7ixqF@qW_}Q76j?XuK92^M$4m&$LL4F5(sL39nc;hGm!2l}x z)Kg1GjIeQs)fTVyF2ISYs;Q~z>Ox+}s~}H%06!aKHO&)s-s~ENWw>CDC;I;%O0&UU8$_kD%snf(!AnT1GybE0T7>Hw8 z$}_&LMwPQR0LSOvfZPB`S+Ee~#K82b=K!%W;BydTB8eNcL;ep_MQ?pYsJP>Xwj5%k zl^SSLaXQ7LzgBwD7`$bly#u=e-F$?jwU@D;#l5WD~?E*YH3d;>=mI69?=Ush7>#08tUR7swW1 z1a_(siyDCEbXde@a1?hgz2C1f8?x+8}vLtNDsNU9CajbDpqnD6pt=_l~Ff{r|^}b0~X{tV5DL4~~@)86lLi_m(}%$RT?r^BCDPBCA9hQA8+N zsSqMtva-L|IgNLD`P_cLe_pp+>g9SoAJ=1D*L8n9?zvj*6u|i8wvG6=Ud5lDo}LEB z*k)Sn>-@!+vVm)Xe{=7|=WU?os3-!UDs63W%kRo40Iqig1Rj=@fZ){Ry>yC5h)PFS zcVl;@IoR2KYuOlJ75e88jCVjC5~+wp+yN$c!s`pTz0orS=MHUc>@reslL3Fmjz*Ie zfM;_VZSY$9AgXd%fQsOuX>$O;EAXQTInoARTTIv5amHZuP%&0%&u+EFD#fFwWNOl@FNow6Mg-W_wR24+%O?6 zziYkGYy*f-V81~i#1-2CcR}EC0K6dXlGoPO5|NU6fQy}M0kRX|l{?HF1dW_l;7St{ z=HC>M?LDjC6x9`JflVRyZLox1>jCx~`H z4JP0vB(W3x@rjW$KtF!s)fBhSIzf800so{xJu8+*qD!sAU7ZUoI*APyB2%;cYV;H$%p4q?oQ((OJn6!6lfn>pkRT6Ku1jY20;h_-AR;L_zqXAN z)bjp)>V3f-<`xVF62LV zGb$TYSg#VpDI4_N-|?DR$MdbfxjOmCaTp{kL}QCJ+@RbWVCt8=!9nrnEpX5#!A63k z6Zl^RiKG){m`V3yrVw9F4lC#M&$YzhNu;m)%3P-h)@Fhq%|>_9jZxNXejroo28Xy7 zI4)*tCU#nhWlZp2EE{x)!|LXBuKPIW*)Zz-{z35b9Pl0l!C769QaKDI{xIzxH2r*G zLz0mMxY^dO1Y<9-&YRNAH48{E;KhHco!Af}s8dqtD!>;b6=n{gBo2=($N}!{>8SG~ z15xr>&j#=aKh+V}A$VR_cd##CSxQn;kO*M3@Pf@fJ+A;bnjEM`>1bmw;0hO;JPdCI zFNZ%~SkQ@`JLfE%24JOukkqoLU_S~9Aonumx(3y;E?9vpA%MSm^!PEelsC~5#|eNg zUs+lT?Z~qOl>;MmP5H3&% z!@v@N`-YRU;9-kqH58I|#=5(|Qb@zr@T&W0tg8#m^*+F+gBS%i<4i5V+fCMFHFQ}C z{2yd;0M19+o-Z}|g5$X|)(Cz8&K3uoA6?A6IC2g#&;T6 z*Z=`Qn*8}n^I=vi)myDsv$L~cFM#$g-9u=D`{S6x^bOIfwZ*fA>MR8xKGUJKE?(~yQNdeCx$qcHq&7iWcsff&;CAeNZk7P7)9MQR=Vv?F z$BYH#x?MpnEod8BgJ*^e_HBKriV7Yweh0v-_Z`f3?k-oH?+LF^@tJrf5ASR3dum^5 zh$pDbjcbGO`Thksezlqn7_{_K8YeZkpx_HgK>#}C1PWwZFpG&FoWb&ug@tD{pLTg- z#jEntG;f0GfI#cYV0h9iH~;UQXI&aMXntV#4);leSxq#8EsAYy%0XWoSYqyY6Hy5Hec{mKNjY-Kx-z-l1wXKoh=fir`DDVXlr{Xu;S z{hQoRRPKSirhDZIa=lk-<=M`BS>j58+%hATl5vBlDnANfNlc%(Vl#FI{FuPE`N8a) zHR=pxG7e<_2FmSQ+tVm2rN*r>zj6OHVkK?cH(BKm9)vJ8gZlvRnM37<5P9IA&ErUL zm;qXFM=;imNP~k?sN5bE8Q}|B^zY7lrJrK<9LZLP6}K5KEXDC?>_(rxdLG1 zgV#()>s+!&ok8Eo8gMAE!TSM+=u-qJS%5`7FslDPY>M2M#d}|4ORAb6d$_koDDX_n z$mp4Uf6-yT2D*_cFE4K#d3Xz?@VhA~x;)?$SYd~QhkI`h{dbq@WDkBIr-0X@_3oP7 z4Gv`BsOf@pq=PowSxt`%d9+GBT;-P3;GFcYPKWKW?}8NMGG|P85-`c zAP;xD(?kxppyRi;m>{y%2QIvNU&sRKNth(p!6!UF3$n6H(>BK!p0R4{=>EA{J>F{% zO+cQu1SeECH{>e#G=QfW^IhOG3u`{yUuj__>sfDSa8W6I2TFZVEhpxcfLap_O+k;` zdc}$*0$Q_HKts#<8weUIDx*f9Du5f<<@%Np(QdIXp}B>BAg`vacgHiNqVW{@sTkn= zzHm4>ItK5#KOj$lMjLf>qhJN|)L>4Sl|+lv$$bKoJDiy~-=i*AojbXF>2q%gbFlRL z?wD1fGc~^^lVJvyfmvZcCzqjzhD{vex6~3*A2X{C(1&zG10Z0)8elgtfznV)z=VYV znVl#KX0;yTa-XfL6ciN_6}^TT4fkz520Wm4tM|lEugApj5fjDsbWx=f6pyCMIyX9Wj$^0TEE(>&0&2|7%cuP)pYt}oM{?6Ffmn{+4k zM=E`ab{tS?KG?3biB(lAXsNflXafd?kKyfD%pYDH?OVUFOW9M;T<2s$f*BTCtFbaj z&2Rw>3l-2vhDjW!%3O0W9S_X_fff~zSRE%SplLnC<>9Watb{)ntkFUby{@dRtQ8a% z64sWpZX2AX&!Ef5;HH~PJH?O=ZsWO86L6>${o=*rhBQk60-7Kn=q>bmfII8g*8NS{ zE)IC{s#E|6I?H|NNyiVFRlyYG$+sCIK%MbuD~>Fb^Wfqb&jGgO8EaJu+iI)1{0o^k zH+AlIUJ;?8PWMI#SC|Dz)Hah{WiNm0%f@y7Q;J*E6)(|ckIfz4ZoRkpa}GuYX$+T- zQ%2oG*U8r`wom~qjZ z+;+g61^Lyc)usr(xNmsP;9wa!oN7O2T`<106+(^LUBMVMppW^r3;%ePm;`2FmVX!K z)gV%j;qBq2URRG*=OvDbNs$J0b8*2C3c89%3@*nm79@;^_FupVfYH9t5yt_e$b+Cz zE|eJsm{mO*IORY2!vxLJ8X-j%nb6uwyLAaxaF+_r(weFFNhz_8gr?KI>I;`v=JdMI z9Wh?_;3&~gHKmd>u~ZL67CWjA3IDLP-sJ1||19RoPNqjWo_zKfODk9CaV&rma12!< zO4$yN6y^FFg5D7@vHI7J{4Ciq zlzbT2@3;bt*NI~c4HLlr@dELmmIf08;Z>;m&#ce1j8r%Tf9>L!lmz~vZ%+8^5qc>}Ne-X= zaBI~0we{G|R6Ne)l%1`4YC=tfCay_FBQuKi93CaEgy>H$K(utfZ0AVOM*u~!Kt+%> zkKeqyrVyf27|4&jKV^X(Hv$VDwqpRAdPAm z!u?4qjTZG+oI5Hipoag3D-5`w-q?EbQ(|}rBDCbVbiRB(j8eo^t2*6d*KDT!=2RY6 zgi;NafCRm$tweTABjHKDfy&6pP$>--SGPjkxQS-KOmB9C0@A1MVUyt0osaLQ@WXgv z5yuJpH`Od!%3oRjXs|*6f}%bs9C={s?e$vmMHgGWJXtaoSM}<_2@6)PgQ?XYruVyp zMIPq{U7b<6^Vw>k%=A($xn`VJOR5K3f+kDQDPf=Q5__T5YBCUGq|SKPCScAh+Nz?w zcCFp>L3j15^E*|EOgtx8MvoKn_t;*bQ8>g4hzaP^e#P#8d<$^J$p3%{pkz4!@mI~$ z-e3&pm#m;-L})0Wsib5$@lr-YqMilL%cXg$PePRUU45vv*N5GM0^WJ^`**9pR!(a6 zGccyxwcSFbG~_T5@R`$jY8Hcu;n!`^kjl<_~PpN}PY3sK1zJ!RGH= zf)HYOLD2FZpYg$<^QvKxM_hQpNaUf>?Gc?bdiw0*Cn(M+MLif}dH##PC_D;rlVa<- z9=V5@m(z=H^TU_(*hg+la;LSwvOw$y8sTK!B;L}YDG85wP2gqtcx5k3Y_T0stTOaj zPUDJ5PY7*R;zqk1FYhl>5FqbGS13lczlw#U765_|xjh7qq}5Qrz%%*9>@Y_qnS^O) z{g6B=7=h;IL(1K2)$PHFEOc{Gz>SVJWxnv7BMLg$Ycgn#K(ewHl1XecIsdC7%rROO`$JA8je5-n^+anF@M!S?VwiuE zhQv>A!V9cg>Ag{eTE2!yXQ&kH;Hnx5I=JyJrKJb62t?LI)O!uM#$*=j#T1dg*Rb!f zM4-TCv+?nKxHvkt=J8=xa+8dLAmy-3p!>;`7}#uufn({t04v~S5bu9gbrQ$NdkG12 zEE2S^kPWO6YkIByS2!{PbQFDLh3L`dEZXTAKIXMr1{c>T=)+@O1ai52?#*fyhgwUv z*V(Ct$u93V4d|yIMEJzd7!41v(_J$P(>&37`JnuqbwY+wxMmYW*RQe|FiWD#;-6(Z zLh$(N10g7ciuV8x?Tr|(|BId(0Yu8B(vD(^y|9ZNeL=NTCgP<#mxPX1&Dv<&$V-iZ zl8MVC@%%|e;z@dK*QahFLV)HZ4VvuUc-`uWvoC!8{=CYogu`puscvmvi>tWbrdi^S z+t6Rkvjix8J1Frl4I%`O^%4Xih5l10nWGD(tR{QPlUHdPaLGXk`VQEV-zn;#EpYrr zUXcSUSFln|iIRi1@t&{hBAdj3c9ma*LHl>MI~P{l1`bixmq(>GgXaw;vOhk%_k@(` zBxI;|JULnH)SQs#IyQqVMvGt=HXOxz2@h1y2tb2)Osqi&ilVTM@!%%jy*Gyc+SA`D z27YRZFmJ}jM3g$pKzl7+m{e-qG~wpW3UaF9vCDm}+b8dv?_p9*9)n+ni)Y7UnUs&u zR^h~2+%n&9EZu?9-YdptVTGdl#&JR77eirxgn(iaA^59^IpUoNLBJ<6fy;@M2czkK zW#b_?Z`&cpSjZ*jNm9kUFL%#hJ&Ys6VU_UIrrK8^(}?A;7rrD9H_+^hZQi-{CH3XY z7@AKnuf(oVik`o9b>DUIP(fdNBYq*QlyBEEZ6*_y8?03_vyv=D^U0M-lJQ+U%Q2-r zm;f*T9hVxzG>Vq?SeHNu{&WeXg>1{qb05PmD^L}xVav*qM{;sL`Esa`$Et?r(Unco zGg5z(1ucGIaVoD6LeeBvpQh+YqQPvDW~lC>HgQ{axvgp-G+n;x!GO{HW;#oM*us1A z;P}S9tJ(1W*qy3;frwVvvDWu9pP*9rp`^cUPw3c;f)EnIL}(;Iu-Pw~k%faN3y&iK zS@#z_`769SU!u2^E43tSPhypoVp>n+(Sr$xoJ93eagrt7Z9VnP+k!Ze-s_yyd7of>7+D@XNKvOpU zolsz zp~l8J2KdDV=Jg61K$fm7K-ejIc{Vp8?}=KM8QiTei5w2uR%J}DocdX*(LDHFskPak zQh~j47=I+i=b5_(e~TZ!4omxwoCFf$f6@vnV;AzfQbPzGTd5(0058D*lB6?=Ny&Z16Q}su#0At{YerN1;x?PZmP`j=OZinsHW6z!zy;B%6198ii$+K8AR(E z1o{P+pKS37(WO^(%x7@;zRP!+BjgG>mg5!>4Fg79Ag1u~&Lu2_NsZ_@V0}wMO7kSS zS~5;RARC3YmMc0A$me2MKUA%fj8ad#stlMUUuo04fy$|;mE^S_e>5W1eM!|kilt%- zdA|&98NI>RQ{@~tE#`vI2qse$JFFP9j*q@qEPu{8{a6AQ@xSTM(NZ3teuM!V4M^ZX z%0SCq{~JYrWlK!7ip)Vv>0%M@>QQxqQE$D3JC9zr!4wf{Sl$^wQkyg%G^fOu&9Y^d>l=^N&Bm@9_kAvmgJ=|Di=VhGzzqLJql!mhxA9cH|xo zA&hY+%yj}GGGOH;7dl0Dk|K`&g;*nTSfoQM?cy+0P& zWtOMM+2>EK&PaXZ_e(_rxUJjcX=8}N@9fdvVHQmOI4S>TLL|lz_n<{RK2-^WRP|F-jq7)Wb0VVH z_ONmTbqZdE9uh|4#ak%n_XXTHBNyMG^2LpgOsmI_D$C9`qjh|6KbP@-=^*6{DG)WX z>QKO^u=-);LCLePOO5JlVcvk8OEAuITZvExlpVFWChuXY$BFv;;Dbu@!YEYaS2YJV z|L;2tgvie{<%~XyU%=3Hc6KlCE&jzBZRCN3sFLgWla5X8V!8F2KPq7p%23{*2rF(} zJg30#z&m*N1)oAKr=|-T`+Y8_XQbh8X=cM>Ueh*s7@DqG8mtO$wt%+oL&r<06L`Z_ z;VjCGFjQ4A6!3`wt~xY}37R?wC{pB?7yWxRfe`tfoLYY;r?Ubp;v$83s>upBV9z0h zgdK}4&)d!1Nbj}XVy&vYRyLEAI@lVur54_}C_93PR@9>4lPKlsxl}MIv+v4uc+s#g zR&9Y{w8{zX@}B8ssZE6|F&TjTfda&C(x;~Njo^kpcxy4CpZ{_N@5&o(OWMC`w@Mg?c zovU`rFTKJy4d(rPdZ}G5lg7k!uDMJ@2|t;q`u@Pt_JpBwsnPmFV5kt$s{22RJCUC^ zR0y$3xH$ZAZ$}7hcF=l(QU>&0p#NUS) zgvjx3AR>X%ZiWgY0o!x?O=EN-31m^^%%S3%{DxG@3({J&gMT2s=PQgamK1&Yj7a%o zgM-x zd<`#{>uDvln9TQ zQ&!}rD($qQK)#ZQn7zjn)oQSNu)d#>DSm;$)@j+_`IhX0mtJUsmmWCYr zc2a-l9C*tf(gV6l-rjkNAWl%uwO{C z7ZP>V*M2ni=@U^@x+ewe32h!EF-~5>Syi?|#w*F%^OL$6BX7xAE%GN=P+&6b1#BJX zO>qF$eOY$TW5~cVrtaX|(uB;o!c4E6VtMd~)Yl7x0Ptcwj0G#Cvfo(=t=bl;h_$}z z&`=l*@N3#3g76ck&`3kv{rhSXTk@= z2SM@B=tp}v?0sEN^AX33=xa+iY>T7&yKNLsoX3;1E5yq2LELUph)7IK-)SOyr)X%)##f_Dp z#IaQC(aWU+6UY<;I5p(Mrkz316=4)WT^j2!b)%zs*J!oLcVlp9CPjyPj$Jqz_IbyT zCL_Aszs#|rT3?>9Jnn2_aHo8Vbvqr-_YYeQjXc-53S)$|(+bH>d`_BAu@+bXh}nFN zH4T>^h#A(o56gQS&ngyFkqPw536mTZ3>D8Lvc#xMoQE<=!i$mP*_+=hu0OtV*Syea z?&2r)3j>dus>e!1tZ}h*jHurkFz`JpkdP`x4%-Rq5fm(J(i?qMADe{!pL{rlL{IjMXNqMVML$2z6Rj2jCL0gn#3>7cK+mRLEX zDb9p_>zIC}aDTubc?cft0VoT8>d{%V45Hn(#!MNuC_fq1((0(dCh5Iur@mAX_j1~N z?QZk;i!SrFbH?-}VSvVbm18~HY^IKn+#{JN){%m?AFiZW=GDwemi^sj-j}gpeC3+% zbYsU|jvaMm!@>9i~tDJv1JnMZ~4 zspzb?E3T9c?STIAeBO^KtInM2+Pc&@6moMi*khAyHcNLED73^^D0JWf!E0I>E40x8 z+O>S60(p)NueaaPpVcAX@6?ofIZ@CNF~C!BjG{aeVa0tNj^u6`{^#ZZr`gGt}TET5Q=f-Vl+ z&3J?7YWMr>2CmedsanP*H;px=Z%FrgBduGK>%T|QSJkK-q&Nk$9qhU(vebTN*)2|kKTm?1D0H}4gKk<{1hJbCB*q!wP zDQ%6ptDO5+Ij+$E*w77bm9{`a*Dh9et(S49ciX3PUwN8u>|)T%%eE;hH2ije%Fh3K zLug>)a*LVc2=9dqlZWEm7e}**x_u`zWhEt}Y#t%6r=Lw~FB40+6Z5X^;O;5g4<(Fl zlawAzmN8c)Jv+%1$fx;{X2m?L#AQuX!si64mb%Xqv`EtwkmZWtfn(9|nB$=t3$c!Y z<9Tg83it0PXMOAF!z9jH)sX4uy?mZmNor+HnRA(Y^97;ANjWxZ(zU+sDW!t@x*T=- zbIfdKk#8M83haEhRK58H`y9&LIZoSG>_eoguX)GWhW9~f=0lKt&vmho8KvHi0sXLT zpI28b?23K1hBS0_AKO#X_7*-U>=wXy2UnwpxL6#D8evJgzA6R*~v z6oD5k-`r4haDCKy%h%5z{E`!_lT@icP|f~p8pv$~4>a{`+>k+^zJ8hL<8@M6LbKG5 z`Hup&zfbFD=_dGE4!l`%3`$7MPxX5t76B*n#txL1pj*e!-k=mlIk{Z7?Wsj<&Wmg* zzoORWAi4Ig0QK6sZhq{$fh|Cju3YI)Gjii}ZQdac4bZp7J>kmVkTf=}(_&L2rmzez;BMr+1U$qb!DQzny&08JeOqGqh6$@Ni$v9?gp&DRZuSdVo8eY8zd5&PENyuMf;Mt%6{@%*S;IP0hsh)xkU` zH@X=cc*%R~yo!Z!=KaT*KNq}UPs(Bk0APDVny~s1Dq;02@eveBid=;kGC4jqAj9r zIVfSNrgQSEm=7hZHnW7^KV$D(hs#AbLlPP5V+oJSSQmCAG=G&ru)p&T-uwByBIL`+ z({6)Y)ulTf1Z{Lp-Sv5#&(<`hp!bkd9S_I}I&a7FaAWH+9q@;ic!u7Nd(hoL)~?0Y zj$2CB%0o$7k=FU79$s@Ur0d*C2zw!8?1hX`XQC`g=j_%8^i>w8up{jr8L4qesZ!YF zka2nI2|0E?x4ZuKg=I@gDI9EViWSG2*!#;0CyGT^m)|@%Q^>QUZ;JQSlq|3h`H4PR zUzv#BA#DAn4^qIiY5mUEJz*yuG3(v>p|R9xTkTM8cFkfga*Z9zt5=GQ-3w{JJ+Y-_ z*)W6i^?S{la*4|!fmEFNPc9Sm3m1CfCF>B7U((cUy1B~M#Js=9DfFcL2`to}8UoOy zEjBS!tc|iVGS{wz>9bVx_A6@WA=M<8Oo+rkrbL)mSd<&{(p0B~u!Vr>Ay;42>vVq* z8aO|=zCx`)XX)S16ly*c<*dUeb{jK(ypt4SP zc(*Cro<()v3d&?Hjz#%*k`P> zL?cR6Lbc8%9NgDBr?zM_`eX&S#l2}UeLj~R{g(igEM6KL(0u7fzr>z_J&sy};tBI) zv~JhPUQF)K#x-bMvR4HALZHi-W$`@4XB(Cv-2F8-+53YiWk4v-BO`wF7djUS?aA_& zcG39sJys<0z!X>0-5$zn z^wOKTH6d5ko(q%(DYUfG4uu6YyHG4=_I{$La>zL^#f2r;?k~suB1E;&yywUsIq@R^kklL2GY{bOU zIr#Ke^%ey=-yCw=KVUq+yDg@)$kAHGH5muHrv3KQVN&ZXEapa~GI`m8SOJ|})eiN* zCkY;b$@-TREOaTiaZX?xmU~H`t>dGu;ir;h7hXzv>O@F!`zpP4NUo1)h)~OH^46>J zvMedZq^~|jHT^H=*t2W-H06Tkr)9{@7nED7SR6ZJd9p>3Q3I}sn9%O4A9GnA^Yzhl z=4z=qF4We%V#3W5HNI@kvAY$dHRr`Z2#pmFEH0HX^eyshKzOw)zCMMe@~$>D&!4ep zGuF*1y5*A;%qmww8cq_Lkjp!8F2!CeOkMX1z_!CB<%}L~WY?dU3v5U3`YR}(K($Sc z7-o7)1v3>QsaW+LPcnC`z4WJnR1qn*7oE}R0&Pf_PHJz3u=ex#M$k2dB!$42W0_V} zKdZ{iwK>r3CF)4JpNN}M^o8QQD+rGFN_ov>@QCpe0G{G!k%bf_b*(Wnd(;SX6?l-4 zPtkLL1DE8*Bp=fh>*jG6L%Es9IY~6JN(@u<60%B(cVK3grIIm{F>TvwvK$p#ZtnFe(7~|8 z5C4qsdhWGK#r#+yX+FDKZXbGfJsvv2g(eR)6S7qGlnWG!SXT`SP{O$2JPd%MptXc# zoEiD}EK|Q~|9}CQaMo5(Y_lsRTO+X~*D8{|x<1TuU@-h>NV3kyc-W=s^Uibc6V^9k zx1=P$6(*f9d&PU<^lk}b!PbkuVdaS+r+e+N7JKoGo`C8#L65KZY)?fOWh5$y@)eP6 z)4GLG2``JPCcMC>$EqEgTGhMdf7h}~%;PL4(#yk0Dy3Da`NqS#!n;cmWr2aMR1=@Z z?tOZ*Uy;6^!wZB3P;yY~a((+geKCInbh&4J9eyn`r68248C%s7v~uZmA#W7h597<^ zQnlfh6nR4cdED%5*q}^=-D}#j(C~ODJ+@SomdVt2qf=z`i~cKNzvL{fv`; zROha+i1*{F4e#A@-RGmIuReaQ6$ zHA5*%LZH2&iA0B{SGz_LmM7FMQD?-zhU7h)vugKg&QnuU^8@9c{B|45f$P3f`TB)= z^2GFnne~xHjZ$9Fb1uQ(o?)?8ux8|}ZK$}rE^RH&XxC45CIMD9P#~*ce)Z|oCDg(g zivS*O{s6#21GV*MAHeB|D%ivIEWw0DJ(n=hwl@9WcN7hi$OhUu*F}`@6XNknrTsm-Z=O8 z>bEh|s+fpZezIU_L!#;|-O$@H@tZ>h_G&6%Do3}%yNl~}fe=;z3;DWnpo#H#JuXhBPTtpN~*gIk>^Y#!VpupBAn!$pQZG34?&dF;&DzA z3`WP&j`?Xw?wk~8S~4yEH0W@qkjT+Rghi}&NqERF+j``JzGif*kq2M9VB58bF$vr9 zp;K7xQ>$Q-vZrKYVMzbn`nQb*aPQG5O-jOM32Nboi%#oe&)imtD3wFt$j6$MH!Jo+ zv`wfUN5pTjYfN5?;E(?i`=svO6x&srkTJf%YgfP^HZYmwwPMk#_~;M&HyL(-rJnD# zj_DXnv&Opyq4Oj~{a}!sV9it3te`i#(@}J*@)1EQtS>;bjl(0!{e#_7g3D51IKoZo za}pA1#J0Qr_C$Ep`b7*|MaY^Uo{ye0HJ`q&sxz|)vAOqx@hgMwqOb5*8AiXBTQif~ z)za0SAh`W@IyrEX|CF!2Sa8&il051Sma@VNh1c(~*fsh+bR$axEX&^8Eo7E0jLbVZ z8Bt8h-F81ZnE3*ti!;F zgcvdP>`Ku}*x*|Rmn63jbB?#jW5qV7Rt21&2tD&F3i;l>?Ydus1*>qMQ%+c4TyYDJ zbFz7pygl_;V6tzgG|xu=bWD$D7I_CEi>b{5F{j~mLvE0ytMYeD0r7 zI$()?7L5|}giwOF_MkW@4dwv@7hGndR;i%Wr2oRp5XlpGyr^bAfH1}S>VGHB>#rxa z&zPrn%oCdnB-TpRvx~JPA#})q2XsJKbcJTcSUk@!W8#`343g*~tvhHQpx4N@GI@J% ze&^ou6!+KM;HW6;!-{i)6H%|C9zkI0r|PBC>zy*>oYmJmMQ2E-mzvTAm8L5dWw1DIC6snHcymUBd(q3d1wv^^ z{>2od+$16VM{t)h-Q8e5Hyh%tH>5`|Ec{<{gmA%WU1G?)p^x5_%u+@{eFRnX{)Cg{Ezu4)C4*BCNeIF1GX@#PZ~l? z0p5N7!QTU@qmT-e2h~Cf_V|^#pU1t2MBsW}B_NOtT+h2t94g3DalU*2FWc54lN*R{ zk!OEB$Y`=Q&0Boo{MGY{d(^K^x?YlQQkAe1liD2FXR^*M#T76ub+*3M)Aq!@zmA>v zdes;gWzTw!-}pN`?ia^A_!CGVexlw`oGDNe6j><50q8~aw>|!iu127O@bNfNWPP~M zBOujo-q;?Z7>!t}bs}|Qq8?&i-d_k<3`n(olp*^#^l|{$yIZ8VbtYlUKbwC@}L@R`b9K93&Ji?(^34VPW+Yw8q zXE>Ffk(OZqEZN``XdO?b<3?e3L8WMmJsAv-`>qL!U*83`{hK_5_CMz3M)+%~BlfIL z6r;}9%?EJmF|kDI8a>_W|FdV>hujQq$E_w$2g1l7$9V~%Av2c{61!>3OIxFaZ9z_gCfCRiJ^^y=D%SeHx$Bm6o{>FK$n`TpPIovtDl~pampYa z6~6+Jp)XxxusUW1&=rF2dO+`zc@LE4KZ+BIfgWpre}SZl)Ollo3W-pw`4BEH|NnFs zOwlon9J4Ztl@aZ$!1~dPW2+ytcwaA+?}!#-h<~E9se#@9wJZu__OBcn3XWSzn;wEh zFS7auN=tqe1|)`J#fESqBZWwA%aJMVf|eYtHo-PtsX~hLO4#Bs3>J1PE>fsZZx0OF zVKLJl?$wCV8(>$a(>2}SHtdy9dY2RBVLQXExxhs1Mp??z+h)osHUHcWnRhOE>M`Gf z{MV}RyLWwk9~xDew^}c*;-phCZypsB;y?h9k5zC;09w$`9k?_{JTpZN!h?GA9+wdL zqsByCRZCGR{u4|AyD-OW;y^)-GHEgvo?nMubu%GF$&fwe#YFr-Wkc77RY9!n#UN^X z62}*~#nhZe-@?CsGws6JFnhA7bz}NR_rtlCkMD6$=ldR1JTni&|J1>(87T~lKPnws zxZg-!)b=HS{c%;r1QGC-4YDXm9Dn1AQQ8+loQ8eCJ%KV)f#N|nGb10Ogg!wj)c`nK z7J#!26wAq?ZBE8rk4$e|ywuKGk%=$*z*yD1)6O@zEm`bSxaEDq53GIPmHa9e90SyO zjN6$N6lRJK=P5;;pWKlN2y26(SZ6UQoi!VHK04q7F9G50334cjc~k~b^IbsCFv&|@^+{m8V=cPr)R`V^G1z0ngfmWQkKmU4!`@rQ>~P- zn-8VXkFWM1bELP!mB3403h^)YwL10S5B60=k!1}v(TPimEr@zv2rL|}DfFHS4FU`Q zH`*dlX0&)bsJ9=W1eSEW{*6b!mnf%3yIPK_;47 zdS^9BMd5Us1&28KV9Oltm!XL}9j^RdLEc(MSM3D6{NB8=Z(Bg`s@}oFCbKZ9PwmVB zk(03O-(ub6#zO}cpe~S!$&QF5j(W}t^d-jzB`_DFU95QxQMy7kfqw#;bo(~p;k5#| zJeM~BYid$ooGNcnzVMPQzK74gedJAX3g3VdWBW2q3eMVv#Sfpu0f+;1*fg8nx_pbT z!!d0Jw=VZ|5bXY~Ygx5#gO|@Mv&4OXfrb+(C9V(v-TuMgVg#E2_D^d=m&W>{oKZZB^lXb;)j-`Cn=+*59bMOp0qhFPT&vSWU;|$3kQg%Z3lKSXA&1v zCk4Ivk7uGjYeRTagz*TWRkv<5<)cy5w8|9G>7~?>^t|ax<%-H4-=@yEo?IUDvU@+k zH}fU++>0|@-qK+n_X!`JZ>iStyN^K&R#;xb2|9E5u*t4SYP3BoK(PwO{6`40L0n8^ z=u3qB*NU`F@PLJz;}JsM&-8kuw5y@CDo_h;a$?OpFD7|UbyiAlZ@;^%Y;`G$I;}V( zok5c&=hba|ll@0Y)bmZxH}E$audi6&6T2QxLPsz95osL;&H6?s`5s&OCXC~^(U^0A zq=yDcqixxo67t_b3Vuure8hM!EHa9;-hQ$$WF<9}x0SV7WFLl_9P- zaHTwo)}oKiVNk>iU)JQ_>FMjG16B8nPYA%9TSSurp2Qv&md|!5E`%KnY<_jmz9D%D zHzb4?ppb*0*Nlp=S)>YKc)zuV&5IVz{$~V`&pajqHte(o}Z5mIaX^`=R?eVg}eV7#`>{xuuD z&nM$@gPG3gvCRI_4F+oTD_BeXtRjG4{ac5*(Ly*=;-C=J(pMHvG)See|HjJ7Q6wzXxsXWCD;A)&W>wd%t=tQm`d)L-(+h*zy~TR7*W-XEZ*0 z=V#}X{V5pLg*O0?OJn4kh3AhIr3Fj+t+D6gcz;ef|I{-Q=m5^+)T%|M2#{?i zu#C}>z3hSk*nOQ@V)j%s^NCAfH*ed4X%UuLB?HnK;qj^it*H%m#-*l*0iQ<-RM# z2zL6;@p>;X+V@hxO?ou2J&tuDY)5Nz*FE!wgeI=Kdg^z-=`g76y&`M@zBQIFe^iDE z~@{(*Es1$Ok38_Fbju!yU**~KM5CT)S{JW>9=O*Z~Wdk_EI2|6; z22(9OTj}PB1?Y7veEGbB%5QTAd@An&#_kiI5;KJTomkVNy^Ha{r$#9EU>l?=Cd&gup*4=a0LPA zJDwZ@>Tn4Z>m>TLbR_$MUHuB>MwwDT2&`eChIx%BB(JJg@g`$ei=y&rK!%W!@ht=T z7gHEbSv`qc8(~$Xy?5`;xBChS34V{sJPC%)G!X%EciFET`mYyj*_8OU`2>}6E%^D~ z;Ta2~0de0H$s>^ZnPl{BD;|j*OXHs{ts24&0LLm;b8xKsS78z;q3hsS_4Xr#$b!t0 zfzkhrd_>gkJ#$VGe`4fs5WP|B-TFf5hrMSzj?1H?ZSLminqc=7~TH_`d_#Q8~Lk- zLHyNY4}L1&2Ib`eozTKUg4LCn&hT0=-Ca-+t}AEG#v>$F2Y(C~61^%oC?X~V2(NTd z>ckK!9F`iC_8BM@C`drBG3Z7{??#^;^uK3U2^9Hh2)C0I0q`=VUHzBWVTs!^B9|TR zUg7e@r%g8BHuQHCGQ+%eTzvtB zLaFmJ5@as_TavKX3?D3wS=O8kR^b3WS~C63xUr3h77-`ok3}1Wa)aVg;efsiY%x&s zqH_waFlgXA<`zo=B{+r4iy}J@O1K;5P1%^PJ_b6r7p<)VTX83epIdupp7rjqFz1 z61RJZs*c;-9ATSyC$Q(==5lS(>bwl3J4hVsKg4m1Q5w}zuoyCuHq?Fwddg2)bvewd zSY*9W10^^%*OOPzuOUx*g%-skTqmw!O@ZmY3DG(f|@FiSe6n|v+ zD_Vm!(b4+Ip%Z21fG)WSArJ@9&T}KG5i8@-7Uk2UohK2TV=5xP0+m*)4ukjf6!{+9 z)RA1Y-cr3cs&H}r1#4r*)#B}w{8_W%3qMRhL^)qE_?_wU`{8mboPPep%X+TQeNKJD z*8KpJ$A||4wvaLy0r9iq1eFUY6v&QVA)zWAfq0AQP$G+6^BX?s6FV#V#14h>Y{m8I zkwvHZuUUIv3F;Xr?3vYuWRV{alq$@w)`S^B%SU7|Q#o7*`gaD%gC}^^uk-BdkT$=tH6H(Ys zz|boxAy9j=^M2VD{4EwXBc{KP>w%m)RuczNIaracLu-jhPNHO?_aE-lC7eT8zk|KJ zl_~CWJvnbk>i*fZJR;N7S?>Dz(NT>oZSSiy)Ygf0);mNQ(mOO%QIbgZxYTd0H7=|8 zc^t6EfA$lE>YpbS;D^E}VDi*QZl=&brNt9Ox#mKl7FISLqkQ?8&MY==E*-0>eR3U6 zy*>Pu)#ehtD>}8G)p*%7Jd+>9mzFV!+mh<%osuwA8KD3T)ZZC0Z*C0hTpeia+c|$r z;G-XXaqNfOU~tK?W|~&@IkMxeIez#ZSll1M%=Pflu|XIUt&hoV6P{2$R3!p>(!&4o z$|9z3U>jQj^bJI`9fneA!!YNDP|IvRc&dP>2jlysm$TQuo{uO}JriGlFS?!4ey1gI zP3g<$vgVM6ygA%*SvbIUqTD`|Fi8puslGd$kAYs24kMB3^JLNgZC-BjpdR>ntOMa( z@IR#|2>n$MVHkWRiKu-q3ZL_?dA86LFp+jts#SM|Fd1fBfJNi0&e2+da-=_r8*Im_Bs@k zv7dhjmk0me$f89nhVqCC3JQt}KCTt461byns_GdywP=ap z;W-c~qN6~di2g(IpAB2in1-#&F9!;F6PLUD`}?I8RVB;g7@<P4is19sR@Kco7)>Om^-+VD zt=u~HlMSPuba!i^S#bvG(+@%lxS@$>xjF@#RGu4j2Rz$A<^m(XkzD(Mx-kP~&4h$v*tw1iSNxct)4Sy}UhYMrlsF7+ZxKC82#vOlKvRw)L~*gpQk z+WyJiw?!I1U|x7nz)N0)onHVR-Z1ucq4qmslkDY^<;6zxcKYI~4u)Gwu+ZNdo7;_k zOu*dQff%p_ZWR9w@u0T@33m7QZ34t9aBA(PZSh}yZs@M?K6Xr(2d$o>x;w2Cm1Jc} zNO)L(-152W&Y2Ci-1iJb;}rWT8B^@qyndi%voaX7yW@4MyUxC_A2fxll>1*>Np*Yf zv$%>KPzQ_rqdROu^q~~AS%C*TivOGvz{nn2JkAs!D8x>V2%MKgLCf&wL>oV=!hIcf z*@)k*f>AkvpfVphOHpP^D9N63_wp*)PTvYs)@$t^uCwCSmc`rHx$L<+6Yuv!K|B>M z7l40l7)9BS&AzKMv(Vw$ohK&5Guowb5ol$W`K`9f0dSQ6b-n_lrqH)Ah<~nyB`}YC z+;A{@O0c#TQ}X2V5BrHYn~S9=J4+Kw$@i!r;gTfw#0D;Ej)e6?(!P>ns1)_f1F6=4zCo=Gua2`s?hPgr|%xyfs}y-f`Xdgg(H*gWa~lY^@DZ*`j1rRk+rB zZ+pXA7VX5tRUM>5j4al_zyI5%UuFZ08uOTksW<<=X#viNj|Vyq1tEl1fpOb{KZ>qc zlyF+%hn&dG`PO-038#56`RU%LduKHxb@MyD_}1O(7H!{nzO|g}WV=16!XAm|D3&gE z;>^jxagxz-ZJH~Yecu(lxOOGu`Zb4}pp=QeYlYHaeu?4uBr#<>%gf)AkzYkWA_1Y$ zmHB^lg$~+I5+ooLfP$r!)=U_g7K+i=C(K=j*{l7~9CNPgGP2uuG$JOo3+y`mW50-g z910!ZTdOuRcu^)2&Rta0I~S&v`0aA>#CR=q)Xa!^*4wGE_pAMMz#=*QeXizy{#3UY z=8LP4TuNBYFJp%Px?uqq1>k}3KVF7@9u(yH@(u)QxR4+mE-oy78Ou#wgBQG{X%JE0 z9pNkmra~0UN8Cfzy%EzLi+&nHVKd=LLjtvbUE_c<^3PB)VDT2=uW>=9Sar3ylyW1Cvobh<_T+$1XtuBRkI_Gg#d%rhe{Tnx=uL>b%rDnyEQ zqc6X-XyNF~uNl4JUzcAYArCfI=9xtJ!9ngEzx&TZ5*YGd^Vl#B4IUEzlSc$qpl}dh ze)OLEPXTfl6JNwZe0eA2j$qQek{5CzH}n5-_7+f8Zd=$e-6h@K-5t_wy1N^tk&aCx zEt^gOK@g;omJsQXP!Q>s?vnc7?K$_{d+t5o|Nr9~Ls8do@3r2w=9+8H=b7)bTqW~3 z{B!vSh1&_S%T5rnp6|q%JX-7~yw%|6a#Z&YR=@HnJ)4kLQX1>3#8z!t<9%@r6HGuI z{V!Yjt-JOIfCIdi{%3OeH`kmgJ&MTU7AFQ63Pd;pE5CYRCShJ-o3_*XB)LH*rTOuJ<-u9tfiimjPhre+IIKsi?mwq3V;)K{0?r7^VhU0JeZBw|liAHI4!Xl| z5YGJN!J!^U0%BM_GD+m!*sr2E;NS=!EtKfVr8AXFLtaAIRX;ii?Wd)fO9b>mV*w66 z`by@U2$9>+8_r#Zm54JShhkmKs+BCAZ$4-;-NKA<8{jZw@WCv;hH)#ongknqG?|1Tp1LaX999{D#W_|CEBp_DWfAa~-8R4dr6qDC zOkFM1Us^X#tQF+t-4=e{ok0Lrtz?Wcoa%n5N5u4b6vLIEiHqSOy%KH(R3edCOz)!= zSlgR(z$G^mKC_4pNjCS++{o!oO0}#a7f%)CA25NEj{66)1NX0f8Pd6w-5ydW4xOnp1JjS|R&) z9BoGQ42XoYrp)WnE7_iFbC1v%JeB=lfb@po3r(%qxz0*FdvSLv(IA+w3{)0>P2BGw z{I9`5-T8l~pH_(otiR{&kJpCVVPmeg26BI6EfhF#k>Q_G?gu_cJ9=oSj|ca}Hc89H zpRCCV5IvUw@81Ov6Kcm|sB>(s(Unn*Lry<8JaT!C8|;M6wSAF#LYs>v5lK>Oa#K7VXc# zgh}JSwAO#~Eof(}L#YWLZvDuZDKW==%Bz@DP6rRM!3Ld>!R8L9*Mm^x@|#&wz2>)X zhWZtUc7b1dTUIB9Hdbk#iP;rr7ks_MyoVt|%Y>q3fRhE@@>lr{0sSM}39K-H6tc4a zzaB9_jfmQqsw%C>O^eT3g*OH?hvGtLQ$4dBQV&i*!*D=DkVYW8hK}zO0 z;m*_V!le_KE9Fk_0+zOS=EqR1_J&nY9p_}&Rxvy^IwTJWrxV1rHTS*cutGHRca*(z z_3vRKo-*M7ekOQsK&TE1f+GOK|5LT$zY#m2ObXCd<3Z^PT_1P$@hQs$yCM=PVy!VS z@F63QmD*|~ZEcjr)U_$rp_7iYR@cdmlA#)!UO?p@y-S;t4VS-~@0}RGz3F!O$xQt; zHQ3^Y!r!s7Cu;xX$9e5HR?77!*KQqiclD_pa^wVlA&>z*3zchJKENREblT7CEMwwqp!$f_Ff@y?|7~<;8y=Db!c8Gn z{$Ee=8|#1q+0c&uQA|VxNZg?cb@sOeXYia2V%X9Dfg1l-sQUvorlPmU zUi4mBY|%_SudhD!EhoQe(MMg}TLuUNDnvNxtJsJ@g zuIpze_0QjEIS}rFq4G08sm~wJ{a>I4P@e?-{{Je-!T@UKFahD@QJ@l$V+<%mRLe{u z19&uOK^DUB7qW`Ko&nI}{8LPp55Qsmp$nk-53~BOLePH~>HVz-sG2N7;{;jQUp)Z0 zLI?uzEr0sHzoQ;NJCHg8@`26(>OuTFa;45ant$Z50a%-JB!IP1N>MvZW)8Q8QloF7~8cyGPh02>E_hMW%8BIcUxsRpBI%AaVBcNsW2wvCex*8% z2QA_1-#aGS4fj!M?3kq7`<3N3ty~7J&)dANUgB8~Kl{E8uh8?Ss|8#^$~}N;K=UDB zIRB`51~dbe0)Xa?ZD`%me*2q0z}SCrplsTt2*0b6w0mhqVsjLc<9)V_adRRLlShke z)5mcP>kS(tcVtaX>(+VlRLsgLXfx9PR8@4R2*gDR2a4X}?c1hZ_IHq(=cxAoGWCD^ zPGA7M|Eui?v;v4Ippu?z3jy7g?=k~J=4TMfPxy` zewat=5NL$n3YAWh?uocXXHnCPqi|AaPhqY%BD1#EVqUP~uCDgH$>MkSwyj2>oVQgH zuc8+T1^Zz=ma^+&V{(aoGZT?KdoFq{cvr&q)B{0wCao z4(y)}XP_B?4}oPt2(25-&$LuUBv4l3`+>=xGbVE`$Ikkr>De<~v&~bU%M!IqIf9rC zEq-AjKv4WwfKXIqr?{B@?_z@Z2Qz=0e&|A=DvDBPKG_7+4}m@VS1byQ;}0>h|JHE^ z+J7rr^4fIwIf#k;LFxUWDfow`W37SiOk&sEOo5E~DP>^l1L4Q#tKZ z=W=h;WNp{Iuh9Ycl9|Jef1pirYrb*HX}{+)y~~x^pKcIY1}XDoAD#d@u0I|0zm&uN zRyGQZ2v82&LgcfaJIntv%ZQ?-1t69<)N%N&#{iuAhrx287WTzI*X=~bZzT>tlm8Om z{naMU)fpmALw9^l!$Nrdxe~}(iryx7+?BV^y5ujf_a_%Ia2s+^RRT5nC9aN6$SUus znSQSoJhu>Fq5zc#ouog#@_)HMsB+F2E#iGUJ<>wRmOS=asZ2147 z-SaoC9%Mhf?fdJlnxMB%xzUXzYPlAqf+ciLxca!-D6bAvCLhG6(r_WQ%_lGz#N2`- z?_umoFtVIFj+X+R{DzvMua@TP`*)Te&z6kh3|Vm4nm*t9cP76&Txt(wC`w+wT8iR{ z0`BW##s@A#{og;pe{01~m>eWYB!A8yB2SvM-Nd2rX0juNZkuOGVAH_j7v*dl-zLYL z&5g?PV-)axn|r<4)IabGvYHk$x=)!?tA(ZQEND1K&d`%H>IE!jc{j~!9B-zSBtS)) z@1reqzvG{LoA_KZDh@wljg`A-=abUY4c4FGZ))`3y#VffxqI$ip8l z^Gt=OUyb7+iea;)c7QaBw*rUUcf<*?b#qX;QS>7yJ))g7cYa43T-Z!W9fdq;y&;in+Y`1_Np9dP~TKlMax<(sP=BKf-X`6LC$xKVhv@rDnbFhPq z^zmLZE(YEWm#$pTTvnGPh$nc+1znCuZ1>%u85Ob8i<;%tw1n`n2z7nE3NqBRt)L#7 z=lnXiNQQ%NV5nV?mraF(&&8sdhlV2ME1+Cdok8#}ym%^YQ$RZ9-IEg|Px;M@+#wdD zo87n{sYI@X8ELP(C~YpS{Jnc;z#l_I{7Yxu7|ZO4Uc9(i((fqIWKN@z#)*IY;F!Op zsc&?&x(;61xKQaqMbPYpm4}@GQp!X`=tGn*RYps5sFCYq+_p|Qi_-=A z%nV=mrR>1L!Qb{}IF+&7FD>+IEk0F{L+ck-VNF&eU@B3;n<}kiSvy<;cUBj4@@3Mk zY4;tD10}U83m6Fb#X>qy5#?hWnhbj3)IAyQ2V_q;@|GKb>8i&g5VNjj_BUWQ}T~e27yR3)OF_1V+QG z-&eo0F>dy03${wVOlYBPFN1c$<;&3zahDLMx!}RZ%^4exDdNQbjHc?h8A(l-t{HY5 zWWzC+UWI^N&DWlPchNW;c0smA?_2yZFrE*dZCfwCo?%;Sw_mCDfBV51EsEEZ*qlX+ zP5b}gG5+Q-@X_q#P6*)Bn43o2ADfGRdzn)uZf?t;KPO)JU`^B}JG#vR>ZhWJR8glF zJj8=RU6PV>F!QfN0v=N}4oj`Tdnf19x3Ep^ZH!r67Q;eAF3nkhUw)Av& zFFQJ>U`>hbkwcL&O|TyVUl@I0MYDN7s{_K4@O^e%oq;Dvs`eZ)XVylbi=QtG!dWaz zTlcCY_stt788OH20m7mIaL%R;jM2)ThTOHzHY$O>Zp-=T4QOl*fMU%En4vLJq}y#? zhD5|r8Db?4>oZY%vC=h=SZSaGGDgg>18xKc?&SooNsHt-I5+@q4EpflgQ!F+Hamx| z8K#RS& zp?GDmF&yFvY`9eqqcSdeatShWcREgC-VaJzTJOs-V%5b0IEBR}*+kfuk34O4t;gpo zweEg}0-+G?NF~`%(Hu9ZFyU9(}$0hWzAANW*K0Xlrnn)Tje+vT|L0= zy)ml*j|=STSd#j-{+X});aRpPA8Je3K^{r(=~c8%HMOg&%ik~zJwZ$_xd(oK;1OzB zxF~funOab_X`M*l8Op5eZ)^l~T?Cfv?FqlFqeLiAJ%lSyRV-Bc8~b(eeVkMxZ84fQ zPof(WVt@Su9Q5*4bZ?_eAiWHO6Ihx-#1tiZ@8@m`VZ|<)N#HGi@I8FMG+I^geGahM z=i@THF*y$MB^qHwAVrYIu0lv7ZumezU?4!(KY#ulxT8|Jl$C>HY-FUex_W$cbpPki z&%_R(4Or=uzTsh&o)aRs&;4bfb?wjcadB)#u>k158@<)kq8r<&nVq4Nm1E||A}lG< zJXp`GPU55q`eC67GDek#0M`mnPEG>1TmtulMMXvV`}=2SX9KSR3ko)UNN;p?h}(?a z;gvTUdlz~PII*43m#IW<=2)PXCn?(R-?P?KEC-+0FQ*TZt+GfmRp)o&r_j8GkWWIt zAFK(&BHC)o>U;q9a}9e9i=IIx+a9oH#y>=-z0lj+0&fN2ODlGVa}?x_)IvOrVk;_2 zt5Wrw6?t_bmqfY{{U|qotFE@yE%QX-Exw?Lo6;b&a(bv$#Sooox=z`Y-U82Qwm zy_tLe-f7w0A^TMCk0;Y+#*P7V(+s&>!2LE3K)Ms$kf9e=Tw)qVP#g3^5d~t}%9_JI zS|mVh_$I8h3Lby>F;svwf>wtPw_d}%C7XdmeR%W@1l%}V2!VFDima`(%sy`L;_95q zVe8`3$JVL*jC5l~?Js^^h!43g1b;Y!miXix zTzY)13wqb~8JIE z>kEa8ds2yhzisullK1zK)I$VpZ@ByKNk6K8**&*an_GR5ax_4B&6r-2Ps64i)n9_F zSi1O9DTR=A$a(=du^f2%+kHxb)l|y@D?@A-UGkFzguZJ<6WYnU=%^s;l?GD>#Z(=b zT_jd1Bvn?p04J2y>|(|Iyc*A4moF*ylg2g|y#$3*FIIiM#e^{o42_*Vs=|UsKG!`& zTZk=EH)Hf1Vl7_xS<-hAVn?Pz3VKUqf}NL`te{G3en>Jui!9@~0YWsB(1$7Tr;__) zy?gpA2DtZ@l)e#KunVwFFwTg_+^rtlnP~F8Zb+nCz_w>) z=NkI@KAg_MHI13=rJzv;A88i`tLm+_CZzM`_}t2V7&Xfsqn|45B*tf!Xg2+)?#3sR7ku4<58Fdxcn9P@D&lL>3{H9o$buhy zKlolu@s=$1dgM*GuY0p17o6FK1mG}IhMpi>F3=GB68DF|+9daW$KyW4PmqGgyR)1! z$_gTUn-|8yWHqHl>>Fx$-g+Qcuy`<>@mT8xry62%*^LMvP(myxx)LY+4f64EdJ5|?o{Wkf17UsHm_yy=wZSjH(Y zCF2!T)+Xb08^uYgR280UPwdJyvg2}~$vk$TfWu z_GDm1^Opx0>(-DGuyh$-h@C8|s3hX$#;2M)7bx9#gEn{<0 z@}#qB;F_pQqT?m^q~=~TdTRVYQD<;MUMQn?V*^cb-Z!C-fK#Zn`~rX6_KiR4`#*N& z$R5pJKXjhQ&(*KjsBm`BqX$d!T#KQCG?ze){w#8hKXs5Dh!{bPZD~mhvJ(}|8}tg# z8(!%k8B@kS_@S9EVcTG(Hc=K2>y$W)Z1OuT(5S77vFn1+Gjt(>v6fP?H8pIHMuEpd z%TeEZRCqmiz}g8O?sD~}MzK7c+PaZ)rB(@EXUae*vvv3rKR0w;IKze~hR=P=e!mm> z=trW&rS(;?-*m`+6SmmVjMxFOyT{@8bE=EWW;>>b8sXFw16fywXWzs~>umh^bIv$N ze-RU!%Ei-8p7J+pMeBd=j&ZQsg(cpGw@G?pqAm{41OvuFoo<#hSx{6g2V z0?rSNaC)zxV1qMw?uBawWk6A3R5|qfhssR>j@GHQ@)Gi~P(fB6Mg0=s3=#8KealU5 zSRSn7j7X>01{0fGbHMC3tMk!a=U;k`E{irnj%tL)*)JcwD7L7g41z2> zu)zaU$@uJ#ZbUmyBFaDCcgA*^fy4}fA>)#B4H;W3ql5}a$0BkolJ_nkl$$mvPc9H- zW>Ms)O)t&iWxvX&;S}{^S)17DF8@sK<@+KIJl1{UtNx?o{AR+WyK%TsdEf9ApQym? z&C0^-{EYi&8aoz3GgU8kPuy31+tBHk@k)gQ;^QnKII4cS=O+8PaT>kGVe z7rV5XJ&@ST&}(_9?%pn4*n6|-={O||){;p3ApF&b?_sqVJ5Rnk#nDdOm^>zfp-`iD z<-URY9!6Cd#wN&WO(e?`i}7_RYK#){2$l1e%Z_@W0WfS^VA$8c>|vFN6)-HZPn+YxJ2v%BeVc#ik+m(D&6&;D&T@9R#8|DjF*al4--69E zj{W?EH!7Y+q!eewDp|P_#w~oB*nU!y`)kFC42PueYz;66B*q(X55CmY+L?>N z6ySQafe95q)Pay6sJe2wv){1J+jMN8OV<@>2}|!VY97LE(rCVpu7@daL&e;g&`0^W zSy+YKgt|)j#8vw9>ko2Szi1ut#qe#Dl17NvUrPjoR|8F+Ww`7vcVN0LTPI%m0NGb#m>oclx{ve5yM5V!8+tZmWrq1%kU_6K*N`@r;Oubi1Ic;}+ z>vOz1o8$td8+t{Mpp6xVp_{-uozUQjNe$#he&Q^&dz~T&(iiG6GeU(SSh^2bUmxN6 zv^AX(H$BQR!rF8lIb=Sj)5_9)ItUfJhB!z}+8e;K4pUO(-Ascc5WzoYS{4>-y^)*y z@-MDSKH0R5w7=<f^r1vuH^p5L3w3}GAv?~s%#+6dA%yv3 zDfHKz<_WUqJv=^GMQOF+1(kNbEP+*VG=`QhYVC*6T%|pYO4YQqhYq);yDD#$#fy1~ z6X`40X6#`siQpireW~?{DhF#j$6y>|;wXIw>N5Vu71Fyk>I4Z9!-6}k3;OhpK_KT! zs_SsO+r+e$hQOLf8}x7|^NhldOysUpNFqd`ZRPPfs{4SEewKyVv%b)XXv&`8&czo? zf0SV$liS>u4}R8N)y-0<$ErN^>!jySs>R!FAy%T6 z1YU9FO1_^TN>txO_wu*v|g&-=_w6cB$Z$&%QzJgEl?{)lk^ z5>v4B@UvtgeC-rKD9MeWK;v%P+p}4)4Ul2KV^U-%kGTkl_YvAt(LSaZ!^W5{T7xf7 zgtbB6DRgCrmla{r#u{7=I0W7V6guh`GYe=+5Njgwq4fP~!>BF8cxwhxHU20ijEr}c zT5+|pek>8fKq2R*r47PDzSBTjKJS}`eT?ooTOGN^>{UY!@^j+qpOsy+i>d(kZ~1`2 zc=;L4s^0_H_aK{gne8U<3;QataDif-dV!<&P3IF3S^@tk;vt7)2s(al5f3~@qOBaZ zLE_+`qQHg&7;Ms4%;8tlJmcRqt!vn*%pXb-&pOd>IVJ5VOK41tB6Ss3S2o0JYfHP# z@ikQ!(hKvLY9$-|p(fmw6Z|F@)1u#0;g*&75*<@0!sX4@Ych&F$I2k!7|`7mD?v0B z!hGfsVU3xF{M3Y!m^Ab`Bc`Ye?qpquKN(WT)v5$KpX6XpRpWIGSiMaw!4RvoYnf{; zH+{o0#glS}<+FNBl9T4ebL)|~%87SRTV!o@WmIQ`w-uPUIvl*3=SzO-;ADxT*~GY` z8k0xrmlS=gJKnPDWEsFNSW=b%G2Ez~bnPA8Wdw(w-U)Gn8dxuaW|i;Q zJZtK0MAJSZ57SNPfLT5z77qI_$bPhBTiW~9eEBV^CVZAfk;0yywNWsV{e6QVtbK4# z;3>QK9#2c?xO=73210s?H-M9;jlrN`f0$O-ZolOpDYG3`3X8mlbo6B$y(*AdP%Y2J1YR~je3~O8%=lP^ z-hKtM^q^HLq@z3L$fIK%F%$V}X{&PGvY*Bie99o@_Y6I|ifFPZUKcVGqdbMggIVsR zKE|mu`ieplj7L9iuyE8vCkx5&cE4kDd=0xx_tJ%ECvH`4wS64PjcWhl(KBzg!8^&! zSiw9o138tT+dwX*ddo&5g+^rAm+4^fg*X-f1hyVUlhL%cisTAD!=_Vs=>3vfc}3B+ z8z^~xywklTId)Ge4xwj>4c@IQu4p+*~+mZ`SdH=*Ra;1o>(63qL$8 zz^w`e^vHo|V^IaOMWe_G6pn>BSebWo1V@X>6808mn5P+9#``$A$%II6E{uLI8(7|j zWOWCEPlv`DAL)S=Qb%0ve<^HsOr*_ZWZo*=z> zjo|kf7l7$|VytW3hG9egnz5BIgPB9u993;FBV)q5IVK-)X}EfNCW=@0l-$}3PSw86 zW07$qB@|^k(H(k^3*jGqNEZk4zoVhgCQXsbl|d%Jbd%8-*T>=sV*EdcCUaq$Bf6g&R=!ZBLDF!Y3VGcE@tpYJUBH7IWxqMe%=Fs!O^>X3hI^(EU=>$`X^8bdw%Z1 zJl6+8o6y4Y5)309l|X8|YESYhQ8VXEVz%FV>ARQN{LDb_CrxCZ_sf_KKlb?KsFEa(2($iNO)mX7X8yi8wP+!sGfwK zI4EroF$taStz&ex003#s?JIilp0?W|JqS~zn7c;kMHlk-aC-1q;$jtFAuo!114<>e z+~sh<7$?>KN#;+2T5PJ?Amhy2)D6q3 zJZt3dFX@rVJeI;wu?x*$Tl6E--Fc2M9X>W=U;2r;!{ZBx4p&Z;h1oj&=o7LU26i1+ z(>2Go;=wqu?Z8k^Y7wdZ=&sf##?OP@I9>P^@4Nj9t9-P!u#vDE-O@*uZ9zsZ$`@I! zc}awFuPCL@l6&G6ht7#KBs>=x-vL$wFcH@Lq*46>i$a}2y{`xuw_n9FlQ~GM!L0Z zo|rII%TSxrovu3JwbIyiB0GZM=Yl-+{_Mg#41VwKbUIV?G5^t~xGz37HNFx0J-3fm zGSd@ftB%2>9WKwd9wybN^M@2aLAUm%q)Bt4Z+7;A(yS}-+>49z{EwuYV2-wqHX2$< z@E)0=_KdPs9{S?6bTF1Xx=|tF>{G4rR5BAG=YjUu^9`uQ({jkT6KnLmZv*#Xv;3mp zUmTxjC^{rj1K=;}2r9M~Fbgxzn zr%Fj4d=W#0-BU8ny@Qb;g^;YB z(tPl`VozsL6pFaB&(1jdDdJ78dbGfk4kHs=x{dru=5yHV8oi(KxNq3++{wBoBJvH| z0IQ5kp`&uj5OnY#FwRuJ!pzSdU7gzLY;;kJ}W<9v;shmj0{7uWmapGibeCl=F;P_K21@s^W*b5`2=K;f?j^B2f1}B z_*hJ+8^ZuQoHSkl?$7XPEw1mZ6Hw7!nE1+QpsV{$P)qg)t{@xej61J$_UQ+z1P`eAPDmDl}O(JgZ;Q#DTZ~);bh&ysuwRnq(FT;Jg(mkTiE|p>Zds zW*4#MK60^E7uSVo6wcAS{c7HlfJPo?BS?go`gqun<|0lo#VXRv!JG}}yUjRy|s_OY`Euvl?DgJx-MoywC5V0v(KT8I4-ycl?AUk9jZU%c) z>aRLxyZ1tu?amuwECR6EhkOTF8}+)v6i7aK5C9$7A zuOEEc2a>e?SPL3h5Cz-4qWf|`d>9b`HN*j(x3B}BVGJqx01W7(afjFO@ zf`?&W-V*BEh_19YZG(Cjdgj6ss%+6dG)x&)`+bqz=M{TI`1(`P8=Lwy!nr@K@e@R; zd2GD#p6|h-rU%~qfX~!xJ~gC{fEyS;Y%EnyiIb3BdFR_z>F6<9^?`SkUTU-GB4)MJWFt7ek)5)SJ_xQ$_v zqZM)Sma47qJ^n48p!$Usn1lI#R~=_8X~X-?0Hc-~xe6`OK2PqVn;mhK7d8H@Q6zXnF23nna|S)U2y`4ghZOd~yRp9e z-IQC$pT!o0Gabo?E?3#CP@5ALtKXw1DYNP8Yn%L_4C+nXD$cYbbwA?$wK}s1l??e+ z7QV*4V&APdp@$--i{7**(cU(jpi|ZxMIGZ3GdV2RC`e*YNzb;@Q9EL8-~(l>07%>0 zXsP(^Te?Wwe$-bkL(N^aPyl~Pq^^4fS<2` zwc}sw_Yq6Q2K?U2oTd;FX+3~Int)gri5R9Ak14-&UU0o!FC-GXE`y|h0^;*n(}S-(h&&+M*OZ^8MiL40~)x(Yr+Dsmt0EtJnoHzZB5B5Td~@WjxD z>gyHf-V9_X$!Ohdz!lr~k_9_S0t9hecg=zqcxRi#O1(}R(Z2h8qB>zF5ZQ1nVJs(jL_ZsL!vows*_$sIO%qF zB2VM(s@=#Ct#G3DWJYxQJiqG~rKzGt>K}x$8BCvY?}CW9ZhEi+0Db~2M6B2YpXpKzt$hY3{Fy8-Uz*YpdmOUGvfCAK46jz}GUKCBt3k+rY;86hyp zLiFGsm#|(ClyQz|ye2Bp@yTSbH4ldUl5iuTRfnFKLH* z#8Mjy1%cR!co<|+c(z7D={FEFd{148Kb9Ax|G5Zk5e3wfIxHSI^%#Lu>5&aoN@SyB zGH%mKGAK;@XNAAJ%l>-h6{O+$BRdFzHpu88f6)lCHj(v0Pu@jNMzG)Ssl$Ymw`hX* zw(nek)R2kz&=>A^zleWTgjUM6pjkKcyKeb#*1Yg4Kg6%{PK}j9C=$I+Ego z>E)U^@}j9&N{QyqVJ_M*yrnY@gng9cU}mqgb>8lUwB)m0qSgiKW`;6~$hxw@A3{3g z-8#8Lny&54s#Cj7dL(JKuPL7eQdS<>v9HzrGA}o@ zoG;KCe#qMeAYlkh-D8hj!HzUXu?^K$Qm7I8aIK3czo4|Kd){l+jOsY%z5c>j9f0+ z#gCk*g2B)XA}(x`AzK^$G46#a`Ij$9+{8)o>#MI*M&R&?y`;5vJmuwqvN0_ud0QDD zo#z96WFA0^vAR{#`xnqy0~UvV?&oGY;w!1RAs8iaL0V6=nTITHf9r$*#V}{QJDurh zK@&kBhqg;J5{DNZ{*q~|AYnI^Mh|faK_3_h*FHUQZ;aU@0|EwK=AnduYyNQ$7c}F1 zXRLt_EXPI*kV<7QUSrcn_)*9x5{?)zB8=8@*!W!coV?YPFww8KVW-5Os&Mi}TyhvOufMh70MZ_f}I42fhtC+CfCf4VZeFz;!Cs zIG|mO_wUeOos4ikEfK>}Y=@a6%wQOTQ)EP?2IGGJdAH$(^Cl`fM(9B=KH#ATW&0cK87NINZ-o7LpOTXWQKN_9)Gps(ls)#NZRu8 zvb%$0P(C^%GL->;0R7`Ic6h|tat0LXfCf?F^p(kedTO-y^cR<4TP1VIK8EId*d~%4 zw4R3Z*X_sE8oIef>b;SQz0av=m{^QcQQWA)csk`Ec*&odokQ9@{}tLLx2*2Feon;s z)oPiCI{d||&^%IQRjJ3Vc~xROk*w3C1m~M?TwYRP--%syDAjoEemd-MRnosr-5@d>Sc6uX_3;!27L z8KoyGiYjt6WBe6q;o$R$-Qs3ud_% zQd&<&7jD0U-tig`mkgf|JF6^P_{1=OBP(IZ4j6u2JwbK6gAy9e0iMcOf<3{* zG(A$EKBw1150^c`$TM5DPETw4>#W9%!v4YZ*BK!%@@%VULPi%{bRaHH@oqjMxB}=) zkNx0$_e%Rip-`<+ppUdf`{i~AN4bUMnD8fU_JC4BAt?n5uzN_F~u<03x0yoE0$q2axRi5sgu+SJ9#^Ly9Lfi6<&YG=jj&~GztQW zwyWQtx0BDQWTt(PSN-PF%N3SCK)eleg|#B~NjS#sq5xA=(p4vQsd17!w}d`sbaUN& zVCeuOAfA8AHhQjmJdDdrV3u~v2rLecIS+AgYX?%=)(JtpVOb@3U+?NSCigiKegm}M z7{3;JAD=6z-%bqUHzsv)+^o@Ltzk&w9|rIKvz7oTcy_5>1JSydpco7hhlx z#HZ`d+|uXsdA_7MOV*xAOJVbS4Sh3lknYXZ6vL~qf7-{-oQt+QJ7m+xU!D!}oaZ6? zwFJ8;?)h~^Z;57{zH$ky8l|n#?NSmJ=zFp(eq5l&v8Pa(hLiEreT~+bEMC9tO;i}S zm6Ymf4AB*wXDOs)0X-rtbE8j&_+u^!IP%U#0MY*@@+yvlB~L5x&{)-2UE0Pg@R?5! zKvw|-%lbnRLq$aoVBSp;6Nu%zuME;qa0~3$sA9ekQ2XG276r?Y^AXp*p+0PXbqud` zD}@6nuVxl`lAh>In@kAD3oBoU4u+tSNQFg~DGeJczVxM*#-c*>SdNG+qd7qIbe{Yo zeLzt4pE6wc1Bp~o=1e+hyQa(%olig53EmSr;9b75h-K6JkCyMY_Sy6^>vKy?f%-5qW zlxk$ye4HH>t7#mK(2PmoYp%Qfq_X@F6y`2*4Gni)ADelaP`z8m0O$stho*9WhHN2K7x;Xde|vg zNT?&L{^$6#D=XTktWOC;uakQ1?&_=`zVojdhWa6R&{~UoeLmZzn}S!`(y>2{d3n=Y z>N}#n^pPvA=QVvn*ypmMeedognQeYeWEj_&;RP*M@8?(6$z7R;;q}#8qU^tDu{h#r z*73H<8P@T@WqIL8tv_DCL-#%ps2-uvUe+Vqzjc z4AP+_96{wQNlGAo@@0VmN9p<%?Vzl$7k-yY_zI^3HwCji{oLaW#jjImNMQb%D^1;C z$;va-r{bY^c2oUThA_b&Xx}4P%!&JUIM`&)<9UF2-07xhnqcmQ@Bs!H`wmpPQ|c8o zKSnt3vho)2*Vwe6AQLH&aWwhrE5z7qu2=z5x7-&7%phgrtxArxz~pOf9pCuC{MCZ$ z_I1ONb6<&@eALk&qsT8wwutgWO_2hYy5IA%jkV&V27L7cjPW#!GQWLilyHL^2~XYIbm zTA$o9PliUfd8im#Wj&ff8pBM4oS9!<51ih~2y}F(RR)E`-x$*HY0Eu3G{S@FLhNH3 zga|GA*Q$MV7rG4LnnWe6@^U_qf9_Ve=>&E(q9^$*vC0nY_7u$I-_Hybh{kHV0t|3P zxhwI+F7C9Q#K-$jK6G3|>|8ZfFoDGVWR{sM9WWiRmoWQ?!Z7X#-9#(=-6Em!K+$A4 zj4Ryd<7SemKu9YigYnYP=-O33tQ<__sbUn2+(|Q4!;NiJGwZCg4G9?ioDm!<)vYB- zW**e<^GbvYIkWg=>&ypk1@|dGxC(B%E}~RTuKtM_X`@$R2qk}wVUn=|pgAs3l|1`BCyR4rkYhNra$TFggWf1#Kbc2;;lp!*Zbrp^ z%rHCW4m!a_Lia%D#^WNgM^C&X9$zogcpJo47Z~?7s{9-7t``l$pLwxeQB2jW#y9LA zSgY?y@~)>9PUMwQ!B4TCZZsENxi|)5>P3pJJQHRuIEre1WLPGfN>6ghcl2U5&WIz7 z>DdS_soZ!xSmSf0g4klRJLJ&k@**t6&`(G4cGv}CE@M3NF{>OM{bT<=PG zvhiVRKym|6~@h!zD;TA0e}ZUpBE zLi`F+_})wU&d~va(7mUE(bO%m)G`PuSU8(7JQ)578`#*JU;Gm_xJ=Ckrw~KW-nLi{ ze-l6mqs8sHlXnw+$_^j62ye7}3(&01bG*yn{&2U+T%`M)xw@|fVv9uQl?0?t##%}r*_Q*BB`=)xJY0RA&8 ztG}5pub%E*PWg2v?aj!B?&<4Tw6d3IOeyDEmD`C=Qi9M<@E~8pPl}5{v^Tc6eW~(& zI&utB1nGA&C0U%Tj{+69_`4sAL~+ylk}cA@4raE_LpF()S!$k&Y&!NIO@?c3LX@ORd61V~h^?>A5l1rorn)`tyj;q0bL-0d@b5kR+O&&`Iax@bW z3oGz$l^rPlzFLkDKlA?~?yREf*wzM%Y}|so1`F~yz!3pjJcZbb}Ai*I4g1ZEF zO>lPt1b24}Ze4KBJ@?+fyC3?g$Jhf3NUFYCwQ5P#`sN&C*?5WFJZQ%yif+q=C3}(M zHEI3nM)=*mUcnrK3qr;r#RO?kBWuP3!362X+VsLI-$bfkql%In(e8c51=B=Y0J!l? z$t@=8Y~A!gtKjSf@osVZ`Pmyje2B{MM4*9LU$QM zw1eC|d_B06vS7|G6xLfL3N1}NBxr5a9*byLe8HvQt)KVIW!6^}g#zEEH$OKqxaoYA zlTdfwItyw2nL)c6@a6cusjE-WK@2{n#^*eF5iV?ie(G4g zB`|_&s?|`eM4BkpZ7bbr{+)48$otM}H<=aG4Xiy0?lC7qWjfC(X?2i{-LRn(4+9fq zdSB2vFzB%PiU_v^#I0((fjUQJr%R`Jc#SChw3{Yh5r z$*p;AnPEB0KPl)n3Dz#ryVV2FwCNYvMY7;Xn2=pjhs7^R%3+Nqnfw{!cJ~lXjQ0*D z7utgEt12_e>xHu(ntZ72u+7K4aE1QQ?|Pdt zW3XbPfhDIZO3JP$Z*=q@q2hoNa$F~FnWglRa5#3`RXHkD99Bt9DG~wSNA3<8ZxpS(^>uG9CH^Er`sX?=ITLAc8Z4F2;A%M>;A6Tdg#}&212A7wa|MCsaKBm)R2Rdtc{o~nhFb9>YF0b zABPgO)amYcw4{M_ffqE2Doa$vKwd==uz(xKfV0E>j9`#G@(t)e5-a2vZYJ}u@%u`p{pI{|U zDX7y)Y(*Z`Ug_XT_z|##tOz|4P(TK->KPzb9I=~0&c}}JxP90ULhZSMaykhy3F1Vt z;8&oxE8Z+5a?JRY{O_=N zHEWEe9H1rwGdjPyLbdh$!pYshBhUKBo*4{U*|$*hS5Y??ioPeSh=!V3(GT(0eavs1 zfF;p}jzUMHR997x1yL-Y9~`v))SYFu=;8P*vw9x=*d76!OMwL#Z`QQN$VWmRoNbR! z6n(QU@1%}y!$gEwZ;q)vIKKT#r=~?3PD;BvA-NRm_@x)lFBT({|h zPqFV0QIo)6_@6#7i+pHhBmuHLhAFJ!qH^k%1YnpnC%7?H75sDIyKLUaUjq|8PIX<` zL;FVGgrN?LGaT9^0c(HWL~@Y zuU|VmJJsU*9_yDWgsNH%=RL1ZVB8Ibh3cTv^kKoGI-n8efUWor4ADUH5{UUDil|0Po31|D`Kvn%elSi; zn2xEikk=m4kTepV_d_JM$Uyq2_k`2H@=WgWAF8W1hN}m0cS~lWnipvwptD8&?ZBX{ zM=0-`n>P{;HK9xA)tfG zuj{C}DYvAAg^y1)S2-{!=-17Hl0$lIPHLY`Dg*-Y_xBG93d+h_T}<1QO236>vbWbk z+va>x0)0g?N+dRLwRCRjw{gen@G8Sa5(nJQQXLzWyH3LdUmXwD>9A`^*DX4#up$Ic zV1?-p9M%q`M5xsrt~$v$Hl>U-M0*%!V;{YsTV&q5rn9q4r9DO}DVC3FNm)?5kp5Ek&c3v7h(7oWs zK4=TP=p_Rl=Aet)$)-j+L!bV62W$WHfTcRMJJQSU$D^*`;mK7-Q?nV*Ve`O*J$4uG zJi({5ijMoGA>WKx?+Laz>hbG5fn@Lzl}vyubS2N_E7_9YVpEkB7=LAxm4xO}ladN! z&KjaiOP}_!YKhCmy8fN?s8+E}x6{wozMdD**5a(&_*I63N+iFyGO{*d2Kw)`JgKX` ztLqI|Yw*p^y`o6N6p5nfPx4md@$RqvjY;c+{=J{9<8#wnz(dBb`sQ}gxvaT9yNQM)N#+^d5zBp>mnwZ^c%KX}>ZG=T5Mgy{8F+bFO zr*1I>1@59*TZh&c7pAG#+2Fs7(uxK0Y~6m7HVeFoT0HB-Zc6udJi1Jq7WVv;b}M>Z z>%RJVK{1+hA56RDG5tlT&-_hqY;;lCm-MmhOYc-(?%6+yLhLtIre=Sb?RKF#Iy7#c zQgjiQ%Eo$Q-rwB44Bo+TwODSBT^S}d-6kYpo3A;vF9VW(|6FV%a9L|4sON9oc#`ki z4-kwJaNk?&m+2L4G&WXaIHX0Q%PU6N8njN&SrLqugNe{??fEgq1zRuvY`c6Y&DXwE zN+jGq{EcoAuB$uy;2YrDdSyTJV)1KU`oZBZON4MAAB+p6x;iT^qMxShK@W~ChMLVU zPwlt-Pmgmpa5noUF)rsY(LdT8eZ^w{mKu8(*4JiUXM%%fI2v@`>y3}5M6+OS)`2t5 z5I;A0dV*D|&XPXNb;0Gr(O_IRU+suY&)>xY6o7hF2Xl>)4%0j!PnJb(e&Uy2Vd-0Z zd=W{WPiH`WtI5DRQ3(&foI1d@!Q~Br0E@xsa%6$LnTl6NhAU7E?~ph25L+UF%->FD zEmb20bB&|NV!rv$N{V(CmMEd`;&AB49g$zIf~(3uwx8V!BZ-QJE*caR+6aJ!Y56#~ zRhaPen#{E>UUPD0OaeJ}e}u{7XQYE8!aKndi>M_$26(QTT^$*n4P zVk9K)Hf~Df+fnDpuBp%f6eA84iJ0bwy2=nFF{<&^DUZZ)&TlC77ZV`h7yq z5c{&RoRH=Eu{lk48l(U)K)}PNz_8jHH@dt5^i<@*uf4$E1>jL27#ESKOnU%?0Hq~^ zk@E8|7*p}a{S}V!nFa0QVj)i+J`OOYAPwUqrN98vWcBE`@OsrghexH4nG94Sdz7=X zfrI8DVe9?<>&16}Qrd&BKE3)X{iQmk{VrHHLWVCVD{sZq-OQ1g|vR zh9!dNN^mbupg5tzn`PZf!;#nxo!|P8svQrm^f+_v^Kjdln6{}g(qT3DvfEOs4L<*T;U{6E=aeE>|z>4Ug zO`h-*0D@6^sMj(OBSKdui;TlxiIqu?Ln`tnQ;F>Lr}z!;hMPaR_J(pmjR?~8eWGXR zISC8#)AHA;>dITq1JDgjI^jBcHX%Y|o#Nu~kP#qxl*7HenV(H)rnRyoRS@6@5SsBt zj-y%xe%5M~_qqwZsZq~A+BwMQr3RQCUGw;Jj+?>SbPxbofP>t8aPd-&`u{{O`A-f( z@z5gzSu4KhKdhf;_1Kf6;)x-{lYof@B1rIighP4yk@txmL!V%a4nnYa#A)#RNSpXM zF&A&A5alNXM_Z@fe)jfd2u0&nRL`1d+p1ZY1jB)5b&%8|!u|GT44)Uf3cDkV?WEJ_ zA2BKgweyaFWBgfvTec7C&cU?{0K$CCVV{yZr)$ZIcaU$5>%tuxv2&Bnv6{zSsB1~5S!yzJ^vBz!hb9&P~5RISQ3 z##zAz5NOze2c;k(5NZc`%Rl>n=1)C&7lHkAvL!&KVF5cp9ti#EM?fX=bm9V7@F*sO z)STE=)l{WA)x7k|xnV2qFXjoT8!>d`jD|GepHQ#>zuv z_BUc;$SjV>?012cV#4$+j1x99i)T)=$2_jBz4Wgh)%DmqNN*|vNLRbao&E{x89()8 z6MKr{83On6X^S&~5)qE^puu!I5^@%z^vx4{N&=puEPl0URo0e<3zA<^aqzz589MNX0Vb7EoTZG>~6B zUn&5r#s=VspWQA`#l!b>-#kYHSk=bhIN?u5>Z_XK;{id33>HXIo}Um!h_X6~Lj%7> zXd>sedcBh=j5}(Y#|K%hYVO+;exQyh33L6}SN#i}xjZ+GO<+yd!f2PY^ZK$PANwSa z{2#vcKMHW*z+7wzE0eHK2j+TaVR3=Xm#*I>JivnR-;`UFa?gL&WD2%W&$r!bk}m(8 zpY?{cX@bO4hD)^VJ=VQF>7$(XSk#+6;`<7ls0|)F=OlPUq>m>@v!p02e2TCln4#p| zf7eXF^9kr1pN<0N1BhGk#@)^#gc>^CWaQ%Y|8aLbLrj54o&j#SSUeQ$|3yUP z;!SWMhhuvdu%w^p>il2ugPmEsmg=DaT{XQkxy#jO_fiqe;!YqwsoM31u0OQ4x+gt8 zeDRG+oCNW4NpcH%XU4Pkq=Hz8v0F>KU(6cci@N@SQ%s})+UmcQt^WwbpUMchVa(y4 z%LqXC|GSJ%eDa=kdg#wOJz6PDGtdw0@DaiH2L{CBm{>i@Uw@8OU<|6kEi#sjy>-whh~-ze7qSx(%5tL!N@ z11u1#?qISxA&sUMwcv~QvN2SOmr(}UIZv1G!Ec&qB}Fo3T8N7pwJ3Rs?Dd$WtE?u{ z_aaZ8lxDFRIMyFe1n&Z`4wrzN_ZK4^3WBu~2;t$oJ35Jm5PYFvbaWiFgyTuLzYJut zfI8>zUXS~4*yjHh)?XDI>F<{1#5bW88$>8wkrkT_Z^W)h`H{mC9axWFyZ_XT@H^{b zp2>_Or09O^G3En^*reg{xKB;mHNTBBsTU#=T-4(vzS+D3vDY0GM;-;tzX}M@j6XpD z0dVl&Ei5I8VYZt8X9(sQo79e z^2@^T_=Rr#HBxA0{o~WM(jAMG{mnaTyg)W!v$)QM#>Re9`dGSHvFv}wwG7+vknmph zFDu)#g8a#5{S*#QLX6;Zi^dHArbR$ZEP&r-^4rII^*`!U2Rm>Di93m-e zxYB)#jrfZC$@&SV!@H!i#cN$QltPRp8$!AdGN89Fu$ZjaRuGuZoufzPb$xz`yER4#`#`O~CbxeM2A1uKtos*ukB8%5Z7>h#GiubZ zd+RLse+U?IWhI}e{h|gHIk7?r72gWIoZk$!VMGn4eh$L7{F+_Aj>Gk{tt-y;S=a%P zM*rI!O*irnR2MUsy#HUt3;4&MiuW1K`h2l?o_l95(BO<4OTyAD9Kjo-J_#(TRoX=N zVWgrkeKLh&OFUTh^)bFT_$e)7TCi#E=$F7v6AV9vwp+WOzQKKTMIdNkf>ZO@PmK31 zgItgg>Q(07cub(v12p7-SZ3h4itqs1aYbNE#RusE|L5X9BlZ6t@A3SNP5qy@B+p;yNn^j0 z)R9gN_-BQx7$P9ud;%=yLO&~1Sv4ngg=A^*YE0Or^LN+tdm<*2zP|g8oT5f!k+%cu zisJk%pnZS&b`My}_^b`K3`Q3hA4_`d|68&}eEm;%0|+-1K{(HTZr~(&0qv&Bb9>7B zJVfL&^cG2f^@O@zN5Fh?4v^gG%V5GiJFi6xLaGA4F1%Fl?2pNAT$L(Ii0+#pX2+qM z?A+u7Q&I%JSzmXm53Cdymek=bn5zWGPyJXk-UkL_vG*E_<*lIm={D%I*aW|Cpywv* z*%SUG^?8cp-%ZwkXv=}$b3TEcxeeC@1!*Md)sU~CLuDjiL%uwse5MU~Hh_$U&?^2; zmg31`*E)%HpFW~2a6j3fA*}p%ISV(;DpS!4TXzDCf)1t~{$(y6KI0Pm9u7FQ5b$&y z5&@ShDTI9OuNnwg{+|5c&j4)T;D6&%dH+W@$omWiW;Fq@`FVxzICD?Yu+HhjP`$4()*^?JoP z>C@Wyej)z8>fKpn&uC0QK;4zT$GAAFCpsx9!O`FLO8~Aq<_X}xfuH~;v#QaoII0wdHMc|i<<)IrT;6g=eZ3%2gd|(PjRzx)^RESeJc?X zBoS#7XnuR*U?*xP5hoH55i(G3ITAAX8AJ^+`6TP<|s1#(!+*y?x>hKSa)tOBd!DG!?l*xK&e%L{f*g zPP+Az2GjwoGVBU%lLAy@;MXz%j4KJr#0 zCOo80GSHYkeIzSlR(0aA@IjTuatH*6mRqf0Y7lnJ86iPEiCD<2q=-zE$NDEoMez36Pa3xU4~>zLMx zDBH;ipeQ^BL&?Hii}Qf#6H-Lzoe>cIKa!ast7RkLZq;rr)R-e@Lx{u@Sdv-Ef)2va zu=px^w@~1dS^2BVt1Hv`Hopx(OTlzyhYr}7p1$*;Lwxa;K^miy&AnVA?y{yc!^W{m)jEC7**4XhJfuklLy`eFoLHiwH-*tAmN*sgR~<`T-yXs_1x{{H;K$gXF00cdjkNtB|G5T0v;0-_kkiI}5UY z4(d4`(fv?h=nY!sTS|y0{T}R&Kn82ICN;Wxl!FWwJrtJv@14s}R0#tkgRIPDrHs`V zHRnUAU>BbmZwJm)QLNmV{>{B&W=VRy&(_W5aYJR+CNgIRWeL1Y5TlrabWY4|o#SR1 zKZ;hb4HSyl=pjh5iBC}s)tv!w#Y=6>#21h9cqZ+gYMxhV@>C`e2c0-!Ap?|*Ua{cD846pD) z$>OJGF(fXRY^1g z$Mm+@x8b>bF>xwzGni`17)D-CHHd{mb8a%Le0K)*<2DU5d@XvgaOtb3IJKCyK5+E* zT{Bl7MrqbV{;TGuVpKIK*$hJ@Z?^X%jj}ItVLph^$AOC8kP#h(du?0DW~@@b{sZ-E z%o90?%oY5QMtz*q5d^A;^6r6_@%xAZh8{_!K!&4|W;x6uOgADp>xBYtm`;W4F$xF< zn`P0vNxKxCY$wx~(A)%MS3E`ZYy}Q5QvTELwqD?8uD2`L<<<@;(f^Lq=R-xzQU&jq z`eONskn&xhmoOcFt2C7(Q~PLk250F|ri}YW+ByXGl_QIySqu^Naa+gAz-!1F7%CyN zW@z6Nxs#xZ?W?LwMX4vsBn2cCzddLJ=DxX_#cvyWvPTk}RVZDoDZ7Vo!?$B?IAvz} zHqI`Z|2N%mPOb~hCOPFBE5yji|{tc8aZ^*PD<$!w`Y_JVxWO-T+WG%>NrnCkc z?`1ugxsy~*^4B$`;#7g+_ok{I;M!aU6%@oQ$zayeB+cwexT}e#^_ffOXcSOI+AywL zaTT0Dy>*zLzoIHdh|pIU8YzGJ!wlMAM&@7Dl`qsNVX0iYLc4w{@lkxvHSF4i)(f%! zC7bcyLy!1&Nbm?=^IPMA$SZX|bgfE(0`aI_I*2ki*7QZ>sDa+XAjL!97MKyYi2*4i zzu4Bys``C~(=aSLJ)_Iy;*N7>eT5(jrWDneA-kNmq-Hn?lH?to_{fO^m+VcH&fn0o z24FQ3Tqt2$x_D55^I(-6jB}`co!~ybEQQ8^&8J8Se$E;=QUsjD;(Af5s}ipZ((FKj zM1&n{{u+ooe0eAO5}s~C95_N(7K-^#`?~j1=R5;0xkTBAe%xk`hO67i-WFasU)e3N z?$M)R;GTYjm+uVDT`}*;lK(iEJmmq!ZdG*F7`z(t6EpR!ERVy3gHD?XQ*8nIhDw$H zOTY&hHMxe}C=3%vx^GX-@3*w2x8@=RlR4)ZJP2gg1icFD7vo`)V4C8-eO$!Lja>%0 z=_(7n*jCp75H)^ib;ZvHG_03-y z@5U!)dq~Dv3nYeq&Q|#}s`xZwToURLRO&o^YITSzehOGNiI_FLs$3AKszAXhRV0GZ zN0w)8$B6BNRLg;fCiZwnuW#)=*U`pp7wXxr~v@)4P~+6Ftohq?#X;!fw5z0tGGLiF^;!QFpCX0h)6?j_$Ik zwS01hQYO%ovx5Gp-pqAb^QWyNLSe zKh~)oB{g#Zj;J1iiMA{&wxM^v=#l6*{NAiYzyAX3ixb_mu;%2-*kgB9@?XK)M85HSA~|c4TY6-h&h^R z-A+st(mLA`8lwF~Gwn?kO6R-2G+jAPNq;>|rXv2GZ!Wk0xJa;_s;8GPgz0}O!XSd< z88SR|g%vlj7B!zVR>l=LKxgP|*x{A#g``0yiISUxR2RUfJ zbdG1mtE)7neTN@5+f`Er9}Bh&yDR*CX>7E20KKpQ4ZKQUk+^?TJmQBoTaGEM((DAZ2O zP`bFjC9yHOWyTD_&>^Y)!DZdRVP^G(@9OQyK*#2p@NQnu8o6gi>!J#eFwr9*PHn9G z%v413eRKIN3(iM4PCJ*VS*aFo<^Yc2OZ^;gxAV|pP?cZ-Unzxft~L-Fv<Ths4JRvTGB%_lG)u=lS061ovu zCzpQsM`B|~x-j1M{Oo6vQ%8Il)|9#Mm%ULmbG;;3{DT~HIhgAX91;=c7fMa!^DU2X zsJ9$p=QOC<8v{(g@!6PYv4jPwm0$&B)41^6T1=Hly&7OiE$6%O%2mNJkff2RN%6Q zR~lCd7bd~9oxD9B4{FfO4985TgJ0&^Ii(8@NJgE}iS2f&rkGW{B_uO2^kcn};ZfzI zz9zt<=>{Px60d*16ABE5mG>!~Xd~oyf_F)wgf$2136EnC+B_vJz9;hh>t(I4XyJx0 z!AdCZZ#LH>62TtEc+C@((fs_%(nZr3?HN1Oy)#@Eww^iGLDa{}W$F|zI4fTq!(m{FpL;}Yo;H3 z;5~^WzOhJPnaD$1s+C`IWYu#W6g7Wa>s*vX*4vgPYVtE9dD#Q6VNt5t{3MBghRKyz zSXFQ5y_3s|!U#tXH%4ddx}}6pZUV10C=ND20O-hwd5%93k#$rpXMxdnLE;C@F+XiO zJMk^+vA_uWMq3d{AdXgQq3>(liB&Gs&KHo?{qpnbQRmIf(?N-@E)n^J-hTW!JyHxE z@ZI_#xO9CsIIC%0DcVptPFK!!=OX>|N0?=q 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 z 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 From eb79855047b8dc399dfa97a2dc457839284cd638 Mon Sep 17 00:00:00 2001 From: Gabrieltaillandier Date: Sat, 20 Jun 2026 17:43:35 +0300 Subject: [PATCH 53/53] feat: add analysis module architecture diagram and wireframe sphere visualization to density contour plotter, while setting default azimuthal step for sampling modes. --- .../introduction/theoretical_foundations.rst | 70 +++++++++ .../analysis/interface/base.py | 17 +-- .../visualization/density_contour_plotter.py | 134 +++++++++++++++++- 3 files changed, 211 insertions(+), 10 deletions(-) diff --git a/docs/source/introduction/theoretical_foundations.rst b/docs/source/introduction/theoretical_foundations.rst index 569d47c..296fecb 100644 --- a/docs/source/introduction/theoretical_foundations.rst +++ b/docs/source/introduction/theoretical_foundations.rst @@ -10,6 +10,76 @@ the analyzers. :local: :depth: 2 +The following diagram summarises the composable components of the +analysis module and how they connect: + +.. mermaid:: + + flowchart TD + %% ---------- role styles ---------- + classDef decision fill:#fff3cd,stroke:#d39e00,color:#222; + classDef option fill:#e7f1ff,stroke:#4a86e8,color:#222; + classDef analyzer fill:#d4edda,stroke:#28a745,color:#222; + classDef shared fill:#f3e8ff,stroke:#8e44ad,color:#222; + + start(["Analysis module"]):::analyzer + + %% ===== Step 1: common to every analyzer ===== + subgraph S1["Step 1 — common setup (all analyzers)"] + direction LR + geo{"Droplet geometry"}:::decision + geo --> spherical:::option + geo --> cylinder_x:::option + geo --> cylinder_y:::option + agg{"Temporal aggregation"}:::decision + agg --> b1["batch_size = 1\nper frame"]:::option + agg --> bn["batch_size = n\nn-frame blocks"]:::option + agg --> ball["batch_size = -1\nall frames pooled"]:::option + end + + %% ===== Step 2: pick a method and configure it ===== + subgraph S2["Step 2 — choose a method and configure it"] + method{"Which analyzer?"}:::decision + method -->|"separable steps,\nper-frame capable"| TA["TrajectoryAnalyzer"]:::analyzer + method -->|"coupled tanh fit,\n2D grid"| CF2["CoupledFit2DAnalyzer"]:::analyzer + method -->|"coupled tanh fit,\n3D grid (spherical only)"| CF3["CoupledFit3DAnalyzer"]:::analyzer + + dens{"DensityEstimator"}:::shared + dens --> gaussian:::option + dens --> binning:::option + + subgraph S2a["2a — TrajectoryAnalyzer components"] + ext["InterfaceExtractor\n= SpaceSampling + DensityEstimator"]:::shared + samp{"SpaceSampling"}:::decision + samp --> rays:::option + samp --> grid:::option + fit{"SurfaceFitter"}:::decision + fit --> slicing:::option + fit --> whole:::option + wall{"WallDetector"}:::decision + wall --> min_plus_offset:::option + wall --> explicit:::option + wall --> from_atoms:::option + ext --- samp + end + + subgraph S2b["2b — coupled-fit components"] + grid_params["grid_params\n(or auto-derived)"]:::option + initial_params["initial_params\n(7-param tanh seed)"]:::option + end + + TA --> S2a + CF2 --> S2b + CF3 --> S2b + ext -. uses .-> dens + CF2 -. uses .-> dens + CF3 -. uses .-> dens + fit -. "mode must match\nthe extractor" .- ext + end + + start --> S1 + S1 --> method + 1. The contact angle and the cap geometry ----------------------------------------- diff --git a/src/wetting_angle_kit/analysis/interface/base.py b/src/wetting_angle_kit/analysis/interface/base.py index bd3517b..1a88783 100644 --- a/src/wetting_angle_kit/analysis/interface/base.py +++ b/src/wetting_angle_kit/analysis/interface/base.py @@ -125,7 +125,7 @@ def extract( def rays( cls, *, - delta_azimuthal: float | None = None, + delta_azimuthal: float | None = 15.0, delta_cylinder: float | None = None, n_rays_sphere: int | None = None, delta_polar: float = 8.0, @@ -140,7 +140,7 @@ def rays( ========================== ========================================= surface_kind, geometry required ray params ========================== ========================================= - slicing, spherical ``delta_azimuthal`` (+ ``delta_polar``) + slicing, spherical [``delta_azimuthal``] (+ ``delta_polar``) slicing, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) whole, spherical ``n_rays_sphere`` whole, cylinder_x/y ``delta_cylinder`` (+ ``delta_polar``) @@ -148,9 +148,10 @@ def rays( Parameters ---------- - delta_azimuthal : float, optional + delta_azimuthal : float or None, default 15.0 Azimuthal step (degrees) between slicing planes for the - spherical slicing mode. + spherical slicing mode. ``None`` disables the parameter + (useful when only cylinder modes are needed). delta_cylinder : float, optional Step (Å) along the cylinder axis between slices for the cylinder modes (both slicing and whole). @@ -186,7 +187,7 @@ def grid( cls, *, grid_params: dict[str, Any] | None = None, - delta_azimuthal: float | None = None, + delta_azimuthal: float | None = 15.0, delta_cylinder: float | None = None, ) -> SpaceSampling: """Fixed-cell grid sampling layout. @@ -213,10 +214,10 @@ def grid( atom bounding box plus a 5 Å buffer, with cell width set to ``density_sigma / 2`` for Gaussian or ``2 Å`` for binning. - delta_azimuthal : float, optional + delta_azimuthal : float or None, default 15.0 Azimuthal step (degrees) between slicing planes for - ``slicing + spherical``. Required for that case; ignored - otherwise. + ``slicing + spherical``. Ignored for cylinder geometries + and whole-fit modes. delta_cylinder : float, optional Step (Å) along the cylinder axis between slicing planes for ``slicing + cylinder``. Required for that case; diff --git a/src/wetting_angle_kit/visualization/density_contour_plotter.py b/src/wetting_angle_kit/visualization/density_contour_plotter.py index ad8ac1b..12b29d7 100644 --- a/src/wetting_angle_kit/visualization/density_contour_plotter.py +++ b/src/wetting_angle_kit/visualization/density_contour_plotter.py @@ -164,6 +164,7 @@ def plot_3d_isosurface( self, *, n_levels: int = 10, + show_fit: bool = True, title: str | None = None, save_path: str | None = None, ) -> go.Figure: @@ -177,6 +178,9 @@ def plot_3d_isosurface( ---------- n_levels : int, default 10 Number of iso-density levels exposed in the slider. + show_fit : bool, default True + If ``True``, overlay the fitted sphere as a black dashed + wireframe (meridians + parallels). title : str, optional Figure title. Defaults to ``"Isosurface ρ ≥ ... — {label}"``. @@ -273,12 +277,20 @@ def plot_3d_isosurface( ) ) + # Fitted sphere wireframe. + sphere_traces: list[go.Scatter3d] = [] + if show_fit: + sphere_traces = self._sphere_wireframe(params) + for tr in sphere_traces: + fig.add_trace(tr) + # Slider steps — each toggles one isosurface; - # wall + colorbar always on (last 2 traces). + # wall + colorbar + sphere wireframe always on. + n_always_on = 2 + len(sphere_traces) # wall, colorbar, sphere lines steps = [] n_iso = len(iso_levels) for i, iso_val in enumerate(iso_levels): - vis = [False] * n_iso + [True, True] # wall + colorbar + vis = [False] * n_iso + [True] * n_always_on vis[i] = True steps.append( { @@ -317,6 +329,124 @@ def plot_3d_isosurface( fig.write_html(save_path) return fig + # ------------------------------------------------------------------ + # Internals — fitted-sphere wireframe for the 3D plot. + # ------------------------------------------------------------------ + + @staticmethod + def _sphere_wireframe( + params: dict, + *, + n_meridians: int = 12, + n_parallels: int = 8, + pts_per_line: int = 80, + ) -> list[go.Scatter3d]: + """Build wireframe traces for the fitted sphere above the wall. + + Only the portion of the sphere at or above the wall height + ``zi_0`` is drawn; anything below is clipped. + + Parameters + ---------- + params : dict + Model parameters with keys ``R_eq``, ``xi_c``, ``yi_c``, + ``zi_c``, ``zi_0``. + n_meridians : int + Number of longitude (meridian) great circles. + n_parallels : int + Number of latitude (parallel) circles evenly spaced + from bottom to top of the sphere. + pts_per_line : int + Points per wireframe line segment. + + Returns + ------- + list[go.Scatter3d] + Wireframe line traces (meridians + parallels), clipped + at the wall height. + """ + R = float(params["R_eq"]) + xc = float(params.get("xi_c", 0.0)) + yc = float(params.get("yi_c", 0.0)) + zc = float(params["zi_c"]) + z0 = float(params["zi_0"]) + + line_style: dict = { + "color": "black", + "width": 3, + "dash": "dash", + } + traces: list[go.Scatter3d] = [] + + def _add_clipped_trace( + x: np.ndarray, + y: np.ndarray, + z: np.ndarray, + ) -> None: + """Append line segments for the portion with ``z >= z0``. + + Contiguous runs of above-wall points are emitted as + separate traces so that Plotly does not draw a line + through the masked region. + """ + mask = z >= z0 + if not mask.any(): + return + # Find contiguous runs of True in *mask*. + diff = np.diff(mask.astype(np.int8)) + starts = np.flatnonzero(diff == 1) + 1 + ends = np.flatnonzero(diff == -1) + 1 + if mask[0]: + starts = np.r_[0, starts] + if mask[-1]: + ends = np.r_[ends, len(mask)] + for s, e in zip(starts, ends, strict=True): + traces.append( + go.Scatter3d( + x=x[s:e], + y=y[s:e], + z=z[s:e], + mode="lines", + line=line_style, + showlegend=False, + hoverinfo="none", + ) + ) + + # Meridians (longitude great circles) — clipped at the wall. + theta_arr = np.linspace(0.0, np.pi, pts_per_line) + for k in range(n_meridians): + phi = 2.0 * np.pi * k / n_meridians + x = xc + R * np.sin(theta_arr) * np.cos(phi) + y = yc + R * np.sin(theta_arr) * np.sin(phi) + z = zc + R * np.cos(theta_arr) + _add_clipped_trace(x, y, z) + + # Parallels (latitude circles) — skip rings below the wall. + phi_arr = np.linspace(0.0, 2.0 * np.pi, pts_per_line) + theta_parallels = np.linspace(0.0, np.pi, n_parallels + 2)[1:-1] + for th in theta_parallels: + z_ring = zc + R * np.cos(th) + if z_ring < z0: + continue + r_ring = R * np.sin(th) + x = xc + r_ring * np.cos(phi_arr) + y = yc + r_ring * np.sin(phi_arr) + z = np.full_like(phi_arr, z_ring) + traces.append( + go.Scatter3d( + x=x, + y=y, + z=z, + mode="lines", + line=line_style, + showlegend=False, + hoverinfo="none", + ) + ) + + return traces + # ------------------------------------------------------------------ # Internals — 3D source extraction. # ------------------------------------------------------------------

VPBePh8Y-{jPN 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