Visualisation Tutorial — Angle Evolution and Density Contour

Two trajectory-level plotters cover the most common visual outputs:

  • 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.

  • 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).

from wetting_angle_kit.analysis import (
    DensityEstimator,
    InterfaceExtractor,
    SpaceSampling,
    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_indices(frame_index=0)

analyzer = TrajectoryAnalyzer(
    parser=LammpsDumpParser(filename),
    atom_indices=oxygen_indices,
    droplet_geometry="spherical",
    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),
    temporal_aggregator=TemporalAggregator(batch_size=1),
)
results = analyzer.analyze(range(0, 24))

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-fit 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 BaseTrajectoryPlotter so plotter.summary() returns a list of 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-fit batches).


2. Density contour plot

For a coupled-fit analysis, 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:

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

filename = "../../tests/trajectories/traj_spherical_drop_4k.lammpstrj"
oxygen_indices = LammpsDumpWaterFinder(
    filename, oxygen_type=1, hydrogen_type=2
).get_water_oxygen_indices(frame_index=0)

coupled_fit = CoupledFit2DAnalyzer(
    parser=LammpsDumpParser(filename),
    atom_indices=oxygen_indices,
    droplet_geometry="spherical",
    grid_params={
        "xi_0": 0.0,
        "xi_f": 70.0,
        "dx": 4.0,
        "zi_0": 0.0,
        "zi_f": 70.0,
        "dz": 4.0,
    },
    temporal_aggregator=TemporalAggregator(batch_size=10),
)
results = coupled_fit.analyze(range(0, 24))

# 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 CoupledFit3DResults and CoupledFit3DBatchResult. The default title indicates the azimuthal collapse so plots are unambiguous.


3. 3D isosurface visualisation

DensityContourPlotter also supports interactive 3D isosurface plots via its plot_3d_isosurface() method. This requires a 3D source (CoupledFit3DResults or CoupledFit3DBatchResult) because the full 3D density grid is needed. The figure includes a density-threshold slider so you can sweep through iso-levels, a semi-transparent wall plane, and (optionally) the fitted sphere wireframe overlay.

Below is a complete example that runs the 3D coupled-fit analysis and then generates both the azimuthally-averaged 2D contour and the full 3D isosurface:

from wetting_angle_kit.analysis import CoupledFit3DAnalyzer
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_indices(frame_index=0)

analyzer = CoupledFit3DAnalyzer(
    parser=LammpsDumpParser(filename),
    atom_indices=oxygen_indices,
    droplet_geometry="spherical",
    grid_params={
        "xi_0": -40.0,
        "xi_f": 40.0,
        "dx": 4.0,
        "yi_0": -40.0,
        "yi_f": 40.0,
        "dy": 4.0,
        "zi_0": 0.0,
        "zi_f": 60.0,
        "dz": 4.0,
    },
    temporal_aggregator=TemporalAggregator(batch_size=-1),
)
results = analyzer.analyze(range(0, 24))

plotter = DensityContourPlotter(results, label="4k spherical drop")

# 2D contour (azimuthally averaged) — works for any source type.
plotter.plot(save_path="density_contour_2d.html")
print("Saved: density_contour_2d.html")

# 3D isosurface with density-threshold slider (3D sources only).
plotter.plot_3d_isosurface(n_levels=10, save_path="density_isosurface_3d.html")
print("Saved: density_isosurface_3d.html")

The slider exposes n_levels iso-density thresholds; each level is coloured consistently using the colorscale (Jet by default) so you can visually identify which density value each surface corresponds to. Set show_fit=False to hide the wireframe sphere if you only want the raw density surface.


4. 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 AngleEvolutionPlotter to plot the per-batch median across slices instead of the mean (slicing results only; ignored for other result types).

  • 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.

  • For the 3D isosurface, using coarser grids (~25 bins per axis) with many pooled frames (batch_size=-1) gives the best visual result. Finer grids produce noisier surfaces without much benefit for visualisation.