Tutorial: Contact Angle Analysis (Coupled Fit, 2D)
This tutorial covers CoupledFit2DAnalyzer, the
coupled-fit alternative to the composable
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
DensityEstimator strategy: the default
DensityEstimator.binning() is a top-hat histogram;
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.
1. Overview
The pipeline does three things per batch:
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 dzifor spherical,dV = box_y · dxi dzifor cylinder).NLLS fit. Fit a seven-parameter hyperbolic-tangent density model
\[\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 radiusR_{eq}and interface thicknesst_1, andh(z - z_0)a vertical sigmoid above the wallz_0of thicknesst_2. The fit returns the parameters(rho1, rho2, R_eq, z_c, z_0, t1, t2).Contact angle. The cap–wall intersection geometry gives the contact angle directly:
\[\cos \theta = \frac{z_0 - z_c}{R_{eq}}.\]
2. Prerequisites
Your trajectory file should contain atom IDs, types, and positions. Example trajectory:
tests/trajectories/traj_10_3_330w_nve_4k_reajust.lammpstrj
3. Example Code
from wetting_angle_kit.analysis import CoupledFit2DAnalyzer
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_10_3_330w_nve_4k_reajust.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 2D sampling grid ---
grid_params = {
"xi_0": 0.0,
"xi_f": 100.0,
"dx": 2.0,
"zi_0": 0.0,
"zi_f": 100.0,
"dz": 4.0,
}
# --- Step 4: Build the analyzer ---
analyzer = CoupledFit2DAnalyzer(
parser=LammpsDumpParser(filename),
atom_indices=oxygen_indices,
droplet_geometry="cylinder_y",
grid_params=grid_params,
# 10-frame pooled batches
temporal_aggregator=TemporalAggregator(batch_size=10),
)
# --- Step 5: Run analysis for a frame range ---
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]:
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
The returned CoupledFit2DResults object exposes:
mean_angle/std_angle— mean and std across batches.per_batch_angles— array of one angle per batch.batches— list ofCoupledFit2DBatchResultentries. Each batch carriesangle,model_params(the seven tanh parameters), andxi_grid/zi_grid/density— the binned density field used for the fit. Feed any batch (or the full results object) intoDensityContourPlotterto draw the density contour with the fitted spherical cap overlaid (see Visualisation Tutorial — Angle Evolution and Density Contour).
Example printed output:
Number of water molecules: 4000
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 cell width: pick
xi_fandzi_fso the droplet sits well inside the grid; pickdxanddzso 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 ``grid_params``? Leaving it
Noneuses an atom-derived default: lateral half-box forxi/zi,dx/dz= 0.5 Å (half the model’s default interface thicknesst1). A warning is emitted to flag that the user didn’t tune the grid.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.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. Passinitial_paramsexplicitly if you see the fit’srho1orrho2pegged at the zero bound (the analyzer warns when this happens).3D extension: if you suspect significant deviation from axisymmetry, swap in
CoupledFit3DAnalyzer. Spherical droplets only; same API plus extrayi_*bin keys. The full tutorial is Tutorial: 3D Coupled-Fit Analyzer.
For a side-by-side density contour plot with the fitted cap overlaid, see Visualisation Tutorial — Angle Evolution and Density Contour.
6. Alternative configurations
6.1 Cylindrical droplet
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
(\(\xi = \sqrt{x^2 + y^2}\)) to perpendicular-to-axis
(\(\xi = |x - x_c|\)) and the density normalisation changes
from spherical (\(dV = 2\pi \xi\, d\xi\, dz\)) to cartesian
(\(dV = L_y\, d\xi\, dz\), with \(L_y\) the box length
along the cylinder axis):
analyzer = CoupledFit2DAnalyzer(
parser=LammpsDumpParser(cylinder_fixture),
atom_indices=oxygen_indices,
droplet_geometry="cylinder_y",
grid_params={
"xi_0": 0.0,
"xi_f": 100.0,
"dx": 2.0,
"zi_0": 0.0,
"zi_f": 100.0,
"dz": 4.0,
},
)
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 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 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:
from wetting_angle_kit.analysis import CoupledFit2DAnalyzer, DensityEstimator
analyzer = CoupledFit2DAnalyzer(
parser=LammpsDumpParser(filename),
atom_indices=oxygen_indices,
droplet_geometry="spherical",
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
# ``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
\((\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:
analyzer = CoupledFit2DAnalyzer(
parser=LammpsDumpParser(filename),
atom_indices=oxygen_indices,
droplet_geometry="spherical",
grid_params=grid_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 \(10^{-6}\)) — that’s the usual sign your initial guess is far from the true minimum or your grid bounds are wrong.
6.4 Single fully-pooled batch
Drop the temporal_aggregator argument (or set
batch_size=-1) to get one angle for the whole trajectory:
results = CoupledFit2DAnalyzer(
parser=LammpsDumpParser(filename),
atom_indices=oxygen_indices,
droplet_geometry="spherical",
grid_params=grid_params,
).analyze(range(0, 24))
print(results.batches[0].angle) # single representative angle
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).