Source code for wetting_angle_kit.analysis.results

"""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`

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 coupled-fit parameters and are 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.


[docs] @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 ``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] angle: float z_wall: float rms_residual: float angle_std: float | None = None
[docs] @dataclass(frozen=True, eq=False, kw_only=True) 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. 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 ``(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 (kept for every slice, including those that produced no angle). slice_popts : ndarray ``(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 @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))
[docs] @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
[docs] @dataclass(frozen=True, eq=False) class CoupledFit2DBatchResult: """Per-batch result from :class:`CoupledFit2DAnalyzer`. Attributes ---------- frames : list[int] Frame indices pooled into this batch. angle : float 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 grid-cell centers (Å). zi_grid : ndarray Vertical grid-cell centers (Å). density : ndarray ``(len(xi_grid), len(zi_grid))`` density sampled on the grid. """ frames: list[int] angle: float model_params: dict[str, float] xi_grid: np.ndarray zi_grid: np.ndarray density: np.ndarray
[docs] @dataclass(frozen=True, eq=False) 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:`CoupledFit3DAnalyzer` rejects non-spherical geometries at construction. Attributes ---------- frames : list[int] Frame indices pooled into this batch. angle : float 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"``, ``"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 grid-cell centers (Å). yi_grid : ndarray y grid-cell centers (Å). zi_grid : ndarray Vertical grid-cell centers (Å). density : ndarray ``(len(xi_grid), len(yi_grid), len(zi_grid))`` density sampled 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
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[Any] 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))
[docs] @dataclass 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)
[docs] @dataclass class CoupledFit2DResults(_AngleResultsMixin): """In-memory results of a :class:`CoupledFit2DAnalyzer.analyze` run. Attributes ---------- batches : list[CoupledFit2DBatchResult] Per-batch results, in the order produced by the aggregator. method_metadata : dict Free-form descriptor (droplet geometry, grid params, initial parameters, batch size). """ batches: list[CoupledFit2DBatchResult] method_metadata: dict[str, Any] = field(default_factory=dict)
[docs] @dataclass class CoupledFit3DResults(_AngleResultsMixin): """In-memory results of a :class:`CoupledFit3DAnalyzer.analyze` run. Attributes ---------- 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, grid params, initial parameters, batch size). """ batches: list[CoupledFit3DBatchResult] method_metadata: dict[str, Any] = field(default_factory=dict)